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:
parent
e7eff028b7
commit
ff986bf4ec
3 changed files with 227 additions and 0 deletions
89
src/lib/tickets/lexical.ts
Normal file
89
src/lib/tickets/lexical.ts
Normal 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
26
src/lib/tickets/staff.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/lib/tickets/ticketMeta.ts
Normal file
112
src/lib/tickets/ticketMeta.ts
Normal 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",
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue