This commit is contained in:
michael committed 2026-09-15 09:51:30 +00:00
1 parent b7f7d7e793
commit c01d747352
14 files changed
+987 -294

No files matched your search

+405 -170
View File
@@ -60,6 +60,46 @@ function logDebug(event: string, details: Record<string, unknown> = {}) {
console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`);
}
process.on("uncaughtException", (error) => {
logDebug("process", {
event: "uncaughtException",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : null
});
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logDebug("process", {
event: "unhandledRejection",
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : null
});
});
app.use("/api", (req, res, next) => {
const requestId = randomUUID();
const startedAt = Date.now();
logDebug("request-start", {
requestId,
method: req.method,
path: req.originalUrl
});
res.on("finish", () => {
logDebug("request-finish", {
requestId,
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - startedAt
});
});
next();
});
function updateRestoreDebug(id: string, update: Partial<RestoreDebugState>) {
if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) {
return;
@@ -256,6 +296,8 @@ type ProjectRow = {
organization_name: string | null;
customer_name: string | null;
work_type: "support" | "consulting" | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
@@ -279,6 +321,7 @@ type ProjectTeamspaceEntryRow = {
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
budget_sync_disabled?: boolean;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
@@ -310,6 +353,7 @@ type ZammadTicketImport = {
preferences: Record<string, unknown>;
teamspaceBudgetMinutes: number | null;
teamspaceProject: string | null;
teamspaceProjects: string[];
};
type ZammadSyncExistingFields = {
@@ -318,6 +362,7 @@ type ZammadSyncExistingFields = {
type: boolean;
projectName: boolean;
budget: boolean;
overrideBudget: boolean;
};
type ZammadSyncChange = {
@@ -440,6 +485,17 @@ function parseZammadBudgetMinutes(value: unknown) {
return Math.round(hours * 60);
}
function parseZammadTeamspaceProjects(value: unknown) {
if (typeof value !== "string") {
return [];
}
return value
.split("+")
.map((part) => part.trim())
.filter(Boolean);
}
function zammadPreferenceValue(ticket: Record<string, unknown>, key: string) {
const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {};
return preferences[key] ?? ticket[key];
@@ -629,6 +685,7 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
}
const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {};
const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project"));
return {
zammadTicketId,
@@ -642,10 +699,8 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
workType,
preferences,
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
teamspaceProject:
typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim()
? String(zammadPreferenceValue(rawTicket, "project")).trim()
: null
teamspaceProject: teamspaceProjects[0] ?? null,
teamspaceProjects
};
}
@@ -855,6 +910,7 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
teamspaceProjects: zammadTicket.teamspaceProjects,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
@@ -1018,6 +1074,7 @@ function parseProjectTeamspaceEntries(
teamspaceProject: optionalString(entry.teamspaceProject),
isDefault,
hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"),
budgetSyncDisabled: optionalBoolean(entry.budgetSyncDisabled, false),
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
displayOrder: index * 1024
};
@@ -1427,10 +1484,14 @@ async function ensureProjectForTicket(
client: QueryClient,
ticketId: string,
userId: string,
defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {}
defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {}
) {
const hasTeamspaceProjectDefault = Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject");
const teamspaceProjectDefault = hasTeamspaceProjectDefault ? defaults.teamspaceProject ?? "" : null;
const teamspaceProjects = defaults.teamspaceProjects?.length
? defaults.teamspaceProjects
: defaults.teamspaceProject
? [defaults.teamspaceProject]
: [];
const teamspaceProjectDefault = teamspaceProjects[0] ?? (Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject") ? defaults.teamspaceProject ?? "" : null);
const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>(
`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required)
@@ -1459,8 +1520,8 @@ async function ensureProjectForTicket(
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, true, $3, $4, 0)
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, true, $3, false, $4, 0)
ON CONFLICT DO NOTHING;
`,
[
@@ -1472,6 +1533,24 @@ async function ensureProjectForTicket(
);
}
const projectResult = result.rows[0]
? { rows: [{ id: result.rows[0].id }] }
: await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM projects
WHERE ticket_id = $1
AND user_id = $2
LIMIT 1;
`,
[ticketId, userId]
);
const projectId = projectResult.rows[0]?.id;
if (result.rows[0] && projectId && teamspaceProjects.length > 1) {
await syncProjectTeamspaceEntriesFromZammad(client, projectId, teamspaceProjects, defaults.hourBudgetMinutes ?? null, false);
}
if (defaults.closeReviewRequired) {
await client.query(
`
@@ -1502,6 +1581,8 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
p.teamspace_project,
p.description,
p.goals,
@@ -1561,6 +1642,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'budget_sync_disabled', pte.budget_sync_disabled,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
@@ -1800,7 +1882,8 @@ function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields {
organization: optionalBoolean(source.organization, false),
type: optionalBoolean(source.type, false),
projectName: optionalBoolean(source.projectName, false),
budget: optionalBoolean(source.budget, false)
budget: optionalBoolean(source.budget, false),
overrideBudget: optionalBoolean(source.overrideBudget, false)
};
}
@@ -1858,6 +1941,15 @@ type SyncProjectSnapshot = {
closed_at: string | null;
default_teamspace_project: string | null;
default_hour_budget_minutes: number | null;
default_budget_sync_disabled: boolean;
teamspace_entries: Array<{
id: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
budget_sync_disabled: boolean;
display_order: number;
}>;
};
function syncComparableValue(value: unknown) {
@@ -1887,10 +1979,14 @@ function syncIdComparableValue(value: unknown) {
}
function syncComparableValueForField(field: string, value: unknown) {
if (field === "Organisation") {
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") {
return syncIdComparableValue(value);
}
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
return value.map((entry) => syncComparableValue(entry));
}
return syncComparableValue(value);
}
@@ -1917,6 +2013,7 @@ function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) {
workType: zammadTicket.workType,
preferences: zammadTicket.preferences,
teamspaceProject: zammadTicket.teamspaceProject,
teamspaceProjects: zammadTicket.teamspaceProjects,
teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes
};
}
@@ -1951,7 +2048,10 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
workType,
preferences: isPlainObject(snapshot.preferences) ? snapshot.preferences : {},
teamspaceBudgetMinutes: typeof snapshot.teamspaceBudgetMinutes === "number" ? snapshot.teamspaceBudgetMinutes : null,
teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null
teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null,
teamspaceProjects: Array.isArray(snapshot.teamspaceProjects)
? snapshot.teamspaceProjects.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean)
: parseZammadTeamspaceProjects(snapshot.teamspaceProject)
};
}
@@ -1977,7 +2077,10 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj
closeReviewRequired: project.close_review_required,
closedAt: project.closed_at,
defaultTeamspaceProject: project.default_teamspace_project,
defaultHourBudgetMinutes: project.default_hour_budget_minutes
defaultHourBudgetMinutes: project.default_hour_budget_minutes,
defaultBudgetSyncDisabled: project.default_budget_sync_disabled,
teamspaceProjects: project.teamspace_entries.map((entry) => entry.teamspace_project).filter(Boolean),
teamspaceEntries: project.teamspace_entries
}
: null
};
@@ -1994,16 +2097,36 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s
p.close_review_required,
p.closed_at,
pte.teamspace_project AS default_teamspace_project,
pte.hour_budget_minutes AS default_hour_budget_minutes
pte.hour_budget_minutes AS default_hour_budget_minutes,
COALESCE(pte.budget_sync_disabled, false) AS default_budget_sync_disabled,
COALESCE(entries.teamspace_entries, '[]'::json) AS teamspace_entries
FROM projects p
LEFT JOIN LATERAL (
SELECT teamspace_project, hour_budget_minutes
SELECT teamspace_project, hour_budget_minutes, budget_sync_disabled
FROM project_teamspace_entries
WHERE project_id = p.id
AND is_default
ORDER BY id ASC
LIMIT 1
) pte ON true
LEFT JOIN LATERAL (
SELECT json_agg(
json_build_object(
'id', ordered.id::text,
'teamspace_project', ordered.teamspace_project,
'is_default', ordered.is_default,
'hour_budget_minutes', ordered.hour_budget_minutes,
'budget_sync_disabled', ordered.budget_sync_disabled,
'display_order', ordered.display_order
)
) AS teamspace_entries
FROM (
SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order
FROM project_teamspace_entries
WHERE project_id = p.id
ORDER BY is_default DESC, display_order ASC, id ASC
) ordered
) entries ON true
WHERE p.ticket_id = $1
AND p.user_id = $2
LIMIT 1;
@@ -2029,6 +2152,93 @@ async function ticketHasAnyProject(client: QueryClient, ticketId: string) {
return Boolean(result.rows[0]?.exists);
}
function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) {
return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : [];
}
function formatTeamspaceProjectList(projects: Array<string | null | undefined>) {
return projects.map((project) => project?.trim()).filter(Boolean).join(" + ");
}
async function syncProjectTeamspaceEntriesFromZammad(
client: QueryClient,
projectId: string,
teamspaceProjects: string[],
budgetMinutes: number | null,
syncBudget: boolean
) {
if (teamspaceProjects.length === 0) {
return;
}
const existingResult = await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM project_teamspace_entries
WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC
FOR UPDATE;
`,
[projectId]
);
const savedIds: string[] = [];
await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [projectId]);
for (const [index, teamspaceProject] of teamspaceProjects.entries()) {
const existing = existingResult.rows[index];
const isDefault = index === 0;
if (existing) {
await client.query(
`
UPDATE project_teamspace_entries
SET teamspace_project = $1,
is_default = $2,
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END,
display_order = $5,
updated_at = now()
WHERE id = $6
AND project_id = $7;
`,
[teamspaceProject, isDefault, syncBudget && isDefault, budgetMinutes, index * 1024, existing.id, projectId]
);
savedIds.push(existing.id);
} else {
const inserted = await client.query<{ id: string }>(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, false, 0, $5)
RETURNING id::text AS id;
`,
[projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024]
);
savedIds.push(inserted.rows[0].id);
}
}
await client.query(
`
DELETE FROM project_teamspace_entries
WHERE project_id = $1
AND NOT (id = ANY($2::bigint[]));
`,
[projectId, savedIds]
);
await client.query(
`
UPDATE projects
SET teamspace_project = $2,
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
updated_at = now()
WHERE id = $1;
`,
[projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes]
);
}
async function planExistingZammadTicketSync(
client: QueryClient,
localTicket: LocalZammadSyncTicket,
@@ -2036,8 +2246,22 @@ async function planExistingZammadTicketSync(
userId: string,
fields: ZammadSyncExistingFields
): Promise<ZammadSyncItem | null> {
const changes: ZammadSyncChange[] = [];
addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId);
addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId);
if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) {
return null;
return changes.length > 0
? {
ticketNumber: localTicket.ticket_number,
action: "update",
reason: "Zammad-Metadaten werden aktualisiert.",
local: localSyncSnapshot(localTicket),
zammad: zammadSyncSnapshot(zammadTicket),
changes
}
: null;
}
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
@@ -2053,8 +2277,6 @@ async function planExistingZammadTicketSync(
};
}
const changes: ZammadSyncChange[] = [];
if (fields.title) {
addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title);
}
@@ -2090,6 +2312,11 @@ async function planExistingZammadTicketSync(
if (shouldManageConsultingProject) {
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId);
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const localTeamspaceProjects = project?.teamspace_entries?.length
? project.teamspace_entries.map((entry) => entry.teamspace_project)
: [project?.teamspace_project ?? null];
const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget);
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
return null;
@@ -2100,11 +2327,11 @@ async function planExistingZammadTicketSync(
}
if (fields.projectName) {
addSyncChange(changes, "Teamspace-Projekt", project?.teamspace_project ?? null, zammadTicket.teamspaceProject);
addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
}
if (fields.budget) {
addSyncChange(changes, "Teamspace-Budget", project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes);
if (canSyncBudget) {
addSyncChange(changes, "Teamspace-Budget", project?.default_hour_budget_minutes ?? project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes);
}
if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) {
@@ -2164,7 +2391,7 @@ async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTic
{ field: "Titel", from: null, to: zammadTicket.title },
{ field: "Organisation", from: null, to: organization.id },
{ field: "Typ", from: null, to: zammadTicket.workType },
{ field: "Projekt", from: null, to: zammadTicket.teamspaceProject ?? "" },
{ field: "Projekt", from: null, to: formatTeamspaceProjectList(zammadTeamspaceProjectList(zammadTicket)) },
{ field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes },
...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : [])
]
@@ -2220,6 +2447,7 @@ async function updateDefaultProjectTeamspaceEntry(
if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) {
params.push(updates.budgetMinutes ?? null);
setParts.push(`hour_budget_minutes = $${params.length}`);
setParts.push("budget_sync_disabled = false");
}
if (setParts.length === 0) {
@@ -2239,6 +2467,58 @@ async function updateDefaultProjectTeamspaceEntry(
);
}
async function applyZammadConsultingProjectSync(
client: QueryClient,
ticketId: string,
projectOwnerUserId: string,
zammadTicket: ZammadTicketImport,
fields: ZammadSyncExistingFields
) {
await ensureProjectForTicket(client, ticketId, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
teamspaceProjects: zammadTicket.teamspaceProjects,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
const project = await getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId);
if (!project) {
return;
}
const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget);
if (fields.projectName) {
await syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget);
} else if (syncBudget) {
await updateDefaultProjectTeamspaceEntry(client, project.id, {
budgetMinutes: zammadTicket.teamspaceBudgetMinutes
});
await client.query(
`
UPDATE projects
SET hour_budget_minutes = $1,
updated_at = now()
WHERE id = $2;
`,
[zammadTicket.teamspaceBudgetMinutes, project.id]
);
}
if (zammadTicket.zammadStateId === 4) {
await client.query(
`
UPDATE projects
SET close_review_required = true,
updated_at = now()
WHERE id = $1;
`,
[project.id]
);
}
}
async function applyZammadTicketSync(
client: QueryClient,
localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null },
@@ -2246,9 +2526,11 @@ async function applyZammadTicketSync(
userId: string,
fields: ZammadSyncExistingFields
) {
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4;
const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type);
const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
if ((fields.organization || !localTicket.work_type) && !organization) {
if (shouldResolveOrganization && !organization) {
return "organization-missing" as const;
}
@@ -2269,12 +2551,12 @@ async function applyZammadTicketSync(
`,
[
localTicket.id,
fields.title,
!metadataOnly && fields.title,
zammadTicket.title,
fields.organization,
!metadataOnly && fields.organization,
organization?.id ?? null,
organization?.name ?? null,
fields.type,
!metadataOnly && fields.type,
zammadTicket.workType,
zammadTicket.zammadTicketId,
zammadTicket.zammadGroupId,
@@ -2285,7 +2567,7 @@ async function applyZammadTicketSync(
]
);
if (fields.organization && organization) {
if (!metadataOnly && fields.organization && organization) {
await client.query(
`
UPDATE sessions
@@ -2297,7 +2579,7 @@ async function applyZammadTicketSync(
);
}
if (fields.type && zammadTicket.workType === "support") {
if (!metadataOnly && fields.type && zammadTicket.workType === "support") {
await deleteAllProjectDataForTicket(client, localTicket.id);
await client.query(
`
@@ -2314,11 +2596,7 @@ async function applyZammadTicketSync(
if (fields.type && zammadTicket.workType === "consulting") {
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields);
await client.query(
`
UPDATE sessions
@@ -2328,55 +2606,6 @@ async function applyZammadTicketSync(
[localTicket.id]
);
const projectUpdateParts: string[] = [];
const projectUpdateParams: unknown[] = [];
if (fields.projectName) {
projectUpdateParams.push(zammadTicket.teamspaceProject);
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
}
if (fields.budget) {
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
}
if (zammadTicket.zammadStateId === 4) {
projectUpdateParts.push("close_review_required = true");
}
if (projectUpdateParts.length > 0) {
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
await client.query(
`
UPDATE projects
SET ${projectUpdateParts.join(", ")},
updated_at = now()
WHERE ticket_id = $${projectUpdateParams.length - 1}
AND user_id = $${projectUpdateParams.length};
`,
projectUpdateParams
);
}
const projectResult = await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM projects
WHERE ticket_id = $1
AND user_id = $2
LIMIT 1;
`,
[localTicket.id, projectOwnerUserId]
);
if (projectResult.rows[0]) {
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
});
}
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
return zammadTicket.zammadStateId === 4 ? "consulting-close-review" as const : "consulting" as const;
}
@@ -2384,61 +2613,7 @@ async function applyZammadTicketSync(
if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") {
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
const projectUpdateParts: string[] = [];
const projectUpdateParams: unknown[] = [];
if (fields.projectName) {
projectUpdateParams.push(zammadTicket.teamspaceProject);
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
}
if (fields.budget) {
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
}
if (zammadTicket.zammadStateId === 4) {
projectUpdateParts.push("close_review_required = true");
}
if (projectUpdateParts.length > 0) {
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
await client.query(
`
UPDATE projects
SET ${projectUpdateParts.join(", ")},
updated_at = now()
WHERE ticket_id = $${projectUpdateParams.length - 1}
AND user_id = $${projectUpdateParams.length};
`,
projectUpdateParams
);
}
const projectResult = await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM projects
WHERE ticket_id = $1
AND user_id = $2
LIMIT 1;
`,
[localTicket.id, projectOwnerUserId]
);
if (projectResult.rows[0]) {
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
});
}
await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields);
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const;
}
@@ -3015,6 +3190,8 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
NULL::timestamptz AS closed_at,
pc.closed_at AS period_closed_at,
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count,
@@ -3150,6 +3327,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
NULL::timestamptz AS closed_at,
NULL::timestamptz AS period_closed_at,
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count,
@@ -3291,6 +3470,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
p.teamspace_project,
p.description,
p.goals,
@@ -3335,6 +3516,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'budget_sync_disabled', pte.budget_sync_disabled,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
@@ -3520,8 +3702,13 @@ app.post("/api/setup/restore/validate", async (req, res) => {
});
app.post("/api/auth/login", async (req, res) => {
const startedAt = Date.now();
const username = requireString(req.body.username, "username");
const password = requireString(req.body.password, "password");
logDebug("login", {
phase: "start",
username
});
const result = await query<{
id: string;
@@ -3550,13 +3737,35 @@ app.post("/api/auth/login", async (req, res) => {
);
const user = result.rows[0];
logDebug("login", {
phase: "user-loaded",
username,
found: Boolean(user),
userId: user?.id ?? null,
role: user?.role ?? null,
active: user?.active ?? null
});
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
const passwordValid = user && user.active ? await verifyPassword(password, user.password_hash) : false;
logDebug("login", {
phase: "password-checked",
username,
userId: user?.id ?? null,
ok: passwordValid
});
if (!user || !user.active || !passwordValid) {
res.status(401).json({ error: "Benutzername oder Passwort ist falsch" });
return;
}
await createAuthSession(res, user.id);
logDebug("login", {
phase: "session-created",
username,
userId: user.id,
durationMs: Date.now() - startedAt
});
res.json({
user: {
id: user.id,
@@ -4856,6 +5065,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
);
await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket?.teamspaceProject ?? null,
teamspaceProjects: zammadTicket?.teamspaceProjects ?? [],
hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null,
closeReviewRequired: zammadTicket?.zammadStateId === 4
});
@@ -4981,6 +5191,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'budget_sync_disabled', pte.budget_sync_disabled,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'display_order', pte.display_order
)
@@ -5005,18 +5216,18 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
throw badRequest("Zammad Organisation ist lokal nicht synchronisiert");
}
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const previewEntries =
zammadTicket.workType === "consulting" && (zammadTicket.teamspaceProject || zammadTicket.teamspaceBudgetMinutes !== null)
? [
{
id: `zammad-${zammadTicket.zammadTicketId}-default`,
teamspace_project: zammadTicket.teamspaceProject,
is_default: true,
hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
zammadTicket.workType === "consulting" && (zammadTeamspaceProjects.length > 0 || zammadTicket.teamspaceBudgetMinutes !== null)
? (zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects : [zammadTicket.teamspaceProject]).map((teamspaceProject, index) => ({
id: `zammad-${zammadTicket.zammadTicketId}-${index}`,
teamspace_project: teamspaceProject,
is_default: index === 0,
hour_budget_minutes: index === 0 ? zammadTicket.teamspaceBudgetMinutes : null,
budget_sync_disabled: false,
manual_consumption_minutes: 0,
display_order: 0
}
]
display_order: index * 1024
}))
: [];
res.json({
@@ -5035,7 +5246,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId),
project_id: null,
is_project: zammadTicket.workType === "consulting",
project_name: zammadTicket.teamspaceProject,
project_name: formatTeamspaceProjectList(zammadTeamspaceProjects),
project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
project_teamspace_entry_count: previewEntries.length,
project_teamspace_entries: previewEntries,
@@ -5655,6 +5866,8 @@ app.get("/api/projects", requireUser, async (req, res) => {
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
p.teamspace_project,
p.description,
p.goals,
@@ -5720,6 +5933,7 @@ app.get("/api/projects", requireUser, async (req, res) => {
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'budget_sync_disabled', pte.budget_sync_disabled,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
@@ -5812,6 +6026,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
p.teamspace_project,
p.description,
p.goals,
@@ -5856,6 +6072,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'budget_sync_disabled', pte.budget_sync_disabled,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
@@ -5932,15 +6149,29 @@ app.post("/api/projects", requireUser, async (req, res) => {
}
if (zammadTicket) {
teamspaceEntries = teamspaceEntries.map((entry) =>
entry.isDefault
? {
...entry,
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes
}
: entry
);
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
teamspaceEntries = zammadTeamspaceProjects.length > 0
? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({
id: null,
teamspaceProject: teamspaceProjectName,
isDefault: index === 0,
hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null,
budgetSyncDisabled: false,
manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0,
displayOrder: index * 1024
}))
: teamspaceEntries.map((entry) =>
entry.isDefault
? {
...entry,
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes,
budgetSyncDisabled: false
}
: entry
);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
}
@@ -6014,6 +6245,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
if (zammadTicket && projectOwnerUserId !== userId) {
await ensureProjectForTicket(client, ticketId, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
teamspaceProjects: zammadTicket.teamspaceProjects,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
@@ -6105,10 +6337,10 @@ app.post("/api/projects", requireUser, async (req, res) => {
for (const entry of teamspaceEntries) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6, $7);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder]
);
}
await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id);
@@ -6118,6 +6350,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
budget_sync_disabled: entry.budgetSyncDisabled,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? 0 : 0,
consumption_minutes: entry.manualConsumptionMinutes,
@@ -6294,14 +6527,15 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
SET teamspace_project = $1,
is_default = $2,
hour_budget_minutes = $3,
manual_consumption_minutes = $4,
display_order = $5,
budget_sync_disabled = $4,
manual_consumption_minutes = $5,
display_order = $6,
updated_at = now()
WHERE id = $6
AND project_id = $7
WHERE id = $7
AND project_id = $8
RETURNING id::text AS id;
`,
[entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id]
[entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id]
);
if (updatedEntry.rowCount === 0) {
@@ -6312,11 +6546,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
} else {
const insertedEntry = await client.query<{ id: string }>(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id::text AS id;
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder]
);
savedEntryIds.push(insertedEntry.rows[0].id);
@@ -6338,6 +6572,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
budget_sync_disabled: entry.budgetSyncDisabled,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0,
consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes,