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;
}

View File

@@ -318,3 +318,282 @@ export async function sendAdminNotificationEmail(
console.error("Failed to send admin notification email:", err);
}
}
// ---------------------------------------------------------------------------
// Feature 5: support ticket emails
// ---------------------------------------------------------------------------
/**
* Email subject prefix that helps customers thread tickets in their
* mail client. We don't have inbound email processing — replies via
* email back to us go nowhere — but the prefix is still useful for
* the customer's own organisation. The id is shortened to 8 chars
* for human readability; collisions on the truncated form within a
* single user's inbox are vanishingly unlikely.
*/
function ticketSubjectPrefix(ticketId: string): string {
return `[PieCed Support #${ticketId.slice(0, 8)}]`;
}
const STATUS_LABELS_EN: Record<string, string> = {
open: "Open",
in_progress: "In progress",
waiting_for_customer: "Waiting for your reply",
resolved: "Resolved",
reopened: "Reopened",
};
/**
* Sent to the customer when they create a ticket — confirmation
* that we received it and a copy of the ticket id for their records.
*/
export async function sendSupportTicketCreatedEmail(params: {
to: string;
contactName: string;
ticketId: string;
title: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeTitle = escapeHtml(params.title);
const shortId = params.ticketId.slice(0, 8);
const subject = `${ticketSubjectPrefix(params.ticketId)} ${params.title}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject,
text: [
`Hello ${params.contactName},`,
"",
`We've received your support request "${params.title}" (reference #${shortId}).`,
"",
"Our team will review and respond as soon as possible. You can track the status and reply at https://app.pieced.ch/support.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Support request received</h2>
<p>Hello ${safeName},</p>
<p>We've received your support request <strong>"${safeTitle}"</strong> (reference #${shortId}).</p>
<p>Our team will review and respond as soon as possible.</p>
<p>
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
View ticket
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send support ticket creation email:", err);
}
}
/**
* Sent to the customer when an admin replies to one of their tickets.
* Includes the body of the reply inline so the customer can read it
* without clicking through (especially useful on mobile).
*/
export async function sendSupportTicketReplyEmail(params: {
to: string;
contactName: string;
ticketId: string;
title: string;
authorName: string;
body: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeTitle = escapeHtml(params.title);
const safeAuthor = escapeHtml(params.authorName);
const safeBody = escapeHtml(params.body);
const shortId = params.ticketId.slice(0, 8);
const subject = `${ticketSubjectPrefix(params.ticketId)} Re: ${params.title}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject,
text: [
`Hello ${params.contactName},`,
"",
`${params.authorName} replied to your ticket "${params.title}" (#${shortId}):`,
"",
params.body,
"",
"Reply or follow up at https://app.pieced.ch/support.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">New reply on your ticket</h2>
<p>Hello ${safeName},</p>
<p><strong>${safeAuthor}</strong> replied to your ticket <strong>"${safeTitle}"</strong> (#${shortId}):</p>
<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
${safeBody}
</div>
<p>
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
View ticket
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send support ticket reply email:", err);
}
}
/**
* Sent to the customer when an admin changes status without a comment.
* If the same admin action included a comment, they'd get the
* reply email instead — caller decides which to send.
*/
export async function sendSupportTicketStatusEmail(params: {
to: string;
contactName: string;
ticketId: string;
title: string;
newStatus: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeTitle = escapeHtml(params.title);
const statusLabel = STATUS_LABELS_EN[params.newStatus] ?? params.newStatus;
const shortId = params.ticketId.slice(0, 8);
const subject = `${ticketSubjectPrefix(params.ticketId)} Status: ${statusLabel}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject,
text: [
`Hello ${params.contactName},`,
"",
`The status of your ticket "${params.title}" (#${shortId}) has been updated to: ${statusLabel}.`,
"",
"View details and respond if needed at https://app.pieced.ch/support.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Ticket status update</h2>
<p>Hello ${safeName},</p>
<p>The status of your ticket <strong>"${safeTitle}"</strong> (#${shortId}) has been updated to:</p>
<p style="font-size: 18px; color: #3b82f6; font-weight: 600;">${escapeHtml(statusLabel)}</p>
<p>
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
View ticket
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send support ticket status email:", err);
}
}
/**
* Notify the platform admin distribution list of a new ticket OR a
* customer reply. Mirror of sendAdminNotificationEmail's pattern —
* uses the same ADMIN_NOTIFICATION_EMAIL env var.
*
* Two trigger reasons supported:
* - 'created' → new ticket from a customer
* - 'replied' → customer replied to existing ticket (we want admin
* visibility, e.g. to know the ticket needs another
* round of attention)
*/
export async function sendSupportAdminNotificationEmail(params: {
reason: "created" | "replied";
ticketId: string;
title: string;
contactName: string;
contactEmail: string;
body?: string; // The new message content (description on create, comment body on reply)
category?: string;
}): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) {
console.warn(
"ADMIN_NOTIFICATION_EMAIL not set; skipping admin support notification"
);
return;
}
const safeContact = escapeHtml(params.contactName);
const safeContactEmail = escapeHtml(params.contactEmail);
const safeTitle = escapeHtml(params.title);
const safeBody = params.body ? escapeHtml(params.body) : "";
const shortId = params.ticketId.slice(0, 8);
const subjectVerb = params.reason === "created" ? "New" : "Reply on";
const subject = `${ticketSubjectPrefix(params.ticketId)} ${subjectVerb}: ${params.title}`;
const headlineHtml =
params.reason === "created"
? `<h2 style="color: #ffffff; margin-top: 0;">New support ticket</h2>`
: `<h2 style="color: #ffffff; margin-top: 0;">Customer replied on ticket</h2>`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject,
text: [
params.reason === "created"
? "A new support ticket was opened:"
: "A customer replied to a support ticket:",
"",
`From: ${params.contactName} <${params.contactEmail}>`,
`Ticket: ${params.title} (#${shortId})`,
params.category ? `Category: ${params.category}` : "",
"",
params.body ? "Message:" : "",
params.body ?? "",
"",
`View at https://app.pieced.ch/support/${params.ticketId}`,
]
.filter((s) => s !== "")
.join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
${headlineHtml}
<table style="width:100%; font-size: 13px; color: #aaa; margin-bottom: 16px;">
<tr><td style="padding: 4px 0; width: 100px;">From</td><td style="padding: 4px 0; color: #fff;">${safeContact} &lt;${safeContactEmail}&gt;</td></tr>
<tr><td style="padding: 4px 0;">Title</td><td style="padding: 4px 0; color: #fff;">${safeTitle} <span style="color: #666;">(#${shortId})</span></td></tr>
${params.category ? `<tr><td style="padding: 4px 0;">Category</td><td style="padding: 4px 0; color: #fff;">${escapeHtml(params.category)}</td></tr>` : ""}
</table>
${
params.body
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">${safeBody}</div>`
: ""
}
<p>
<a href="https://app.pieced.ch/support/${params.ticketId}" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Open in admin queue
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Admin notification</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send admin support notification:", err);
}
}