From ff986bf4ecc81fc7df6b86b2a4a33448fa099fae Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 16 Aug 2026 18:12:33 -0400 Subject: [PATCH] feat(helpdesk): add ticket domain libs Add shared ticket metadata (labels, styles), staff resolution, and plaintext-to-lexical conversion helpers. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/lib/tickets/lexical.ts | 89 +++++++++++++++++++++++++++ src/lib/tickets/staff.ts | 26 ++++++++ src/lib/tickets/ticketMeta.ts | 112 ++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 src/lib/tickets/lexical.ts create mode 100644 src/lib/tickets/staff.ts create mode 100644 src/lib/tickets/ticketMeta.ts diff --git a/src/lib/tickets/lexical.ts b/src/lib/tickets/lexical.ts new file mode 100644 index 0000000..0c44466 --- /dev/null +++ b/src/lib/tickets/lexical.ts @@ -0,0 +1,89 @@ +/** + * Converts plain text (from client textareas) into Payload-compatible lexical + * editor state so richText fields can be populated from simple forms. + * + * Paragraph breaks: double newlines split paragraphs; single newlines become + * line breaks inside a paragraph. + */ + +interface LexicalTextNode { + type: "text"; + version: 1; + detail: 0; + format: 0; + mode: "normal"; + style: ""; + text: string; + [k: string]: unknown; +} + +interface LexicalLinebreakNode { + type: "linebreak"; + version: 1; + [k: string]: unknown; +} + +type LexicalInlineNode = LexicalTextNode | LexicalLinebreakNode; + +interface LexicalParagraphNode { + type: "paragraph"; + version: 1; + direction: "ltr"; + format: ""; + indent: 0; + textFormat: 0; + textStyle: ""; + children: LexicalInlineNode[]; + [k: string]: unknown; +} + +export interface PlaintextLexicalState { + root: { + type: "root"; + version: 1; + direction: "ltr"; + format: ""; + indent: 0; + children: LexicalParagraphNode[]; + }; + [k: string]: unknown; +} + +function inlineNodes(text: string): LexicalInlineNode[] { + const nodes: LexicalInlineNode[] = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].length > 0) { + nodes.push({ type: "text", version: 1, detail: 0, format: 0, mode: "normal", style: "", text: lines[i] }); + } + if (i < lines.length - 1) { + nodes.push({ type: "linebreak", version: 1 }); + } + } + return nodes; +} + +export function plaintextToLexical(text: string): PlaintextLexicalState { + const trimmed = text.trim(); + const paragraphs = trimmed.length === 0 ? [""] : trimmed.split(/\n{2,}/); + + return { + root: { + type: "root", + version: 1, + direction: "ltr", + format: "", + indent: 0, + children: paragraphs.map((para) => ({ + type: "paragraph", + version: 1, + direction: "ltr", + format: "", + indent: 0, + textFormat: 0, + textStyle: "", + children: inlineNodes(para), + })), + }, + }; +} diff --git a/src/lib/tickets/staff.ts b/src/lib/tickets/staff.ts new file mode 100644 index 0000000..35e3811 --- /dev/null +++ b/src/lib/tickets/staff.ts @@ -0,0 +1,26 @@ +/** + * Helpdesk staff resolution. "Staff" = users with the `admin` or `developer` + * role — the people who can manage tickets. Used to fan out notifications when + * a new ticket is created. + */ + +type PayloadType = Awaited>; + +export async function getStaffUserIds(payload: PayloadType): Promise { + try { + const res = await payload.find({ + collection: "users", + where: { + or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }], + }, + limit: 50, + depth: 0, + select: { username: true }, + overrideAccess: true, + }); + return res.docs.map((d) => d.id); + } catch (err) { + console.error("[Tickets] Failed to resolve staff recipients:", err); + return []; + } +} diff --git a/src/lib/tickets/ticketMeta.ts b/src/lib/tickets/ticketMeta.ts new file mode 100644 index 0000000..d3c5c40 --- /dev/null +++ b/src/lib/tickets/ticketMeta.ts @@ -0,0 +1,112 @@ +/** + * Shared ticket vocabulary: status/priority/category options, human labels, and + * UI style maps. Single source of truth used by the collection, server actions, + * and the frontend. + */ + +export const TICKET_STATUSES = [ + { label: "Open", value: "open" }, + { label: "In Review", value: "in_review" }, + { label: "Planning", value: "planning" }, + { label: "In Progress", value: "in_progress" }, + { label: "Waiting on Reporter", value: "waiting_on_reporter" }, + { label: "In Testing", value: "in_testing" }, + { label: "Resolved", value: "resolved" }, + { label: "Canceled", value: "canceled" }, + { label: "Closed", value: "closed" }, +] as const; + +export type TicketStatus = (typeof TICKET_STATUSES)[number]["value"]; + +export const TICKET_PRIORITIES = [ + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, + { label: "Critical", value: "critical" }, +] as const; + +export type TicketPriority = (typeof TICKET_PRIORITIES)[number]["value"]; + +export const TICKET_CATEGORIES = [ + { label: "Bug Report", value: "bug_report" }, + { label: "Feature Request", value: "feature_request" }, + { label: "Support Request", value: "support_request" }, + { label: "Other", value: "other" }, +] as const; + +export type TicketCategory = (typeof TICKET_CATEGORIES)[number]["value"]; + +export const STATUS_LABEL: Record = { + open: "Open", + in_review: "In Review", + planning: "Planning", + in_progress: "In Progress", + waiting_on_reporter: "Waiting on Reporter", + in_testing: "In Testing", + resolved: "Resolved", + canceled: "Canceled", + closed: "Closed", +}; + +export const PRIORITY_LABEL: Record = { + low: "Low", + medium: "Medium", + high: "High", + critical: "Critical", +}; + +export const CATEGORY_LABEL: Record = { + bug_report: "Bug Report", + feature_request: "Feature Request", + support_request: "Support Request", + other: "Other", +}; + +export const statusLabel = (value: string | null | undefined): string => + value && value in STATUS_LABEL + ? STATUS_LABEL[value as TicketStatus] + : value ?? "Unknown"; + +export const priorityLabel = (value: string | null | undefined): string => + value && value in PRIORITY_LABEL + ? PRIORITY_LABEL[value as TicketPriority] + : value ?? "Unknown"; + +export const categoryLabel = (value: string | null | undefined): string => + value && value in CATEGORY_LABEL + ? CATEGORY_LABEL[value as TicketCategory] + : value ?? "Unknown"; + +/** Statuses that count as the ticket being done — sets `closedAt`. */ +export const CLOSING_STATUSES: readonly TicketStatus[] = [ + "resolved", + "canceled", + "closed", +]; + +/** Badge color classes per status, keyed for use with the shadcn `Badge` component. */ +export const STATUS_STYLES: Record = { + open: "bg-sky-500/15 text-sky-400 border-sky-500/30", + in_review: "bg-violet-500/15 text-violet-400 border-violet-500/30", + planning: "bg-amber-500/15 text-amber-400 border-amber-500/30", + in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30", + waiting_on_reporter: "bg-orange-500/15 text-orange-400 border-orange-500/30", + in_testing: "bg-cyan-500/15 text-cyan-400 border-cyan-500/30", + resolved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + canceled: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", + closed: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", +}; + +export const PRIORITY_STYLES: Record = { + low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", + medium: "bg-sky-500/15 text-sky-400 border-sky-500/30", + high: "bg-amber-500/15 text-amber-400 border-amber-500/30", + critical: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +export const CATEGORY_STYLES: Record = { + bug_report: "bg-red-500/15 text-red-400 border-red-500/30", + feature_request: "bg-violet-500/15 text-violet-400 border-violet-500/30", + support_request: "bg-sky-500/15 text-sky-400 border-sky-500/30", + other: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", +};