From 4594ff3d7c5f1f7a43af8fe54b723235e6201468 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 1 Sep 2026 17:48:37 -0400 Subject: [PATCH] feat(tickets): add ticket mutation services Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/lib/tickets/mutations.ts | 217 +++++++++++++++++++++++++++++++++++ src/lib/tickets/voteState.ts | 42 +++++++ 2 files changed, 259 insertions(+) create mode 100644 src/lib/tickets/mutations.ts create mode 100644 src/lib/tickets/voteState.ts diff --git a/src/lib/tickets/mutations.ts b/src/lib/tickets/mutations.ts new file mode 100644 index 0000000..acfa5c5 --- /dev/null +++ b/src/lib/tickets/mutations.ts @@ -0,0 +1,217 @@ +import type { Payload } from "payload"; +import type { Ticket, User } from "@/payload-types"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { plaintextToLexical } from "@/lib/tickets/lexical"; +import { + CATEGORY_LABEL, + PRIORITY_LABEL, + TICKET_CATEGORIES, + TICKET_PRIORITIES, + type TicketCategory, + type TicketPriority, +} from "@/lib/tickets/ticketMeta"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; + +export interface TicketEditInput { + readonly ticketId: number; + readonly title: string; + readonly description: string; + readonly category: TicketCategory; + readonly priority: TicketPriority; +} + +export interface TicketVoteResult { + readonly voteCount: number; + readonly voted: boolean; +} + +type TicketEditSnapshot = Readonly<{ + title: string; + description: string; + category: TicketCategory; + priority: TicketPriority; +}>; + +const EDITABLE_TICKET_FIELDS = ["title", "description", "category", "priority"] as const; + +function relationId(relation: number | User): number { + return typeof relation === "object" ? relation.id : relation; +} + +async function ticketById(payload: Payload, ticketId: number): Promise { + if (!Number.isInteger(ticketId) || ticketId <= 0) { + throw new Error("Ticket not found."); + } + return payload.findByID({ + collection: "tickets", + id: ticketId, + depth: 0, + overrideAccess: true, + }); +} + +function snapshotOf(ticket: Ticket): TicketEditSnapshot { + return { + title: ticket.title, + description: convertLexicalToPlaintext({ data: ticket.description }), + category: ticket.category, + priority: ticket.priority, + }; +} + +export async function updateTicketByAuthor( + payload: Payload, + user: User, + input: TicketEditInput, +): Promise { + const ticket = await ticketById(payload, input.ticketId); + if (relationId(ticket.reporter) !== user.id) { + throw new Error("Only the ticket author can edit this ticket."); + } + + const title = input.title.trim(); + if (!title) throw new Error("Title is required."); + if (title.length > 200) throw new Error("Title must be 200 characters or fewer."); + + const description = input.description.trim(); + if (!description) throw new Error("Description is required."); + if (!TICKET_CATEGORIES.some((option) => option.value === input.category)) { + throw new Error("Invalid category."); + } + if (!TICKET_PRIORITIES.some((option) => option.value === input.priority)) { + throw new Error("Invalid priority."); + } + + const before = snapshotOf(ticket); + const after = { + title, + description, + category: input.category, + priority: input.priority, + } satisfies TicketEditSnapshot; + const changedFields = EDITABLE_TICKET_FIELDS.filter( + (field) => before[field] !== after[field], + ); + if (changedFields.length === 0) return ticket; + + const editedAt = new Date().toISOString(); + const readableFields = changedFields.map((field) => { + if (field === "category") return `category to ${CATEGORY_LABEL[after.category]}`; + if (field === "priority") return `priority to ${PRIORITY_LABEL[after.priority]}`; + return field; + }); + const activity = [ + ...(ticket.activity ?? []), + { + type: "system" as const, + content: plaintextToLexical(`Ticket details edited: ${readableFields.join(", ")}`), + author: user.id, + created_at: editedAt, + }, + ]; + const updated = await payload.update({ + collection: "tickets", + id: ticket.id, + data: { + title: after.title, + description: plaintextToLexical(after.description), + category: after.category, + priority: after.priority, + activity, + editHistory: [ + ...(ticket.editHistory ?? []), + { before, after, editedBy: user.id, editedAt }, + ], + }, + depth: 0, + overrideAccess: true, + }); + + await emitGameEvent(payload, { + type: EventTypes.TicketEdit, + message: `Ticket #${ticket.id} details edited`, + actor: user.id, + targetCollection: "tickets", + targetId: ticket.id, + data: { before, after, fields: changedFields }, + }); + return updated; +} + +export async function setTicketVoteForUser( + payload: Payload, + user: User, + ticketId: number, + voted: boolean, +): Promise { + const ticket = await ticketById(payload, ticketId); + if (ticket.status === "canceled") { + throw new Error("Canceled tickets cannot receive votes."); + } + + const existing = await payload.find({ + collection: "ticket-votes", + where: { + and: [{ ticket: { equals: ticket.id } }, { user: { equals: user.id } }], + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + let changed = false; + + if (voted && existing.docs.length === 0) { + try { + await payload.create({ + collection: "ticket-votes", + data: { ticket: ticket.id, user: user.id }, + depth: 0, + overrideAccess: true, + }); + changed = true; + } catch (error) { + const concurrentVote = await payload.find({ + collection: "ticket-votes", + where: { + and: [{ ticket: { equals: ticket.id } }, { user: { equals: user.id } }], + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + if (concurrentVote.docs.length === 0) throw error; + } + } + + if (!voted && existing.docs[0]) { + await payload.delete({ + collection: "ticket-votes", + id: existing.docs[0].id, + depth: 0, + overrideAccess: true, + }); + changed = true; + } + + const votes = await payload.find({ + collection: "ticket-votes", + where: { ticket: { equals: ticket.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + + if (changed) { + await emitGameEvent(payload, { + type: voted ? EventTypes.TicketVote : EventTypes.TicketUnvote, + message: `${voted ? "Upvoted" : "Removed upvote from"} ticket #${ticket.id}`, + actor: user.id, + targetCollection: "tickets", + targetId: ticket.id, + data: { voted, voteCount: votes.totalDocs }, + }); + } + + return { voteCount: votes.totalDocs, voted }; +} diff --git a/src/lib/tickets/voteState.ts b/src/lib/tickets/voteState.ts new file mode 100644 index 0000000..3563a42 --- /dev/null +++ b/src/lib/tickets/voteState.ts @@ -0,0 +1,42 @@ +import type { Payload } from "payload"; +import type { Ticket, User } from "@/payload-types"; + +export interface TicketVoteSummary { + readonly ticketId: number; + readonly voteCount: number; + readonly voted: boolean; +} + +function relationId(relation: number | Ticket | User): number { + return typeof relation === "object" ? relation.id : relation; +} + +export async function getTicketVoteSummaries( + payload: Payload, + userId: number, + ticketIds: readonly number[], +): Promise { + if (ticketIds.length === 0) return []; + + const votes = await payload.find({ + collection: "ticket-votes", + where: { ticket: { in: ticketIds } }, + limit: 10000, + depth: 0, + overrideAccess: true, + }); + const counts = new Map(); + const voted = new Set(); + + for (const vote of votes.docs) { + const ticketId = relationId(vote.ticket); + counts.set(ticketId, (counts.get(ticketId) ?? 0) + 1); + if (relationId(vote.user) === userId) voted.add(ticketId); + } + + return ticketIds.map((ticketId) => ({ + ticketId, + voteCount: counts.get(ticketId) ?? 0, + voted: voted.has(ticketId), + })); +}