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:
parent
dbf7a0f180
commit
4594ff3d7c
2 changed files with 259 additions and 0 deletions
217
src/lib/tickets/mutations.ts
Normal file
217
src/lib/tickets/mutations.ts
Normal 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 };
|
||||
}
|
||||
42
src/lib/tickets/voteState.ts
Normal file
42
src/lib/tickets/voteState.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Loading…
Reference in a new issue