This commit is contained in:
michael committed 2026-09-15 11:49:47 +00:00
1 parent 235c19e51b
commit 227644cfeb
8 files changed
+1041 -309

No files matched your search

+193 -59
View File
@@ -1097,6 +1097,32 @@ function parseProjectTeamspaceEntries(
return entries;
}
type ParsedProjectTeamspaceEntry = ReturnType<typeof parseProjectTeamspaceEntries>[number];
function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) {
return (left ?? null) !== (right ?? null);
}
function protectNewManualBudgetEntries(entries: ParsedProjectTeamspaceEntry[]) {
return entries.map((entry) => entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
}
function protectChangedManualBudgetEntries(
entries: ParsedProjectTeamspaceEntry[],
currentEntries: Array<{ id: string; hour_budget_minutes: number | null }>
) {
const currentById = new Map(currentEntries.map((entry) => [entry.id, entry.hour_budget_minutes]));
return entries.map((entry) => {
const currentBudget = entry.id ? currentById.get(String(entry.id)) : undefined;
const manuallyChanged = entry.id
? currentBudget !== undefined && budgetMinutesChanged(currentBudget, entry.hourBudgetMinutes)
: entry.hourBudgetMinutes !== null;
return manuallyChanged ? { ...entry, budgetSyncDisabled: true } : entry;
});
}
function parseTodoPriority(value: unknown) {
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
throw badRequest("priority must be low, medium, high or urgent");
@@ -1994,8 +2020,11 @@ function syncComparableValueForField(field: string, value: unknown) {
return syncIdComparableValue(value);
}
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
return value.map((entry) => syncComparableValue(entry));
if (field === "Teamspace-Projekte") {
const entries = Array.isArray(value)
? value.map((entry) => (typeof entry === "string" ? entry : null))
: parseZammadTeamspaceProjects(value);
return normalizedTeamspaceProjectList(entries).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
}
return syncComparableValue(value);
@@ -2007,7 +2036,7 @@ function syncValueChanged(field: string, left: unknown, right: unknown) {
function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
if (syncValueChanged(field, from, to)) {
changes.push({ field, from: syncComparableValue(from), to: syncComparableValue(to) });
changes.push({ field, from: syncComparableValueForField(field, from), to: syncComparableValueForField(field, to) });
}
}
@@ -2068,7 +2097,11 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
};
}
function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null) {
function localSyncSnapshot(
localTicket: LocalZammadSyncTicket,
project: SyncProjectSnapshot | null = null,
options: { zammadPendingTodoExists?: boolean | null } = {}
) {
return {
ticketId: localTicket.id,
ticketNumber: localTicket.ticket_number,
@@ -2081,6 +2114,7 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj
zammadPriorityId: localTicket.zammad_priority_id,
zammadStateId: localTicket.zammad_state_id,
zammadOwnerId: localTicket.zammad_owner_id,
zammadPendingTodoExists: options.zammadPendingTodoExists ?? null,
project: project
? {
id: project.id,
@@ -2169,8 +2203,60 @@ function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) {
return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : [];
}
function normalizedTeamspaceProjectList(projects: Array<string | null | undefined>) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const project of projects) {
const value = project?.trim();
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
function formatTeamspaceProjectList(projects: Array<string | null | undefined>) {
return projects.map((project) => project?.trim()).filter(Boolean).join(" + ");
return normalizedTeamspaceProjectList(projects).join(" + ");
}
function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
const local = normalizedTeamspaceProjectList(localProjects);
const zammad = normalizedTeamspaceProjectList(zammadProjects);
const localSet = new Set(local);
const zammadSet = new Set(zammad);
return {
local,
zammad,
additions: zammad.filter((project) => !localSet.has(project)),
missingInZammad: local.filter((project) => !zammadSet.has(project))
};
}
function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
const diff = teamspaceProjectDiff(localProjects, zammadProjects);
if (diff.additions.length > 0) {
changes.push({
field: "Teamspace-Projekte",
from: diff.local,
to: [...diff.local, ...diff.additions]
});
}
if (diff.missingInZammad.length > 0) {
changes.push({
field: "Teamspace-Prüfung",
from: diff.missingInZammad,
to: "nicht mehr aus Zammad; bitte manuell prüfen"
});
}
}
function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) {
@@ -2224,7 +2310,16 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us
await client.query(
`
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order)
VALUES ($1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2));
SELECT $1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)
WHERE NOT EXISTS (
SELECT 1
FROM ticket_todos
WHERE ticket_id = $1
AND user_id = $2
AND title = $3
AND starts_on = $5::date
AND ends_on = $5::date
);
`,
[ticketId, userId, payload.title, payload.description, payload.day]
);
@@ -2237,13 +2332,15 @@ async function syncProjectTeamspaceEntriesFromZammad(
budgetMinutes: number | null,
syncBudget: boolean
) {
if (teamspaceProjects.length === 0) {
const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects);
if (zammadProjects.length === 0) {
return;
}
const existingResult = await client.query<{ id: string }>(
const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>(
`
SELECT id::text AS id
SELECT id::text AS id, teamspace_project, is_default, display_order
FROM project_teamspace_entries
WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC
@@ -2251,51 +2348,69 @@ async function syncProjectTeamspaceEntriesFromZammad(
`,
[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) {
if (existingResult.rows.length === 0) {
for (const [index, teamspaceProject] of zammadProjects.entries()) {
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;
VALUES ($1, $2, $3, $4, false, 0, $5);
`,
[projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024]
[projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024]
);
savedIds.push(inserted.rows[0].id);
}
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, zammadProjects[0] ?? null, syncBudget, budgetMinutes]
);
return;
}
await client.query(
`
DELETE FROM project_teamspace_entries
WHERE project_id = $1
AND NOT (id = ANY($2::bigint[]));
`,
[projectId, savedIds]
);
let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0];
if (!defaultEntry.is_default) {
await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]);
defaultEntry = { ...defaultEntry, is_default: true };
}
const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project)));
let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024;
for (const teamspaceProject of zammadProjects) {
if (existingTeamspaceProjects.has(teamspaceProject)) {
continue;
}
await client.query(
`
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, false, NULL, false, 0, $3);
`,
[projectId, teamspaceProject, nextDisplayOrder]
);
nextDisplayOrder += 1024;
}
if (syncBudget) {
await client.query(
`
UPDATE project_teamspace_entries
SET hour_budget_minutes = $1,
budget_sync_disabled = false,
updated_at = now()
WHERE id = $2
AND project_id = $3;
`,
[budgetMinutes, defaultEntry.id, projectId]
);
}
await client.query(
`
@@ -2305,7 +2420,7 @@ async function syncProjectTeamspaceEntriesFromZammad(
updated_at = now()
WHERE id = $1;
`,
[projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes]
[projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes]
);
}
@@ -2317,12 +2432,13 @@ async function planExistingZammadTicketSync(
fields: ZammadSyncExistingFields
): Promise<ZammadSyncItem | null> {
const changes: ZammadSyncChange[] = [];
const pendingTodo = zammadPendingTodoPayload(zammadTicket);
const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null;
addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId);
addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId);
const pendingTodo = zammadPendingTodoPayload(zammadTicket);
if (fields.pendingTodo && pendingTodo && !(await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket))) {
if (fields.pendingTodo && pendingTodo && !pendingTodoExists) {
changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` });
}
@@ -2332,7 +2448,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number,
action: "update",
reason: "Zammad-Metadaten werden aktualisiert.",
local: localSyncSnapshot(localTicket),
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket),
changes
}
@@ -2346,7 +2462,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number,
action: "skip",
reason: "Zammad-Organisation ist lokal nicht synchronisiert.",
local: localSyncSnapshot(localTicket),
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket),
changes: []
};
@@ -2374,7 +2490,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip",
reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket),
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket),
changes
};
@@ -2390,7 +2506,7 @@ async function planExistingZammadTicketSync(
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const localTeamspaceProjects = project?.teamspace_entries?.length
? project.teamspace_entries.map((entry) => entry.teamspace_project)
: [project?.teamspace_project ?? null];
: project?.teamspace_project ? [project.teamspace_project] : [];
const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget);
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
@@ -2402,7 +2518,7 @@ async function planExistingZammadTicketSync(
}
if (fields.projectName) {
addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects);
}
if (canSyncBudget) {
@@ -2424,7 +2540,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip",
reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket, project),
local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket),
changes
};
@@ -6230,6 +6346,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
if (zammadTicket) {
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null
&& budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes);
teamspaceEntries = zammadTeamspaceProjects.length > 0
? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({
@@ -6237,7 +6355,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
teamspaceProject: teamspaceProjectName,
isDefault: index === 0,
hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null,
budgetSyncDisabled: false,
budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged),
manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0,
displayOrder: index * 1024
}))
@@ -6247,10 +6365,14 @@ app.post("/api/projects", requireUser, async (req, res) => {
...entry,
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes,
budgetSyncDisabled: false
budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes))
}
: entry
);
)
.map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
} else {
teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
}
@@ -6474,8 +6596,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
@@ -6498,6 +6620,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
return null;
}
const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>(
`
SELECT id::text AS id, hour_budget_minutes
FROM project_teamspace_entries
WHERE project_id = $1
FOR UPDATE;
`,
[projectId]
);
teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
const organization = await getOrganizationByZammadId(client, organizationId);
if (!organization) {