1
0
Fork 0

feat(tickets): add ticket mutation services

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-01 17:48:37 -04:00
parent dbf7a0f180
commit 4594ff3d7c
2 changed files with 259 additions and 0 deletions

View file

@ -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<Ticket> {
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<Ticket> {
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<TicketVoteResult> {
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 };
}

View file

@ -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<readonly TicketVoteSummary[]> {
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<number, number>();
const voted = new Set<number>();
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),
}));
}