Add project and todo management

This commit is contained in:
michael committed 2026-09-02 11:03:50 +02:00
1 parent 2792051a16
commit 3da436423f
12 files changed
+1865 -43

No files matched your search

+657 -26
View File
@@ -144,6 +144,24 @@ type OrganizationRow = {
synced_at: string;
};
type ProjectRow = {
id: string;
ticket_id: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
work_type: "support" | "consulting" | null;
teamspace_project: string | null;
description: string;
goals: string;
starts_on: string | null;
ends_on: string | null;
hour_budget_minutes: number | null;
created_at: string;
updated_at: string;
};
const zammadBaseUrlSettingKey = "zammad.base_url";
const zammadApiKeySettingKey = "zammad.api_key";
@@ -192,6 +210,44 @@ function optionalBoolean(value: unknown, fallback: boolean) {
return value;
}
function parseOptionalDateOnly(value: unknown, field: string) {
if (value === undefined || value === null || value === "") {
return null;
}
return parseDay(String(value)).start;
}
function parseOptionalNonNegativeInteger(value: unknown, field: string) {
if (value === undefined || value === null || value === "") {
return null;
}
const number = Number(value);
if (!Number.isInteger(number) || number < 0) {
throw badRequest(`${field} must be a non-negative integer`);
}
return number;
}
function parseTodoPriority(value: unknown) {
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
throw badRequest("priority must be low, medium, high or urgent");
}
return value;
}
function parseTodoStatus(value: unknown) {
if (value !== "open" && value !== "done") {
throw badRequest("status must be open or done");
}
return value;
}
const periodConfigs: Record<string, PeriodConfig> = {
months: {
type: "month",
@@ -514,6 +570,16 @@ async function cleanupEmptyTickets(client: { query: (text: string, params?: unkn
SELECT 1
FROM sessions s
WHERE s.ticket_id = t.id
)
AND NOT EXISTS (
SELECT 1
FROM projects p
WHERE p.ticket_id = t.id
)
AND NOT EXISTS (
SELECT 1
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
);
`,
[uniqueTicketIds]
@@ -1261,7 +1327,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
}
const sessionWhere = where.join(" AND ");
const [sessionsResult, dayBillingResult, monthsResult, closureResult] = await Promise.all([
const [sessionsResult, dayBillingResult, monthsResult, closureResult, projectResult, todosResult] = await Promise.all([
query(
`
SELECT
@@ -1329,7 +1395,62 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
),
filters.month
? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId])
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 })
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }),
query(
`
SELECT
p.id::text AS id,
p.ticket_id::text AS ticket_id,
t.ticket_number,
t.organization_id::text AS organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
p.teamspace_project,
p.description,
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.created_at,
p.updated_at
FROM projects p
JOIN tickets t ON t.id = p.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
WHERE p.ticket_id = $1
AND p.user_id = $2;
`,
[ticketId, userId]
),
query(
`
SELECT
tt.id::text AS id,
tt.ticket_id::text AS ticket_id,
t.ticket_number,
COALESCE(o.name, t.customer_name) AS organization_name,
p.id::text AS project_id,
p.teamspace_project,
tt.title,
tt.description,
tt.priority,
tt.status,
tt.completed_at,
tt.created_at,
tt.updated_at
FROM ticket_todos tt
JOIN tickets t ON t.id = tt.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id
WHERE tt.ticket_id = $1
AND tt.user_id = $2
ORDER BY
CASE tt.status WHEN 'open' THEN 0 ELSE 1 END,
CASE tt.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
tt.updated_at DESC;
`,
[ticketId, userId]
)
]);
const sessions = sessionsResult.rows;
@@ -1358,6 +1479,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
},
sessions,
dayBillings: dayBillingResult.rows,
project: projectResult.rows[0] ?? null,
todos: todosResult.rows,
canClose: false,
openCount,
ticketOpenCount: Number(ticket.ticket_open_count ?? 0)
@@ -1866,6 +1989,8 @@ const databaseExportTables = [
"app_settings",
"organizations",
"tickets",
"projects",
"ticket_todos",
"recurring_billings",
"recurring_billing_slots",
"recurring_billing_exceptions",
@@ -2775,14 +2900,15 @@ app.post("/api/sessions", requireUser, async (req, res) => {
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const created = await withTransaction(async (client) => {
const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>(
`
SELECT id, organization_id, customer_name, work_type
FROM tickets
WHERE ticket_number = $1
FOR UPDATE;
SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id
FROM tickets t
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
WHERE t.ticket_number = $1
FOR UPDATE OF t;
`,
[ticketNumber]
[ticketNumber, user.id]
);
const existingTicket = existingTicketResult.rows[0] ?? null;
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId;
@@ -2805,14 +2931,16 @@ app.post("/api/sessions", requireUser, async (req, res) => {
DO UPDATE SET
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
work_type = CASE WHEN EXISTS (
SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5
) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END
RETURNING id, ticket_number, organization_id, customer_name, work_type;
`,
[ticketNumber, organization.id, organization.name, requestedWorkType]
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id]
);
const ticket = ticketResult.rows[0];
const workType = ticket.work_type ?? requestedWorkType;
const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType;
if (!workType) {
throw badRequest("workType must be support or consulting");
@@ -2872,6 +3000,7 @@ app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const userId = currentUser(req).id;
const result = await query(
`
SELECT
@@ -2880,12 +3009,16 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
t.organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type
t.work_type,
p.id::text AS project_id,
(p.id IS NOT NULL) AS is_project,
p.teamspace_project AS project_name
FROM tickets t
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $2
WHERE t.ticket_number = $1;
`,
[ticketNumber]
[ticketNumber, userId]
);
res.json({
@@ -2915,23 +3048,30 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
COALESCE(o.name, t.customer_name, '') AS organization_name,
COALESCE(o.name, t.customer_name, '') AS customer_name,
t.work_type,
MAX(s.started_at) AS latest_started_at,
to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period,
COUNT(s.id)::int AS session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes
p.id::text AS project_id,
(p.id IS NOT NULL) AS is_project,
p.teamspace_project AS project_name,
MAX(s.started_at) FILTER (WHERE s.user_id = $1) AS latest_started_at,
COALESCE(to_char(MAX(s.started_at) FILTER (WHERE s.user_id = $1) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM'), '') AS latest_period,
COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.user_id = $1), 0)::int AS total_minutes
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
WHERE s.user_id = $1
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $1
WHERE (s.user_id = $1 OR p.id IS NOT NULL)
AND (
t.ticket_number ILIKE $2
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
OR COALESCE(o.name, '') ILIKE $2
OR COALESCE(t.customer_name, '') ILIKE $2
OR COALESCE(s.customer_name, '') ILIKE $2
OR s.activity ILIKE $2
OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2)
OR (s.user_id = $1 AND s.activity ILIKE $2)
OR COALESCE(p.teamspace_project, '') ILIKE $2
OR COALESCE(p.description, '') ILIKE $2
OR COALESCE(p.goals, '') ILIKE $2
)
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project
ORDER BY
CASE
WHEN lower(t.ticket_number) = lower($4) THEN 0
@@ -2939,7 +3079,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
WHEN t.ticket_number ILIKE $3 THEN 2
ELSE 3
END,
MAX(s.started_at) DESC,
MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST,
t.ticket_number ASC
LIMIT 12;
`,
@@ -2949,6 +3089,484 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
res.json({ tickets: result.rows });
});
app.get("/api/projects", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const result = await query(
`
SELECT
p.id::text AS id,
p.ticket_id::text AS ticket_id,
t.ticket_number,
t.organization_id::text AS organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
p.teamspace_project,
p.description,
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.created_at,
p.updated_at,
COALESCE(session_totals.session_count, 0)::int AS session_count,
COALESCE(session_totals.billed_minutes, 0)::int AS billed_minutes,
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
COALESCE(todo_totals.done_todos, 0)::int AS done_todos
FROM projects p
JOIN tickets t ON t.id = p.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS session_count,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes
FROM sessions s
WHERE s.ticket_id = p.ticket_id
AND s.user_id = p.user_id
) session_totals ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos
FROM ticket_todos tt
WHERE tt.ticket_id = p.ticket_id
AND tt.user_id = p.user_id
) todo_totals ON true
WHERE p.user_id = $1
ORDER BY p.updated_at DESC, t.ticket_number ASC;
`,
[userId]
);
res.json({ projects: result.rows });
});
app.get("/api/projects/:projectId", requireUser, async (req, res) => {
const projectId = parsePositiveInteger(req.params.projectId, "projectId");
const userId = currentUser(req).id;
const result = await query<ProjectRow>(
`
SELECT
p.id::text AS id,
p.ticket_id::text AS ticket_id,
t.ticket_number,
t.organization_id::text AS organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
p.teamspace_project,
p.description,
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.created_at,
p.updated_at
FROM projects p
JOIN tickets t ON t.id = p.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
WHERE p.id = $1
AND p.user_id = $2;
`,
[projectId, userId]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "Projekt nicht gefunden" });
return;
}
res.json({ project: result.rows[0] });
});
app.post("/api/projects", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId);
const teamspaceProject = optionalString(req.body.teamspaceProject);
const description = optionalString(req.body.description) ?? "";
const goals = optionalString(req.body.goals) ?? "";
const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn");
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
}
try {
const created = await withTransaction(async (client) => {
const existingTicket = await client.query<{
id: string;
organization_id: string | null;
customer_name: string | null;
}>(
`
SELECT id, organization_id::text AS organization_id, customer_name
FROM tickets
WHERE ticket_number = $1
FOR UPDATE;
`,
[ticketNumber]
);
const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId;
if (!organizationId) {
return "organization-required" as const;
}
const organization = await getOrganizationByZammadId(client, organizationId);
if (!organization) {
return "organization-not-found" as const;
}
const ticketResult = await client.query<{ id: string }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, 'consulting')
ON CONFLICT (ticket_number)
DO UPDATE SET
organization_id = EXCLUDED.organization_id,
customer_name = EXCLUDED.customer_name,
work_type = 'consulting'
RETURNING id;
`,
[ticketNumber, organization.id, organization.name]
);
const ticketId = ticketResult.rows[0].id;
await client.query(
`
UPDATE sessions
SET organization_id = $1,
customer_name = $2,
work_type = 'consulting'
WHERE ticket_id = $3
AND user_id = $4
AND recurring_billing_id IS NULL;
`,
[organization.id, organization.name, ticketId, userId]
);
const projectResult = await client.query<ProjectRow>(
`
INSERT INTO projects (
user_id,
ticket_id,
teamspace_project,
description,
goals,
starts_on,
ends_on,
hour_budget_minutes
)
VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8)
RETURNING
id::text,
ticket_id::text,
$9::text AS ticket_number,
$10::text AS organization_id,
$11::text AS organization_name,
$11::text AS customer_name,
'consulting'::text AS work_type,
teamspace_project,
description,
goals,
starts_on::text,
ends_on::text,
hour_budget_minutes,
created_at,
updated_at;
`,
[userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name]
);
return projectResult.rows[0];
});
if (created === "organization-required") {
res.status(400).json({ error: "Organisation ist erforderlich, wenn das Ticket noch keine Stammdaten hat" });
return;
}
if (created === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden" });
return;
}
res.status(201).json({ project: created });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Dieses Ticket ist bereits als Projekt angelegt" });
return;
}
throw error;
}
});
app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
const projectId = parsePositiveInteger(req.params.projectId, "projectId");
const userId = currentUser(req).id;
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
const organizationId = parseOrganizationId(req.body.organizationId);
const teamspaceProject = optionalString(req.body.teamspaceProject);
const description = optionalString(req.body.description) ?? "";
const goals = optionalString(req.body.goals) ?? "";
const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn");
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
}
try {
const updated = await withTransaction(async (client) => {
const projectResult = await client.query<{ id: string; ticket_id: string }>(
`
SELECT id, ticket_id
FROM projects
WHERE id = $1
AND user_id = $2
FOR UPDATE;
`,
[projectId, userId]
);
if (projectResult.rowCount === 0) {
return null;
}
const organization = await getOrganizationByZammadId(client, organizationId);
if (!organization) {
return "organization-not-found" as const;
}
await client.query(
`
UPDATE tickets
SET ticket_number = $1,
organization_id = $2,
customer_name = $3,
work_type = 'consulting'
WHERE id = $4;
`,
[ticketNumber, organization.id, organization.name, projectResult.rows[0].ticket_id]
);
await client.query(
`
UPDATE sessions
SET organization_id = $1,
customer_name = $2,
work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END
WHERE ticket_id = $3
AND user_id = $4;
`,
[organization.id, organization.name, projectResult.rows[0].ticket_id, userId]
);
const result = await client.query<ProjectRow>(
`
UPDATE projects
SET teamspace_project = $1,
description = $2,
goals = $3,
starts_on = $4::date,
ends_on = $5::date,
hour_budget_minutes = $6,
updated_at = now()
WHERE id = $7
AND user_id = $8
RETURNING
id::text,
ticket_id::text,
$9::text AS ticket_number,
$10::text AS organization_id,
$11::text AS organization_name,
$11::text AS customer_name,
'consulting'::text AS work_type,
teamspace_project,
description,
goals,
starts_on::text,
ends_on::text,
hour_budget_minutes,
created_at,
updated_at;
`,
[teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name]
);
return result.rows[0];
});
if (updated === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden" });
return;
}
if (!updated) {
res.status(404).json({ error: "Projekt nicht gefunden" });
return;
}
res.json({ project: updated });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Ticketnummer ist bereits vergeben" });
return;
}
throw error;
}
});
app.get("/api/todos", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const status = req.query.status === "done" || req.query.status === "open" ? req.query.status : "all";
const params: unknown[] = [userId];
const where = ["tt.user_id = $1"];
if (status !== "all") {
params.push(status);
where.push(`tt.status = $${params.length}`);
}
const result = await query(
`
SELECT
tt.id::text AS id,
tt.ticket_id::text AS ticket_id,
t.ticket_number,
COALESCE(o.name, t.customer_name) AS organization_name,
p.id::text AS project_id,
p.teamspace_project,
tt.title,
tt.description,
tt.priority,
tt.status,
tt.completed_at,
tt.created_at,
tt.updated_at
FROM ticket_todos tt
JOIN tickets t ON t.id = tt.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id
WHERE ${where.join(" AND ")}
ORDER BY
CASE tt.status WHEN 'open' THEN 0 ELSE 1 END,
CASE tt.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
tt.updated_at DESC;
`,
params
);
res.json({ todos: result.rows });
});
app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const userId = currentUser(req).id;
const title = requireString(req.body.title, "title");
const description = optionalString(req.body.description) ?? "";
const priority = parseTodoPriority(req.body.priority ?? "medium");
const ticketResult = await query<{ id: string }>("SELECT id FROM tickets WHERE id = $1;", [ticketId]);
if (ticketResult.rowCount === 0) {
res.status(404).json({ error: "Ticket nicht gefunden" });
return;
}
const result = await query(
`
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority)
VALUES ($1, $2, $3, $4, $5)
RETURNING
id::text,
ticket_id::text,
title,
description,
priority,
status,
completed_at,
created_at,
updated_at;
`,
[ticketId, userId, title, description, priority]
);
res.status(201).json({ todo: result.rows[0] });
});
app.patch("/api/todos/:todoId", requireUser, async (req, res) => {
const todoId = parsePositiveInteger(req.params.todoId, "todoId");
const userId = currentUser(req).id;
const title = requireString(req.body.title, "title");
const description = optionalString(req.body.description) ?? "";
const priority = parseTodoPriority(req.body.priority);
const status = parseTodoStatus(req.body.status);
const result = await query(
`
UPDATE ticket_todos
SET title = $1,
description = $2,
priority = $3,
status = $4,
completed_at = CASE WHEN $4 = 'done' THEN COALESCE(completed_at, now()) ELSE NULL END,
updated_at = now()
WHERE id = $5
AND user_id = $6
RETURNING
id::text,
ticket_id::text,
title,
description,
priority,
status,
completed_at,
created_at,
updated_at;
`,
[title, description, priority, status, todoId, userId]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "ToDo nicht gefunden" });
return;
}
res.json({ todo: result.rows[0] });
});
app.delete("/api/todos/:todoId", requireUser, async (req, res) => {
const todoId = parsePositiveInteger(req.params.todoId, "todoId");
const userId = currentUser(req).id;
const result = await query(
`
DELETE FROM ticket_todos
WHERE id = $1
AND user_id = $2
RETURNING id::text;
`,
[todoId, userId]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "ToDo nicht gefunden" });
return;
}
res.json({ deleted: result.rows[0] });
});
app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await getTicketDetails(ticketId, currentUser(req).id, {
@@ -2979,6 +3597,16 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
return "organization-not-found" as const;
}
const projectResult = await client.query<{ id: string }>(
`
SELECT id
FROM projects
WHERE ticket_id = $1
AND user_id = $2;
`,
[ticketId, currentUser(req).id]
);
const effectiveWorkType = projectResult.rowCount && projectResult.rowCount > 0 ? "consulting" : workType;
const ticketResult = await client.query(
`
UPDATE tickets
@@ -2989,7 +3617,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
WHERE id = $5
RETURNING id, ticket_number, organization_id, customer_name, work_type;
`,
[ticketNumber, organization.id, organization.name, workType, ticketId]
[ticketNumber, organization.id, organization.name, effectiveWorkType, ticketId]
);
if (ticketResult.rowCount === 0) {
@@ -3004,7 +3632,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END
WHERE ticket_id = $4;
`,
[organization.id, organization.name, workType, ticketId]
[organization.id, organization.name, effectiveWorkType, ticketId]
);
return ticketResult.rows[0];
@@ -3487,6 +4115,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
work_type: "support" | "consulting";
recurring_billing_id: string | null;
recurring_work_type: "support" | "consulting" | null;
project_id: string | null;
month_closed_at: string | null;
}>(
`
@@ -3497,9 +4126,11 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
s.work_type,
s.recurring_billing_id,
rb.work_type AS recurring_work_type,
p.id AS project_id,
mc.closed_at AS month_closed_at
FROM sessions s
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id
LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_id
LEFT JOIN month_closures mc
ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date
AND mc.user_id = s.user_id
@@ -3529,7 +4160,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
return "month-closed" as const;
}
const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
const effectiveWorkType = current.project_id ? "consulting" : current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId);
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);