diff --git a/src/collections/tickets/Tickets.ts b/src/collections/tickets/Tickets.ts new file mode 100644 index 0000000..1300159 --- /dev/null +++ b/src/collections/tickets/Tickets.ts @@ -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; + const prev = originalDoc as Record | 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 ". + } + 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" }, + }, + }, + ], +}; diff --git a/src/payload-types.ts b/src/payload-types.ts index 7e97ecd..ba51680 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | LoadoutsSelect; 'market-listings': MarketListingsSelect | MarketListingsSelect; 'market-negotiations': MarketNegotiationsSelect | MarketNegotiationsSelect; + tickets: TicketsSelect | TicketsSelect; maps: MapsSelect | MapsSelect; 'narrative-events': NarrativeEventsSelect | NarrativeEventsSelect; 'mission-files': MissionFilesSelect | MissionFilesSelect; @@ -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 { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tickets_select". + */ +export interface TicketsSelect { + 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". diff --git a/src/payload.config.ts b/src/payload.config.ts index 52e72a6..d16a637 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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,