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;
|
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".
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue