This commit is contained in:
michael committed 2026-09-23 11:09:10 +00:00
1 parent 9be6c6e36a
commit fa6db04370
16 files changed
+1123 -118

No files matched your search

+447 -20
View File
@@ -226,6 +226,20 @@ function parseOptionalTeamspaceEntryId(value: unknown) {
return parsePositiveInteger(value, "projectTeamspaceEntryId");
}
function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const minutes = Number(value);
if (![5, 10, 15, 30].includes(minutes)) {
throw badRequest("zammadTimeUnitRoundingMinutes must be 5, 10, 15 or 30");
}
return minutes;
}
function optionalTicketSessionMonth(value: unknown) {
if (value === undefined || value === null || value === "" || value === "all") {
return null;
@@ -239,11 +253,11 @@ function optionalTicketSessionStatus(value: unknown) {
return "all" as const;
}
if (value === "open" || value === "billed" || value === "non_billable") {
if (value === "open" || value === "billable" || value === "billed" || value === "non_billable") {
return value;
}
throw badRequest("status must be all, open, billed or non_billable");
throw badRequest("status must be all, open, billable, billed or non_billable");
}
function optionalTicketSessionSource(value: unknown) {
@@ -560,6 +574,204 @@ async function fetchZammadJson(baseUrl: string, apiToken: string, path: string)
return response.json() as Promise<unknown>;
}
async function postZammadJson(baseUrl: string, apiToken: string, path: string, payload: Record<string, unknown>) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Token token=${apiToken}`,
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`Zammad Article konnte nicht erstellt werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 });
}
return response.json() as Promise<unknown>;
}
function zammadTimeUnit(durationSeconds: number, roundingEnabled: boolean, roundingMinutes: number) {
const minutes = durationSeconds / 60;
const roundedMinutes = roundingEnabled ? Math.ceil(minutes / roundingMinutes) * roundingMinutes : minutes;
return Number((roundedMinutes / 60).toFixed(2));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
async function zammadTimeUnitForUser(userId: string, durationSeconds: number) {
const userResult = await query<{
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
SELECT zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes
FROM users
WHERE id = $1;
`,
[userId]
);
const userSettings = userResult.rows[0] ?? {
zammad_time_unit_rounding_enabled: false,
zammad_time_unit_rounding_minutes: 15
};
return zammadTimeUnit(
durationSeconds,
Boolean(userSettings.zammad_time_unit_rounding_enabled),
Number(userSettings.zammad_time_unit_rounding_minutes ?? 15)
);
}
type ZammadSessionArticlePayload = {
ticket_id: number;
type: "note";
internal: true;
subject: string;
body: string;
content_type: "text/plain";
time_unit: number;
};
function zammadSessionArticlePayload(input: {
zammadTicketId: string | number;
activity: string;
timeUnit: number;
}): ZammadSessionArticlePayload {
const ticketId = Number(input.zammadTicketId);
if (!Number.isInteger(ticketId) || ticketId < 1) {
throw badRequest("Ticket hat keine gültige Zammad-Ticket-ID");
}
return {
ticket_id: ticketId,
type: "note",
internal: true,
subject: "TicketTracker Session",
body: input.activity,
content_type: "text/plain",
time_unit: input.timeUnit
};
}
async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) {
const credentials = await getZammadCredentialsForUser(userId);
return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload);
}
async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
userId: string;
sessionId: string | number;
ticketId: string | number;
ticketNumber: string;
zammadTicketId: string | number | null;
activity: string;
timeUnit: number;
error: string;
}) {
await client.query(
`
INSERT INTO zammad_session_article_queue (
user_id,
session_id,
ticket_id,
ticket_number,
zammad_ticket_id,
activity,
time_unit,
last_error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (session_id) WHERE status = 'pending'
DO UPDATE SET
zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id),
activity = EXCLUDED.activity,
time_unit = EXCLUDED.time_unit,
last_error = EXCLUDED.last_error,
updated_at = now();
`,
[
input.userId,
input.sessionId,
input.ticketId,
input.ticketNumber,
input.zammadTicketId,
input.activity,
input.timeUnit,
input.error.slice(0, 1000)
]
);
}
function zammadArticleId(payload: unknown) {
return isPlainObject(payload) ? numberOrNull(payload.id) : null;
}
type ZammadSessionArticleQueueRow = {
id: string;
user_id: string;
session_id: string;
ticket_id: string;
ticket_number: string;
zammad_ticket_id: string | null;
activity: string;
time_unit: string | number;
};
async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) {
let zammadTicketId = row.zammad_ticket_id;
if (!zammadTicketId) {
const zammadTicket = await fetchZammadTicketByNumber(row.user_id, row.ticket_number);
if (!zammadTicket) {
throw new Error("Ticket wurde in Zammad nicht gefunden");
}
await withTransaction(async (client) => {
await importZammadTicketForUser(client, zammadTicket, row.user_id);
});
zammadTicketId = String(zammadTicket.zammadTicketId);
await query(
`
UPDATE zammad_session_article_queue
SET zammad_ticket_id = $1, updated_at = now()
WHERE id = $2;
`,
[zammadTicketId, row.id]
);
}
const payload = zammadSessionArticlePayload({
zammadTicketId,
activity: row.activity,
timeUnit: Number(row.time_unit)
});
const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload);
const articleId = zammadArticleId(zammadResponse);
await query(
`
UPDATE zammad_session_article_queue
SET status = 'sent',
sent_at = now(),
zammad_article_id = $1,
last_error = NULL,
last_attempt_at = now(),
updated_at = now()
WHERE id = $2;
`,
[articleId, row.id]
);
return articleId;
}
function zammadTicketCandidates(payload: unknown): Record<string, unknown>[] {
if (Array.isArray(payload)) {
return payload.filter(isPlainObject);
@@ -799,6 +1011,8 @@ type SessionCreationInput = {
requestedWorkType: "support" | "consulting" | null;
useZammadLookup: boolean;
requestedProjectTeamspaceEntryId: number | null;
billingStatus: "billable" | "billed" | "non_billable" | null;
createZammadArticle: boolean;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
@@ -818,6 +1032,8 @@ async function createSessionForUser(input: SessionCreationInput) {
requestedWorkType,
useZammadLookup,
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
startedAt,
endedAt,
durationSeconds,
@@ -947,7 +1163,7 @@ async function createSessionForUser(input: SessionCreationInput) {
throw badRequest("organizationId must reference an existing organization");
}
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null; zammad_ticket_id: string | null }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, $4)
@@ -962,7 +1178,7 @@ async function createSessionForUser(input: SessionCreationInput) {
) THEN 'consulting'
ELSE COALESCE(tickets.work_type, EXCLUDED.work_type)
END
RETURNING id, ticket_number, organization_id, customer_name, work_type;
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]
);
@@ -1011,9 +1227,11 @@ async function createSessionForUser(input: SessionCreationInput) {
ended_at,
duration_seconds,
rounded_minutes,
project_teamspace_entry_id
project_teamspace_entry_id,
billing_status,
billing_updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CASE WHEN $12::text IS NULL THEN NULL ELSE now() END)
RETURNING *;
`,
[
@@ -1027,9 +1245,60 @@ async function createSessionForUser(input: SessionCreationInput) {
endedAt.toISOString(),
durationSeconds,
roundedMinutes,
projectTeamspaceEntryId
projectTeamspaceEntryId,
billingStatus
]
);
const session = sessionResult.rows[0];
if (createZammadArticle) {
const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds);
let zammadArticleTicketId = ticket.zammad_ticket_id;
let zammadArticleError: string | null = null;
if (!zammadArticleTicketId && useZammadLookup) {
try {
const articleZammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber);
if (articleZammadTicket) {
await importZammadTicketForUser(client, articleZammadTicket, userId);
zammadArticleTicketId = String(articleZammadTicket.zammadTicketId);
} else {
zammadArticleError = "Ticket wurde in Zammad nicht gefunden";
}
} catch (error) {
zammadArticleError = errorMessage(error);
}
}
if (!zammadArticleError && zammadArticleTicketId) {
try {
const payload = zammadSessionArticlePayload({
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit
});
await postZammadSessionArticleForUser(userId, payload);
} catch (error) {
zammadArticleError = errorMessage(error);
}
} else if (!zammadArticleError) {
zammadArticleError = "Ticket hat keine Zammad-Ticket-ID";
}
if (zammadArticleError) {
await enqueueZammadSessionArticle(client, {
userId,
sessionId: session.id,
ticketId: ticket.id,
ticketNumber,
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
error: zammadArticleError
});
}
}
await reopenPeriodsForSession(client, ticket.id, startedAt, userId);
if (workType === "consulting") {
@@ -1044,13 +1313,13 @@ async function createSessionForUser(input: SessionCreationInput) {
SET session_id = $1
WHERE id = $2;
`,
[sessionResult.rows[0].id, eventId]
[session.id, eventId]
);
}
return {
ticket,
session: sessionResult.rows[0]
session
};
});
}
@@ -1404,6 +1673,8 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
requestedWorkType: null,
useZammadLookup: true,
requestedProjectTeamspaceEntryId: null,
billingStatus: null,
createZammadArticle: false,
startedAt,
endedAt,
durationSeconds,
@@ -3517,9 +3788,11 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
ticket_crm.crm_billed_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
false AS requires_closure,
@@ -3634,6 +3907,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
sessions: totalSessions,
minutes: totalMinutes,
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0,
billableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.billable_minutes, 0),
nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0),
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
openSessions: openSessions.length
@@ -3714,9 +3988,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS session_count,
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
@@ -3756,9 +4032,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
@@ -3780,9 +4058,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(day_sessions.session_count, 0)::int AS sessions,
td.tracked_minutes::int AS total_minutes,
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.billable_minutes, 0)::int AS billable_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
COALESCE(day_sessions.billable_sessions, 0)::int AS billable_sessions,
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3796,16 +4076,18 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
day,
SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(billable_minutes, 0))::int AS billable_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
SUM(billed_sessions)::int AS billed_sessions,
SUM(billable_sessions)::int AS billable_sessions,
SUM(non_billable_sessions)::int AS non_billable_sessions,
SUM(open_count)::int AS open_sessions
FROM session_group
GROUP BY day
) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.billable_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.billable_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
ORDER BY td.day ASC;
`,
[period.startIso, period.endIso, userId]
@@ -3820,9 +4102,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3841,9 +4125,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3865,9 +4151,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
@@ -3996,9 +4284,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sessions: 0,
minutes: 0,
billed_minutes: 0,
billable_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
billed_sessions: 0,
billable_sessions: 0,
non_billable_sessions: 0,
open_sessions: 0,
recurring_minutes: 0,
@@ -4020,9 +4310,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sessions: totals.sessions,
minutes: totals.minutes,
billedMinutes: totals.billed_minutes,
billableMinutes: totals.billable_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
billedSessions: totals.billed_sessions,
billableSessions: totals.billable_sessions,
nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
@@ -4196,7 +4488,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
};
}
async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) {
async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billable" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) {
if (filters.month) {
await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso);
}
@@ -4545,11 +4837,15 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
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;
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;
`,
[username, displayName, await hashPassword(password)]
);
@@ -4606,6 +4902,10 @@ app.post("/api/auth/login", async (req, res) => {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
SELECT
@@ -4617,7 +4917,11 @@ app.post("/api/auth/login", async (req, res) => {
password_hash,
zammad_user_id::text AS zammad_user_id,
(zammad_api_token IS NOT NULL) AS has_zammad_api_token,
zammad_email_session_webhook_enabled
zammad_email_session_webhook_enabled,
session_billing_prompt_enabled,
zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes
FROM users
WHERE lower(username) = lower($1);
`,
@@ -4663,7 +4967,11 @@ app.post("/api/auth/login", async (req, res) => {
active: user.active,
zammad_user_id: user.zammad_user_id,
has_zammad_api_token: user.has_zammad_api_token,
zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled
zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled,
session_billing_prompt_enabled: user.session_billing_prompt_enabled,
zammad_session_note_default_enabled: user.zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes
}
});
});
@@ -4869,6 +5177,18 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadEmailSessionWebhookEnabled = user.role === "user"
? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled)
: false;
const sessionBillingPromptEnabled = user.role === "user"
? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled)
: false;
const zammadSessionNoteDefaultEnabled = user.role === "user"
? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled)
: false;
const zammadTimeUnitRoundingEnabled = user.role === "user"
? optionalBoolean(req.body.zammadTimeUnitRoundingEnabled, user.zammad_time_unit_rounding_enabled)
: false;
const zammadTimeUnitRoundingMinutes = user.role === "user"
? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes)
: 15;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -4897,9 +5217,13 @@ app.patch("/api/auth/me", async (req, res) => {
zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END,
zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END,
zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled END,
session_billing_prompt_enabled = CASE WHEN $8::boolean THEN $10 ELSE session_billing_prompt_enabled END,
zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_enabled END,
zammad_time_unit_rounding_enabled = CASE WHEN $8::boolean THEN $12 ELSE zammad_time_unit_rounding_enabled END,
zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes 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;
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;
`,
[
username,
@@ -4910,7 +5234,11 @@ app.patch("/api/auth/me", async (req, res) => {
zammadApiToken ? encryptSecret(zammadApiToken) : null,
zammadUserId,
user.role === "user",
zammadEmailSessionWebhookEnabled
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
]
);
@@ -4938,7 +5266,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, 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_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -4962,7 +5290,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, 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, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -5023,7 +5351,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, 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, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -5446,7 +5774,13 @@ app.get("/api/export/months/:month", requireUser, async (req, res) => {
row.start_time,
row.end_time,
row.rounded_minutes,
row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen",
row.billing_status === "billed"
? "Abgerechnet"
: row.billing_status === "billable"
? "Abrechenbar"
: row.billing_status === "non_billable"
? "Nicht abrechenbar"
: "Offen",
row.teamspace_minutes ?? "",
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
row.activity
@@ -5993,6 +6327,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
const requestedWorkType = optionalWorkType(req.body.workType);
const useZammadLookup = optionalBoolean(req.body.zammadLookup, false);
const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId);
const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus);
const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
@@ -6005,6 +6341,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
requestedWorkType,
useZammadLookup,
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
startedAt,
endedAt,
durationSeconds
@@ -6013,6 +6351,94 @@ app.post("/api/sessions", requireUser, async (req, res) => {
res.status(201).json(created);
});
async function zammadSessionArticleQueueResponse(userId: string) {
const result = await query(
`
SELECT
q.id::text AS id,
q.session_id::text AS session_id,
q.ticket_id::text AS ticket_id,
q.ticket_number,
q.zammad_ticket_id::text AS zammad_ticket_id,
q.activity,
q.time_unit::float AS time_unit,
q.attempt_count,
q.last_error,
q.last_attempt_at,
q.created_at,
s.started_at,
s.rounded_minutes
FROM zammad_session_article_queue q
JOIN sessions s ON s.id = q.session_id
WHERE q.user_id = $1
AND q.status = 'pending'
ORDER BY q.created_at ASC;
`,
[userId]
);
return result.rows;
}
app.get("/api/zammad/session-article-queue", requireUser, async (req, res) => {
const userId = currentUser(req).id;
res.json({ queue: await zammadSessionArticleQueueResponse(userId) });
});
app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const pendingResult = await query<ZammadSessionArticleQueueRow>(
`
SELECT
id::text AS id,
user_id::text AS user_id,
session_id::text AS session_id,
ticket_id::text AS ticket_id,
ticket_number,
zammad_ticket_id::text AS zammad_ticket_id,
activity,
time_unit::text AS time_unit
FROM zammad_session_article_queue
WHERE user_id = $1
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 100;
`,
[userId]
);
const results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }> = [];
for (const row of pendingResult.rows) {
try {
const articleId = await retryZammadSessionArticleQueueRow(row);
results.push({ id: row.id, status: "sent", zammadArticleId: articleId });
} catch (error) {
const message = errorMessage(error);
await query(
`
UPDATE zammad_session_article_queue
SET attempt_count = attempt_count + 1,
last_error = $1,
last_attempt_at = now(),
updated_at = now()
WHERE id = $2
AND status = 'pending';
`,
[message.slice(0, 1000), row.id]
);
results.push({ id: row.id, status: "failed", error: message });
}
}
res.json({
sent: results.filter((result) => result.status === "sent").length,
failed: results.filter((result) => result.status === "failed").length,
results,
queue: await zammadSessionArticleQueueResponse(userId)
});
});
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
res.json(await getPeriodOverview(config, period, currentUser(req).id));
@@ -8213,6 +8639,7 @@ app.get("/api/months/:month/overview", requireUser, async (req, res) => {
COUNT(s.id)::int AS session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
tm.closed_at IS NOT NULL AS closed,