Update
This commit is contained in:
1 parent
35cb578a96
commit
d5952d82bb
11 files changed
+1081
-119
No files matched your search
+17
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user