1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-16 18:12:33 -04:00
parent e7eff028b7
commit ff986bf4ec
3 changed files with 227 additions and 0 deletions

View file

@ -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),
})),
},
};
}

26
src/lib/tickets/staff.ts Normal file
View file

@ -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<ReturnType<typeof import("payload").getPayload>>;
export async function getStaffUserIds(payload: PayloadType): Promise<number[]> {
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 [];
}
}

View file

@ -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<TicketStatus, string> = {
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<TicketPriority, string> = {
low: "Low",
medium: "Medium",
high: "High",
critical: "Critical",
};
export const CATEGORY_LABEL: Record<TicketCategory, string> = {
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<TicketStatus, string> = {
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<TicketPriority, string> = {
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<TicketCategory, string> = {
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",
};