From dbf7a0f180f59f3323637ada71e1a84cb50932ec Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 1 Sep 2026 17:48:34 -0400 Subject: [PATCH] feat(tickets): add ticket collections and edit history Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/collections/tickets/TicketVotes.ts | 37 +++++++++++++++ src/collections/tickets/Tickets.ts | 62 +++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/collections/tickets/TicketVotes.ts diff --git a/src/collections/tickets/TicketVotes.ts b/src/collections/tickets/TicketVotes.ts new file mode 100644 index 0000000..10de5f5 --- /dev/null +++ b/src/collections/tickets/TicketVotes.ts @@ -0,0 +1,37 @@ +import type { CollectionConfig } from "payload"; + +export const TicketVotes: CollectionConfig = { + slug: "ticket-votes", + admin: { + group: "Helpdesk", + defaultColumns: ["ticket", "user", "createdAt"], + }, + access: { + read: ({ req }) => Boolean(req.user), + create: () => false, + update: () => false, + delete: () => false, + }, + indexes: [ + { + fields: ["ticket", "user"], + unique: true, + }, + ], + fields: [ + { + name: "ticket", + type: "relationship", + relationTo: "tickets", + required: true, + index: true, + }, + { + name: "user", + type: "relationship", + relationTo: "users", + required: true, + index: true, + }, + ], +}; diff --git a/src/collections/tickets/Tickets.ts b/src/collections/tickets/Tickets.ts index acbacba..523dd61 100644 --- a/src/collections/tickets/Tickets.ts +++ b/src/collections/tickets/Tickets.ts @@ -1,5 +1,5 @@ import { CollectionConfig } from "payload"; -import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission"; +import { hasPermission } from "@/utils/access-control/hasPermission"; import { CLOSING_STATUSES, STATUS_LABEL, @@ -30,6 +30,20 @@ const systemEntry = (message: string, createdAt?: string) => ({ created_at: createdAt ?? new Date().toISOString(), }); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function immutableEditValue(value: unknown): string | null { + if (!isRecord(value)) return null; + return JSON.stringify({ + before: value.before, + after: value.after, + editedBy: resolveUserId(value.editedBy), + editedAt: value.editedAt, + }); +} + export const Tickets: CollectionConfig = { slug: "tickets", admin: { @@ -74,6 +88,17 @@ export const Tickets: CollectionConfig = { // entry appended by the create server action instead. if (!prev || !prev.id) return data; + if (next.editHistory !== undefined) { + const previousEdits = Array.isArray(prev.editHistory) ? prev.editHistory : []; + const nextEdits = Array.isArray(next.editHistory) ? next.editHistory : []; + const existingEditsChanged = previousEdits.some( + (entry, index) => immutableEditValue(entry) !== immutableEditValue(nextEdits[index]), + ); + if (nextEdits.length < previousEdits.length || existingEditsChanged) { + throw new Error("Ticket edit history is immutable."); + } + } + const entries = [...((next.activity as unknown[]) ?? [])]; const now = new Date().toISOString(); const changes: string[] = []; @@ -230,6 +255,41 @@ export const Tickets: CollectionConfig = { }, ], }, + { + name: "editHistory", + type: "array", + admin: { + readOnly: true, + description: "Immutable before/after snapshots captured when the author edits a ticket.", + }, + fields: [ + { + name: "before", + type: "json", + required: true, + }, + { + name: "after", + type: "json", + required: true, + }, + { + name: "editedBy", + type: "relationship", + relationTo: "users", + required: true, + }, + { + name: "editedAt", + type: "date", + required: true, + admin: { + readOnly: true, + date: { pickerAppearance: "dayAndTime" }, + }, + }, + ], + }, { name: "closedAt", type: "date",