diff --git a/backend/src/index.ts b/backend/src/index.ts index 3fde079..c336ba5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -132,17 +132,17 @@ async function setAppSetting(key: string, value: string) { ); } -async function getOrganizationById( +async function getOrganizationByZammadId( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, - organizationId: number + zammadId: number ) { const result = await client.query( ` - SELECT id, zammad_id, name, synced_at + SELECT zammad_id::text AS id, zammad_id::text AS zammad_id, name, synced_at FROM organizations - WHERE id = $1; + WHERE zammad_id = $1; `, - [organizationId] + [zammadId] ); return result.rows[0] ?? null; @@ -332,7 +332,7 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st rb.valid_until::text, rb.start_time::text FROM recurring_billings rb - JOIN organizations o ON o.id = rb.organization_id + JOIN organizations o ON o.zammad_id = rb.organization_id WHERE rb.user_id = $1 AND rb.valid_from < $3::date AND (rb.valid_until IS NULL OR rb.valid_until >= $2::date); @@ -558,7 +558,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use NULL::timestamptz AS closed_at FROM tickets t JOIN sessions s ON s.ticket_id = t.id - LEFT JOIN organizations o ON o.id = t.organization_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY @@ -587,7 +587,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use s.rounded_minutes FROM sessions s JOIN tickets t ON t.id = s.ticket_id - LEFT JOIN organizations so ON so.id = s.organization_id + LEFT JOIN organizations so ON so.zammad_id = s.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 @@ -681,8 +681,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { s.recurring_billing_id FROM sessions s JOIN tickets t ON t.id = s.ticket_id - LEFT JOIN organizations so ON so.id = s.organization_id - LEFT JOIN organizations ot ON ot.id = t.organization_id + LEFT JOIN organizations so ON so.zammad_id = s.organization_id + LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 @@ -904,8 +904,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { s.rounded_minutes FROM sessions s JOIN tickets t ON t.id = s.ticket_id - LEFT JOIN organizations so ON so.id = s.organization_id - LEFT JOIN organizations o ON o.id = t.organization_id + LEFT JOIN organizations so ON so.zammad_id = s.organization_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 @@ -1066,7 +1066,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count, COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count FROM tickets t - LEFT JOIN organizations o ON o.id = t.organization_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id ${periodClosureJoin} LEFT JOIN LATERAL ( SELECT @@ -1096,13 +1096,13 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke ( SELECT name FROM organizations - WHERE id = sessions.organization_id + WHERE zammad_id = sessions.organization_id ) AS organization_name, COALESCE( ( SELECT name FROM organizations - WHERE id = sessions.organization_id + WHERE zammad_id = sessions.organization_id ), customer_name ) AS customer_name, @@ -1473,7 +1473,7 @@ app.get("/api/organizations", async (req, res) => { const result = await query( ` - SELECT id, zammad_id, name, synced_at + SELECT zammad_id::text AS id, zammad_id::text AS zammad_id, name, synced_at FROM organizations ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY lower(name) ASC @@ -1596,30 +1596,30 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) { const staleResult = zammadIds.length > 0 - ? await client.query<{ id: string }>( + ? await client.query<{ zammad_id: string }>( ` - SELECT id + SELECT zammad_id::text AS zammad_id FROM organizations WHERE NOT (zammad_id = ANY($1::bigint[])); `, [zammadIds] ) - : await client.query<{ id: string }>( + : await client.query<{ zammad_id: string }>( ` - SELECT id + SELECT zammad_id::text AS zammad_id FROM organizations; ` ); - const staleIds = staleResult.rows.map((row) => row.id); + const staleZammadIds = staleResult.rows.map((row) => row.zammad_id); - if (staleIds.length > 0) { + if (staleZammadIds.length > 0) { const ticketResult = await client.query( ` UPDATE tickets SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, - [staleIds] + [staleZammadIds] ); const sessionResult = await client.query( ` @@ -1627,14 +1627,14 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, - [staleIds] + [staleZammadIds] ); const deleteResult = await client.query( ` DELETE FROM organizations - WHERE id = ANY($1::bigint[]); + WHERE zammad_id = ANY($1::bigint[]); `, - [staleIds] + [staleZammadIds] ); unlinkedTickets = ticketResult.rowCount ?? 0; @@ -1980,8 +1980,8 @@ app.get("/api/export/months/:month", requireUser, async (req, res) => { s.activity FROM sessions s JOIN tickets t ON t.id = s.ticket_id - LEFT JOIN organizations so ON so.id = s.organization_id - LEFT JOIN organizations ot ON ot.id = t.organization_id + LEFT JOIN organizations so ON so.zammad_id = s.organization_id + LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id LEFT JOIN ticket_day_billings tdb ON tdb.ticket_id = s.ticket_id AND tdb.user_id = s.user_id @@ -2149,7 +2149,7 @@ async function recurringBillingResponse(userId: string) { ) AS slots FROM recurring_billings rb JOIN users owner ON owner.id = rb.user_id - JOIN organizations o ON o.id = rb.organization_id + JOIN organizations o ON o.zammad_id = rb.organization_id LEFT JOIN recurring_billing_slots rbs ON rbs.recurring_billing_id = rb.id WHERE rb.user_id = $1 GROUP BY rb.id, owner.username, owner.display_name, o.name @@ -2170,7 +2170,7 @@ app.post("/api/recurring-billings", requireUser, async (req, res) => { const payload = parseRecurringBillingPayload(req.body); const created = await withTransaction(async (client) => { - const organization = await getOrganizationById(client, payload.organizationId); + const organization = await getOrganizationByZammadId(client, payload.organizationId); if (!organization) { return "organization-not-found" as const; @@ -2272,9 +2272,9 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => ` SELECT id FROM recurring_billings - WHERE s.id = $1 - AND s.user_id = $2 - FOR UPDATE OF s; + WHERE id = $1 + AND user_id = $2 + FOR UPDATE; `, [billingId, userId] ); @@ -2283,7 +2283,7 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => return { status: "not-found" as const }; } - const organization = await getOrganizationById(client, payload.organizationId); + const organization = await getOrganizationByZammadId(client, payload.organizationId); if (!organization) { return { status: "organization-not-found" as const }; @@ -2579,7 +2579,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { throw badRequest("organizationId is required"); } - const organization = await getOrganizationById(client, effectiveOrganizationId); + const organization = await getOrganizationByZammadId(client, effectiveOrganizationId); if (!organization) { throw badRequest("organizationId must reference an existing organization"); @@ -2670,7 +2670,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { COALESCE(o.name, t.customer_name) AS customer_name, t.work_type FROM tickets t - LEFT JOIN organizations o ON o.id = t.organization_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE t.ticket_number = $1; `, [ticketNumber] @@ -2709,7 +2709,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes FROM tickets t JOIN sessions s ON s.ticket_id = t.id - LEFT JOIN organizations o ON o.id = t.organization_id + LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE s.user_id = $1 AND ( t.ticket_number ILIKE $2 @@ -2745,7 +2745,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { try { const result = await withTransaction(async (client) => { - const organization = await getOrganizationById(client, organizationId); + const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { return "organization-not-found" as const; @@ -3232,7 +3232,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => { const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const updated = await withTransaction(async (client) => { - const organization = await getOrganizationById(client, organizationId); + const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { return "organization-not-found" as const; diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 99d1c0f..8c49f9d 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -97,7 +97,7 @@ export async function migrate() { CREATE TABLE IF NOT EXISTS tickets ( id BIGSERIAL PRIMARY KEY, ticket_number TEXT NOT NULL UNIQUE, - organization_id BIGINT REFERENCES organizations(id), + organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT, work_type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -107,7 +107,7 @@ export async function migrate() { `); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;"); - await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); await query(` ALTER TABLE tickets @@ -130,7 +130,7 @@ export async function migrate() { CREATE TABLE IF NOT EXISTS sessions ( id BIGSERIAL PRIMARY KEY, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, - organization_id BIGINT REFERENCES organizations(id), + organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT NOT NULL, activity TEXT NOT NULL, work_type TEXT NOT NULL, @@ -151,7 +151,7 @@ export async function migrate() { `); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);"); - await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); @@ -173,7 +173,7 @@ export async function migrate() { id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ticket_number TEXT, - organization_id BIGINT NOT NULL REFERENCES organizations(id), + organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id), activity TEXT NOT NULL, work_type TEXT NOT NULL, recurrence_type TEXT NOT NULL, @@ -191,6 +191,82 @@ export async function migrate() { ); `); + await query(` + ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_organization_id_fkey; + ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_organization_id_fkey; + ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_organization_id_fkey; + `); + + await query(` + UPDATE tickets t + SET organization_id = o.zammad_id + FROM organizations o + WHERE t.organization_id = o.id + AND NOT EXISTS ( + SELECT 1 + FROM organizations current_org + WHERE current_org.zammad_id = t.organization_id + ); + `); + + await query(` + UPDATE sessions s + SET organization_id = o.zammad_id + FROM organizations o + WHERE s.organization_id = o.id + AND NOT EXISTS ( + SELECT 1 + FROM organizations current_org + WHERE current_org.zammad_id = s.organization_id + ); + `); + + await query(` + UPDATE recurring_billings rb + SET organization_id = o.zammad_id + FROM organizations o + WHERE rb.organization_id = o.id + AND NOT EXISTS ( + SELECT 1 + FROM organizations current_org + WHERE current_org.zammad_id = rb.organization_id + ); + `); + + await query(` + UPDATE tickets + SET organization_id = NULL + WHERE organization_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM organizations o + WHERE o.zammad_id = tickets.organization_id + ); + `); + + await query(` + UPDATE sessions + SET organization_id = NULL + WHERE organization_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM organizations o + WHERE o.zammad_id = sessions.organization_id + ); + `); + + await query(` + ALTER TABLE tickets + ADD CONSTRAINT tickets_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); + ALTER TABLE sessions + ADD CONSTRAINT sessions_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); + ALTER TABLE recurring_billings + ADD CONSTRAINT recurring_billings_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); + `); + await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;"); await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;"); await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");