Update
This commit is contained in:
1 parent
9be6c6e36a
commit
fa6db04370
16 files changed
+1123
-118
No files matched your search
+447
-20
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user