Compare commits
No commits in common. "b4bb328acda90534f73aacee028cb5fe0b876a04" and "f1239ad593b822d77bc1920ed9747d34ef97f132" have entirely different histories.
b4bb328acd
...
f1239ad593
21 changed files with 20 additions and 21992 deletions
13
DESIGN.md
13
DESIGN.md
|
|
@ -140,19 +140,6 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
||||||
- **Layout**: the transfer list is a single document scroll region inside the authenticated shell;
|
- **Layout**: the transfer list is a single document scroll region inside the authenticated shell;
|
||||||
cards use an intrinsic one-column-to-two-column grid and remain readable at narrow widths.
|
cards use an intrinsic one-column-to-two-column grid and remain readable at narrow widths.
|
||||||
|
|
||||||
### Helpdesk ticket controls
|
|
||||||
|
|
||||||
- **Structure**: ticket list and detail surfaces use the existing `Item`, `Badge`, `Button`, and
|
|
||||||
`Dialog` primitives. The author-only edit dialog contains labeled title, category, priority, and
|
|
||||||
description controls; vote state uses one `Button` with a visible count.
|
|
||||||
- **States**: edit controls expose default, pending, success, and error states. Vote controls expose
|
|
||||||
unvoted, voted (`aria-pressed`), pending, and disabled states; canceled tickets disable voting.
|
|
||||||
- **Accessibility**: edit dialogs have a title and description, every field has an associated label,
|
|
||||||
and vote buttons announce both the action and count without relying on iconography.
|
|
||||||
- **Motion**: use existing shadcn dialog and button transitions only.
|
|
||||||
- **Layout**: controls wrap on narrow screens and never turn a card-wide navigation link into a
|
|
||||||
nested interactive target.
|
|
||||||
|
|
||||||
## 6. Motion & Interaction
|
## 6. Motion & Interaction
|
||||||
|
|
||||||
### Timing
|
### Timing
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react";
|
import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTicketVoteSummaries } from "@/lib/tickets/voteState";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Ticket — Polaris Task Force",
|
title: "Ticket — Polaris Task Force",
|
||||||
|
|
@ -44,10 +43,7 @@ export default async function TicketPage({ params }: TicketPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedTicket = ticket as unknown as Ticket;
|
const typedTicket = ticket as unknown as Ticket;
|
||||||
const [isStaff, voteSummaries] = await Promise.all([
|
const isStaff = await hasPermission(payload, user, "tickets:staff");
|
||||||
hasPermission(payload, user, "tickets:staff"),
|
|
||||||
getTicketVoteSummaries(payload, user.id, [typedTicket.id]),
|
|
||||||
]);
|
|
||||||
const assigneeOptions = isStaff
|
const assigneeOptions = isStaff
|
||||||
? (
|
? (
|
||||||
await payload.find({
|
await payload.find({
|
||||||
|
|
@ -83,7 +79,6 @@ export default async function TicketPage({ params }: TicketPageProps) {
|
||||||
currentUserId={user.id}
|
currentUserId={user.id}
|
||||||
isStaff={isStaff}
|
isStaff={isStaff}
|
||||||
assigneeOptions={assigneeOptions}
|
assigneeOptions={assigneeOptions}
|
||||||
voteSummary={voteSummaries[0] ?? { ticketId: typedTicket.id, voteCount: 0, voted: false }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,6 @@ import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||||
import { getStaffUserIds } from "@/lib/tickets/staff";
|
import { getStaffUserIds } from "@/lib/tickets/staff";
|
||||||
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
|
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||||
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||||
import {
|
|
||||||
setTicketVoteForUser,
|
|
||||||
updateTicketByAuthor,
|
|
||||||
type TicketEditInput,
|
|
||||||
type TicketVoteResult,
|
|
||||||
} from "@/lib/tickets/mutations";
|
|
||||||
|
|
||||||
export interface ActionResult<T = undefined> {
|
export interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -147,35 +141,6 @@ export async function createTicket(input: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editTicket(input: TicketEditInput): Promise<ActionResult<Ticket>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const ticket = await updateTicketByAuthor(payload, user, input);
|
|
||||||
return { success: true, data: ticket };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setTicketVote(
|
|
||||||
ticketId: number,
|
|
||||||
voted: boolean,
|
|
||||||
): Promise<ActionResult<TicketVoteResult>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
const vote = await setTicketVoteForUser(payload, user, ticketId, voted);
|
|
||||||
return { success: true, data: vote };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return message === "Unauthorized"
|
|
||||||
? { success: false, error: "You must be logged in." }
|
|
||||||
: { success: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function replyToTicket(ticketId: number, content: string): Promise<ActionResult> {
|
export async function replyToTicket(ticketId: number, content: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
|
|
@ -203,28 +168,14 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
|
||||||
];
|
];
|
||||||
|
|
||||||
const reopen = CLOSING_STATUSES.includes(ticket.status);
|
const reopen = CLOSING_STATUSES.includes(ticket.status);
|
||||||
const reporterReplied = !isStaff;
|
|
||||||
const autoResume = reporterReplied && ticket.status === "waiting_on_reporter";
|
|
||||||
const nextStatus = reopen ? "open" : autoResume ? "in_progress" : undefined;
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "tickets",
|
collection: "tickets",
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
data: nextStatus ? { activity: newActivity, status: nextStatus } : { activity: newActivity },
|
data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (autoResume) {
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.TicketStatusChange,
|
|
||||||
message: `Ticket #${ticketId} status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
|
|
||||||
actor: userId,
|
|
||||||
targetCollection: "tickets",
|
|
||||||
targetId: ticketId,
|
|
||||||
data: { from: "waiting_on_reporter", to: "in_progress" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.TicketReply,
|
type: EventTypes.TicketReply,
|
||||||
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,
|
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { Ticket } from "@/payload-types";
|
||||||
import { TicketsView } from "@/components/frontend/helpdesk/TicketsView";
|
import { TicketsView } from "@/components/frontend/helpdesk/TicketsView";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { LifeBuoyIcon } from "lucide-react";
|
import { LifeBuoyIcon } from "lucide-react";
|
||||||
import { getTicketVoteSummaries } from "@/lib/tickets/voteState";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Helpdesk — Polaris Task Force",
|
title: "Helpdesk — Polaris Task Force",
|
||||||
|
|
@ -32,14 +31,7 @@ export default async function HelpdeskPage() {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
});
|
});
|
||||||
const tickets = ticketsRes.docs as unknown as Ticket[];
|
const tickets = ticketsRes.docs as unknown as Ticket[];
|
||||||
const [isStaff, voteSummaries] = await Promise.all([
|
const isStaff = await hasPermission(payload, user, "tickets:staff");
|
||||||
hasPermission(payload, user, "tickets:staff"),
|
|
||||||
getTicketVoteSummaries(
|
|
||||||
payload,
|
|
||||||
user.id,
|
|
||||||
tickets.map((ticket) => ticket.id),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-5">
|
<div className="flex flex-col gap-6 p-5">
|
||||||
|
|
@ -51,7 +43,7 @@ export default async function HelpdeskPage() {
|
||||||
<p className="text-sm text-muted-foreground">Submit and track support requests.</p>
|
<p className="text-sm text-muted-foreground">Submit and track support requests.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TicketsView tickets={tickets} voteSummaries={voteSummaries} isStaff={isStaff} />
|
<TicketsView tickets={tickets} currentUserId={user.id} isStaff={isStaff} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import {
|
import {
|
||||||
CLOSING_STATUSES,
|
CLOSING_STATUSES,
|
||||||
STATUS_LABEL,
|
STATUS_LABEL,
|
||||||
|
|
@ -30,20 +30,6 @@ const systemEntry = (message: string, createdAt?: string) => ({
|
||||||
created_at: createdAt ?? new Date().toISOString(),
|
created_at: createdAt ?? new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
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 = {
|
export const Tickets: CollectionConfig = {
|
||||||
slug: "tickets",
|
slug: "tickets",
|
||||||
admin: {
|
admin: {
|
||||||
|
|
@ -88,17 +74,6 @@ export const Tickets: CollectionConfig = {
|
||||||
// entry appended by the create server action instead.
|
// entry appended by the create server action instead.
|
||||||
if (!prev || !prev.id) return data;
|
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 entries = [...((next.activity as unknown[]) ?? [])];
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const changes: string[] = [];
|
const changes: string[] = [];
|
||||||
|
|
@ -255,41 +230,6 @@ 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",
|
name: "closedAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { PencilIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { Ticket } from "@/payload-types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { editTicket } from "@/app/(frontend)/helpdesk/actions";
|
|
||||||
import {
|
|
||||||
TICKET_CATEGORIES,
|
|
||||||
TICKET_PRIORITIES,
|
|
||||||
type TicketCategory,
|
|
||||||
type TicketPriority,
|
|
||||||
} from "@/lib/tickets/ticketMeta";
|
|
||||||
|
|
||||||
interface EditTicketDialogProps {
|
|
||||||
readonly ticket: Ticket;
|
|
||||||
readonly description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTicketCategory(value: string): value is TicketCategory {
|
|
||||||
return TICKET_CATEGORIES.some((option) => option.value === value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTicketPriority(value: string): value is TicketPriority {
|
|
||||||
return TICKET_PRIORITIES.some((option) => option.value === value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditTicketDialog({ ticket, description }: EditTicketDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [title, setTitle] = useState(ticket.title);
|
|
||||||
const [details, setDetails] = useState(description);
|
|
||||||
const [category, setCategory] = useState<TicketCategory>(ticket.category);
|
|
||||||
const [priority, setPriority] = useState<TicketPriority>(ticket.priority);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setPending(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await editTicket({
|
|
||||||
ticketId: ticket.id,
|
|
||||||
title,
|
|
||||||
description: details,
|
|
||||||
category,
|
|
||||||
priority,
|
|
||||||
});
|
|
||||||
setPending(false);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? "Could not edit the ticket.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
toast.success("Ticket updated");
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button type="button" size="sm" variant="outline">
|
|
||||||
<PencilIcon data-icon="inline-start" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit ticket</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update the request details. The previous and new values are retained in the audit trail.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`ticket-${ticket.id}-edit-title`}>Title</Label>
|
|
||||||
<Input
|
|
||||||
id={`ticket-${ticket.id}-edit-title`}
|
|
||||||
value={title}
|
|
||||||
maxLength={200}
|
|
||||||
disabled={pending}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`ticket-${ticket.id}-edit-category`}>Category</Label>
|
|
||||||
<Select
|
|
||||||
value={category}
|
|
||||||
disabled={pending}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (isTicketCategory(value)) setCategory(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`ticket-${ticket.id}-edit-category`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{TICKET_CATEGORIES.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`ticket-${ticket.id}-edit-priority`}>Priority</Label>
|
|
||||||
<Select
|
|
||||||
value={priority}
|
|
||||||
disabled={pending}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (isTicketPriority(value)) setPriority(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`ticket-${ticket.id}-edit-priority`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{TICKET_PRIORITIES.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`ticket-${ticket.id}-edit-description`}>Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id={`ticket-${ticket.id}-edit-description`}
|
|
||||||
rows={6}
|
|
||||||
value={details}
|
|
||||||
disabled={pending}
|
|
||||||
onChange={(event) => setDetails(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" disabled={pending} onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? "Saving..." : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,12 +10,9 @@ import {
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
||||||
import { TicketVoteButton } from "@/components/frontend/helpdesk/TicketVoteButton";
|
|
||||||
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
|
|
||||||
|
|
||||||
interface TicketCardProps {
|
interface TicketCardProps {
|
||||||
readonly ticket: Ticket;
|
readonly ticket: Ticket;
|
||||||
readonly voteSummary: TicketVoteSummary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRelation = number | User | null | undefined;
|
type UserRelation = number | User | null | undefined;
|
||||||
|
|
@ -42,23 +39,25 @@ function timeAgo(iso: string): string {
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCard({ ticket, voteSummary }: TicketCardProps) {
|
export function TicketCard({ ticket }: TicketCardProps) {
|
||||||
const description = convertLexicalToPlaintext({ data: ticket.description });
|
const description = convertLexicalToPlaintext({ data: ticket.description });
|
||||||
const reporterName = userName(ticket.reporter, "Unknown reporter");
|
const reporterName = userName(ticket.reporter, "Unknown reporter");
|
||||||
const assigneeName = userName(ticket.assignee, "Unassigned");
|
const assigneeName = userName(ticket.assignee, "Unassigned");
|
||||||
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
|
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item variant="outline" className="h-full min-h-48 transition-colors hover:bg-accent/50">
|
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full">
|
||||||
|
<Item
|
||||||
|
variant="outline"
|
||||||
|
className="h-full min-h-48 transition-colors group-hover:bg-accent/50"
|
||||||
|
>
|
||||||
<ItemHeader className="items-start">
|
<ItemHeader className="items-start">
|
||||||
<TicketCategoryBadge category={ticket.category} />
|
<TicketCategoryBadge category={ticket.category} />
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent className="w-full min-w-0 flex-1 gap-3">
|
<ItemContent className="w-full min-w-0 flex-1 gap-3">
|
||||||
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
|
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
|
||||||
<Link href={`/helpdesk/${ticket.id}`} className="hover:underline">
|
|
||||||
{ticket.title}
|
{ticket.title}
|
||||||
</Link>
|
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription>{description || "No description provided."}</ItemDescription>
|
<ItemDescription>{description || "No description provided."}</ItemDescription>
|
||||||
<TicketBadges ticket={ticket} />
|
<TicketBadges ticket={ticket} />
|
||||||
|
|
@ -71,13 +70,8 @@ export function TicketCard({ ticket, voteSummary }: TicketCardProps) {
|
||||||
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||||
{timeAgo(ticket.updatedAt)}
|
{timeAgo(ticket.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
<TicketVoteButton
|
|
||||||
ticketId={ticket.id}
|
|
||||||
voteCount={voteSummary.voteCount}
|
|
||||||
voted={voteSummary.voted}
|
|
||||||
disabled={ticket.status === "canceled"}
|
|
||||||
/>
|
|
||||||
</ItemFooter>
|
</ItemFooter>
|
||||||
</Item>
|
</Item>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
|
||||||
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
||||||
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||||
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
|
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
|
||||||
import { EditTicketDialog } from "@/components/frontend/helpdesk/EditTicketDialog";
|
|
||||||
import { TicketVoteButton } from "@/components/frontend/helpdesk/TicketVoteButton";
|
|
||||||
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
|
|
||||||
|
|
||||||
interface AssigneeOption {
|
interface AssigneeOption {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
|
|
@ -36,7 +33,6 @@ interface TicketDetailProps {
|
||||||
readonly currentUserId: number;
|
readonly currentUserId: number;
|
||||||
readonly isStaff: boolean;
|
readonly isStaff: boolean;
|
||||||
readonly assigneeOptions: readonly AssigneeOption[];
|
readonly assigneeOptions: readonly AssigneeOption[];
|
||||||
readonly voteSummary: TicketVoteSummary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRelation = number | User | null | undefined;
|
type UserRelation = number | User | null | undefined;
|
||||||
|
|
@ -81,7 +77,6 @@ export function TicketDetail({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isStaff,
|
isStaff,
|
||||||
assigneeOptions,
|
assigneeOptions,
|
||||||
voteSummary,
|
|
||||||
}: TicketDetailProps) {
|
}: TicketDetailProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [status, setStatus] = useState<TicketStatus>(ticket.status);
|
const [status, setStatus] = useState<TicketStatus>(ticket.status);
|
||||||
|
|
@ -110,7 +105,6 @@ export function TicketDetail({
|
||||||
const canCancel =
|
const canCancel =
|
||||||
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
||||||
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
||||||
const isAuthor = relationId(ticket.reporter) === currentUserId;
|
|
||||||
|
|
||||||
async function applyControl(kind: ControlKind) {
|
async function applyControl(kind: ControlKind) {
|
||||||
setControlBusy(kind);
|
setControlBusy(kind);
|
||||||
|
|
@ -189,15 +183,6 @@ export function TicketDetail({
|
||||||
<TicketBadges ticket={ticket} />
|
<TicketBadges ticket={ticket} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<TicketVoteButton
|
|
||||||
ticketId={ticket.id}
|
|
||||||
voteCount={voteSummary.voteCount}
|
|
||||||
voted={voteSummary.voted}
|
|
||||||
disabled={ticket.status === "canceled"}
|
|
||||||
/>
|
|
||||||
{isAuthor && <EditTicketDialog ticket={ticket} description={descriptionText} />}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4">
|
||||||
<MetaItem label="Reporter" value={reporterName} />
|
<MetaItem label="Reporter" value={reporterName} />
|
||||||
<MetaItem label="Assignee" value={assigneeName} />
|
<MetaItem label="Assignee" value={assigneeName} />
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ArrowBigUpIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { setTicketVote } from "@/app/(frontend)/helpdesk/actions";
|
|
||||||
|
|
||||||
interface TicketVoteButtonProps {
|
|
||||||
readonly ticketId: number;
|
|
||||||
readonly voteCount: number;
|
|
||||||
readonly voted: boolean;
|
|
||||||
readonly disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketVoteButton({
|
|
||||||
ticketId,
|
|
||||||
voteCount,
|
|
||||||
voted,
|
|
||||||
disabled = false,
|
|
||||||
}: TicketVoteButtonProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [selected, setSelected] = useState(voted);
|
|
||||||
const [count, setCount] = useState(voteCount);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelected(voted);
|
|
||||||
setCount(voteCount);
|
|
||||||
}, [voteCount, voted]);
|
|
||||||
|
|
||||||
async function handleVote() {
|
|
||||||
const nextVoted = !selected;
|
|
||||||
setPending(true);
|
|
||||||
const result = await setTicketVote(ticketId, nextVoted);
|
|
||||||
setPending(false);
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
toast.error(result.error ?? "Could not update your vote.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelected(result.data.voted);
|
|
||||||
setCount(result.data.voteCount);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = selected ? `Remove upvote from ticket ${ticketId}` : `Upvote ticket ${ticketId}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={selected ? "default" : "outline"}
|
|
||||||
aria-label={label}
|
|
||||||
aria-pressed={selected}
|
|
||||||
disabled={disabled || pending}
|
|
||||||
title={disabled ? "Canceled tickets cannot receive votes" : undefined}
|
|
||||||
onClick={() => void handleVote()}
|
|
||||||
>
|
|
||||||
<ArrowBigUpIcon data-icon="inline-start" />
|
|
||||||
<span>{count.toLocaleString()}</span>
|
|
||||||
<span className="sr-only">{count === 1 ? "upvote" : "upvotes"}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -13,13 +13,12 @@ import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/ticket
|
||||||
import { InboxIcon, PlusIcon, SearchIcon } from "lucide-react";
|
import { InboxIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
|
|
||||||
|
|
||||||
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
||||||
|
|
||||||
interface TicketsViewProps {
|
interface TicketsViewProps {
|
||||||
readonly tickets: readonly Ticket[];
|
readonly tickets: readonly Ticket[];
|
||||||
readonly voteSummaries: readonly TicketVoteSummary[];
|
readonly currentUserId: number;
|
||||||
readonly isStaff: boolean;
|
readonly isStaff: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,14 +54,10 @@ function isUnassignedTicket(ticket: Ticket): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketsView({ tickets, voteSummaries, isStaff }: TicketsViewProps) {
|
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
|
||||||
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const votesByTicket = useMemo(
|
|
||||||
() => new Map(voteSummaries.map((summary) => [summary.ticketId, summary])),
|
|
||||||
[voteSummaries],
|
|
||||||
);
|
|
||||||
|
|
||||||
usePageCommands([
|
usePageCommands([
|
||||||
{
|
{
|
||||||
|
|
@ -205,17 +200,7 @@ export function TicketsView({ tickets, voteSummaries, isStaff }: TicketsViewProp
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
{filteredTickets.map((ticket) => (
|
{filteredTickets.map((ticket) => (
|
||||||
<TicketCard
|
<TicketCard key={ticket.id} ticket={ticket} />
|
||||||
key={ticket.id}
|
|
||||||
ticket={ticket}
|
|
||||||
voteSummary={
|
|
||||||
votesByTicket.get(ticket.id) ?? {
|
|
||||||
ticketId: ticket.id,
|
|
||||||
voteCount: 0,
|
|
||||||
voted: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,50 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TABLE "tickets_edit_history" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"before" jsonb NOT NULL,
|
|
||||||
"after" jsonb NOT NULL,
|
|
||||||
"edited_by_id" integer NOT NULL,
|
|
||||||
"edited_at" timestamp(3) with time zone NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "ticket_votes" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"ticket_id" integer NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
||||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "ticket_votes_id" integer;
|
|
||||||
ALTER TABLE "tickets_edit_history" ADD CONSTRAINT "tickets_edit_history_edited_by_id_users_id_fk" FOREIGN KEY ("edited_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "tickets_edit_history" ADD CONSTRAINT "tickets_edit_history_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
ALTER TABLE "ticket_votes" ADD CONSTRAINT "ticket_votes_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "ticket_votes" ADD CONSTRAINT "ticket_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
CREATE INDEX "tickets_edit_history_order_idx" ON "tickets_edit_history" USING btree ("_order");
|
|
||||||
CREATE INDEX "tickets_edit_history_parent_id_idx" ON "tickets_edit_history" USING btree ("_parent_id");
|
|
||||||
CREATE INDEX "tickets_edit_history_edited_by_idx" ON "tickets_edit_history" USING btree ("edited_by_id");
|
|
||||||
CREATE INDEX "ticket_votes_ticket_idx" ON "ticket_votes" USING btree ("ticket_id");
|
|
||||||
CREATE INDEX "ticket_votes_user_idx" ON "ticket_votes" USING btree ("user_id");
|
|
||||||
CREATE INDEX "ticket_votes_updated_at_idx" ON "ticket_votes" USING btree ("updated_at");
|
|
||||||
CREATE INDEX "ticket_votes_created_at_idx" ON "ticket_votes" USING btree ("created_at");
|
|
||||||
CREATE UNIQUE INDEX "ticket_user_idx" ON "ticket_votes" USING btree ("ticket_id","user_id");
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_ticket_votes_fk" FOREIGN KEY ("ticket_votes_id") REFERENCES "public"."ticket_votes"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "payload_locked_documents_rels_ticket_votes_id_idx" ON "payload_locked_documents_rels" USING btree ("ticket_votes_id");`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "tickets_edit_history" DISABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "ticket_votes" DISABLE ROW LEVEL SECURITY;
|
|
||||||
DROP TABLE "tickets_edit_history" CASCADE;
|
|
||||||
DROP TABLE "ticket_votes" CASCADE;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_ticket_votes_fk";
|
|
||||||
|
|
||||||
DROP INDEX "payload_locked_documents_rels_ticket_votes_id_idx";
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "ticket_votes_id";`)
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,6 @@ import * as migration_20260828_044506 from './20260828_044506';
|
||||||
import * as migration_20260831_142503 from './20260831_142503';
|
import * as migration_20260831_142503 from './20260831_142503';
|
||||||
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
|
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
|
||||||
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
|
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
|
||||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -83,11 +82,6 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
|
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
|
||||||
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
|
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
|
||||||
name: '20260901_014006_add_assignment_transfer_event_target',
|
name: '20260901_014006_add_assignment_transfer_event_target'
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
|
|
||||||
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
|
|
||||||
name: '20260901_213108_add_ticket_votes_and_edit_history'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ export const enum_game_event_logs_target_collection = pgEnum(
|
||||||
"evaluations",
|
"evaluations",
|
||||||
"tickets",
|
"tickets",
|
||||||
"game-servers",
|
"game-servers",
|
||||||
"assignment-transfers",
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
export const enum_users_roles = pgEnum("enum_users_roles", [
|
export const enum_users_roles = pgEnum("enum_users_roles", [
|
||||||
|
|
@ -296,41 +295,13 @@ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [
|
||||||
"discord:staff",
|
"discord:staff",
|
||||||
"discord:announce",
|
"discord:announce",
|
||||||
]);
|
]);
|
||||||
export const enum_awards_grant_config_allowed_grantor_assignment_types = pgEnum(
|
|
||||||
"enum_awards_grant_config_allowed_grantor_assignment_types",
|
|
||||||
["division", "squad", "team", "special"],
|
|
||||||
);
|
|
||||||
export const enum_awards_type = pgEnum("enum_awards_type", ["commendation", "ribbon", "medal"]);
|
export const enum_awards_type = pgEnum("enum_awards_type", ["commendation", "ribbon", "medal"]);
|
||||||
export const enum_awards_grant_config_recipient_scope = pgEnum(
|
|
||||||
"enum_awards_grant_config_recipient_scope",
|
|
||||||
["same_command", "whitelist", "anyone"],
|
|
||||||
);
|
|
||||||
export const enum_assignments_type = pgEnum("enum_assignments_type", [
|
export const enum_assignments_type = pgEnum("enum_assignments_type", [
|
||||||
"division",
|
"division",
|
||||||
"squad",
|
"squad",
|
||||||
"team",
|
"team",
|
||||||
"special",
|
"special",
|
||||||
]);
|
]);
|
||||||
export const enum_assignment_transfers_status = pgEnum("enum_assignment_transfers_status", [
|
|
||||||
"pending",
|
|
||||||
"rejected",
|
|
||||||
"awaiting_superuser",
|
|
||||||
"completed",
|
|
||||||
"cancelled",
|
|
||||||
"denied",
|
|
||||||
]);
|
|
||||||
export const enum_assignment_transfers_escalation_reason = pgEnum(
|
|
||||||
"enum_assignment_transfers_escalation_reason",
|
|
||||||
["no_leaders", "appeal", "leader_changed"],
|
|
||||||
);
|
|
||||||
export const enum_assignment_transfers_from_leader_decision = pgEnum(
|
|
||||||
"enum_assignment_transfers_from_leader_decision",
|
|
||||||
["pending", "approved", "rejected", "not_required"],
|
|
||||||
);
|
|
||||||
export const enum_assignment_transfers_to_leader_decision = pgEnum(
|
|
||||||
"enum_assignment_transfers_to_leader_decision",
|
|
||||||
["pending", "approved", "rejected", "not_required"],
|
|
||||||
);
|
|
||||||
export const enum_evaluations_kind = pgEnum("enum_evaluations_kind", [
|
export const enum_evaluations_kind = pgEnum("enum_evaluations_kind", [
|
||||||
"leader-direct",
|
"leader-direct",
|
||||||
"leader-indirect",
|
"leader-indirect",
|
||||||
|
|
@ -389,10 +360,6 @@ export const enum_campaigns_status = pgEnum("enum_campaigns_status", [
|
||||||
"inProgress",
|
"inProgress",
|
||||||
"completed",
|
"completed",
|
||||||
]);
|
]);
|
||||||
export const enum_campaigns_campaign_mode = pgEnum("enum_campaigns_campaign_mode", [
|
|
||||||
"official",
|
|
||||||
"custom",
|
|
||||||
]);
|
|
||||||
export const enum_factions_type = pgEnum("enum_factions_type", [
|
export const enum_factions_type = pgEnum("enum_factions_type", [
|
||||||
"playerFaction",
|
"playerFaction",
|
||||||
"majorFaction",
|
"majorFaction",
|
||||||
|
|
@ -1107,9 +1074,6 @@ export const users = pgTable(
|
||||||
preferences_display_xpDisplayMini: boolean("preferences_display_xp_display_mini").default(
|
preferences_display_xpDisplayMini: boolean("preferences_display_xp_display_mini").default(
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
preferences_display_showBackgroundTexture: boolean(
|
|
||||||
"preferences_display_show_background_texture",
|
|
||||||
).default(true),
|
|
||||||
preferences_discord_enabled: boolean("preferences_discord_enabled").default(false),
|
preferences_discord_enabled: boolean("preferences_discord_enabled").default(false),
|
||||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
|
|
@ -1436,25 +1400,6 @@ export const profiles_rels = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const awards_grant_config_allowed_grantor_assignment_types = pgTable(
|
|
||||||
"awards_grant_config_allowed_grantor_assignment_types",
|
|
||||||
{
|
|
||||||
order: integer("order").notNull(),
|
|
||||||
parent: integer("parent_id").notNull(),
|
|
||||||
value: enum_awards_grant_config_allowed_grantor_assignment_types("value"),
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
},
|
|
||||||
(columns) => [
|
|
||||||
index("awards_grant_config_allowed_grantor_assignment_types_order_idx").on(columns.order),
|
|
||||||
index("awards_grant_config_allowed_grantor_assignment_types_parent_idx").on(columns.parent),
|
|
||||||
foreignKey({
|
|
||||||
columns: [columns["parent"]],
|
|
||||||
foreignColumns: [awards.id],
|
|
||||||
name: "awards_grant_config_allowed_grantor_assignment_types_parent_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const awards = pgTable(
|
export const awards = pgTable(
|
||||||
"awards",
|
"awards",
|
||||||
{
|
{
|
||||||
|
|
@ -1462,19 +1407,12 @@ export const awards = pgTable(
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
description: jsonb("description").notNull(),
|
description: jsonb("description").notNull(),
|
||||||
type: enum_awards_type("type").default("commendation"),
|
type: enum_awards_type("type").default("commendation"),
|
||||||
commendationColor: varchar("commendation_color").default("#92400e"),
|
|
||||||
image: integer("image_id").references(() => media.id, {
|
image: integer("image_id").references(() => media.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
experiencePoints: numeric("experience_points", { mode: "number" }),
|
experiencePoints: numeric("experience_points", { mode: "number" }),
|
||||||
ribbonDesign: jsonb("ribbon_design"),
|
ribbonDesign: jsonb("ribbon_design"),
|
||||||
medalDesign: jsonb("medal_design"),
|
medalDesign: jsonb("medal_design"),
|
||||||
grantConfig_maxGrantsPerGrantor: numeric("grant_config_max_grants_per_grantor", {
|
|
||||||
mode: "number",
|
|
||||||
}),
|
|
||||||
grantConfig_recipientScope: enum_awards_grant_config_recipient_scope(
|
|
||||||
"grant_config_recipient_scope",
|
|
||||||
).default("same_command"),
|
|
||||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|
@ -1490,33 +1428,6 @@ export const awards = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const awards_rels = pgTable(
|
|
||||||
"awards_rels",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
order: integer("order"),
|
|
||||||
parent: integer("parent_id").notNull(),
|
|
||||||
path: varchar("path").notNull(),
|
|
||||||
assignmentsID: integer("assignments_id"),
|
|
||||||
},
|
|
||||||
(columns) => [
|
|
||||||
index("awards_rels_order_idx").on(columns.order),
|
|
||||||
index("awards_rels_parent_idx").on(columns.parent),
|
|
||||||
index("awards_rels_path_idx").on(columns.path),
|
|
||||||
index("awards_rels_assignments_id_idx").on(columns.assignmentsID),
|
|
||||||
foreignKey({
|
|
||||||
columns: [columns["parent"]],
|
|
||||||
foreignColumns: [awards.id],
|
|
||||||
name: "awards_rels_parent_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
foreignKey({
|
|
||||||
columns: [columns["assignmentsID"]],
|
|
||||||
foreignColumns: [assignments.id],
|
|
||||||
name: "awards_rels_assignments_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const qualifications = pgTable(
|
export const qualifications = pgTable(
|
||||||
"qualifications",
|
"qualifications",
|
||||||
{
|
{
|
||||||
|
|
@ -1595,98 +1506,6 @@ export const assignments_rels = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const assignment_transfers = pgTable(
|
|
||||||
"assignment_transfers",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
requester: integer("requester_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
fromAssignment: integer("from_assignment_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => assignments.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
toAssignment: integer("to_assignment_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => assignments.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
status: enum_assignment_transfers_status("status").notNull().default("pending"),
|
|
||||||
escalationReason: enum_assignment_transfers_escalation_reason("escalation_reason"),
|
|
||||||
fromLeaderDecision: enum_assignment_transfers_from_leader_decision("from_leader_decision")
|
|
||||||
.notNull()
|
|
||||||
.default("pending"),
|
|
||||||
toLeaderDecision: enum_assignment_transfers_to_leader_decision("to_leader_decision")
|
|
||||||
.notNull()
|
|
||||||
.default("pending"),
|
|
||||||
fromDecisionBy: integer("from_decision_by_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
toDecisionBy: integer("to_decision_by_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
fromDecidedAt: timestamp("from_decided_at", {
|
|
||||||
mode: "string",
|
|
||||||
withTimezone: true,
|
|
||||||
precision: 3,
|
|
||||||
}),
|
|
||||||
toDecidedAt: timestamp("to_decided_at", { mode: "string", withTimezone: true, precision: 3 }),
|
|
||||||
fromLeaderSnapshot: integer("from_leader_snapshot_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
toLeaderSnapshot: integer("to_leader_snapshot_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
rejectionReason: varchar("rejection_reason"),
|
|
||||||
requestNote: varchar("request_note"),
|
|
||||||
appealNote: varchar("appeal_note"),
|
|
||||||
resolvedBy: integer("resolved_by_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
resolutionNote: varchar("resolution_note"),
|
|
||||||
resolvedAt: timestamp("resolved_at", { mode: "string", withTimezone: true, precision: 3 }),
|
|
||||||
discordFromLeaderMessageId: varchar("discord_from_leader_message_id"),
|
|
||||||
discordToLeaderMessageId: varchar("discord_to_leader_message_id"),
|
|
||||||
discordRequesterMessageId: varchar("discord_requester_message_id"),
|
|
||||||
sourceNotifiedToUserId: integer("source_notified_to_user_id_id").references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
destinationNotifiedToUserId: integer("destination_notified_to_user_id_id").references(
|
|
||||||
() => users.id,
|
|
||||||
{
|
|
||||||
onDelete: "set null",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
discordDeliveredKeys: jsonb("discord_delivered_keys").default(sql`'[]'::jsonb`),
|
|
||||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(columns) => [
|
|
||||||
index("assignment_transfers_requester_idx").on(columns.requester),
|
|
||||||
index("assignment_transfers_from_assignment_idx").on(columns.fromAssignment),
|
|
||||||
index("assignment_transfers_to_assignment_idx").on(columns.toAssignment),
|
|
||||||
index("assignment_transfers_status_idx").on(columns.status),
|
|
||||||
index("assignment_transfers_from_decision_by_idx").on(columns.fromDecisionBy),
|
|
||||||
index("assignment_transfers_to_decision_by_idx").on(columns.toDecisionBy),
|
|
||||||
index("assignment_transfers_from_leader_snapshot_idx").on(columns.fromLeaderSnapshot),
|
|
||||||
index("assignment_transfers_to_leader_snapshot_idx").on(columns.toLeaderSnapshot),
|
|
||||||
index("assignment_transfers_resolved_by_idx").on(columns.resolvedBy),
|
|
||||||
index("assignment_transfers_source_notified_to_user_id_idx").on(columns.sourceNotifiedToUserId),
|
|
||||||
index("assignment_transfers_destination_notified_to_user_id_idx").on(
|
|
||||||
columns.destinationNotifiedToUserId,
|
|
||||||
),
|
|
||||||
index("assignment_transfers_updated_at_idx").on(columns.updatedAt),
|
|
||||||
index("assignment_transfers_created_at_idx").on(columns.createdAt),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const experience = pgTable(
|
export const experience = pgTable(
|
||||||
"experience",
|
"experience",
|
||||||
{
|
{
|
||||||
|
|
@ -2133,7 +1952,6 @@ export const campaigns = pgTable(
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
summary: varchar("summary").notNull().default("An Arma 3 campaign."),
|
summary: varchar("summary").notNull().default("An Arma 3 campaign."),
|
||||||
status: enum_campaigns_status("status").notNull().default("concept"),
|
status: enum_campaigns_status("status").notNull().default("concept"),
|
||||||
campaignMode: enum_campaigns_campaign_mode("campaign_mode").notNull().default("custom"),
|
|
||||||
description: jsonb("description"),
|
description: jsonb("description"),
|
||||||
startDate: timestamp("start_date", { mode: "string", withTimezone: true, precision: 3 }),
|
startDate: timestamp("start_date", { mode: "string", withTimezone: true, precision: 3 }),
|
||||||
endDate: timestamp("end_date", { mode: "string", withTimezone: true, precision: 3 }),
|
endDate: timestamp("end_date", { mode: "string", withTimezone: true, precision: 3 }),
|
||||||
|
|
@ -3591,37 +3409,6 @@ export const tickets_activity = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const tickets_edit_history = pgTable(
|
|
||||||
"tickets_edit_history",
|
|
||||||
{
|
|
||||||
_order: integer("_order").notNull(),
|
|
||||||
_parentID: integer("_parent_id").notNull(),
|
|
||||||
id: varchar("id").primaryKey(),
|
|
||||||
before: jsonb("before").notNull(),
|
|
||||||
after: jsonb("after").notNull(),
|
|
||||||
editedBy: integer("edited_by_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
editedAt: timestamp("edited_at", {
|
|
||||||
mode: "string",
|
|
||||||
withTimezone: true,
|
|
||||||
precision: 3,
|
|
||||||
}).notNull(),
|
|
||||||
},
|
|
||||||
(columns) => [
|
|
||||||
index("tickets_edit_history_order_idx").on(columns._order),
|
|
||||||
index("tickets_edit_history_parent_id_idx").on(columns._parentID),
|
|
||||||
index("tickets_edit_history_edited_by_idx").on(columns.editedBy),
|
|
||||||
foreignKey({
|
|
||||||
columns: [columns["_parentID"]],
|
|
||||||
foreignColumns: [tickets.id],
|
|
||||||
name: "tickets_edit_history_parent_id_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const tickets = pgTable(
|
export const tickets = pgTable(
|
||||||
"tickets",
|
"tickets",
|
||||||
{
|
{
|
||||||
|
|
@ -3707,36 +3494,6 @@ export const tickets_rels = pgTable(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ticket_votes = pgTable(
|
|
||||||
"ticket_votes",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
ticket: integer("ticket_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => tickets.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
user: integer("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(columns) => [
|
|
||||||
index("ticket_votes_ticket_idx").on(columns.ticket),
|
|
||||||
index("ticket_votes_user_idx").on(columns.user),
|
|
||||||
index("ticket_votes_updated_at_idx").on(columns.updatedAt),
|
|
||||||
index("ticket_votes_created_at_idx").on(columns.createdAt),
|
|
||||||
uniqueIndex("ticket_user_idx").on(columns.ticket, columns.user),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const projects = pgTable(
|
export const projects = pgTable(
|
||||||
"projects",
|
"projects",
|
||||||
{
|
{
|
||||||
|
|
@ -4178,7 +3935,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
awardsID: integer("awards_id"),
|
awardsID: integer("awards_id"),
|
||||||
qualificationsID: integer("qualifications_id"),
|
qualificationsID: integer("qualifications_id"),
|
||||||
assignmentsID: integer("assignments_id"),
|
assignmentsID: integer("assignments_id"),
|
||||||
"assignment-transfersID": integer("assignment_transfers_id"),
|
|
||||||
experienceID: integer("experience_id"),
|
experienceID: integer("experience_id"),
|
||||||
"user-notificationsID": integer("user_notifications_id"),
|
"user-notificationsID": integer("user_notifications_id"),
|
||||||
evaluationsID: integer("evaluations_id"),
|
evaluationsID: integer("evaluations_id"),
|
||||||
|
|
@ -4200,7 +3956,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
"market-listingsID": integer("market_listings_id"),
|
"market-listingsID": integer("market_listings_id"),
|
||||||
"market-negotiationsID": integer("market_negotiations_id"),
|
"market-negotiationsID": integer("market_negotiations_id"),
|
||||||
ticketsID: integer("tickets_id"),
|
ticketsID: integer("tickets_id"),
|
||||||
"ticket-votesID": integer("ticket_votes_id"),
|
|
||||||
projectsID: integer("projects_id"),
|
projectsID: integer("projects_id"),
|
||||||
sprintsID: integer("sprints_id"),
|
sprintsID: integer("sprints_id"),
|
||||||
releasesID: integer("releases_id"),
|
releasesID: integer("releases_id"),
|
||||||
|
|
@ -4234,9 +3989,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
index("payload_locked_documents_rels_awards_id_idx").on(columns.awardsID),
|
index("payload_locked_documents_rels_awards_id_idx").on(columns.awardsID),
|
||||||
index("payload_locked_documents_rels_qualifications_id_idx").on(columns.qualificationsID),
|
index("payload_locked_documents_rels_qualifications_id_idx").on(columns.qualificationsID),
|
||||||
index("payload_locked_documents_rels_assignments_id_idx").on(columns.assignmentsID),
|
index("payload_locked_documents_rels_assignments_id_idx").on(columns.assignmentsID),
|
||||||
index("payload_locked_documents_rels_assignment_transfers_id_idx").on(
|
|
||||||
columns["assignment-transfersID"],
|
|
||||||
),
|
|
||||||
index("payload_locked_documents_rels_experience_id_idx").on(columns.experienceID),
|
index("payload_locked_documents_rels_experience_id_idx").on(columns.experienceID),
|
||||||
index("payload_locked_documents_rels_user_notifications_id_idx").on(
|
index("payload_locked_documents_rels_user_notifications_id_idx").on(
|
||||||
columns["user-notificationsID"],
|
columns["user-notificationsID"],
|
||||||
|
|
@ -4266,7 +4018,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
columns["market-negotiationsID"],
|
columns["market-negotiationsID"],
|
||||||
),
|
),
|
||||||
index("payload_locked_documents_rels_tickets_id_idx").on(columns.ticketsID),
|
index("payload_locked_documents_rels_tickets_id_idx").on(columns.ticketsID),
|
||||||
index("payload_locked_documents_rels_ticket_votes_id_idx").on(columns["ticket-votesID"]),
|
|
||||||
index("payload_locked_documents_rels_projects_id_idx").on(columns.projectsID),
|
index("payload_locked_documents_rels_projects_id_idx").on(columns.projectsID),
|
||||||
index("payload_locked_documents_rels_sprints_id_idx").on(columns.sprintsID),
|
index("payload_locked_documents_rels_sprints_id_idx").on(columns.sprintsID),
|
||||||
index("payload_locked_documents_rels_releases_id_idx").on(columns.releasesID),
|
index("payload_locked_documents_rels_releases_id_idx").on(columns.releasesID),
|
||||||
|
|
@ -4358,11 +4109,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
foreignColumns: [assignments.id],
|
foreignColumns: [assignments.id],
|
||||||
name: "payload_locked_documents_rels_assignments_fk",
|
name: "payload_locked_documents_rels_assignments_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
foreignKey({
|
|
||||||
columns: [columns["assignment-transfersID"]],
|
|
||||||
foreignColumns: [assignment_transfers.id],
|
|
||||||
name: "payload_locked_documents_rels_assignment_transfers_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [columns["experienceID"]],
|
columns: [columns["experienceID"]],
|
||||||
foreignColumns: [experience.id],
|
foreignColumns: [experience.id],
|
||||||
|
|
@ -4468,11 +4214,6 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
foreignColumns: [tickets.id],
|
foreignColumns: [tickets.id],
|
||||||
name: "payload_locked_documents_rels_tickets_fk",
|
name: "payload_locked_documents_rels_tickets_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
foreignKey({
|
|
||||||
columns: [columns["ticket-votesID"]],
|
|
||||||
foreignColumns: [ticket_votes.id],
|
|
||||||
name: "payload_locked_documents_rels_ticket_votes_fk",
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [columns["projectsID"]],
|
columns: [columns["projectsID"]],
|
||||||
foreignColumns: [projects.id],
|
foreignColumns: [projects.id],
|
||||||
|
|
@ -5051,43 +4792,12 @@ export const relations_profiles = relations(profiles, ({ one, many }) => ({
|
||||||
relationName: "_rels",
|
relationName: "_rels",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export const relations_awards_grant_config_allowed_grantor_assignment_types = relations(
|
export const relations_awards = relations(awards, ({ one }) => ({
|
||||||
awards_grant_config_allowed_grantor_assignment_types,
|
|
||||||
({ one }) => ({
|
|
||||||
parent: one(awards, {
|
|
||||||
fields: [awards_grant_config_allowed_grantor_assignment_types.parent],
|
|
||||||
references: [awards.id],
|
|
||||||
relationName: "grantConfig_allowedGrantorAssignmentTypes",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
export const relations_awards_rels = relations(awards_rels, ({ one }) => ({
|
|
||||||
parent: one(awards, {
|
|
||||||
fields: [awards_rels.parent],
|
|
||||||
references: [awards.id],
|
|
||||||
relationName: "_rels",
|
|
||||||
}),
|
|
||||||
assignmentsID: one(assignments, {
|
|
||||||
fields: [awards_rels.assignmentsID],
|
|
||||||
references: [assignments.id],
|
|
||||||
relationName: "assignments",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
export const relations_awards = relations(awards, ({ one, many }) => ({
|
|
||||||
image: one(media, {
|
image: one(media, {
|
||||||
fields: [awards.image],
|
fields: [awards.image],
|
||||||
references: [media.id],
|
references: [media.id],
|
||||||
relationName: "image",
|
relationName: "image",
|
||||||
}),
|
}),
|
||||||
grantConfig_allowedGrantorAssignmentTypes: many(
|
|
||||||
awards_grant_config_allowed_grantor_assignment_types,
|
|
||||||
{
|
|
||||||
relationName: "grantConfig_allowedGrantorAssignmentTypes",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_rels: many(awards_rels, {
|
|
||||||
relationName: "_rels",
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
export const relations_qualifications = relations(qualifications, () => ({}));
|
export const relations_qualifications = relations(qualifications, () => ({}));
|
||||||
export const relations_assignments_rels = relations(assignments_rels, ({ one }) => ({
|
export const relations_assignments_rels = relations(assignments_rels, ({ one }) => ({
|
||||||
|
|
@ -5117,58 +4827,6 @@ export const relations_assignments = relations(assignments, ({ one, many }) => (
|
||||||
relationName: "_rels",
|
relationName: "_rels",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export const relations_assignment_transfers = relations(assignment_transfers, ({ one }) => ({
|
|
||||||
requester: one(users, {
|
|
||||||
fields: [assignment_transfers.requester],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "requester",
|
|
||||||
}),
|
|
||||||
fromAssignment: one(assignments, {
|
|
||||||
fields: [assignment_transfers.fromAssignment],
|
|
||||||
references: [assignments.id],
|
|
||||||
relationName: "fromAssignment",
|
|
||||||
}),
|
|
||||||
toAssignment: one(assignments, {
|
|
||||||
fields: [assignment_transfers.toAssignment],
|
|
||||||
references: [assignments.id],
|
|
||||||
relationName: "toAssignment",
|
|
||||||
}),
|
|
||||||
fromDecisionBy: one(users, {
|
|
||||||
fields: [assignment_transfers.fromDecisionBy],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "fromDecisionBy",
|
|
||||||
}),
|
|
||||||
toDecisionBy: one(users, {
|
|
||||||
fields: [assignment_transfers.toDecisionBy],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "toDecisionBy",
|
|
||||||
}),
|
|
||||||
fromLeaderSnapshot: one(users, {
|
|
||||||
fields: [assignment_transfers.fromLeaderSnapshot],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "fromLeaderSnapshot",
|
|
||||||
}),
|
|
||||||
toLeaderSnapshot: one(users, {
|
|
||||||
fields: [assignment_transfers.toLeaderSnapshot],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "toLeaderSnapshot",
|
|
||||||
}),
|
|
||||||
resolvedBy: one(users, {
|
|
||||||
fields: [assignment_transfers.resolvedBy],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "resolvedBy",
|
|
||||||
}),
|
|
||||||
sourceNotifiedToUserId: one(users, {
|
|
||||||
fields: [assignment_transfers.sourceNotifiedToUserId],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "sourceNotifiedToUserId",
|
|
||||||
}),
|
|
||||||
destinationNotifiedToUserId: one(users, {
|
|
||||||
fields: [assignment_transfers.destinationNotifiedToUserId],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "destinationNotifiedToUserId",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
export const relations_experience = relations(experience, () => ({}));
|
export const relations_experience = relations(experience, () => ({}));
|
||||||
export const relations_user_notifications = relations(user_notifications, ({ one }) => ({
|
export const relations_user_notifications = relations(user_notifications, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
|
|
@ -6113,18 +5771,6 @@ export const relations_tickets_activity = relations(tickets_activity, ({ one })
|
||||||
relationName: "author",
|
relationName: "author",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export const relations_tickets_edit_history = relations(tickets_edit_history, ({ one }) => ({
|
|
||||||
_parentID: one(tickets, {
|
|
||||||
fields: [tickets_edit_history._parentID],
|
|
||||||
references: [tickets.id],
|
|
||||||
relationName: "editHistory",
|
|
||||||
}),
|
|
||||||
editedBy: one(users, {
|
|
||||||
fields: [tickets_edit_history.editedBy],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "editedBy",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
export const relations_tickets_rels = relations(tickets_rels, ({ one }) => ({
|
export const relations_tickets_rels = relations(tickets_rels, ({ one }) => ({
|
||||||
parent: one(tickets, {
|
parent: one(tickets, {
|
||||||
fields: [tickets_rels.parent],
|
fields: [tickets_rels.parent],
|
||||||
|
|
@ -6151,9 +5797,6 @@ export const relations_tickets = relations(tickets, ({ one, many }) => ({
|
||||||
activity: many(tickets_activity, {
|
activity: many(tickets_activity, {
|
||||||
relationName: "activity",
|
relationName: "activity",
|
||||||
}),
|
}),
|
||||||
editHistory: many(tickets_edit_history, {
|
|
||||||
relationName: "editHistory",
|
|
||||||
}),
|
|
||||||
project: one(projects, {
|
project: one(projects, {
|
||||||
fields: [tickets.project],
|
fields: [tickets.project],
|
||||||
references: [projects.id],
|
references: [projects.id],
|
||||||
|
|
@ -6178,18 +5821,6 @@ export const relations_tickets = relations(tickets, ({ one, many }) => ({
|
||||||
relationName: "_rels",
|
relationName: "_rels",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export const relations_ticket_votes = relations(ticket_votes, ({ one }) => ({
|
|
||||||
ticket: one(tickets, {
|
|
||||||
fields: [ticket_votes.ticket],
|
|
||||||
references: [tickets.id],
|
|
||||||
relationName: "ticket",
|
|
||||||
}),
|
|
||||||
user: one(users, {
|
|
||||||
fields: [ticket_votes.user],
|
|
||||||
references: [users.id],
|
|
||||||
relationName: "user",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
export const relations_projects = relations(projects, ({ one }) => ({
|
export const relations_projects = relations(projects, ({ one }) => ({
|
||||||
lead: one(users, {
|
lead: one(users, {
|
||||||
fields: [projects.lead],
|
fields: [projects.lead],
|
||||||
|
|
@ -6355,11 +5986,6 @@ export const relations_payload_locked_documents_rels = relations(
|
||||||
references: [assignments.id],
|
references: [assignments.id],
|
||||||
relationName: "assignments",
|
relationName: "assignments",
|
||||||
}),
|
}),
|
||||||
"assignment-transfersID": one(assignment_transfers, {
|
|
||||||
fields: [payload_locked_documents_rels["assignment-transfersID"]],
|
|
||||||
references: [assignment_transfers.id],
|
|
||||||
relationName: "assignment-transfers",
|
|
||||||
}),
|
|
||||||
experienceID: one(experience, {
|
experienceID: one(experience, {
|
||||||
fields: [payload_locked_documents_rels.experienceID],
|
fields: [payload_locked_documents_rels.experienceID],
|
||||||
references: [experience.id],
|
references: [experience.id],
|
||||||
|
|
@ -6465,11 +6091,6 @@ export const relations_payload_locked_documents_rels = relations(
|
||||||
references: [tickets.id],
|
references: [tickets.id],
|
||||||
relationName: "tickets",
|
relationName: "tickets",
|
||||||
}),
|
}),
|
||||||
"ticket-votesID": one(ticket_votes, {
|
|
||||||
fields: [payload_locked_documents_rels["ticket-votesID"]],
|
|
||||||
references: [ticket_votes.id],
|
|
||||||
relationName: "ticket-votes",
|
|
||||||
}),
|
|
||||||
projectsID: one(projects, {
|
projectsID: one(projects, {
|
||||||
fields: [payload_locked_documents_rels.projectsID],
|
fields: [payload_locked_documents_rels.projectsID],
|
||||||
references: [projects.id],
|
references: [projects.id],
|
||||||
|
|
@ -6652,14 +6273,8 @@ type DatabaseSchema = {
|
||||||
enum_users_preferences_discord_muted_types: typeof enum_users_preferences_discord_muted_types;
|
enum_users_preferences_discord_muted_types: typeof enum_users_preferences_discord_muted_types;
|
||||||
enum_users_preferences_display_lead_or_actual: typeof enum_users_preferences_display_lead_or_actual;
|
enum_users_preferences_display_lead_or_actual: typeof enum_users_preferences_display_lead_or_actual;
|
||||||
enum_roles_permissions: typeof enum_roles_permissions;
|
enum_roles_permissions: typeof enum_roles_permissions;
|
||||||
enum_awards_grant_config_allowed_grantor_assignment_types: typeof enum_awards_grant_config_allowed_grantor_assignment_types;
|
|
||||||
enum_awards_type: typeof enum_awards_type;
|
enum_awards_type: typeof enum_awards_type;
|
||||||
enum_awards_grant_config_recipient_scope: typeof enum_awards_grant_config_recipient_scope;
|
|
||||||
enum_assignments_type: typeof enum_assignments_type;
|
enum_assignments_type: typeof enum_assignments_type;
|
||||||
enum_assignment_transfers_status: typeof enum_assignment_transfers_status;
|
|
||||||
enum_assignment_transfers_escalation_reason: typeof enum_assignment_transfers_escalation_reason;
|
|
||||||
enum_assignment_transfers_from_leader_decision: typeof enum_assignment_transfers_from_leader_decision;
|
|
||||||
enum_assignment_transfers_to_leader_decision: typeof enum_assignment_transfers_to_leader_decision;
|
|
||||||
enum_evaluations_kind: typeof enum_evaluations_kind;
|
enum_evaluations_kind: typeof enum_evaluations_kind;
|
||||||
enum_evaluations_level: typeof enum_evaluations_level;
|
enum_evaluations_level: typeof enum_evaluations_level;
|
||||||
enum_missions_factions_involved_threat_level: typeof enum_missions_factions_involved_threat_level;
|
enum_missions_factions_involved_threat_level: typeof enum_missions_factions_involved_threat_level;
|
||||||
|
|
@ -6672,7 +6287,6 @@ type DatabaseSchema = {
|
||||||
enum_missions_civilian_presence: typeof enum_missions_civilian_presence;
|
enum_missions_civilian_presence: typeof enum_missions_civilian_presence;
|
||||||
enum_mission_attendances_response: typeof enum_mission_attendances_response;
|
enum_mission_attendances_response: typeof enum_mission_attendances_response;
|
||||||
enum_campaigns_status: typeof enum_campaigns_status;
|
enum_campaigns_status: typeof enum_campaigns_status;
|
||||||
enum_campaigns_campaign_mode: typeof enum_campaigns_campaign_mode;
|
|
||||||
enum_factions_type: typeof enum_factions_type;
|
enum_factions_type: typeof enum_factions_type;
|
||||||
enum_technologies_approval_status: typeof enum_technologies_approval_status;
|
enum_technologies_approval_status: typeof enum_technologies_approval_status;
|
||||||
enum_technologies_type: typeof enum_technologies_type;
|
enum_technologies_type: typeof enum_technologies_type;
|
||||||
|
|
@ -6745,13 +6359,10 @@ type DatabaseSchema = {
|
||||||
profiles_progression_awards: typeof profiles_progression_awards;
|
profiles_progression_awards: typeof profiles_progression_awards;
|
||||||
profiles: typeof profiles;
|
profiles: typeof profiles;
|
||||||
profiles_rels: typeof profiles_rels;
|
profiles_rels: typeof profiles_rels;
|
||||||
awards_grant_config_allowed_grantor_assignment_types: typeof awards_grant_config_allowed_grantor_assignment_types;
|
|
||||||
awards: typeof awards;
|
awards: typeof awards;
|
||||||
awards_rels: typeof awards_rels;
|
|
||||||
qualifications: typeof qualifications;
|
qualifications: typeof qualifications;
|
||||||
assignments: typeof assignments;
|
assignments: typeof assignments;
|
||||||
assignments_rels: typeof assignments_rels;
|
assignments_rels: typeof assignments_rels;
|
||||||
assignment_transfers: typeof assignment_transfers;
|
|
||||||
experience: typeof experience;
|
experience: typeof experience;
|
||||||
user_notifications: typeof user_notifications;
|
user_notifications: typeof user_notifications;
|
||||||
evaluations: typeof evaluations;
|
evaluations: typeof evaluations;
|
||||||
|
|
@ -6811,10 +6422,8 @@ type DatabaseSchema = {
|
||||||
market_negotiations_history: typeof market_negotiations_history;
|
market_negotiations_history: typeof market_negotiations_history;
|
||||||
market_negotiations: typeof market_negotiations;
|
market_negotiations: typeof market_negotiations;
|
||||||
tickets_activity: typeof tickets_activity;
|
tickets_activity: typeof tickets_activity;
|
||||||
tickets_edit_history: typeof tickets_edit_history;
|
|
||||||
tickets: typeof tickets;
|
tickets: typeof tickets;
|
||||||
tickets_rels: typeof tickets_rels;
|
tickets_rels: typeof tickets_rels;
|
||||||
ticket_votes: typeof ticket_votes;
|
|
||||||
projects: typeof projects;
|
projects: typeof projects;
|
||||||
sprints: typeof sprints;
|
sprints: typeof sprints;
|
||||||
releases: typeof releases;
|
releases: typeof releases;
|
||||||
|
|
@ -6866,13 +6475,10 @@ type DatabaseSchema = {
|
||||||
relations_profiles_progression_awards: typeof relations_profiles_progression_awards;
|
relations_profiles_progression_awards: typeof relations_profiles_progression_awards;
|
||||||
relations_profiles_rels: typeof relations_profiles_rels;
|
relations_profiles_rels: typeof relations_profiles_rels;
|
||||||
relations_profiles: typeof relations_profiles;
|
relations_profiles: typeof relations_profiles;
|
||||||
relations_awards_grant_config_allowed_grantor_assignment_types: typeof relations_awards_grant_config_allowed_grantor_assignment_types;
|
|
||||||
relations_awards_rels: typeof relations_awards_rels;
|
|
||||||
relations_awards: typeof relations_awards;
|
relations_awards: typeof relations_awards;
|
||||||
relations_qualifications: typeof relations_qualifications;
|
relations_qualifications: typeof relations_qualifications;
|
||||||
relations_assignments_rels: typeof relations_assignments_rels;
|
relations_assignments_rels: typeof relations_assignments_rels;
|
||||||
relations_assignments: typeof relations_assignments;
|
relations_assignments: typeof relations_assignments;
|
||||||
relations_assignment_transfers: typeof relations_assignment_transfers;
|
|
||||||
relations_experience: typeof relations_experience;
|
relations_experience: typeof relations_experience;
|
||||||
relations_user_notifications: typeof relations_user_notifications;
|
relations_user_notifications: typeof relations_user_notifications;
|
||||||
relations_evaluations: typeof relations_evaluations;
|
relations_evaluations: typeof relations_evaluations;
|
||||||
|
|
@ -6932,10 +6538,8 @@ type DatabaseSchema = {
|
||||||
relations_market_negotiations_history: typeof relations_market_negotiations_history;
|
relations_market_negotiations_history: typeof relations_market_negotiations_history;
|
||||||
relations_market_negotiations: typeof relations_market_negotiations;
|
relations_market_negotiations: typeof relations_market_negotiations;
|
||||||
relations_tickets_activity: typeof relations_tickets_activity;
|
relations_tickets_activity: typeof relations_tickets_activity;
|
||||||
relations_tickets_edit_history: typeof relations_tickets_edit_history;
|
|
||||||
relations_tickets_rels: typeof relations_tickets_rels;
|
relations_tickets_rels: typeof relations_tickets_rels;
|
||||||
relations_tickets: typeof relations_tickets;
|
relations_tickets: typeof relations_tickets;
|
||||||
relations_ticket_votes: typeof relations_ticket_votes;
|
|
||||||
relations_projects: typeof relations_projects;
|
relations_projects: typeof relations_projects;
|
||||||
relations_sprints: typeof relations_sprints;
|
relations_sprints: typeof relations_sprints;
|
||||||
relations_releases: typeof relations_releases;
|
relations_releases: typeof relations_releases;
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ export interface Config {
|
||||||
'market-listings': MarketListing;
|
'market-listings': MarketListing;
|
||||||
'market-negotiations': MarketNegotiation;
|
'market-negotiations': MarketNegotiation;
|
||||||
tickets: Ticket;
|
tickets: Ticket;
|
||||||
'ticket-votes': TicketVote;
|
|
||||||
projects: Project;
|
projects: Project;
|
||||||
sprints: Sprint;
|
sprints: Sprint;
|
||||||
releases: Release;
|
releases: Release;
|
||||||
|
|
@ -166,7 +165,6 @@ export interface Config {
|
||||||
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
||||||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||||
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
||||||
'ticket-votes': TicketVotesSelect<false> | TicketVotesSelect<true>;
|
|
||||||
projects: ProjectsSelect<false> | ProjectsSelect<true>;
|
projects: ProjectsSelect<false> | ProjectsSelect<true>;
|
||||||
sprints: SprintsSelect<false> | SprintsSelect<true>;
|
sprints: SprintsSelect<false> | SprintsSelect<true>;
|
||||||
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
||||||
|
|
@ -2778,34 +2776,6 @@ export interface Ticket {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
/**
|
|
||||||
* Immutable before/after snapshots captured when the author edits a ticket.
|
|
||||||
*/
|
|
||||||
editHistory?:
|
|
||||||
| {
|
|
||||||
before:
|
|
||||||
| {
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
| unknown[]
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null;
|
|
||||||
after:
|
|
||||||
| {
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
| unknown[]
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null;
|
|
||||||
editedBy: number | User;
|
|
||||||
editedAt: string;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* Set automatically when the ticket reaches a closing status.
|
* Set automatically when the ticket reaches a closing status.
|
||||||
*/
|
*/
|
||||||
|
|
@ -2961,17 +2931,6 @@ export interface Label {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "ticket-votes".
|
|
||||||
*/
|
|
||||||
export interface TicketVote {
|
|
||||||
id: number;
|
|
||||||
ticket: number | Ticket;
|
|
||||||
user: number | User;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "narrative-events".
|
* via the `definition` "narrative-events".
|
||||||
|
|
@ -3323,10 +3282,6 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'tickets';
|
relationTo: 'tickets';
|
||||||
value: number | Ticket;
|
value: number | Ticket;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
|
||||||
relationTo: 'ticket-votes';
|
|
||||||
value: number | TicketVote;
|
|
||||||
} | null)
|
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'projects';
|
relationTo: 'projects';
|
||||||
value: number | Project;
|
value: number | Project;
|
||||||
|
|
@ -4549,15 +4504,6 @@ export interface TicketsSelect<T extends boolean = true> {
|
||||||
created_at?: T;
|
created_at?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
editHistory?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
before?: T;
|
|
||||||
after?: T;
|
|
||||||
editedBy?: T;
|
|
||||||
editedAt?: T;
|
|
||||||
id?: T;
|
|
||||||
};
|
|
||||||
closedAt?: T;
|
closedAt?: T;
|
||||||
project?: T;
|
project?: T;
|
||||||
sprint?: T;
|
sprint?: T;
|
||||||
|
|
@ -4573,16 +4519,6 @@ export interface TicketsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "ticket-votes_select".
|
|
||||||
*/
|
|
||||||
export interface TicketVotesSelect<T extends boolean = true> {
|
|
||||||
ticket?: T;
|
|
||||||
user?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "projects_select".
|
* via the `definition` "projects_select".
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||||
import { Evaluations } from "@/collections/users/Evaluations";
|
import { Evaluations } from "@/collections/users/Evaluations";
|
||||||
import { Tickets } from "@/collections/tickets/Tickets";
|
import { Tickets } from "@/collections/tickets/Tickets";
|
||||||
import { TicketVotes } from "@/collections/tickets/TicketVotes";
|
|
||||||
import { Projects } from "@/collections/projects/Projects";
|
import { Projects } from "@/collections/projects/Projects";
|
||||||
import { Sprints } from "@/collections/projects/Sprints";
|
import { Sprints } from "@/collections/projects/Sprints";
|
||||||
import { Releases } from "@/collections/projects/Releases";
|
import { Releases } from "@/collections/projects/Releases";
|
||||||
|
|
@ -238,7 +237,6 @@ export default buildConfig({
|
||||||
|
|
||||||
// Helpdesk
|
// Helpdesk
|
||||||
Tickets,
|
Tickets,
|
||||||
TicketVotes,
|
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
Projects,
|
Projects,
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,6 @@ export const EventTypes = {
|
||||||
TicketReply: "ticket:reply",
|
TicketReply: "ticket:reply",
|
||||||
TicketStatusChange: "ticket:status-change",
|
TicketStatusChange: "ticket:status-change",
|
||||||
TicketAssign: "ticket:assign",
|
TicketAssign: "ticket:assign",
|
||||||
TicketEdit: "ticket:edit",
|
|
||||||
TicketVote: "ticket:vote",
|
|
||||||
TicketUnvote: "ticket:unvote",
|
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
ProjectCreate: "project:create",
|
ProjectCreate: "project:create",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import type { Ticket, User } from "@/payload-types";
|
||||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||||
import { getStaffUserIds } from "@/lib/tickets/staff";
|
import { getStaffUserIds } from "@/lib/tickets/staff";
|
||||||
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||||
import { setTicketVoteForUser, updateTicketByAuthor } from "@/lib/tickets/mutations";
|
|
||||||
|
|
||||||
let payload: Payload;
|
let payload: Payload;
|
||||||
|
|
||||||
|
|
@ -109,19 +108,6 @@ describe("Tickets", () => {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
const ticketIds = tickets.docs.map((ticket) => ticket.id);
|
|
||||||
if (ticketIds.length > 0) {
|
|
||||||
const votes = await payload.find({
|
|
||||||
collection: "ticket-votes",
|
|
||||||
where: { ticket: { in: ticketIds } },
|
|
||||||
limit: 500,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
for (const vote of votes.docs) {
|
|
||||||
await payload.delete({ collection: "ticket-votes", id: vote.id, overrideAccess: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const ticket of tickets.docs) {
|
for (const ticket of tickets.docs) {
|
||||||
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
|
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
|
||||||
}
|
}
|
||||||
|
|
@ -251,92 +237,6 @@ describe("Tickets", () => {
|
||||||
expect(idOf(populated.assignee)).toBeNull();
|
expect(idOf(populated.assignee)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-transitions waiting_on_reporter → in_progress when reporter replies", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id);
|
|
||||||
// Set status to waiting_on_reporter
|
|
||||||
await payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: { status: "waiting_on_reporter" },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
let populated = await fetchTicket(ticket.id);
|
|
||||||
expect(populated.status).toBe("waiting_on_reporter");
|
|
||||||
// Verify no closedAt set by this transition (waiting_on_reporter is not a closing status)
|
|
||||||
expect(populated.closedAt).toBeNull();
|
|
||||||
|
|
||||||
// Reporter replies (activity + status change via raw payload.update — this triggers
|
|
||||||
// the beforeChange hook which appends the system entry; the action also includes
|
|
||||||
// the auto-resume logic that sets status to in_progress)
|
|
||||||
await payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: {
|
|
||||||
activity: [
|
|
||||||
...((await fetchTicket(ticket.id)).activity ?? []),
|
|
||||||
{
|
|
||||||
type: "client_message",
|
|
||||||
content: plaintextToLexical("This is the reporter's reply."),
|
|
||||||
author: reporter.id,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: "in_progress",
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
populated = await fetchTicket(ticket.id);
|
|
||||||
expect(populated.status).toBe("in_progress");
|
|
||||||
expect(populated.closedAt).toBeNull();
|
|
||||||
|
|
||||||
// System entry from beforeChange hook
|
|
||||||
expect(activityTexts(populated)).toContain(
|
|
||||||
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-transition when staff replies to waiting_on_reporter", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id);
|
|
||||||
// Set status to waiting_on_reporter
|
|
||||||
await payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: { status: "waiting_on_reporter" },
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Staff replies (activity change only — the action would NOT set status since
|
|
||||||
// staff replies don't auto-resume)
|
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: {
|
|
||||||
activity: [
|
|
||||||
...((await fetchTicket(ticket.id)).activity ?? []),
|
|
||||||
{
|
|
||||||
type: "agent_message",
|
|
||||||
content: plaintextToLexical("Staff comment."),
|
|
||||||
author: staff.id,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Status should NOT auto-transition (staff reply doesn't auto-resume)
|
|
||||||
expect(updated.status).toBe("waiting_on_reporter");
|
|
||||||
// Only the system entry from the direct payload update, no auto-transition entry
|
|
||||||
expect(activityTexts(updated)).not.toContain(
|
|
||||||
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves client/agent messages appended by actions", async () => {
|
it("preserves client/agent messages appended by actions", async () => {
|
||||||
const ticket = await createTicket(reporter.id);
|
const ticket = await createTicket(reporter.id);
|
||||||
|
|
||||||
|
|
@ -372,111 +272,4 @@ describe("Tickets", () => {
|
||||||
expect(STATUS_LABEL[status.value]).toBe(status.label);
|
expect(STATUS_LABEL[status.value]).toBe(status.label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records immutable before and after snapshots when the author edits a ticket", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id, `${RUN} Editable`);
|
|
||||||
|
|
||||||
await updateTicketByAuthor(payload, reporter, {
|
|
||||||
ticketId: ticket.id,
|
|
||||||
title: `${RUN} Edited title`,
|
|
||||||
description: `${RUN} edited description`,
|
|
||||||
category: "feature_request",
|
|
||||||
priority: "critical",
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await fetchTicket(ticket.id);
|
|
||||||
expect(updated.title).toBe(`${RUN} Edited title`);
|
|
||||||
expect(convertLexicalToPlaintext({ data: updated.description })).toBe(
|
|
||||||
`${RUN} edited description`,
|
|
||||||
);
|
|
||||||
expect(updated.editHistory).toHaveLength(1);
|
|
||||||
expect(updated.editHistory?.[0]?.before).toEqual({
|
|
||||||
title: `${RUN} Editable`,
|
|
||||||
description: `${RUN} description with multiple\nlines of detail.`,
|
|
||||||
category: "bug_report",
|
|
||||||
priority: "high",
|
|
||||||
});
|
|
||||||
expect(updated.editHistory?.[0]?.after).toEqual({
|
|
||||||
title: `${RUN} Edited title`,
|
|
||||||
description: `${RUN} edited description`,
|
|
||||||
category: "feature_request",
|
|
||||||
priority: "critical",
|
|
||||||
});
|
|
||||||
expect(activityTexts(updated).some((text) => text.includes("Ticket details edited"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: { editHistory: [] },
|
|
||||||
overrideAccess: true,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Ticket edit history is immutable");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects ticket edits from users other than the author", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id, `${RUN} Author only`);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateTicketByAuthor(payload, assignee, {
|
|
||||||
ticketId: ticket.id,
|
|
||||||
title: `${RUN} Unauthorized edit`,
|
|
||||||
description: `${RUN} should not be saved`,
|
|
||||||
category: "other",
|
|
||||||
priority: "low",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Only the ticket author can edit this ticket");
|
|
||||||
|
|
||||||
expect((await fetchTicket(ticket.id)).title).toBe(`${RUN} Author only`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets and clears one durable vote per user idempotently", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id, `${RUN} Voting`);
|
|
||||||
|
|
||||||
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
|
|
||||||
voteCount: 1,
|
|
||||||
voted: true,
|
|
||||||
});
|
|
||||||
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
|
|
||||||
voteCount: 1,
|
|
||||||
voted: true,
|
|
||||||
});
|
|
||||||
expect(await setTicketVoteForUser(payload, assignee, ticket.id, true)).toEqual({
|
|
||||||
voteCount: 2,
|
|
||||||
voted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
payload.create({
|
|
||||||
collection: "ticket-votes",
|
|
||||||
data: { ticket: ticket.id, user: reporter.id },
|
|
||||||
overrideAccess: true,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
|
|
||||||
voteCount: 1,
|
|
||||||
voted: false,
|
|
||||||
});
|
|
||||||
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
|
|
||||||
voteCount: 1,
|
|
||||||
voted: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects voting only when a ticket is canceled", async () => {
|
|
||||||
const ticket = await createTicket(reporter.id, `${RUN} Canceled voting`);
|
|
||||||
await payload.update({
|
|
||||||
collection: "tickets",
|
|
||||||
id: ticket.id,
|
|
||||||
data: { status: "canceled" },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(setTicketVoteForUser(payload, reporter, ticket.id, true)).rejects.toThrow(
|
|
||||||
"Canceled tickets cannot receive votes",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue