This commit is contained in:
276
src/lib/db.ts
276
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user