This commit is contained in:
michael committed 2026-09-30 16:59:14 +02:00
1 parent 35cb578a96
commit d5952d82bb
11 files changed
+1081 -119

No files matched your search

+17 -2
View File
@@ -24,6 +24,11 @@ export type AuthUser = {
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
};
declare global {
@@ -91,7 +96,12 @@ function publicUser(user: AuthUser) {
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
};
}
@@ -155,7 +165,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
u.zammad_atoss_email_sync_enabled,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id,
u.project_followup_enabled,
u.project_followup_weeks,
u.project_followup_second_enabled,
u.project_followup_second_weeks,
u.project_followup_complete_on_session
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+542 -34
View File
@@ -457,6 +457,20 @@ function parseZammadArticleTypeId(value: unknown, fallback = 6) {
return typeId;
}
function parseProjectFollowupWeeks(value: unknown, fallback = 4) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const weeks = Number(value);
if (!Number.isInteger(weeks) || weeks < 1 || weeks > 52) {
throw badRequest("projectFollowupWeeks must be an integer between 1 and 52");
}
return weeks;
}
function optionalTicketSessionMonth(value: unknown) {
if (value === undefined || value === null || value === "" || value === "all") {
return null;
@@ -1653,7 +1667,7 @@ async function fetchZammadMergedTargetTicket(baseUrl: string, apiToken: string,
throw new Error(`Zammad-Merge-Ziel-ID für Ticket-ID ${sourceTicketId} wurde in ticket_history nicht gefunden (${zammadHistoryDebugSummary(history, sourceTicketId)})`);
}
const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId, false);
const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId);
const sourceOrganizationId = numberOrNull(rawTicket.organization_id);
const normalizedTargetTicket = targetTicket && !numberOrNull(targetTicket.organization_id) && sourceOrganizationId
? { ...targetTicket, organization_id: sourceOrganizationId }
@@ -1855,6 +1869,12 @@ async function createSessionForUser(input: SessionCreationInput) {
throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 });
}
const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber;
if (effectiveTicketNumber !== ticketNumber && await ticketHasRecurringBilling({ query }, effectiveTicketNumber, userId)) {
throw Object.assign(new Error("Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten."), { status: 409 });
}
if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) {
await query(
`
@@ -1928,7 +1948,7 @@ async function createSessionForUser(input: SessionCreationInput) {
LIMIT 1
FOR UPDATE OF t;
`,
[ticketNumber, userId]
[effectiveTicketNumber, userId]
);
const existingTicket = existingTicketResult.rows[0] ?? null;
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId;
@@ -1960,7 +1980,7 @@ async function createSessionForUser(input: SessionCreationInput) {
END
RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id;
`,
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId]
[effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId]
);
const ticket = ticketResult.rows[0];
@@ -2077,7 +2097,7 @@ async function createSessionForUser(input: SessionCreationInput) {
userId,
sessionId: session.id,
ticketId: ticket.id,
ticketNumber,
ticketNumber: effectiveTicketNumber,
zammadTicketId: zammadArticleTicketId,
activity,
articleBody: zammadArticleBody,
@@ -2092,6 +2112,7 @@ async function createSessionForUser(input: SessionCreationInput) {
if (workType === "consulting") {
await reopenProjectsForTicket(client, ticket.id);
await showProjectsForUserTicket(client, ticket.id, userId);
await completeAutomaticProjectFollowupsForSession(client, userId, ticket.id, startedAt);
}
if (eventId) {
@@ -2592,7 +2613,7 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<Zam
}
}
const tickets: ZammadTicketImport[] = [];
const ticketsByZammadId = new Map<number, ZammadTicketImport>();
for (const [id, candidate] of candidates.entries()) {
const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, id) ?? candidate;
@@ -2600,11 +2621,15 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<Zam
const zammadTicket = mergedTargetTicket ?? zammadTicketImportFromRaw(rawTicket);
if (zammadTicket && zammadTicket.workType === "consulting" && zammadTicket.zammadOwnerId === Number(zammadUserId) && zammadTicket.zammadStateId !== 4) {
tickets.push(zammadTicket);
const existingTicket = ticketsByZammadId.get(zammadTicket.zammadTicketId);
if (!existingTicket || (!existingTicket.mergedFromTicketId && zammadTicket.mergedFromTicketId)) {
ticketsByZammadId.set(zammadTicket.zammadTicketId, zammadTicket);
}
}
}
return tickets;
return Array.from(ticketsByZammadId.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true }));
}
async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) {
@@ -2663,11 +2688,20 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
zammad_priority_id,
zammad_state_id,
zammad_owner_id,
zammad_merged_from_ticket_id,
zammad_merged_from_ticket_number,
zammad_preferences
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
ON CONFLICT (ticket_number) DO UPDATE SET
ticket_number = tickets.ticket_number
zammad_ticket_id = COALESCE(tickets.zammad_ticket_id, EXCLUDED.zammad_ticket_id),
zammad_group_id = EXCLUDED.zammad_group_id,
zammad_priority_id = EXCLUDED.zammad_priority_id,
zammad_state_id = EXCLUDED.zammad_state_id,
zammad_owner_id = EXCLUDED.zammad_owner_id,
zammad_merged_from_ticket_id = COALESCE(EXCLUDED.zammad_merged_from_ticket_id, tickets.zammad_merged_from_ticket_id),
zammad_merged_from_ticket_number = COALESCE(EXCLUDED.zammad_merged_from_ticket_number, tickets.zammad_merged_from_ticket_number),
zammad_preferences = EXCLUDED.zammad_preferences
RETURNING id::text;
`,
[
@@ -2681,6 +2715,8 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
zammadTicket.zammadPriorityId,
zammadTicket.zammadStateId,
zammadTicket.zammadOwnerId,
zammadTicket.mergedFromTicketId,
zammadTicket.mergedFromTicketNumber,
JSON.stringify(zammadTicket.preferences)
]
);
@@ -4225,6 +4261,36 @@ function isBlankTeamspaceProject(value: string | null | undefined) {
return !value?.trim();
}
type ProjectTeamspaceEntrySyncRow = {
id: string;
teamspace_project: string | null;
teamspace_description: string | null;
is_default: boolean;
display_order: number;
budget_sync_disabled: boolean;
};
async function loadProjectTeamspaceEntriesForUpdate(client: QueryClient, projectId: string) {
const result = await client.query<ProjectTeamspaceEntrySyncRow>(
`
SELECT
id::text AS id,
teamspace_project,
teamspace_description,
is_default,
display_order,
budget_sync_disabled
FROM project_teamspace_entries
WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC
FOR UPDATE;
`,
[projectId]
);
return result.rows;
}
function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
const local = normalizedTeamspaceProjectList(localProjects);
const zammad = normalizedTeamspaceProjectList(zammadProjects);
@@ -4426,7 +4492,8 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
SELECT
COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes,
COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes,
BOOL_OR(budget_sync_disabled) AS budget_sync_disabled
BOOL_OR(budget_sync_disabled) AS budget_sync_disabled,
NULLIF(string_agg(DISTINCT NULLIF(teamspace_description, ''), E'\n\n'), '') AS teamspace_description
FROM project_teamspace_entries
WHERE id = ANY($1::bigint[])
)
@@ -4434,6 +4501,10 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
SET hour_budget_minutes = COALESCE(target.hour_budget_minutes, source_totals.hour_budget_minutes),
budget_sync_disabled = target.budget_sync_disabled OR COALESCE(source_totals.budget_sync_disabled, false),
manual_consumption_minutes = target.manual_consumption_minutes + source_totals.manual_consumption_minutes,
teamspace_description = CASE
WHEN NULLIF(target.teamspace_description, '') IS NULL THEN COALESCE(source_totals.teamspace_description, target.teamspace_description)
ELSE target.teamspace_description
END,
updated_at = now()
FROM source_totals
WHERE target.id = $2::bigint;
@@ -4481,6 +4552,37 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
);
}
async function dedupeProjectTeamspaceEntries(client: QueryClient, projectId: string) {
const entries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
const groups = new Map<string, ProjectTeamspaceEntrySyncRow[]>();
for (const entry of entries) {
const teamspaceProject = entry.teamspace_project?.trim();
if (!teamspaceProject) {
continue;
}
groups.set(teamspaceProject, [...(groups.get(teamspaceProject) ?? []), entry]);
}
for (const groupEntries of groups.values()) {
if (groupEntries.length < 2) {
continue;
}
const [targetEntry, ...sourceEntries] = [...groupEntries].sort((left, right) =>
Number(right.is_default) - Number(left.is_default) ||
Number(Boolean(right.teamspace_description?.trim())) - Number(Boolean(left.teamspace_description?.trim())) ||
Number(right.budget_sync_disabled) - Number(left.budget_sync_disabled) ||
Number(left.display_order ?? 0) - Number(right.display_order ?? 0) ||
Number(left.id) - Number(right.id)
);
await mergeTeamspaceEntriesIntoTarget(client, sourceEntries.map((entry) => entry.id), targetEntry.id);
}
}
async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) {
await client.query(
`
@@ -4541,18 +4643,9 @@ async function syncProjectTeamspaceEntriesFromZammad(
return;
}
const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>(
`
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
FOR UPDATE;
`,
[projectId]
);
let existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
if (existingResult.rows.length === 0) {
if (existingEntries.length === 0) {
for (const [index, teamspaceProject] of zammadProjects.entries()) {
await client.query(
`
@@ -4576,7 +4669,6 @@ async function syncProjectTeamspaceEntriesFromZammad(
return;
}
let existingEntries = existingResult.rows;
let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0];
if (!defaultEntry.is_default) {
@@ -4624,6 +4716,10 @@ async function syncProjectTeamspaceEntriesFromZammad(
}
}
await dedupeProjectTeamspaceEntries(client, projectId);
existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0];
const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingEntries.map((entry) => entry.teamspace_project)));
let nextDisplayOrder = Math.max(...existingEntries.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024;
@@ -5813,6 +5909,7 @@ async function mergeProjectRowsIntoTicket(client: QueryClient, sourceTicketId: s
);
await client.query("DELETE FROM projects WHERE id = $1;", [sourceProject.id]);
await dedupeProjectTeamspaceEntries(client, targetProject.id);
}
await client.query(
@@ -6852,6 +6949,354 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio
};
}
async function getProjectFollowups(userId: string) {
return withTransaction(async (client) => {
const userResult = await client.query<{
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
}>("SELECT project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks FROM users WHERE id = $1;", [userId]);
const enabled = Boolean(userResult.rows[0]?.project_followup_enabled ?? true);
const weeks = Number(userResult.rows[0]?.project_followup_weeks ?? 4);
const secondEnabled = Boolean(userResult.rows[0]?.project_followup_second_enabled ?? true);
const secondWeeks = Number(userResult.rows[0]?.project_followup_second_weeks ?? 2);
if (!enabled) {
return {
enabled,
weeks,
secondEnabled,
secondWeeks,
createdTodos: 0,
updatedTodos: 0,
projects: []
};
}
async function loadRows() {
return client.query<{
project_id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
last_session_at: string | null;
project_updated_at: string;
open_followup_todo_id: string | null;
existing_followup_todo_id: string | null;
existing_followup_todo_status: "open" | "done" | null;
reminder_id: string | null;
followup_state: "open" | "done" | "dismissed" | null;
followup_stage: number;
followup_title: string;
open_todos: number;
overdue_todos: number;
followup_due_on: string;
}>(
`
WITH followup_projects AS (
SELECT
p.id,
p.ticket_id,
p.updated_at AS project_updated_at,
GREATEST(
p.updated_at,
COALESCE(entry_updates.latest_entry_updated_at, '-infinity'::timestamptz),
COALESCE(session_updates.latest_session_created_at, '-infinity'::timestamptz),
COALESCE(todo_updates.latest_todo_updated_at, '-infinity'::timestamptz),
COALESCE(day_billing_updates.latest_day_billing_updated_at, '-infinity'::timestamptz)
) AS latest_tt_update,
session_updates.latest_session_at,
session_updates.last_session_at,
entry_totals.total_hour_budget_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + COALESCE(entry_totals.total_manual_consumption_minutes, 0))::int AS consumption_minutes
FROM projects p
JOIN tickets ft ON ft.id = p.ticket_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
LEFT JOIN LATERAL (
SELECT MAX(pte.updated_at) AS latest_entry_updated_at
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_updates ON true
LEFT JOIN LATERAL (
SELECT
MAX(s.created_at) AS latest_session_created_at,
MAX(s.started_at) AS latest_session_at,
MAX(s.started_at)::text AS last_session_at
FROM sessions s
WHERE s.ticket_id = p.ticket_id
AND s.user_id = $1
) session_updates ON true
LEFT JOIN LATERAL (
SELECT MAX(tt.updated_at) AS latest_todo_updated_at
FROM ticket_todos tt
WHERE tt.ticket_id = p.ticket_id
AND tt.user_id = $1
AND tt.title <> 'Wiedervorlage ' || ft.ticket_number
AND NOT EXISTS (
SELECT 1
FROM project_followup_reminders pfr
WHERE pfr.todo_id = tt.id
AND pfr.user_id = $1
)
) todo_updates ON true
LEFT JOIN LATERAL (
SELECT MAX(tdb.updated_at) AS latest_day_billing_updated_at
FROM ticket_day_billings tdb
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = $1
) day_billing_updates ON true
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes
FROM ticket_day_billings tdb
WHERE tdb.ticket_id = p.ticket_id
) teamspace_totals ON true
WHERE p.closed_at IS NULL
AND COALESCE(ft.work_type, 'consulting') = 'consulting'
AND (
p.user_id = $1
OR EXISTS (
SELECT 1
FROM sessions viewer_sessions
WHERE viewer_sessions.ticket_id = p.ticket_id
AND viewer_sessions.user_id = $1
)
)
AND NOT EXISTS (
SELECT 1
FROM hidden_user_projects hup
WHERE hup.user_id = $1
AND hup.project_id = p.id
)
)
SELECT
fp.id::text AS project_id,
t.id::text AS ticket_id,
t.ticket_number,
t.title,
COALESCE(o.name, t.customer_name) AS organization_name,
p.teamspace_project,
fp.total_hour_budget_minutes AS hour_budget_minutes,
fp.consumption_minutes,
CASE
WHEN fp.total_hour_budget_minutes IS NULL THEN NULL
ELSE (fp.total_hour_budget_minutes - fp.consumption_minutes)::int
END AS remaining_minutes,
fp.latest_tt_update::text AS latest_tt_update,
fp.last_session_at,
fp.project_updated_at::text AS project_updated_at,
CASE WHEN stage1_reminder.id IS NULL THEN 1 ELSE 2 END AS followup_stage,
CASE
WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number
ELSE '2. Wiedervorlage ' || t.ticket_number
END AS followup_title,
existing_followup_todo.id::text AS existing_followup_todo_id,
existing_followup_todo.status AS existing_followup_todo_status,
CASE WHEN existing_followup_todo.status = 'open' THEN existing_followup_todo.id::text ELSE NULL END AS open_followup_todo_id,
CASE WHEN stage1_reminder.id IS NULL THEN NULL ELSE stage2_reminder.id::text END AS reminder_id,
CASE
WHEN stage1_reminder.id IS NULL THEN NULL
WHEN stage2_reminder.id IS NULL THEN NULL
WHEN stage2_reminder.todo_id IS NULL THEN 'dismissed'
WHEN stage2_todo.status = 'done' THEN 'done'
ELSE 'open'
END AS followup_state,
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos,
CASE
WHEN stage1_reminder.id IS NULL THEN ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date::text
ELSE (stage1_reminder.due_on + ($3::int * interval '1 week'))::date::text
END AS followup_due_on
FROM followup_projects fp
JOIN projects p ON p.id = fp.id
JOIN tickets t ON t.id = fp.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN project_followup_reminders stage1_reminder
ON stage1_reminder.user_id = $1
AND stage1_reminder.project_id = fp.id
AND stage1_reminder.latest_tt_update_at = fp.latest_tt_update
AND stage1_reminder.stage = 1
LEFT JOIN ticket_todos stage1_todo ON stage1_todo.id = stage1_reminder.todo_id
LEFT JOIN project_followup_reminders stage2_reminder
ON stage2_reminder.user_id = $1
AND stage2_reminder.project_id = fp.id
AND stage2_reminder.latest_tt_update_at = fp.latest_tt_update
AND stage2_reminder.stage = 2
LEFT JOIN ticket_todos stage2_todo ON stage2_todo.id = stage2_reminder.todo_id
LEFT JOIN LATERAL (
SELECT id, status
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
AND tt.user_id = $1
AND tt.title = CASE
WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number
ELSE '2. Wiedervorlage ' || t.ticket_number
END
ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, tt.id ASC
LIMIT 1
) existing_followup_todo ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
AND tt.user_id = $1
) todo_totals ON true
WHERE (
stage1_reminder.id IS NULL
AND ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date
)
OR (
$4::boolean
AND
stage1_reminder.id IS NOT NULL
AND stage2_reminder.id IS NULL
AND (stage1_reminder.todo_id IS NULL OR stage1_todo.status = 'done')
AND NOT EXISTS (
SELECT 1
FROM sessions reset_session
WHERE reset_session.ticket_id = fp.ticket_id
AND reset_session.user_id = $1
AND (reset_session.started_at AT TIME ZONE 'Europe/Berlin')::date >= stage1_reminder.due_on
)
AND (stage1_reminder.due_on + ($3::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date
)
ORDER BY fp.latest_tt_update ASC, t.ticket_number ASC;
`,
[userId, weeks, secondWeeks, secondEnabled]
);
}
const firstResult = await loadRows();
let createdTodos = 0;
let registeredTodos = 0;
for (const row of firstResult.rows) {
const periodDescription = row.followup_stage === 1
? `seit ${weeks} Woche(n) keine TT-Aktualisierung`
: `zweite Wiedervorlage nach weiteren ${secondWeeks} Woche(n) ohne neue Session`;
const description = `Automatisch erstellt: Consulting-Projekt hatte ${periodDescription} (${row.hour_budget_minutes === null ? "ohne hinterlegtes Budget" : `mit ${Math.ceil(Number(row.remaining_minutes ?? 0) / 60)} h Budget-Rest`}).`;
if (row.reminder_id) {
continue;
}
if (row.existing_followup_todo_id) {
const reminderResult = await client.query(
`
INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::date)
ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO NOTHING;
`,
[userId, row.project_id, row.ticket_id, row.existing_followup_todo_id, row.followup_stage, row.latest_tt_update, row.followup_due_on]
);
registeredTodos += reminderResult.rowCount ?? 0;
continue;
}
const insertResult = await client.query<{ id: string }>(
`
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order)
SELECT
$1,
$2,
$3,
$4,
'medium',
$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 status = 'open'
AND title = $3
)
RETURNING id::text;
`,
[
row.ticket_id,
userId,
row.followup_title,
description,
row.followup_due_on
]
);
createdTodos += insertResult.rowCount ?? 0;
const todoId = insertResult.rows[0]?.id ?? null;
await client.query(
`
INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on)
VALUES ($1, $2, $3, $4::bigint, $5, $6::timestamptz, $7::date)
ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO UPDATE
SET todo_id = COALESCE(project_followup_reminders.todo_id, EXCLUDED.todo_id),
due_on = EXCLUDED.due_on,
updated_at = now();
`,
[userId, row.project_id, row.ticket_id, todoId, row.followup_stage, row.latest_tt_update, row.followup_due_on]
);
}
const finalResult = createdTodos > 0 || registeredTodos > 0 ? await loadRows() : firstResult;
return {
enabled,
weeks,
secondEnabled,
secondWeeks,
createdTodos,
updatedTodos: 0,
projects: finalResult.rows
};
});
}
async function completeAutomaticProjectFollowupsForSession(client: QueryClient, userId: string, ticketId: string, completedAt: Date) {
const userResult = await client.query<{ project_followup_enabled: boolean; project_followup_complete_on_session: boolean }>(
"SELECT project_followup_enabled, project_followup_complete_on_session FROM users WHERE id = $1;",
[userId]
);
if (!Boolean(userResult.rows[0]?.project_followup_enabled ?? true) || !Boolean(userResult.rows[0]?.project_followup_complete_on_session ?? true)) {
return;
}
await client.query(
`
UPDATE ticket_todos tt
SET status = 'done',
completed_at = COALESCE(tt.completed_at, $3::timestamptz),
updated_at = now()
FROM project_followup_reminders pfr
WHERE pfr.todo_id = tt.id
AND pfr.user_id = $1
AND pfr.ticket_id = $2
AND tt.user_id = $1
AND tt.ticket_id = $2
AND tt.status = 'open';
`,
[userId, ticketId, completedAt.toISOString()]
);
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId);
@@ -7415,11 +7860,16 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session;
`,
[username, displayName, await hashPassword(password)]
);
@@ -7482,6 +7932,11 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}>(
`
SELECT
@@ -7499,7 +7954,12 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes,
zammad_atoss_email_sync_enabled,
COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id
COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id,
project_followup_enabled,
project_followup_weeks,
project_followup_second_enabled,
project_followup_second_weeks,
project_followup_complete_on_session
FROM users
WHERE lower(username) = lower($1);
`,
@@ -7551,7 +8011,12 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes,
zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled,
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
}
});
});
@@ -7775,6 +8240,21 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadArticleTypeId = user.role === "user"
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
: 6;
const projectFollowupEnabled = user.role === "user"
? optionalBoolean(req.body.projectFollowupEnabled, user.project_followup_enabled)
: true;
const projectFollowupWeeks = user.role === "user"
? parseProjectFollowupWeeks(req.body.projectFollowupWeeks, user.project_followup_weeks)
: 4;
const projectFollowupSecondEnabled = user.role === "user"
? optionalBoolean(req.body.projectFollowupSecondEnabled, user.project_followup_second_enabled)
: true;
const projectFollowupSecondWeeks = user.role === "user"
? parseProjectFollowupWeeks(req.body.projectFollowupSecondWeeks, user.project_followup_second_weeks)
: 2;
const projectFollowupCompleteOnSession = user.role === "user"
? optionalBoolean(req.body.projectFollowupCompleteOnSession, user.project_followup_complete_on_session)
: true;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -7809,9 +8289,14 @@ app.patch("/api/auth/me", async (req, res) => {
zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END,
zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END,
zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END,
project_followup_enabled = CASE WHEN $8::boolean THEN $16 ELSE project_followup_enabled END,
project_followup_weeks = CASE WHEN $8::boolean THEN $17 ELSE project_followup_weeks END,
project_followup_second_enabled = CASE WHEN $8::boolean THEN $18 ELSE project_followup_second_enabled END,
project_followup_second_weeks = CASE WHEN $8::boolean THEN $19 ELSE project_followup_second_weeks END,
project_followup_complete_on_session = CASE WHEN $8::boolean THEN $20 ELSE project_followup_complete_on_session END,
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session;
`,
[
username,
@@ -7828,7 +8313,12 @@ app.patch("/api/auth/me", async (req, res) => {
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadAtossEmailSyncEnabled,
zammadArticleTypeId
zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
]
);
@@ -7856,7 +8346,7 @@ app.patch("/api/auth/me", async (req, res) => {
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query(
`
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -7880,7 +8370,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -7941,7 +8431,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -9251,6 +9741,10 @@ app.get("/api/statistics/weeks/:week", requireUser, async (req, res) => {
res.json(await getStatisticsOverview(week, currentUser(req).id, "week"));
});
app.get("/api/statistics/project-followups", requireUser, async (req, res) => {
res.json(await getProjectFollowups(currentUser(req).id));
});
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const userId = currentUser(req).id;
@@ -9317,7 +9811,10 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) project_entries ON true
WHERE t.ticket_number = $1;
WHERE t.ticket_number = $1
OR t.zammad_merged_from_ticket_number = $1
ORDER BY CASE WHEN t.ticket_number = $1 THEN 0 ELSE 1 END, t.id ASC
LIMIT 1;
`;
let result = await query(lookupSql, [ticketNumber, userId]);
@@ -9325,6 +9822,15 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber);
if (zammadTicket) {
result = await query(lookupSql, [zammadTicket.ticketNumber, userId]);
if ((result.rowCount ?? 0) > 0) {
res.json({
ticket: result.rows[0]
});
return;
}
const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId);
if (!organization) {
@@ -10618,6 +11124,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
return;
}
const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber;
if (zammadTicket) {
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
@@ -10671,7 +11179,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
WHERE ticket_number = $1
FOR UPDATE;
`,
[ticketNumber]
[effectiveTicketNumber]
);
const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId;
@@ -10698,7 +11206,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
work_type = 'consulting'
RETURNING id;
`,
[ticketNumber, title, organization.id, organization.name]
[effectiveTicketNumber, title, organization.id, organization.name]
);
const ticketId = ticketResult.rows[0].id;
@@ -10796,7 +11304,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
endsOn,
defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes,
ticketNumber,
effectiveTicketNumber,
organization.id,
organization.name,
title,
+62
View File
@@ -93,6 +93,11 @@ export async function migrate() {
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
@@ -106,6 +111,16 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid,
ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52);
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid,
ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52);
`);
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -920,6 +935,53 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
await query(`
CREATE TABLE IF NOT EXISTS project_followup_reminders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL,
stage INTEGER NOT NULL DEFAULT 1,
latest_tt_update_at TIMESTAMPTZ NOT NULL,
due_on DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2))
);
`);
await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;");
await query(`
ALTER TABLE project_followup_reminders
DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid,
ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2));
`);
await query(`
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT c.conname
INTO constraint_name
FROM pg_constraint c
WHERE c.conrelid = 'project_followup_reminders'::regclass
AND c.contype = 'u'
AND c.conkey = ARRAY[
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at')
]::smallint[];
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(`