Add project and todo management
This commit is contained in:
1 parent
2792051a16
commit
3da436423f
12 files changed
+1865
-43
No files matched your search
+657
-26
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user