This commit is contained in:
michael committed 2026-09-23 13:34:38 +00:00
1 parent 0c14a3acbd
commit 2105a0c5f7
11 files changed
+442 -86

No files matched your search

+158 -15
View File
@@ -240,6 +240,20 @@ function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) {
return minutes;
}
function parseZammadArticleTypeId(value: unknown, fallback = 6) {
if (value === undefined || value === null || value === "") {
return fallback ?? 6;
}
const typeId = Number(value);
if (![1, 2, 3, 4, 5, 6].includes(typeId)) {
throw badRequest("zammadArticleTypeId must be 1, 2, 3, 4, 5 or 6");
}
return typeId;
}
function optionalTicketSessionMonth(value: unknown) {
if (value === undefined || value === null || value === "" || value === "all") {
return null;
@@ -635,12 +649,14 @@ type ZammadSessionArticlePayload = {
body: string;
content_type: "text/plain";
time_unit: number;
accounted_time_type_id: number;
};
function zammadSessionArticlePayload(input: {
zammadTicketId: string | number;
activity: string;
timeUnit: number;
articleTypeId: number;
}): ZammadSessionArticlePayload {
const ticketId = Number(input.zammadTicketId);
@@ -655,7 +671,8 @@ function zammadSessionArticlePayload(input: {
subject: "TicketTracker Session",
body: input.activity,
content_type: "text/plain",
time_unit: input.timeUnit
time_unit: input.timeUnit,
accounted_time_type_id: input.articleTypeId
};
}
@@ -672,6 +689,7 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
zammadTicketId: string | number | null;
activity: string;
timeUnit: number;
articleTypeId: number;
error: string;
}) {
await client.query(
@@ -684,14 +702,16 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
zammad_ticket_id,
activity,
time_unit,
zammad_article_type_id,
last_error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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,
zammad_article_type_id = EXCLUDED.zammad_article_type_id,
last_error = EXCLUDED.last_error,
updated_at = now();
`,
@@ -703,11 +723,55 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
input.zammadTicketId,
input.activity,
input.timeUnit,
input.articleTypeId,
input.error.slice(0, 1000)
]
);
}
async function recordZammadSessionArticleSent(client: pg.PoolClient, input: {
userId: string;
sessionId: string | number;
ticketId: string | number;
ticketNumber: string;
zammadTicketId: string | number;
activity: string;
timeUnit: number;
articleTypeId: number;
articleId: number | null;
}) {
await client.query(
`
INSERT INTO zammad_session_article_queue (
user_id,
session_id,
ticket_id,
ticket_number,
zammad_ticket_id,
activity,
time_unit,
zammad_article_type_id,
status,
sent_at,
zammad_article_id,
last_error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'sent', now(), $9, NULL);
`,
[
input.userId,
input.sessionId,
input.ticketId,
input.ticketNumber,
input.zammadTicketId,
input.activity,
input.timeUnit,
input.articleTypeId,
input.articleId
]
);
}
function zammadArticleId(payload: unknown) {
return isPlainObject(payload) ? numberOrNull(payload.id) : null;
}
@@ -721,6 +785,7 @@ type ZammadSessionArticleQueueRow = {
zammad_ticket_id: string | null;
activity: string;
time_unit: string | number;
zammad_article_type_id: number | null;
};
async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) {
@@ -750,7 +815,8 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR
const payload = zammadSessionArticlePayload({
zammadTicketId,
activity: row.activity,
timeUnit: Number(row.time_unit)
timeUnit: Number(row.time_unit),
articleTypeId: Number(row.zammad_article_type_id ?? 6)
});
const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload);
const articleId = zammadArticleId(zammadResponse);
@@ -1013,6 +1079,7 @@ type SessionCreationInput = {
requestedProjectTeamspaceEntryId: number | null;
billingStatus: "billable" | "billed" | "non_billable" | null;
createZammadArticle: boolean;
zammadArticleTypeId: number;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
@@ -1034,6 +1101,7 @@ async function createSessionForUser(input: SessionCreationInput) {
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
zammadArticleTypeId,
startedAt,
endedAt,
durationSeconds,
@@ -1251,6 +1319,18 @@ async function createSessionForUser(input: SessionCreationInput) {
);
const session = sessionResult.rows[0];
const zammadArticle: {
requested: boolean;
status: "not_requested" | "sent" | "queued";
error: string | null;
articleId: number | null;
} = {
requested: createZammadArticle,
status: createZammadArticle ? "queued" : "not_requested",
error: null,
articleId: null
};
if (createZammadArticle) {
const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds);
let zammadArticleTicketId = ticket.zammad_ticket_id;
@@ -1276,9 +1356,23 @@ async function createSessionForUser(input: SessionCreationInput) {
const payload = zammadSessionArticlePayload({
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit
timeUnit,
articleTypeId: zammadArticleTypeId
});
const zammadResponse = await postZammadSessionArticleForUser(userId, payload);
zammadArticle.status = "sent";
zammadArticle.articleId = zammadArticleId(zammadResponse);
await recordZammadSessionArticleSent(client, {
userId,
sessionId: session.id,
ticketId: ticket.id,
ticketNumber,
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
articleTypeId: zammadArticleTypeId,
articleId: zammadArticle.articleId
});
await postZammadSessionArticleForUser(userId, payload);
} catch (error) {
zammadArticleError = errorMessage(error);
}
@@ -1287,6 +1381,8 @@ async function createSessionForUser(input: SessionCreationInput) {
}
if (zammadArticleError) {
zammadArticle.status = "queued";
zammadArticle.error = zammadArticleError;
await enqueueZammadSessionArticle(client, {
userId,
sessionId: session.id,
@@ -1295,6 +1391,7 @@ async function createSessionForUser(input: SessionCreationInput) {
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
articleTypeId: zammadArticleTypeId,
error: zammadArticleError
});
}
@@ -1319,7 +1416,8 @@ async function createSessionForUser(input: SessionCreationInput) {
return {
ticket,
session
session,
zammadArticle
};
});
}
@@ -1675,6 +1773,7 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
requestedProjectTeamspaceEntryId: null,
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
startedAt,
endedAt,
durationSeconds,
@@ -4433,6 +4532,27 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
recurring_billing_id,
recurring_billing_slot_id,
recurring_occurrence_date,
(
SELECT status
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_status,
(
SELECT zammad_article_id::text
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_id,
(
SELECT zammad_article_type_id
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_type_id,
project_teamspace_entry_id::text AS project_teamspace_entry_id,
(
SELECT teamspace_project
@@ -4591,10 +4711,20 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
s.recurring_billing_id,
s.recurring_billing_slot_id,
s.recurring_occurrence_date,
za.status AS zammad_article_status,
za.zammad_article_id::text AS zammad_article_id,
za.zammad_article_type_id,
s.project_teamspace_entry_id::text AS project_teamspace_entry_id,
pte.teamspace_project AS project_teamspace_project
FROM sessions s
LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id
LEFT JOIN LATERAL (
SELECT status, zammad_article_id, zammad_article_type_id
FROM zammad_session_article_queue
WHERE session_id = s.id
ORDER BY updated_at DESC
LIMIT 1
) za ON true
WHERE ${sessionWhere}
ORDER BY s.started_at ASC;
`,
@@ -4841,11 +4971,12 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>(
`
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;
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_article_type_id;
`,
[username, displayName, await hashPassword(password)]
);
@@ -4906,6 +5037,7 @@ app.post("/api/auth/login", async (req, res) => {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>(
`
SELECT
@@ -4921,7 +5053,8 @@ app.post("/api/auth/login", async (req, res) => {
session_billing_prompt_enabled,
zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes
zammad_time_unit_rounding_minutes,
zammad_article_type_id
FROM users
WHERE lower(username) = lower($1);
`,
@@ -4971,7 +5104,8 @@ app.post("/api/auth/login", async (req, res) => {
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
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes,
zammad_article_type_id: user.zammad_article_type_id
}
});
});
@@ -5189,6 +5323,9 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadTimeUnitRoundingMinutes = user.role === "user"
? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes)
: 15;
const zammadArticleTypeId = user.role === "user"
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
: 6;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -5221,9 +5358,10 @@ app.patch("/api/auth/me", async (req, res) => {
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,
zammad_article_type_id = CASE WHEN $8::boolean THEN $14 ELSE zammad_article_type_id 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;
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_article_type_id;
`,
[
username,
@@ -5238,7 +5376,8 @@ app.patch("/api/auth/me", async (req, res) => {
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
]
);
@@ -5266,7 +5405,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_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, 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, zammad_article_type_id, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -5290,7 +5429,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, 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_article_type_id, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -5351,7 +5490,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, 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_article_type_id, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -6329,6 +6468,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
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 zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
@@ -6343,6 +6483,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
zammadArticleTypeId,
startedAt,
endedAt,
durationSeconds
@@ -6362,6 +6503,7 @@ async function zammadSessionArticleQueueResponse(userId: string) {
q.zammad_ticket_id::text AS zammad_ticket_id,
q.activity,
q.time_unit::float AS time_unit,
q.zammad_article_type_id,
q.attempt_count,
q.last_error,
q.last_attempt_at,
@@ -6398,7 +6540,8 @@ app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res
ticket_number,
zammad_ticket_id::text AS zammad_ticket_id,
activity,
time_unit::text AS time_unit
time_unit::text AS time_unit,
zammad_article_type_id
FROM zammad_session_article_queue
WHERE user_id = $1
AND status = 'pending'