Support org
All checks were successful
Build and Push / build (push) Successful in 1m30s

This commit is contained in:
2026-05-02 10:50:06 +02:00
parent b023c068eb
commit 8273d08f15
19 changed files with 1974 additions and 5 deletions

View File

@@ -1,5 +1,15 @@
import { Pool } from "pg";
import type { BillingAddress, OrgBilling, TenantRequest, TenantRequestStatus } from "@/types";
import type {
BillingAddress,
OrgBilling,
SupportTicket,
SupportTicketComment,
SupportTicketCommentAuthorKind,
SupportTicketCategory,
SupportTicketStatus,
TenantRequest,
TenantRequestStatus,
} from "@/types";
import { listTenants, getTenant } from "./k8s";
// ---------------------------------------------------------------------------
@@ -190,6 +200,70 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org —
-- coworkers in the same org cannot see each other's tickets. The
-- index on (zitadel_user_id, status) is what most customer-side
-- queries hit; the index on (status, updated_at DESC) is for the
-- admin queue.
--
-- contact_email / contact_name are frozen at creation time so the
-- ticket retains a working "reply-to" identity even if the user
-- later changes their email or display name in ZITADEL.
CREATE TABLE IF NOT EXISTS support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
contact_email TEXT NOT NULL,
contact_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- CHECK constraints added separately so re-running the migration
-- against an existing table (without these constraints) works.
-- IF NOT EXISTS isn't supported on ADD CONSTRAINT, hence the
-- DO $$ wrapper.
DO $$ BEGIN
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_category_check
CHECK (category IN ('bug','feature_request','question','billing','other'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_status_check
CHECK (status IN ('open','in_progress','waiting_for_customer','resolved','reopened'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_support_tickets_user
ON support_tickets(zitadel_user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_support_tickets_status
ON support_tickets(status, updated_at DESC);
-- Threaded comments. ON DELETE CASCADE so deleting a ticket
-- cleans up its history; we don't currently expose ticket
-- deletion in the UI but the cascade keeps options open.
CREATE TABLE IF NOT EXISTS support_ticket_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
author_user_id TEXT NOT NULL,
author_name TEXT NOT NULL,
author_kind TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$ BEGIN
ALTER TABLE support_ticket_comments ADD CONSTRAINT support_ticket_comments_author_kind_check
CHECK (author_kind IN ('customer','admin'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
ON support_ticket_comments(ticket_id, created_at);
`;
let migrated = false;
@@ -1045,3 +1119,203 @@ export async function removeAllAssignmentsForUser(
[orgId, userId]
);
}
// ---------------------------------------------------------------------------
// Feature 5: support tickets
// ---------------------------------------------------------------------------
function rowToSupportTicket(row: any): SupportTicket {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
title: row.title,
description: row.description,
category: row.category as SupportTicketCategory,
status: row.status as SupportTicketStatus,
contactEmail: row.contact_email,
contactName: row.contact_name,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
function rowToSupportTicketComment(row: any): SupportTicketComment {
return {
id: row.id,
ticketId: row.ticket_id,
authorUserId: row.author_user_id,
authorName: row.author_name,
authorKind: row.author_kind as SupportTicketCommentAuthorKind,
body: row.body,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
};
}
/**
* Create a new support ticket. The contact_name/contact_email are
* snapshotted from the session at creation time — see SupportTicket
* doc for why.
*/
export async function createSupportTicket(params: {
zitadelOrgId: string;
zitadelUserId: string;
title: string;
description: string;
category: SupportTicketCategory;
contactName: string;
contactEmail: string;
}): Promise<SupportTicket> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO support_tickets (
zitadel_org_id, zitadel_user_id, title, description, category,
contact_name, contact_email
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.title,
params.description,
params.category,
params.contactName,
params.contactEmail,
]
);
return rowToSupportTicket(result.rows[0]);
}
/** Tickets created by a single user, newest activity first. */
export async function listSupportTicketsForUser(
zitadelUserId: string
): Promise<SupportTicket[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_tickets
WHERE zitadel_user_id = $1
ORDER BY updated_at DESC`,
[zitadelUserId]
);
return result.rows.map(rowToSupportTicket);
}
/**
* Admin queue. Returns every ticket across all users/orgs, newest
* activity first. Pending tickets (open/reopened) bubble to the top
* by virtue of recent activity, but the API doesn't sort by status —
* the admin UI handles filtering and bucketing.
*/
export async function listAllSupportTickets(): Promise<SupportTicket[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_tickets ORDER BY updated_at DESC`
);
return result.rows.map(rowToSupportTicket);
}
export async function getSupportTicketById(
id: string
): Promise<SupportTicket | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM support_tickets WHERE id = $1",
[id]
);
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
}
export async function listCommentsForTicket(
ticketId: string
): Promise<SupportTicketComment[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_ticket_comments
WHERE ticket_id = $1
ORDER BY created_at`,
[ticketId]
);
return result.rows.map(rowToSupportTicketComment);
}
/**
* Insert a comment. Bumps the parent ticket's `updated_at` so the
* activity sort orders work — done in a transaction so the two are
* atomic from any concurrent reader's perspective.
*
* Caller is responsible for status auto-bumping (e.g. customer
* replying to a `waiting_for_customer` ticket → `in_progress`); the
* DB layer just writes what it's told.
*/
export async function createSupportTicketComment(params: {
ticketId: string;
authorUserId: string;
authorName: string;
authorKind: SupportTicketCommentAuthorKind;
body: string;
}): Promise<SupportTicketComment> {
await ensureSchema();
const client = await getPool().connect();
try {
await client.query("BEGIN");
const inserted = await client.query(
`INSERT INTO support_ticket_comments (
ticket_id, author_user_id, author_name, author_kind, body
) VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
params.ticketId,
params.authorUserId,
params.authorName,
params.authorKind,
params.body,
]
);
await client.query(
"UPDATE support_tickets SET updated_at = now() WHERE id = $1",
[params.ticketId]
);
await client.query("COMMIT");
return rowToSupportTicketComment(inserted.rows[0]);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/**
* Update mutable fields on a ticket. Only category and status are
* mutable; title/description are frozen post-creation. Returns the
* updated row so callers can email the right contact_email
* afterwards.
*/
export async function updateSupportTicket(
id: string,
changes: { status?: SupportTicketStatus; category?: SupportTicketCategory }
): Promise<SupportTicket | null> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [id];
let idx = 2;
if (changes.status !== undefined) {
sets.push(`status = $${idx}`);
values.push(changes.status);
idx++;
}
if (changes.category !== undefined) {
sets.push(`category = $${idx}`);
values.push(changes.category);
idx++;
}
// No-op early exit. Without an actual change we still want
// updated_at refreshed if the caller asked for one, but if they
// passed neither field there's nothing to do.
if (sets.length === 1) return getSupportTicketById(id);
const result = await getPool().query(
`UPDATE support_tickets SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
}