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);
+40
View File
@@ -435,6 +435,42 @@ export async function migrate() {
);
`);
await query(`
CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
teamspace_project TEXT,
description TEXT NOT NULL DEFAULT '',
goals TEXT NOT NULL DEFAULT '',
starts_on DATE,
ends_on DATE,
hour_budget_minutes INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, ticket_id),
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
);
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'medium',
status TEXT NOT NULL DEFAULT 'open',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done'))
);
`);
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
@@ -450,6 +486,10 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(`