Update
This commit is contained in:
1 parent
b7f7d7e793
commit
c01d747352
14 files changed
+987
-294
No files matched your search
+405
-170
@@ -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,
|
||||
|
||||
@@ -503,6 +503,7 @@ export async function migrate() {
|
||||
teamspace_project TEXT,
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
hour_budget_minutes INTEGER,
|
||||
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
|
||||
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
@@ -512,6 +513,7 @@ export async function migrate() {
|
||||
);
|
||||
`);
|
||||
|
||||
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
|
||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
|
||||
await query(`
|
||||
|
||||
Reference in new issue
Block a user