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:
parent
94cd7c5626
commit
e7eff028b7
3 changed files with 362 additions and 0 deletions
234
src/collections/tickets/Tickets.ts
Normal file
234
src/collections/tickets/Tickets.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -98,6 +98,7 @@ export interface Config {
|
|||
loadouts: Loadout;
|
||||
'market-listings': MarketListing;
|
||||
'market-negotiations': MarketNegotiation;
|
||||
tickets: Ticket;
|
||||
maps: Map;
|
||||
'narrative-events': NarrativeEvent;
|
||||
'mission-files': MissionFile;
|
||||
|
|
@ -147,6 +148,7 @@ export interface Config {
|
|||
loadouts: LoadoutsSelect<false> | LoadoutsSelect<true>;
|
||||
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
||||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
||||
maps: MapsSelect<false> | MapsSelect<true>;
|
||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||
|
|
@ -1227,6 +1229,7 @@ export interface GameEventLog {
|
|||
| 'loadouts'
|
||||
| 'market-listings'
|
||||
| 'market-negotiations'
|
||||
| 'tickets'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -1272,6 +1275,10 @@ export interface User {
|
|||
| 'finance:withdraw'
|
||||
| 'finance:transfer'
|
||||
| 'shipment:completed'
|
||||
| 'ticket:created'
|
||||
| 'ticket:assigned'
|
||||
| 'ticket:reply'
|
||||
| 'ticket:status'
|
||||
)[]
|
||||
| null;
|
||||
};
|
||||
|
|
@ -1295,6 +1302,10 @@ export interface User {
|
|||
| 'finance:withdraw'
|
||||
| 'finance:transfer'
|
||||
| 'shipment:completed'
|
||||
| 'ticket:created'
|
||||
| 'ticket:assigned'
|
||||
| 'ticket:reply'
|
||||
| 'ticket:status'
|
||||
)[]
|
||||
| null;
|
||||
};
|
||||
|
|
@ -2204,6 +2215,90 @@ export interface MarketNegotiation {
|
|||
updatedAt: 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
|
||||
* via the `definition` "narrative-events".
|
||||
|
|
@ -2393,6 +2488,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'market-negotiations';
|
||||
value: number | MarketNegotiation;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'tickets';
|
||||
value: number | Ticket;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'maps';
|
||||
value: number | Map;
|
||||
|
|
@ -3443,6 +3542,31 @@ export interface MarketNegotiationsSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "maps_select".
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { Loadouts } from "@/collections/locker/Loadouts";
|
|||
import { MarketListings } from "@/collections/market/MarketListings";
|
||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
|
|
@ -124,6 +125,9 @@ export default buildConfig({
|
|||
MarketListings,
|
||||
MarketNegotiations,
|
||||
|
||||
// Helpdesk
|
||||
Tickets,
|
||||
|
||||
// World
|
||||
Maps,
|
||||
NarrativeEvents,
|
||||
|
|
|
|||
Loading…
Reference in a new issue