1
0
Fork 0

feat(tickets): add Tickets collection

Add the helpdesk Tickets collection with status/priority/category enums, reporter/assignee relationships, activity timeline, and closedAt lifecycle. Register in payload.config and refresh generated types.

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:27 -04:00
parent 94cd7c5626
commit e7eff028b7
3 changed files with 362 additions and 0 deletions

View file

@ -0,0 +1,234 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import {
CLOSING_STATUSES,
STATUS_LABEL,
TICKET_CATEGORIES,
TICKET_PRIORITIES,
TICKET_STATUSES,
} from "@/lib/tickets/ticketMeta";
import { plaintextToLexical } from "@/lib/tickets/lexical";
type StaffUser = {
id: number;
payloadDisplayName?: string | null;
username?: string | null;
};
const resolveUserId = (value: unknown): number | null => {
if (value == null) return null;
if (typeof value === "object") {
const obj = value as { id?: unknown };
return typeof obj.id === "number" ? obj.id : null;
}
return typeof value === "number" ? value : null;
};
const systemEntry = (message: string, createdAt?: string) => ({
type: "system",
content: plaintextToLexical(message),
created_at: createdAt ?? new Date().toISOString(),
});
export const Tickets: CollectionConfig = {
slug: "tickets",
admin: {
group: "Helpdesk",
useAsTitle: "title",
defaultColumns: ["title", "status", "priority", "category", "reporter", "assignee", "updatedAt"],
listSearchableFields: ["title", "id"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => {
if (!req.user) return false;
if (hasRoles(["admin", "developer"], req.user)) return true;
return { reporter: { equals: req.user.id } };
},
create: ({ req }) => !!req.user,
update: ({ req }) => {
if (!req.user) return false;
return hasRoles(["admin", "developer"], req.user) ? true : false;
},
delete: isDeveloper,
},
hooks: {
beforeChange: [
async ({ data, originalDoc, req }) => {
if (!data) return data;
const next = data as Record<string, unknown>;
const prev = originalDoc as Record<string, unknown> | undefined;
// Only diff against an existing document — creates start with a system
// entry appended by the create server action instead.
if (!prev || !prev.id) return data;
const entries = [...((next.activity as unknown[]) ?? [])];
const now = new Date().toISOString();
const changes: string[] = [];
// Status transitions → system entry + closedAt management.
const prevStatus = prev.status as string | undefined;
const nextStatus = next.status as string | undefined;
if (nextStatus && prevStatus !== nextStatus) {
const from = STATUS_LABEL[prevStatus as keyof typeof STATUS_LABEL] ?? prevStatus;
const to = STATUS_LABEL[nextStatus as keyof typeof STATUS_LABEL] ?? nextStatus;
changes.push(`Status changed from ${from} to ${to}`);
if (CLOSING_STATUSES.includes(nextStatus as (typeof CLOSING_STATUSES)[number])) {
next.closedAt = now;
} else if (prev.closedAt) {
next.closedAt = null;
}
}
// Assignment changes → system entry.
const prevAssigneeId = resolveUserId(prev.assignee);
const nextAssigneeId = resolveUserId(next.assignee);
if (prevAssigneeId !== nextAssigneeId) {
if (nextAssigneeId == null) {
changes.push("Ticket unassigned");
} else {
let name = `User ${nextAssigneeId}`;
try {
const assignee = await req.payload.findByID({
collection: "users",
id: nextAssigneeId,
depth: 0,
overrideAccess: true,
});
const staffUser = assignee as unknown as StaffUser;
name = staffUser.payloadDisplayName || staffUser.username || name;
} catch {
// Name resolution is best-effort — fall back to "User <id>".
}
changes.push(`Ticket assigned to ${name}`);
}
}
for (const message of changes) {
entries.push(systemEntry(message, now));
}
if (changes.length > 0) {
next.activity = entries;
}
return data;
},
],
},
fields: [
{
name: "title",
type: "text",
required: true,
admin: {
description: "Short, descriptive summary of the issue or request.",
},
},
{
name: "description",
type: "richText",
required: true,
admin: {
description: "Full details of the issue or request.",
},
},
{
name: "category",
type: "select",
required: true,
defaultValue: "support_request",
options: TICKET_CATEGORIES.map((c) => ({ label: c.label, value: c.value })),
index: true,
},
{
name: "priority",
type: "select",
required: true,
defaultValue: "medium",
options: TICKET_PRIORITIES.map((p) => ({ label: p.label, value: p.value })),
index: true,
},
{
name: "status",
type: "select",
required: true,
defaultValue: "open",
options: TICKET_STATUSES.map((s) => ({ label: s.label, value: s.value })),
index: true,
},
{
name: "reporter",
type: "relationship",
relationTo: "users",
required: true,
index: true,
admin: {
description: "The user who submitted this ticket.",
},
},
{
name: "assignee",
type: "relationship",
relationTo: "users",
admin: {
description: "Staff member responsible for this ticket.",
},
},
{
name: "activity",
type: "array",
admin: {
description:
"Append-only activity timeline. Messages between the reporter and staff plus system events.",
},
fields: [
{
name: "type",
type: "select",
required: true,
defaultValue: "system",
options: [
{ label: "System", value: "system" },
{ label: "Staff message", value: "agent_message" },
{ label: "Reporter message", value: "client_message" },
],
},
{
name: "content",
type: "richText",
required: true,
},
{
name: "author",
type: "relationship",
relationTo: "users",
admin: {
description: "Who wrote this entry. Omitted for system events.",
},
},
{
name: "created_at",
type: "date",
admin: {
readOnly: true,
date: { pickerAppearance: "dayAndTime" },
},
defaultValue: () => new Date().toISOString(),
},
],
},
{
name: "closedAt",
type: "date",
admin: {
readOnly: true,
description: "Set automatically when the ticket reaches a closing status.",
date: { pickerAppearance: "dayAndTime" },
},
},
],
};

View file

@ -98,6 +98,7 @@ export interface Config {
loadouts: Loadout; loadouts: Loadout;
'market-listings': MarketListing; 'market-listings': MarketListing;
'market-negotiations': MarketNegotiation; 'market-negotiations': MarketNegotiation;
tickets: Ticket;
maps: Map; maps: Map;
'narrative-events': NarrativeEvent; 'narrative-events': NarrativeEvent;
'mission-files': MissionFile; 'mission-files': MissionFile;
@ -147,6 +148,7 @@ export interface Config {
loadouts: LoadoutsSelect<false> | LoadoutsSelect<true>; loadouts: LoadoutsSelect<false> | LoadoutsSelect<true>;
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>; 'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>; 'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
tickets: TicketsSelect<false> | TicketsSelect<true>;
maps: MapsSelect<false> | MapsSelect<true>; maps: MapsSelect<false> | MapsSelect<true>;
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>; 'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>; 'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
@ -1227,6 +1229,7 @@ export interface GameEventLog {
| 'loadouts' | 'loadouts'
| 'market-listings' | 'market-listings'
| 'market-negotiations' | 'market-negotiations'
| 'tickets'
) )
| null; | null;
targetId?: number | null; targetId?: number | null;
@ -1272,6 +1275,10 @@ export interface User {
| 'finance:withdraw' | 'finance:withdraw'
| 'finance:transfer' | 'finance:transfer'
| 'shipment:completed' | 'shipment:completed'
| 'ticket:created'
| 'ticket:assigned'
| 'ticket:reply'
| 'ticket:status'
)[] )[]
| null; | null;
}; };
@ -1295,6 +1302,10 @@ export interface User {
| 'finance:withdraw' | 'finance:withdraw'
| 'finance:transfer' | 'finance:transfer'
| 'shipment:completed' | 'shipment:completed'
| 'ticket:created'
| 'ticket:assigned'
| 'ticket:reply'
| 'ticket:status'
)[] )[]
| null; | null;
}; };
@ -2204,6 +2215,90 @@ export interface MarketNegotiation {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tickets".
*/
export interface Ticket {
id: number;
/**
* Short, descriptive summary of the issue or request.
*/
title: string;
/**
* Full details of the issue or request.
*/
description: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
category: 'bug_report' | 'feature_request' | 'support_request' | 'other';
priority: 'low' | 'medium' | 'high' | 'critical';
status:
| 'open'
| 'in_review'
| 'planning'
| 'in_progress'
| 'waiting_on_reporter'
| 'in_testing'
| 'resolved'
| 'canceled'
| 'closed';
/**
* The user who submitted this ticket.
*/
reporter: number | User;
/**
* Staff member responsible for this ticket.
*/
assignee?: (number | null) | User;
/**
* Append-only activity timeline. Messages between the reporter and staff plus system events.
*/
activity?:
| {
type: 'system' | 'agent_message' | 'client_message';
content: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
/**
* Who wrote this entry. Omitted for system events.
*/
author?: (number | null) | User;
created_at?: string | null;
id?: string | null;
}[]
| null;
/**
* Set automatically when the ticket reaches a closing status.
*/
closedAt?: string | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "narrative-events". * via the `definition` "narrative-events".
@ -2393,6 +2488,10 @@ export interface PayloadLockedDocument {
relationTo: 'market-negotiations'; relationTo: 'market-negotiations';
value: number | MarketNegotiation; value: number | MarketNegotiation;
} | null) } | null)
| ({
relationTo: 'tickets';
value: number | Ticket;
} | null)
| ({ | ({
relationTo: 'maps'; relationTo: 'maps';
value: number | Map; value: number | Map;
@ -3443,6 +3542,31 @@ export interface MarketNegotiationsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tickets_select".
*/
export interface TicketsSelect<T extends boolean = true> {
title?: T;
description?: T;
category?: T;
priority?: T;
status?: T;
reporter?: T;
assignee?: T;
activity?:
| T
| {
type?: T;
content?: T;
author?: T;
created_at?: T;
id?: T;
};
closedAt?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "maps_select". * via the `definition` "maps_select".

View file

@ -42,6 +42,7 @@ import { Loadouts } from "@/collections/locker/Loadouts";
import { MarketListings } from "@/collections/market/MarketListings"; import { MarketListings } from "@/collections/market/MarketListings";
import { MarketNegotiations } from "@/collections/market/MarketNegotiations"; import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
import { UserNotifications } from "@/collections/users/UserNotifications"; import { UserNotifications } from "@/collections/users/UserNotifications";
import { Tickets } from "@/collections/tickets/Tickets";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@ -124,6 +125,9 @@ export default buildConfig({
MarketListings, MarketListings,
MarketNegotiations, MarketNegotiations,
// Helpdesk
Tickets,
// World // World
Maps, Maps,
NarrativeEvents, NarrativeEvents,