Compare commits
13 commits
9dd3226c0a
...
dad652b1d4
| Author | SHA1 | Date | |
|---|---|---|---|
| dad652b1d4 | |||
| 56b79e142e | |||
| 931c60e234 | |||
| 9ec4555200 | |||
| 6480b47273 | |||
| 5064890452 | |||
| c80ecf9141 | |||
| 14d92bf277 | |||
| 40f23e8774 | |||
| ff986bf4ec | |||
| e7eff028b7 | |||
| 94cd7c5626 | |||
| 6419c9676f |
25 changed files with 18739 additions and 3 deletions
83
src/app/(frontend)/helpdesk/[id]/page.tsx
Normal file
83
src/app/(frontend)/helpdesk/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = {
|
||||
title: "Ticket — Polaris Task Force",
|
||||
};
|
||||
|
||||
interface TicketPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function TicketPage({ params }: TicketPageProps) {
|
||||
const { id } = await params;
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const ticket = await payload
|
||||
.findByID({
|
||||
collection: "tickets",
|
||||
id: Number(id),
|
||||
depth: 2,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!ticket) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const typedTicket = ticket as unknown as Ticket;
|
||||
const isStaff = hasRoles(["admin", "developer"], user);
|
||||
const assigneeOptions = isStaff
|
||||
? (
|
||||
await payload.find({
|
||||
collection: "users",
|
||||
sort: "username",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
})
|
||||
).docs.map((staffUser) => ({
|
||||
id: staffUser.id,
|
||||
label: staffUser.payloadDisplayName || staffUser.username,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/helpdesk"
|
||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3" />
|
||||
Helpdesk
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoyIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Ticket #{typedTicket.id}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketDetail
|
||||
ticket={typedTicket}
|
||||
currentUserId={user.id}
|
||||
isStaff={isStaff}
|
||||
assigneeOptions={assigneeOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/app/(frontend)/helpdesk/actions.ts
Normal file
350
src/app/(frontend)/helpdesk/actions.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
import { getStaffUserIds } from "@/lib/tickets/staff";
|
||||
import {
|
||||
CLOSING_STATUSES,
|
||||
STATUS_LABEL,
|
||||
TICKET_STATUSES,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string {
|
||||
return user.payloadDisplayName || user.username || "A member";
|
||||
}
|
||||
|
||||
function reporterIdOf(ticket: Ticket): number | null {
|
||||
return typeof ticket.reporter === "object" && ticket.reporter
|
||||
? (ticket.reporter as { id: number }).id
|
||||
: (ticket.reporter as number | null);
|
||||
}
|
||||
|
||||
function assigneeIdOf(ticket: Ticket): number | null {
|
||||
return typeof ticket.assignee === "object" && ticket.assignee
|
||||
? (ticket.assignee as { id: number }).id
|
||||
: (ticket.assignee as number | null);
|
||||
}
|
||||
|
||||
function lexicalFor(text: string): NonNullable<Ticket["description"]> {
|
||||
return plaintextToLexical(text) as unknown as NonNullable<Ticket["description"]>;
|
||||
}
|
||||
|
||||
async function getTicketOrThrow(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
ticketId: number,
|
||||
): Promise<Ticket> {
|
||||
const ticket = (await payload
|
||||
.findByID({ collection: "tickets", id: ticketId, depth: 0, overrideAccess: true })
|
||||
.catch(() => null)) as unknown as Ticket | null;
|
||||
if (!ticket) throw new Error("Ticket not found.");
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export async function createTicket(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
category?: TicketCategory;
|
||||
priority?: TicketPriority;
|
||||
}): Promise<ActionResult<Ticket>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
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.");
|
||||
|
||||
const category = input.category ?? "support_request";
|
||||
const priority = input.priority ?? "medium";
|
||||
|
||||
const createdTicket = (await payload.create({
|
||||
collection: "tickets",
|
||||
data: {
|
||||
title,
|
||||
description: lexicalFor(description),
|
||||
category,
|
||||
priority,
|
||||
status: "open",
|
||||
reporter: userId,
|
||||
activity: [
|
||||
{
|
||||
type: "system",
|
||||
content: lexicalFor("Ticket created"),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Ticket;
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.TicketCreate,
|
||||
message: `Ticket #${createdTicket.id} created: ${title}`,
|
||||
actor: userId,
|
||||
targetCollection: "tickets",
|
||||
targetId: createdTicket.id,
|
||||
data: { category, priority },
|
||||
});
|
||||
|
||||
const reporterName = displayNameOf(user);
|
||||
const staffIds = await getStaffUserIds(payload);
|
||||
for (const staffId of staffIds) {
|
||||
if (staffId === userId) continue;
|
||||
await notifyUser(payload, {
|
||||
userId: staffId,
|
||||
type: "ticket:created",
|
||||
title: "New ticket",
|
||||
message: `${title} — ${reporterName}`,
|
||||
link: `/helpdesk/${createdTicket.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, data: createdTicket };
|
||||
} 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> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
const ticket = await getTicketOrThrow(payload, ticketId);
|
||||
|
||||
const isStaff = hasRoles(["admin", "developer"], user);
|
||||
const reporterId = reporterIdOf(ticket);
|
||||
if (!isStaff && reporterId !== userId) {
|
||||
throw new Error("You don't have access to this ticket.");
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) throw new Error("Message is required.");
|
||||
|
||||
const messageType = isStaff ? "agent_message" : "client_message";
|
||||
const newActivity: NonNullable<Ticket["activity"]>[number][] = [
|
||||
...(ticket.activity ?? []),
|
||||
{
|
||||
type: messageType,
|
||||
content: lexicalFor(trimmed),
|
||||
author: userId,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const reopen = CLOSING_STATUSES.includes(ticket.status);
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticketId,
|
||||
data: reopen
|
||||
? { activity: newActivity, status: "open" }
|
||||
: { activity: newActivity },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.TicketReply,
|
||||
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,
|
||||
actor: userId,
|
||||
targetCollection: "tickets",
|
||||
targetId: ticketId,
|
||||
});
|
||||
|
||||
const replyMessage = `"${ticket.title}" — you have a new reply.`;
|
||||
if (isStaff) {
|
||||
if (reporterId != null && reporterId !== userId) {
|
||||
await notifyUser(payload, {
|
||||
userId: reporterId,
|
||||
type: "ticket:reply",
|
||||
title: "New reply",
|
||||
message: replyMessage,
|
||||
link: `/helpdesk/${ticketId}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const assigneeId = assigneeIdOf(ticket);
|
||||
if (assigneeId != null && assigneeId !== userId) {
|
||||
await notifyUser(payload, {
|
||||
userId: assigneeId,
|
||||
type: "ticket:reply",
|
||||
title: "New reply",
|
||||
message: replyMessage,
|
||||
link: `/helpdesk/${ticketId}`,
|
||||
});
|
||||
} else {
|
||||
const staffIds = await getStaffUserIds(payload);
|
||||
for (const staffId of staffIds) {
|
||||
if (staffId === userId) continue;
|
||||
await notifyUser(payload, {
|
||||
userId: staffId,
|
||||
type: "ticket:reply",
|
||||
title: "New reply",
|
||||
message: replyMessage,
|
||||
link: `/helpdesk/${ticketId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} 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 updateTicketStatus(ticketId: number, status: TicketStatus): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
if (!TICKET_STATUSES.some((s) => s.value === status)) {
|
||||
throw new Error("Invalid status.");
|
||||
}
|
||||
|
||||
const ticket = await getTicketOrThrow(payload, ticketId);
|
||||
if (status === ticket.status) return { success: true };
|
||||
|
||||
const isStaff = hasRoles(["admin", "developer"], user);
|
||||
const reporterId = reporterIdOf(ticket);
|
||||
if (!isStaff) {
|
||||
const reporterCancel =
|
||||
reporterId === userId &&
|
||||
status === "canceled" &&
|
||||
!CLOSING_STATUSES.includes(ticket.status);
|
||||
if (!reporterCancel) throw new Error("Only staff can change ticket status.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticketId,
|
||||
data: { status },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.TicketStatusChange,
|
||||
message: `Ticket #${ticketId} status changed to ${STATUS_LABEL[status]}`,
|
||||
actor: userId,
|
||||
targetCollection: "tickets",
|
||||
targetId: ticketId,
|
||||
data: { from: ticket.status, to: status },
|
||||
});
|
||||
|
||||
const recipients = new Set<number>();
|
||||
if (reporterId != null) recipients.add(reporterId);
|
||||
const assigneeId = assigneeIdOf(ticket);
|
||||
if (assigneeId != null) recipients.add(assigneeId);
|
||||
recipients.delete(userId);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
await notifyUser(payload, {
|
||||
userId: recipientId,
|
||||
type: "ticket:status",
|
||||
title: "Ticket updated",
|
||||
message: `Status changed to ${STATUS_LABEL[status]} on "${ticket.title}".`,
|
||||
link: `/helpdesk/${ticketId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} 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 assignTicket(ticketId: number, assigneeId: number | null): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
if (!hasRoles(["admin", "developer"], user)) {
|
||||
throw new Error("Only staff can assign tickets.");
|
||||
}
|
||||
|
||||
const ticket = await getTicketOrThrow(payload, ticketId);
|
||||
|
||||
if (assigneeId != null) {
|
||||
const assignee = (await payload
|
||||
.findByID({ collection: "users", id: assigneeId, depth: 0, overrideAccess: true })
|
||||
.catch(() => null)) as unknown as { id: number } | null;
|
||||
if (!assignee) throw new Error("Assignee not found.");
|
||||
}
|
||||
|
||||
if (assigneeId === assigneeIdOf(ticket)) return { success: true };
|
||||
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticketId,
|
||||
data: { assignee: assigneeId ?? null },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.TicketAssign,
|
||||
message: `Ticket #${ticketId} assigned`,
|
||||
actor: userId,
|
||||
targetCollection: "tickets",
|
||||
targetId: ticketId,
|
||||
data: { assignee: assigneeId },
|
||||
});
|
||||
|
||||
if (assigneeId != null && assigneeId !== userId) {
|
||||
await notifyUser(payload, {
|
||||
userId: assigneeId,
|
||||
type: "ticket:assigned",
|
||||
title: "Ticket assigned",
|
||||
message: `You were assigned "${ticket.title}".`,
|
||||
link: `/helpdesk/${ticketId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
47
src/app/(frontend)/helpdesk/page.tsx
Normal file
47
src/app/(frontend)/helpdesk/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import { TicketsView } from "@/components/frontend/helpdesk/TicketsView";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { LifeBuoyIcon } from "lucide-react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Helpdesk — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function HelpdeskPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const ticketsRes = await payload.find({
|
||||
collection: "tickets",
|
||||
sort: "-updatedAt",
|
||||
limit: 200,
|
||||
depth: 2,
|
||||
});
|
||||
const tickets = ticketsRes.docs as unknown as Ticket[];
|
||||
const isStaff = hasRoles(["admin", "developer"], user);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoyIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Helpdesk</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Submit and track support requests.</p>
|
||||
</div>
|
||||
|
||||
<TicketsView tickets={tickets} currentUserId={user.id} isStaff={isStaff} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Loadouts", value: "loadouts" },
|
||||
{ label: "Market Listings", value: "market-listings" },
|
||||
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||
{ label: "Tickets", value: "tickets" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
234
src/collections/tickets/Tickets.ts
Normal file
234
src/collections/tickets/Tickets.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import {
|
||||
CLOSING_STATUSES,
|
||||
STATUS_LABEL,
|
||||
TICKET_CATEGORIES,
|
||||
TICKET_PRIORITIES,
|
||||
TICKET_STATUSES,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
|
||||
type StaffUser = {
|
||||
id: number;
|
||||
payloadDisplayName?: string | null;
|
||||
username?: string | null;
|
||||
};
|
||||
|
||||
const resolveUserId = (value: unknown): number | null => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as { id?: unknown };
|
||||
return typeof obj.id === "number" ? obj.id : null;
|
||||
}
|
||||
return typeof value === "number" ? value : null;
|
||||
};
|
||||
|
||||
const systemEntry = (message: string, createdAt?: string) => ({
|
||||
type: "system",
|
||||
content: plaintextToLexical(message),
|
||||
created_at: createdAt ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const Tickets: CollectionConfig = {
|
||||
slug: "tickets",
|
||||
admin: {
|
||||
group: "Helpdesk",
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "priority", "category", "reporter", "assignee", "updatedAt"],
|
||||
listSearchableFields: ["title", "id"],
|
||||
pagination: { defaultLimit: 50 },
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
if (hasRoles(["admin", "developer"], req.user)) return true;
|
||||
return { reporter: { equals: req.user.id } };
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return hasRoles(["admin", "developer"], req.user) ? true : false;
|
||||
},
|
||||
delete: isDeveloper,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, originalDoc, req }) => {
|
||||
if (!data) return data;
|
||||
|
||||
const next = data as Record<string, unknown>;
|
||||
const prev = originalDoc as Record<string, unknown> | undefined;
|
||||
|
||||
// Only diff against an existing document — creates start with a system
|
||||
// entry appended by the create server action instead.
|
||||
if (!prev || !prev.id) return data;
|
||||
|
||||
const entries = [...((next.activity as unknown[]) ?? [])];
|
||||
const now = new Date().toISOString();
|
||||
const changes: string[] = [];
|
||||
|
||||
// Status transitions → system entry + closedAt management.
|
||||
const prevStatus = prev.status as string | undefined;
|
||||
const nextStatus = next.status as string | undefined;
|
||||
if (nextStatus && prevStatus !== nextStatus) {
|
||||
const from = STATUS_LABEL[prevStatus as keyof typeof STATUS_LABEL] ?? prevStatus;
|
||||
const to = STATUS_LABEL[nextStatus as keyof typeof STATUS_LABEL] ?? nextStatus;
|
||||
changes.push(`Status changed from ${from} to ${to}`);
|
||||
if (CLOSING_STATUSES.includes(nextStatus as (typeof CLOSING_STATUSES)[number])) {
|
||||
next.closedAt = now;
|
||||
} else if (prev.closedAt) {
|
||||
next.closedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Assignment changes → system entry.
|
||||
const prevAssigneeId = resolveUserId(prev.assignee);
|
||||
const nextAssigneeId = resolveUserId(next.assignee);
|
||||
if (prevAssigneeId !== nextAssigneeId) {
|
||||
if (nextAssigneeId == null) {
|
||||
changes.push("Ticket unassigned");
|
||||
} else {
|
||||
let name = `User ${nextAssigneeId}`;
|
||||
try {
|
||||
const assignee = await req.payload.findByID({
|
||||
collection: "users",
|
||||
id: nextAssigneeId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const staffUser = assignee as unknown as StaffUser;
|
||||
name = staffUser.payloadDisplayName || staffUser.username || name;
|
||||
} catch {
|
||||
// Name resolution is best-effort — fall back to "User <id>".
|
||||
}
|
||||
changes.push(`Ticket assigned to ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of changes) {
|
||||
entries.push(systemEntry(message, now));
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
next.activity = entries;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Short, descriptive summary of the issue or request.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Full details of the issue or request.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "support_request",
|
||||
options: TICKET_CATEGORIES.map((c) => ({ label: c.label, value: c.value })),
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "priority",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "medium",
|
||||
options: TICKET_PRIORITIES.map((p) => ({ label: p.label, value: p.value })),
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "open",
|
||||
options: TICKET_STATUSES.map((s) => ({ label: s.label, value: s.value })),
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "reporter",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "The user who submitted this ticket.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "assignee",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
description: "Staff member responsible for this ticket.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "activity",
|
||||
type: "array",
|
||||
admin: {
|
||||
description:
|
||||
"Append-only activity timeline. Messages between the reporter and staff plus system events.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "system",
|
||||
options: [
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Staff message", value: "agent_message" },
|
||||
{ label: "Reporter message", value: "client_message" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
description: "Who wrote this entry. Omitted for system events.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
type: "date",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "closedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: "Set automatically when the ticket reaches a closing status.",
|
||||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack } from "lucide-react";
|
||||
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react";
|
||||
|
||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
||||
|
|
@ -32,6 +32,11 @@ const data = {
|
|||
url: "/roster",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Helpdesk",
|
||||
url: "/helpdesk",
|
||||
icon: LifeBuoy,
|
||||
},
|
||||
],
|
||||
navIntelligence: [
|
||||
{
|
||||
|
|
|
|||
276
src/components/frontend/helpdesk/CreateTicketDialog.tsx
Normal file
276
src/components/frontend/helpdesk/CreateTicketDialog.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createTicket } from "@/app/(frontend)/helpdesk/actions";
|
||||
import {
|
||||
TICKET_CATEGORIES,
|
||||
TICKET_PRIORITIES,
|
||||
type TicketCategory,
|
||||
type TicketPriority,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
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 CreateTicketDialog() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState<TicketCategory>("support_request");
|
||||
const [priority, setPriority] = useState<TicketPriority>("medium");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setCategory("support_request");
|
||||
setPriority("medium");
|
||||
setDescription("");
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedTitle) {
|
||||
setError("Add a short title for your request.");
|
||||
return;
|
||||
}
|
||||
if (!trimmedDescription) {
|
||||
setError("Describe the issue or request so the team can help.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
const result = await createTicket({
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
category,
|
||||
priority,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Ticket created");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Could not create the ticket.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const getColorForCategoryLabel = (label: string) => {
|
||||
switch (label.toLowerCase()) {
|
||||
case "support_request":
|
||||
return "text-blue-300";
|
||||
case "feature_request":
|
||||
return "text-green-300";
|
||||
case "bug_report":
|
||||
return "text-red-300";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const getColorForPriorityLabel = (label: string) => {
|
||||
switch (label.toLowerCase()) {
|
||||
case "low":
|
||||
return "text-zinc-300";
|
||||
case "medium":
|
||||
return "text-blue-300";
|
||||
case "high":
|
||||
return "text-amber-300";
|
||||
case "critical":
|
||||
return "text-red-300";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
New ticket
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share the details of what you need help with. The helpdesk will follow up here.
|
||||
</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-title">Title</Label>
|
||||
<Input
|
||||
id="ticket-title"
|
||||
placeholder="What do you need help with?"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-category">Category</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(value) => {
|
||||
if (isTicketCategory(value)) setCategory(value);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="ticket-category"
|
||||
className={cn("w-full", getColorForCategoryLabel(category))}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TICKET_CATEGORIES.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={getColorForCategoryLabel(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-priority">Priority</Label>
|
||||
<Select
|
||||
value={priority}
|
||||
onValueChange={(value) => {
|
||||
if (isTicketPriority(value)) setPriority(value);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="ticket-priority"
|
||||
className={cn("w-full", getColorForPriorityLabel(priority))}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TICKET_PRIORITIES.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={getColorForPriorityLabel(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{priority === "critical" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Alert variant="destructive" className="text-sm">
|
||||
<AlertTitle className="text-lg">Important!</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Marking this ticket as critical means it <strong>requires immediate attention</strong> and may
|
||||
impact the stability or functionality of the system.
|
||||
</p>
|
||||
<p>
|
||||
Please ensure that the description clearly outlines the issue and any potential
|
||||
impact it may have so that I can address it promptly.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-description">Description</Label>
|
||||
<Textarea
|
||||
id="ticket-description"
|
||||
rows={5}
|
||||
placeholder="Include what happened, what you expected, and any useful context."
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create ticket"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
65
src/components/frontend/helpdesk/TicketBadges.tsx
Normal file
65
src/components/frontend/helpdesk/TicketBadges.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Ticket } from "@/payload-types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CATEGORY_LABEL,
|
||||
CATEGORY_STYLES,
|
||||
PRIORITY_LABEL,
|
||||
PRIORITY_STYLES,
|
||||
STATUS_LABEL,
|
||||
STATUS_STYLES,
|
||||
type TicketCategory,
|
||||
type TicketPriority,
|
||||
type TicketStatus,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
|
||||
interface TicketStatusBadgeProps {
|
||||
readonly status: TicketStatus;
|
||||
}
|
||||
|
||||
interface TicketPriorityBadgeProps {
|
||||
readonly priority: TicketPriority;
|
||||
}
|
||||
|
||||
interface TicketCategoryBadgeProps {
|
||||
readonly category: TicketCategory;
|
||||
}
|
||||
|
||||
interface TicketBadgesProps {
|
||||
readonly ticket: Pick<Ticket, "status" | "priority">;
|
||||
}
|
||||
|
||||
const badgeTextClass = "text-[10px] uppercase tracking-wide";
|
||||
|
||||
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn(badgeTextClass, STATUS_STYLES[status])}>
|
||||
{STATUS_LABEL[status]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketPriorityBadge({ priority }: TicketPriorityBadgeProps) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn(badgeTextClass, PRIORITY_STYLES[priority])}>
|
||||
{PRIORITY_LABEL[priority]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketCategoryBadge({ category }: TicketCategoryBadgeProps) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn(badgeTextClass, CATEGORY_STYLES[category])}>
|
||||
{CATEGORY_LABEL[category]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketBadges({ ticket }: TicketBadgesProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/frontend/helpdesk/TicketCard.tsx
Normal file
79
src/components/frontend/helpdesk/TicketCard.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import Link from "next/link";
|
||||
import type { Ticket, User } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
TicketBadges,
|
||||
TicketCategoryBadge,
|
||||
} from "@/components/frontend/helpdesk/TicketBadges";
|
||||
|
||||
interface TicketCardProps {
|
||||
readonly ticket: Ticket;
|
||||
}
|
||||
|
||||
type UserRelation = number | User | null | undefined;
|
||||
|
||||
function resolveUser(relation: UserRelation): User | null {
|
||||
return typeof relation === "object" && relation ? relation : null;
|
||||
}
|
||||
|
||||
function userName(relation: UserRelation, fallback: string): string {
|
||||
const user = resolveUser(relation);
|
||||
if (user) return user.payloadDisplayName || user.username || user.displayName;
|
||||
if (typeof relation === "number") return `User #${relation}`;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function TicketCard({ ticket }: TicketCardProps) {
|
||||
const description = convertLexicalToPlaintext({ data: ticket.description });
|
||||
const reporterName = userName(ticket.reporter, "Unknown reporter");
|
||||
const assigneeName = userName(ticket.assignee, "Unassigned");
|
||||
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TicketCategoryBadge category={ticket.category} />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
||||
</ItemHeader>
|
||||
<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">
|
||||
{ticket.title}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{description || "No description provided."}
|
||||
</ItemDescription>
|
||||
<TicketBadges ticket={ticket} />
|
||||
</ItemContent>
|
||||
<ItemFooter className="w-full flex-wrap items-end">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>opened by {reporterName}</span>
|
||||
{hasAssignee && <span>assigned to {assigneeName}</span>}
|
||||
</div>
|
||||
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||
{timeAgo(ticket.updatedAt)}
|
||||
</span>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
227
src/components/frontend/helpdesk/TicketDetail.tsx
Normal file
227
src/components/frontend/helpdesk/TicketDetail.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { Ticket, User } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
||||
import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
|
||||
import { replyToTicket, updateTicketStatus, assignTicket } from "@/app/(frontend)/helpdesk/actions";
|
||||
import {
|
||||
CLOSING_STATUSES,
|
||||
TICKET_STATUSES,
|
||||
type TicketStatus,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
|
||||
|
||||
interface AssigneeOption {
|
||||
readonly id: number;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface TicketDetailProps {
|
||||
readonly ticket: Ticket;
|
||||
readonly currentUserId: number;
|
||||
readonly isStaff: boolean;
|
||||
readonly assigneeOptions: readonly AssigneeOption[];
|
||||
}
|
||||
|
||||
type UserRelation = number | User | null | undefined;
|
||||
type ControlKind = "status" | "assignee";
|
||||
|
||||
function relationId(relation: UserRelation): number | null {
|
||||
return typeof relation === "object" && relation ? relation.id : relation ?? null;
|
||||
}
|
||||
|
||||
function displayName(relation: UserRelation, fallback: string): string {
|
||||
if (typeof relation === "object" && relation) {
|
||||
return relation.payloadDisplayName || relation.username || relation.displayName;
|
||||
}
|
||||
return typeof relation === "number" ? `User #${relation}` : fallback;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
return iso
|
||||
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(iso),
|
||||
)
|
||||
: "—";
|
||||
}
|
||||
|
||||
function isTicketStatus(value: string): value is TicketStatus {
|
||||
return TICKET_STATUSES.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { readonly label: string; readonly value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketDetail({
|
||||
ticket,
|
||||
currentUserId,
|
||||
isStaff,
|
||||
assigneeOptions,
|
||||
}: TicketDetailProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status);
|
||||
const [assignee, setAssignee] = useState(() => {
|
||||
const id = relationId(ticket.assignee);
|
||||
return id === null ? "unassigned" : String(id);
|
||||
});
|
||||
const [controlBusy, setControlBusy] = useState<ControlKind | null>(null);
|
||||
const [controlError, setControlError] = useState<string | null>(null);
|
||||
const [reply, setReply] = useState("");
|
||||
const [replyBusy, setReplyBusy] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [cancelBusy, setCancelBusy] = useState(false);
|
||||
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const reporterName = displayName(ticket.reporter, "Unknown reporter");
|
||||
const assigneeName = displayName(ticket.assignee, "Unassigned");
|
||||
const canCancel = relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
||||
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
||||
|
||||
async function applyControl(kind: ControlKind) {
|
||||
setControlBusy(kind);
|
||||
setControlError(null);
|
||||
const assigneeId = assignee === "unassigned" ? null : Number(assignee);
|
||||
if (kind === "assignee" && (assigneeId === null || !Number.isInteger(assigneeId))) {
|
||||
setControlError("Choose a valid assignee.");
|
||||
setControlBusy(null);
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
kind === "status"
|
||||
? await updateTicketStatus(ticket.id, status)
|
||||
: await assignTicket(ticket.id, assigneeId);
|
||||
setControlBusy(null);
|
||||
if (result.success) {
|
||||
toast.success(kind === "status" ? "Ticket status updated" : "Ticket assignment updated");
|
||||
router.refresh();
|
||||
} else {
|
||||
setControlError(result.error ?? "Could not update the ticket.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReply() {
|
||||
const content = reply.trim();
|
||||
if (!content) {
|
||||
setReplyError("Write a reply before sending.");
|
||||
return;
|
||||
}
|
||||
setReplyBusy(true);
|
||||
setReplyError(null);
|
||||
const result = await replyToTicket(ticket.id, content);
|
||||
setReplyBusy(false);
|
||||
if (result.success) {
|
||||
setReply("");
|
||||
toast.success("Reply sent");
|
||||
router.refresh();
|
||||
} else {
|
||||
setReplyError(result.error ?? "Could not send the reply.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
setCancelBusy(true);
|
||||
const result = await updateTicketStatus(ticket.id, "canceled");
|
||||
setCancelBusy(false);
|
||||
if (result.success) {
|
||||
setConfirmCancel(false);
|
||||
toast.success("Ticket canceled");
|
||||
router.refresh();
|
||||
} else {
|
||||
setReplyError(result.error ?? "Could not cancel the ticket.");
|
||||
}
|
||||
}
|
||||
|
||||
function requestCancel() {
|
||||
if (confirmCancel) {
|
||||
void handleCancel();
|
||||
return;
|
||||
}
|
||||
setConfirmCancel(true);
|
||||
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
||||
cancelTimerRef.current = setTimeout(() => {
|
||||
setConfirmCancel(false);
|
||||
cancelTimerRef.current = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-5xl flex-col gap-6">
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<h2 className="min-w-0 flex-1 text-xl font-semibold leading-tight">{ticket.title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5"><TicketCategoryBadge category={ticket.category} /><TicketBadges ticket={ticket} /></div>
|
||||
</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">
|
||||
<MetaItem label="Reporter" value={reporterName} /><MetaItem label="Assignee" value={assigneeName} />
|
||||
<MetaItem label="Created" value={formatDate(ticket.createdAt)} /><MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
|
||||
<MetaItem label="Closed" value={formatDate(ticket.closedAt)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2"><CalendarClockIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Description</h3></div>
|
||||
<div className="rounded-md border bg-muted/20 p-4 text-sm leading-relaxed text-muted-foreground [&_p]:mb-2 [&_p:last-child]:mb-0 [&_strong]:text-foreground [&_em]:italic">
|
||||
{descriptionText ? <RichText data={ticket.description} /> : <p>No description provided.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isStaff && (
|
||||
<Item variant="outline" className="bg-muted/20">
|
||||
<ItemHeader><div className="flex items-center gap-2"><Settings2Icon className="size-4 text-muted-foreground" /><ItemTitle>Staff controls</ItemTitle></div></ItemHeader>
|
||||
<ItemContent className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-status">Status</Label><div className="flex gap-2">
|
||||
<Select value={status} onValueChange={(value) => { if (isTicketStatus(value)) setStatus(value); }} disabled={controlBusy !== null}>
|
||||
<SelectTrigger id="ticket-status" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{TICKET_STATUSES.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => void applyControl("status")} disabled={controlBusy !== null}>{controlBusy === "status" ? "Applying..." : "Apply"}</Button>
|
||||
</div></div>
|
||||
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-assignee">Assignee</Label><div className="flex gap-2">
|
||||
<Select value={assignee} onValueChange={setAssignee} disabled={controlBusy !== null}>
|
||||
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="unassigned">Unassigned</SelectItem>{assigneeOptions.map((option) => <SelectItem key={option.id} value={String(option.id)}>{option.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => void applyControl("assignee")} disabled={controlBusy !== null}>{controlBusy === "assignee" ? "Applying..." : "Apply"}</Button>
|
||||
</div></div>
|
||||
{controlError && <p role="alert" className="text-sm text-red-400 sm:col-span-2">{controlError}</p>}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2"><HistoryIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity</h3><span className="text-xs text-muted-foreground">{ticket.activity?.length ?? 0} entries</span></div>
|
||||
<Item variant="outline" className="bg-muted/20"><ItemContent className="w-full"><TicketTimeline ticket={ticket} currentUserId={currentUserId} /></ItemContent></Item>
|
||||
</section>
|
||||
|
||||
<Item variant="outline">
|
||||
<ItemHeader><div className="flex items-center gap-2"><SendIcon className="size-4 text-muted-foreground" /><ItemTitle>Reply</ItemTitle></div><span className="text-xs text-muted-foreground">{isStaff ? "Reply as staff" : "Send reply"}</span></ItemHeader>
|
||||
<ItemContent className="w-full gap-3">
|
||||
<Textarea rows={4} value={reply} onChange={(event) => setReply(event.target.value)} placeholder={isStaff ? "Write an update for the reporter..." : "Add more information..."} disabled={replyBusy} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">{replyError && <p role="alert" className="text-sm text-red-400">{replyError}</p>}<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}</Button></div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
{canCancel && <div className="flex justify-end"><Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}</Button></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/frontend/helpdesk/TicketTimeline.tsx
Normal file
113
src/components/frontend/helpdesk/TicketTimeline.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import type { Ticket, User } from "@/payload-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HistoryIcon } from "lucide-react";
|
||||
|
||||
interface TicketTimelineProps {
|
||||
readonly ticket: Pick<Ticket, "activity" | "reporter">;
|
||||
readonly currentUserId: number;
|
||||
}
|
||||
|
||||
type UserRelation = number | User | null | undefined;
|
||||
|
||||
function resolveUser(relation: UserRelation): User | null {
|
||||
return typeof relation === "object" && relation ? relation : null;
|
||||
}
|
||||
|
||||
function userId(relation: UserRelation): number | null {
|
||||
return typeof relation === "object" && relation ? relation.id : relation ?? null;
|
||||
}
|
||||
|
||||
function displayName(relation: UserRelation, fallback: string): string {
|
||||
const user = resolveUser(relation);
|
||||
if (user) return user.payloadDisplayName || user.username || user.displayName;
|
||||
if (typeof relation === "number") return `User #${relation}`;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "Unknown time";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function TicketTimeline({ ticket, currentUserId }: TicketTimelineProps) {
|
||||
const activities = [...(ticket.activity ?? [])].sort(
|
||||
(left, right) =>
|
||||
new Date(left.created_at ?? 0).getTime() - new Date(right.created_at ?? 0).getTime(),
|
||||
);
|
||||
const reporterName = displayName(ticket.reporter, "Reporter");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activities.length === 0 && (
|
||||
<p className="py-4 text-center text-sm italic text-muted-foreground">No activity yet.</p>
|
||||
)}
|
||||
{activities.map((activity, index) => {
|
||||
const key = activity.id ?? `${activity.type}-${activity.created_at ?? "unknown"}-${index}`;
|
||||
const content = convertLexicalToPlaintext({ data: activity.content });
|
||||
|
||||
if (activity.type === "system") {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center justify-center gap-2 px-2 py-2 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<span aria-hidden="true" className="size-1.5 rounded-full bg-muted-foreground/50" />
|
||||
<HistoryIcon className="size-3.5" />
|
||||
<span>{content || "Ticket activity updated"}</span>
|
||||
<time className="font-mono text-[10px]" dateTime={activity.created_at ?? undefined}>
|
||||
{formatDate(activity.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isClientMessage = activity.type === "client_message";
|
||||
const authorId = userId(activity.author);
|
||||
const authorName = displayName(
|
||||
activity.author,
|
||||
isClientMessage ? reporterName : "Staff member",
|
||||
);
|
||||
const bubbleLabel = isClientMessage
|
||||
? authorId === currentUserId
|
||||
? "You"
|
||||
: reporterName
|
||||
: "Staff";
|
||||
|
||||
return (
|
||||
<div key={key} className={cn("flex", isClientMessage ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[min(85%,42rem)] flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
|
||||
isClientMessage
|
||||
? "border-blue-500/30 bg-blue-500/10"
|
||||
: "border-amber-500/30 bg-amber-500/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{bubbleLabel}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none prose-invert [&_p]:my-1 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal",
|
||||
isClientMessage ? "text-blue-100/90" : "text-amber-100/90",
|
||||
)}
|
||||
>
|
||||
<RichText data={activity.content} />
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{authorName} · {formatDate(activity.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
src/components/frontend/helpdesk/TicketsView.tsx
Normal file
196
src/components/frontend/helpdesk/TicketsView.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
|
||||
import { TicketCard } from "@/components/frontend/helpdesk/TicketCard";
|
||||
import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||
import { InboxIcon, SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
||||
|
||||
interface TicketsViewProps {
|
||||
readonly tickets: readonly Ticket[];
|
||||
readonly currentUserId: number;
|
||||
readonly isStaff: boolean;
|
||||
}
|
||||
|
||||
const OPEN_STATUSES: readonly TicketStatus[] = [
|
||||
"open",
|
||||
"in_review",
|
||||
"planning",
|
||||
"in_progress",
|
||||
"waiting_on_reporter",
|
||||
"in_testing",
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Resolved", value: "resolved" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
{ label: "Unassigned", value: "unassigned" },
|
||||
] as const satisfies readonly { label: string; value: TicketFilter }[];
|
||||
|
||||
function isTicketFilter(value: string): value is TicketFilter {
|
||||
return FILTER_OPTIONS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
function isOpenTicket(ticket: Ticket): boolean {
|
||||
return OPEN_STATUSES.includes(ticket.status);
|
||||
}
|
||||
|
||||
function isUnassignedTicket(ticket: Ticket): boolean {
|
||||
return (
|
||||
(ticket.assignee === null || ticket.assignee === undefined) &&
|
||||
!CLOSING_STATUSES.includes(ticket.status)
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const counts = useMemo<Record<TicketFilter, number>>(
|
||||
() => ({
|
||||
all: tickets.length,
|
||||
open: tickets.filter(isOpenTicket).length,
|
||||
resolved: tickets.filter((ticket) => ticket.status === "resolved").length,
|
||||
closed: tickets.filter((ticket) => ticket.status === "canceled" || ticket.status === "closed")
|
||||
.length,
|
||||
unassigned: tickets.filter(isUnassignedTicket).length,
|
||||
}),
|
||||
[tickets],
|
||||
);
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return tickets.filter((ticket) => {
|
||||
const matchesFilter =
|
||||
activeFilter === "all" ||
|
||||
(activeFilter === "open" && isOpenTicket(ticket)) ||
|
||||
(activeFilter === "resolved" && ticket.status === "resolved") ||
|
||||
(activeFilter === "closed" &&
|
||||
(ticket.status === "canceled" || ticket.status === "closed")) ||
|
||||
(activeFilter === "unassigned" && isUnassignedTicket(ticket));
|
||||
if (!matchesFilter) return false;
|
||||
if (!query) return true;
|
||||
|
||||
const description = convertLexicalToPlaintext({ data: ticket.description });
|
||||
return `${ticket.title} ${description}`.toLowerCase().includes(query);
|
||||
});
|
||||
}, [activeFilter, search, tickets]);
|
||||
|
||||
const visibleFilters = isStaff
|
||||
? FILTER_OPTIONS
|
||||
: FILTER_OPTIONS.filter((option) => option.value !== "unassigned");
|
||||
|
||||
// Helper to derive a small per-filter class from STATUS_STYLES.
|
||||
// STATUS_STYLES is keyed by TicketStatus and contains a space-separated
|
||||
// class string (e.g. "bg-emerald-50 text-emerald-700"). The original
|
||||
// implementation attempted to build a complex mapping but had type errors
|
||||
// and didn't return values from the map. Keep this simple and typed: pick
|
||||
// the second token (usually the foreground color) when available.
|
||||
const statusStyles = STATUS_STYLES as Record<string, string>;
|
||||
|
||||
function classForFilter(filter: TicketFilter): string {
|
||||
switch (filter) {
|
||||
case "open":
|
||||
return (statusStyles.open ?? "").split(" ")[1] ?? "";
|
||||
case "resolved":
|
||||
return (statusStyles.resolved ?? "").split(" ")[1] ?? "";
|
||||
case "closed":
|
||||
// prefer explicit closed style, fall back to canceled if present
|
||||
return (statusStyles.closed ?? statusStyles.canceled ?? "").split(" ")[1] ?? "";
|
||||
case "unassigned":
|
||||
return "text-muted-foreground";
|
||||
case "all":
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{isStaff ? "Ticket queue" : "Your tickets"}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{tickets.length} total</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isStaff
|
||||
? "Review incoming requests and keep members updated."
|
||||
: "Keep an eye on your open requests."}
|
||||
</p>
|
||||
</div>
|
||||
<CreateTicketDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Tabs
|
||||
value={activeFilter}
|
||||
onValueChange={(value) => {
|
||||
if (isTicketFilter(value)) setActiveFilter(value);
|
||||
}}
|
||||
>
|
||||
<TabsList className="max-w-full justify-start sm:w-fit gap-1">
|
||||
{visibleFilters.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={cn("flex-none", classForFilter(option.value))}
|
||||
>
|
||||
{option.label}
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{counts[option.value]}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="relative w-full lg:max-w-xs">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search tickets"
|
||||
className="pl-8"
|
||||
placeholder="Search tickets..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTickets.length === 0 ? (
|
||||
<Item variant="outline" className="">
|
||||
<ItemMedia variant="icon">
|
||||
<InboxIcon className="text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="items-center gap-1">
|
||||
<ItemTitle>{tickets.length === 0 ? "No tickets yet" : "No matching tickets"}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{tickets.length === 0
|
||||
? "Create a ticket to send a request to the helpdesk."
|
||||
: "Try a different filter or search phrase."}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredTickets.map((ticket) => (
|
||||
<TicketCard key={ticket.id} ticket={ticket} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -74,6 +74,14 @@ export function notificationLabel(type?: string | null): string {
|
|||
return "Bank transfer";
|
||||
case "shipment:completed":
|
||||
return "Shipment completed";
|
||||
case "ticket:created":
|
||||
return "New ticket";
|
||||
case "ticket:assigned":
|
||||
return "Ticket assigned";
|
||||
case "ticket:reply":
|
||||
return "New reply";
|
||||
case "ticket:status":
|
||||
return "Ticket updated";
|
||||
default:
|
||||
return type ?? "Notification";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
|
|||
{ label: "Bank withdrawal", value: "finance:withdraw" },
|
||||
{ label: "Bank transfer", value: "finance:transfer" },
|
||||
{ label: "Shipment completed", value: "shipment:completed" },
|
||||
{ label: "New tickets", value: "ticket:created" },
|
||||
{ label: "Ticket assigned", value: "ticket:assigned" },
|
||||
{ label: "Ticket replies", value: "ticket:reply" },
|
||||
{ label: "Ticket status changes", value: "ticket:status" },
|
||||
] as const;
|
||||
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
|
||||
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);
|
||||
|
|
|
|||
89
src/lib/tickets/lexical.ts
Normal file
89
src/lib/tickets/lexical.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Converts plain text (from client textareas) into Payload-compatible lexical
|
||||
* editor state so richText fields can be populated from simple forms.
|
||||
*
|
||||
* Paragraph breaks: double newlines split paragraphs; single newlines become
|
||||
* line breaks inside a paragraph.
|
||||
*/
|
||||
|
||||
interface LexicalTextNode {
|
||||
type: "text";
|
||||
version: 1;
|
||||
detail: 0;
|
||||
format: 0;
|
||||
mode: "normal";
|
||||
style: "";
|
||||
text: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface LexicalLinebreakNode {
|
||||
type: "linebreak";
|
||||
version: 1;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
type LexicalInlineNode = LexicalTextNode | LexicalLinebreakNode;
|
||||
|
||||
interface LexicalParagraphNode {
|
||||
type: "paragraph";
|
||||
version: 1;
|
||||
direction: "ltr";
|
||||
format: "";
|
||||
indent: 0;
|
||||
textFormat: 0;
|
||||
textStyle: "";
|
||||
children: LexicalInlineNode[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlaintextLexicalState {
|
||||
root: {
|
||||
type: "root";
|
||||
version: 1;
|
||||
direction: "ltr";
|
||||
format: "";
|
||||
indent: 0;
|
||||
children: LexicalParagraphNode[];
|
||||
};
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function inlineNodes(text: string): LexicalInlineNode[] {
|
||||
const nodes: LexicalInlineNode[] = [];
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].length > 0) {
|
||||
nodes.push({ type: "text", version: 1, detail: 0, format: 0, mode: "normal", style: "", text: lines[i] });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
nodes.push({ type: "linebreak", version: 1 });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function plaintextToLexical(text: string): PlaintextLexicalState {
|
||||
const trimmed = text.trim();
|
||||
const paragraphs = trimmed.length === 0 ? [""] : trimmed.split(/\n{2,}/);
|
||||
|
||||
return {
|
||||
root: {
|
||||
type: "root",
|
||||
version: 1,
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
children: paragraphs.map((para) => ({
|
||||
type: "paragraph",
|
||||
version: 1,
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
textStyle: "",
|
||||
children: inlineNodes(para),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
26
src/lib/tickets/staff.ts
Normal file
26
src/lib/tickets/staff.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Helpdesk staff resolution. "Staff" = users with the `admin` or `developer`
|
||||
* role — the people who can manage tickets. Used to fan out notifications when
|
||||
* a new ticket is created.
|
||||
*/
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export async function getStaffUserIds(payload: PayloadType): Promise<number[]> {
|
||||
try {
|
||||
const res = await payload.find({
|
||||
collection: "users",
|
||||
where: {
|
||||
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
|
||||
},
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
select: { username: true },
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs.map((d) => d.id);
|
||||
} catch (err) {
|
||||
console.error("[Tickets] Failed to resolve staff recipients:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
112
src/lib/tickets/ticketMeta.ts
Normal file
112
src/lib/tickets/ticketMeta.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Shared ticket vocabulary: status/priority/category options, human labels, and
|
||||
* UI style maps. Single source of truth used by the collection, server actions,
|
||||
* and the frontend.
|
||||
*/
|
||||
|
||||
export const TICKET_STATUSES = [
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "In Review", value: "in_review" },
|
||||
{ label: "Planning", value: "planning" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Waiting on Reporter", value: "waiting_on_reporter" },
|
||||
{ label: "In Testing", value: "in_testing" },
|
||||
{ label: "Resolved", value: "resolved" },
|
||||
{ label: "Canceled", value: "canceled" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
] as const;
|
||||
|
||||
export type TicketStatus = (typeof TICKET_STATUSES)[number]["value"];
|
||||
|
||||
export const TICKET_PRIORITIES = [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
] as const;
|
||||
|
||||
export type TicketPriority = (typeof TICKET_PRIORITIES)[number]["value"];
|
||||
|
||||
export const TICKET_CATEGORIES = [
|
||||
{ label: "Bug Report", value: "bug_report" },
|
||||
{ label: "Feature Request", value: "feature_request" },
|
||||
{ label: "Support Request", value: "support_request" },
|
||||
{ label: "Other", value: "other" },
|
||||
] as const;
|
||||
|
||||
export type TicketCategory = (typeof TICKET_CATEGORIES)[number]["value"];
|
||||
|
||||
export const STATUS_LABEL: Record<TicketStatus, string> = {
|
||||
open: "Open",
|
||||
in_review: "In Review",
|
||||
planning: "Planning",
|
||||
in_progress: "In Progress",
|
||||
waiting_on_reporter: "Waiting on Reporter",
|
||||
in_testing: "In Testing",
|
||||
resolved: "Resolved",
|
||||
canceled: "Canceled",
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
export const PRIORITY_LABEL: Record<TicketPriority, string> = {
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
high: "High",
|
||||
critical: "Critical",
|
||||
};
|
||||
|
||||
export const CATEGORY_LABEL: Record<TicketCategory, string> = {
|
||||
bug_report: "Bug Report",
|
||||
feature_request: "Feature Request",
|
||||
support_request: "Support Request",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export const statusLabel = (value: string | null | undefined): string =>
|
||||
value && value in STATUS_LABEL
|
||||
? STATUS_LABEL[value as TicketStatus]
|
||||
: value ?? "Unknown";
|
||||
|
||||
export const priorityLabel = (value: string | null | undefined): string =>
|
||||
value && value in PRIORITY_LABEL
|
||||
? PRIORITY_LABEL[value as TicketPriority]
|
||||
: value ?? "Unknown";
|
||||
|
||||
export const categoryLabel = (value: string | null | undefined): string =>
|
||||
value && value in CATEGORY_LABEL
|
||||
? CATEGORY_LABEL[value as TicketCategory]
|
||||
: value ?? "Unknown";
|
||||
|
||||
/** Statuses that count as the ticket being done — sets `closedAt`. */
|
||||
export const CLOSING_STATUSES: readonly TicketStatus[] = [
|
||||
"resolved",
|
||||
"canceled",
|
||||
"closed",
|
||||
];
|
||||
|
||||
/** Badge color classes per status, keyed for use with the shadcn `Badge` component. */
|
||||
export const STATUS_STYLES: Record<TicketStatus, string> = {
|
||||
open: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
in_review: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
planning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
waiting_on_reporter: "bg-orange-500/15 text-orange-400 border-orange-500/30",
|
||||
in_testing: "bg-cyan-500/15 text-cyan-400 border-cyan-500/30",
|
||||
resolved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
canceled: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
closed: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
export const PRIORITY_STYLES: Record<TicketPriority, string> = {
|
||||
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
medium: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
high: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
export const CATEGORY_STYLES: Record<TicketCategory, string> = {
|
||||
bug_report: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
feature_request: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
support_request: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
other: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
15515
src/migrations/20260816_213455.json
Normal file
15515
src/migrations/20260816_213455.json
Normal file
File diff suppressed because it is too large
Load diff
923
src/migrations/20260816_213455.ts
Normal file
923
src/migrations/20260816_213455.ts
Normal file
|
|
@ -0,0 +1,923 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_game_vehicles_status" AS ENUM('idle', 'assigned', 'in_transit', 'damaged', 'destroyed');
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'tickets');
|
||||
CREATE TYPE "public"."enum_users_preferences_notifications_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
|
||||
CREATE TYPE "public"."enum_users_preferences_discord_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
|
||||
CREATE TYPE "public"."enum_mission_attendances_response" AS ENUM('yes', 'no', 'tentative');
|
||||
CREATE TYPE "public"."enum_technologies_approval_status" AS ENUM('in_progress', 'ready_for_review', 'approved', 'rejected', 'revision_requested');
|
||||
CREATE TYPE "public"."enum_assets_skin_configuration_applies_to_asset_types" AS ENUM('weapon', 'uniform', 'vest', 'backpack', 'helmet');
|
||||
CREATE TYPE "public"."enum_assets_approval_status" AS ENUM('in_progress', 'ready_for_review', 'approved', 'rejected', 'revision_requested');
|
||||
CREATE TYPE "public"."enum_resources_approval_status" AS ENUM('in_progress', 'ready_for_review', 'approved', 'rejected', 'revision_requested');
|
||||
CREATE TYPE "public"."enum_vehicles_approval_status" AS ENUM('in_progress', 'ready_for_review', 'approved', 'rejected', 'revision_requested');
|
||||
CREATE TYPE "public"."enum_structures_approval_status" AS ENUM('in_progress', 'ready_for_review', 'approved', 'rejected', 'revision_requested');
|
||||
CREATE TYPE "public"."enum_shipments_status" AS ENUM('pending', 'dispatched', 'in_transit', 'arrived', 'completed', 'cancelled', 'failed', 'stranded');
|
||||
CREATE TYPE "public"."enum_bank_accounts_account_type" AS ENUM('treasury', 'faction', 'personal');
|
||||
CREATE TYPE "public"."enum_bank_accounts_status" AS ENUM('open', 'frozen', 'closed');
|
||||
CREATE TYPE "public"."enum_bank_transactions_type" AS ENUM('deposit', 'withdrawal', 'transfer', 'payment', 'fee', 'salary', 'adjustment');
|
||||
CREATE TYPE "public"."enum_bank_transactions_status" AS ENUM('completed', 'reversed');
|
||||
CREATE TYPE "public"."enum_ledger_entries_type" AS ENUM('deposit', 'withdrawal', 'transfer', 'payment', 'fee', 'salary', 'adjustment');
|
||||
CREATE TYPE "public"."enum_market_listings_status" AS ENUM('active', 'sold', 'cancelled', 'expired');
|
||||
CREATE TYPE "public"."enum_market_negotiations_history_from" AS ENUM('buyer', 'seller');
|
||||
CREATE TYPE "public"."enum_market_negotiations_status" AS ENUM('open', 'accepted', 'rejected', 'closed', 'cancelled', 'expired');
|
||||
CREATE TYPE "public"."enum_market_negotiations_proposed_by" AS ENUM('buyer', 'seller');
|
||||
CREATE TYPE "public"."enum_tickets_activity_type" AS ENUM('system', 'agent_message', 'client_message');
|
||||
CREATE TYPE "public"."enum_tickets_category" AS ENUM('bug_report', 'feature_request', 'support_request', 'other');
|
||||
CREATE TYPE "public"."enum_tickets_priority" AS ENUM('low', 'medium', 'high', 'critical');
|
||||
CREATE TYPE "public"."enum_tickets_status" AS ENUM('open', 'in_review', 'planning', 'in_progress', 'waiting_on_reporter', 'in_testing', 'resolved', 'canceled', 'closed');
|
||||
CREATE TYPE "public"."enum_narrative_events_triggers_type" AS ENUM('manual', 'mission_complete', 'timer', 'condition');
|
||||
ALTER TYPE "public"."enum_users_roles" ADD VALUE 'trusted' BEFORE 'admin';
|
||||
ALTER TYPE "public"."enum_factions_type" ADD VALUE 'playerFaction' BEFORE 'majorFaction';
|
||||
ALTER TYPE "public"."enum_assets_asset_type" ADD VALUE 'skin' BEFORE 'miscellaneous';
|
||||
CREATE TABLE "game_structures_void_storage" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount" numeric NOT NULL,
|
||||
"grid_x" numeric NOT NULL,
|
||||
"grid_y" numeric NOT NULL,
|
||||
"rotated" boolean DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE "game_vehicles_stored_resources" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount" numeric NOT NULL,
|
||||
"grid_x" numeric NOT NULL,
|
||||
"grid_y" numeric NOT NULL,
|
||||
"rotated" boolean DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE "game_vehicles_void_storage" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"resource_id" integer NOT NULL,
|
||||
"amount" numeric NOT NULL,
|
||||
"grid_x" numeric NOT NULL,
|
||||
"grid_y" numeric NOT NULL,
|
||||
"rotated" boolean DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE "game_vehicles" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"type_id" integer NOT NULL,
|
||||
"deployed_at_id" integer,
|
||||
"faction_id" integer,
|
||||
"status" "enum_game_vehicles_status" DEFAULT 'idle' NOT NULL,
|
||||
"current_fuel" numeric DEFAULT 0 NOT NULL,
|
||||
"current_health" numeric NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "game_npcs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"occupation" varchar,
|
||||
"location_id" integer,
|
||||
"faction_id" integer,
|
||||
"bio" varchar,
|
||||
"is_generated" boolean DEFAULT false,
|
||||
"is_in_game" boolean DEFAULT true,
|
||||
"vendor_enabled" boolean DEFAULT false,
|
||||
"vendor_price_modifier" numeric DEFAULT 1,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "game_npcs_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"assets_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "game_event_logs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"system" boolean DEFAULT true,
|
||||
"timestamp" timestamp(3) with time zone NOT NULL,
|
||||
"type" varchar NOT NULL,
|
||||
"message" varchar NOT NULL,
|
||||
"actor_id" integer,
|
||||
"structure_id" integer,
|
||||
"target_collection" "enum_game_event_logs_target_collection",
|
||||
"target_id" numeric,
|
||||
"data" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users_preferences_notifications_muted_types" (
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"value" "enum_users_preferences_notifications_muted_types",
|
||||
"id" serial PRIMARY KEY NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users_preferences_discord_muted_types" (
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"value" "enum_users_preferences_discord_muted_types",
|
||||
"id" serial PRIMARY KEY NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "user_notifications" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"type" varchar,
|
||||
"title" varchar NOT NULL,
|
||||
"message" varchar,
|
||||
"link" varchar,
|
||||
"read" boolean DEFAULT false,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "mission_attendances" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"mission_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"response" "enum_mission_attendances_response" NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "factions_relationships" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"target_faction_id" integer NOT NULL,
|
||||
"value" numeric DEFAULT 0 NOT NULL,
|
||||
"notes" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "assets_skin_configuration_applies_to_asset_types" (
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"value" "enum_assets_skin_configuration_applies_to_asset_types",
|
||||
"id" serial PRIMARY KEY NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "shipments_cargo" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"amount" numeric NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "shipments" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"origin_id" integer NOT NULL,
|
||||
"destination_id" integer NOT NULL,
|
||||
"transport_vehicle_id" integer NOT NULL,
|
||||
"distance" numeric NOT NULL,
|
||||
"fuel_cost" numeric NOT NULL,
|
||||
"fuel_consumed" numeric DEFAULT 0,
|
||||
"status" "enum_shipments_status" DEFAULT 'pending' NOT NULL,
|
||||
"dispatched_at" timestamp(3) with time zone,
|
||||
"estimated_arrival" timestamp(3) with time zone,
|
||||
"completed_at" timestamp(3) with time zone,
|
||||
"auto_return" boolean DEFAULT false,
|
||||
"created_by_id" integer,
|
||||
"failure_reason" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "shipments_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"resources_id" integer,
|
||||
"assets_id" integer,
|
||||
"vehicles_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "bank_accounts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"account_type" "enum_bank_accounts_account_type" DEFAULT 'personal' NOT NULL,
|
||||
"owner_faction_id" integer,
|
||||
"owner_user_id" integer,
|
||||
"currency_id" integer NOT NULL,
|
||||
"balance" numeric DEFAULT 0,
|
||||
"status" "enum_bank_accounts_status" DEFAULT 'open' NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "bank_transactions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"transaction_number" varchar NOT NULL,
|
||||
"type" "enum_bank_transactions_type" NOT NULL,
|
||||
"from_account_id" integer,
|
||||
"to_account_id" integer,
|
||||
"amount" numeric NOT NULL,
|
||||
"fee" numeric DEFAULT 0,
|
||||
"memo" varchar,
|
||||
"actor_id" integer,
|
||||
"status" "enum_bank_transactions_status" DEFAULT 'completed' NOT NULL,
|
||||
"reference_id" integer,
|
||||
"timestamp" timestamp(3) with time zone NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ledger_entries" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"transaction_id" integer NOT NULL,
|
||||
"type" "enum_ledger_entries_type" NOT NULL,
|
||||
"amount" numeric NOT NULL,
|
||||
"balance_after" numeric,
|
||||
"memo" varchar,
|
||||
"timestamp" timestamp(3) with time zone NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "locker_storages_items_equipment_attachments" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" varchar NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"asset_id" integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "locker_storages_items" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"asset_id" integer NOT NULL,
|
||||
"quantity" numeric DEFAULT 1 NOT NULL,
|
||||
"grid_x" numeric DEFAULT 0 NOT NULL,
|
||||
"grid_y" numeric DEFAULT 0 NOT NULL,
|
||||
"rotated" boolean DEFAULT false,
|
||||
"equipment_skin_id" integer,
|
||||
"equipment_custom_name" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "locker_storages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar DEFAULT 'Personal Locker' NOT NULL,
|
||||
"owner_user_id" integer NOT NULL,
|
||||
"item_count" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "loadouts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"description" varchar,
|
||||
"owner_user_id" integer NOT NULL,
|
||||
"equipped" boolean DEFAULT false,
|
||||
"slots_primary_id" integer,
|
||||
"slots_secondary_id" integer,
|
||||
"slots_launcher_id" integer,
|
||||
"slots_uniform_id" integer,
|
||||
"slots_vest_id" integer,
|
||||
"slots_backpack_id" integer,
|
||||
"slots_helmet_id" integer,
|
||||
"slots_facewear_id" integer,
|
||||
"slots_nvg_id" integer,
|
||||
"slots_radio_id" integer,
|
||||
"slots_optic_id" integer,
|
||||
"slots_muzzle_id" integer,
|
||||
"slots_bipod_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "market_listings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"asset_id" integer NOT NULL,
|
||||
"seller_id" integer,
|
||||
"npc_id" integer,
|
||||
"quantity" numeric DEFAULT 1 NOT NULL,
|
||||
"price" numeric NOT NULL,
|
||||
"desired_price" numeric,
|
||||
"min_price" numeric,
|
||||
"currency_id" integer,
|
||||
"status" "enum_market_listings_status" DEFAULT 'active' NOT NULL,
|
||||
"is_auto_generated" boolean DEFAULT false,
|
||||
"listed_at" timestamp(3) with time zone NOT NULL,
|
||||
"expires_at" timestamp(3) with time zone,
|
||||
"sold_at" timestamp(3) with time zone,
|
||||
"buyer_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "market_negotiations_history" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"amount" numeric NOT NULL,
|
||||
"from" "enum_market_negotiations_history_from" NOT NULL,
|
||||
"note" varchar,
|
||||
"at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "market_negotiations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"listing_id" integer NOT NULL,
|
||||
"buyer_id" integer NOT NULL,
|
||||
"status" "enum_market_negotiations_status" DEFAULT 'open' NOT NULL,
|
||||
"patience" numeric DEFAULT 0,
|
||||
"quantity" numeric DEFAULT 1,
|
||||
"amount" numeric NOT NULL,
|
||||
"proposed_by" "enum_market_negotiations_proposed_by" DEFAULT 'buyer' NOT NULL,
|
||||
"accepted_price" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "tickets_activity" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"type" "enum_tickets_activity_type" DEFAULT 'system' NOT NULL,
|
||||
"content" jsonb NOT NULL,
|
||||
"author_id" integer,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "tickets" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"description" jsonb NOT NULL,
|
||||
"category" "enum_tickets_category" DEFAULT 'support_request' NOT NULL,
|
||||
"priority" "enum_tickets_priority" DEFAULT 'medium' NOT NULL,
|
||||
"status" "enum_tickets_status" DEFAULT 'open' NOT NULL,
|
||||
"reporter_id" integer NOT NULL,
|
||||
"assignee_id" integer,
|
||||
"closed_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "game_rules_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"assignments_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "missions" ALTER COLUMN "game_details_game_setup_mission_file_id" DROP NOT NULL;
|
||||
ALTER TABLE "structures_auto_generated_resources" ALTER COLUMN "min_frequency" SET DEFAULT '1';
|
||||
ALTER TABLE "game_structures_stored_resources" ADD COLUMN "grid_x" numeric DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE "game_structures_stored_resources" ADD COLUMN "grid_y" numeric DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE "game_structures_stored_resources" ALTER COLUMN "grid_x" DROP DEFAULT;
|
||||
ALTER TABLE "game_structures_stored_resources" ALTER COLUMN "grid_y" DROP DEFAULT;
|
||||
ALTER TABLE "game_structures_stored_resources" ADD COLUMN "rotated" boolean DEFAULT false;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "faction_id" integer;
|
||||
ALTER TABLE "users" ADD COLUMN "discord_id" varchar;
|
||||
ALTER TABLE "users" ADD COLUMN "preferences_notifications_muted_all" boolean DEFAULT false;
|
||||
ALTER TABLE "users" ADD COLUMN "preferences_display_show_callsign" boolean DEFAULT false;
|
||||
ALTER TABLE "users" ADD COLUMN "preferences_discord_enabled" boolean DEFAULT false;
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_flappy_bird_games_played" numeric DEFAULT 0;
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_flappy_bird_total_passes" numeric DEFAULT 0;
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_flappy_bird_best_score" numeric DEFAULT 0;
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_flappy_bird_xp_earned" numeric DEFAULT 0;
|
||||
ALTER TABLE "profiles_rels" ADD COLUMN "qualifications_id" integer;
|
||||
ALTER TABLE "awards" ADD COLUMN "ribbon_design" jsonb;
|
||||
ALTER TABLE "awards" ADD COLUMN "medal_design" jsonb;
|
||||
ALTER TABLE "missions_factions_involved" ADD COLUMN "faction_id" integer NOT NULL;
|
||||
ALTER TABLE "missions" ADD COLUMN "discord_message_id" varchar;
|
||||
ALTER TABLE "missions" ADD COLUMN "discord_attendance_hash" varchar;
|
||||
ALTER TABLE "missions" ADD COLUMN "cover_image_id" integer;
|
||||
ALTER TABLE "campaigns" ADD COLUMN "cover_image_id" integer;
|
||||
ALTER TABLE "factions" ADD COLUMN "reputation" numeric DEFAULT 0;
|
||||
ALTER TABLE "factions" ADD COLUMN "cover_image_id" integer;
|
||||
ALTER TABLE "technologies" ADD COLUMN "approval_status" "enum_technologies_approval_status" DEFAULT 'in_progress' NOT NULL;
|
||||
ALTER TABLE "assets" ADD COLUMN "approval_status" "enum_assets_approval_status" DEFAULT 'in_progress' NOT NULL;
|
||||
ALTER TABLE "assets" ADD COLUMN "storage_dimensions_grid_width" numeric DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE "assets" ADD COLUMN "storage_dimensions_grid_height" numeric DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE "assets" ADD COLUMN "storage_dimensions_stackable" boolean DEFAULT true;
|
||||
ALTER TABLE "assets" ADD COLUMN "storage_dimensions_max_stack_size" numeric;
|
||||
ALTER TABLE "assets" ADD COLUMN "storage_dimensions_rotatable" boolean DEFAULT true;
|
||||
ALTER TABLE "resources" ADD COLUMN "approval_status" "enum_resources_approval_status" DEFAULT 'in_progress' NOT NULL;
|
||||
ALTER TABLE "resources" ADD COLUMN "grid_width" numeric DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE "resources" ADD COLUMN "grid_height" numeric DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE "resources" ADD COLUMN "stackable" boolean DEFAULT true;
|
||||
ALTER TABLE "resources" ADD COLUMN "max_stack_size" numeric;
|
||||
ALTER TABLE "resources" ADD COLUMN "rotatable" boolean DEFAULT true;
|
||||
ALTER TABLE "vehicles" ADD COLUMN "approval_status" "enum_vehicles_approval_status" DEFAULT 'in_progress' NOT NULL;
|
||||
ALTER TABLE "vehicles" ADD COLUMN "fuel_fuel_type_id" integer NOT NULL;
|
||||
ALTER TABLE "vehicles" ADD COLUMN "fuel_fuel_capacity" numeric NOT NULL;
|
||||
ALTER TABLE "vehicles" ADD COLUMN "fuel_fuel_consumption_rate" numeric DEFAULT 1 NOT NULL;
|
||||
ALTER TABLE "structures" ADD COLUMN "approval_status" "enum_structures_approval_status" DEFAULT 'in_progress' NOT NULL;
|
||||
ALTER TABLE "structures" ADD COLUMN "restrict_to_allowed" boolean DEFAULT false;
|
||||
ALTER TABLE "narrative_events" ADD COLUMN "flow_data" jsonb NOT NULL;
|
||||
ALTER TABLE "narrative_events" ADD COLUMN "triggers_type" "enum_narrative_events_triggers_type" DEFAULT 'manual';
|
||||
ALTER TABLE "narrative_events" ADD COLUMN "triggers_condition" jsonb;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "game_vehicles_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "game_npcs_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "game_event_logs_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "user_notifications_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "mission_attendances_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "shipments_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "bank_accounts_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "bank_transactions_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "ledger_entries_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "locker_storages_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "loadouts_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "market_listings_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "market_negotiations_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "tickets_id" integer;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "proximity_threshold" numeric DEFAULT 10;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "game_tick_interval_minutes" numeric DEFAULT 10;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "locker_grid_width" numeric DEFAULT 10;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "locker_grid_height" numeric DEFAULT 6;
|
||||
ALTER TABLE "game_structures_void_storage" ADD CONSTRAINT "game_structures_void_storage_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_structures_void_storage" ADD CONSTRAINT "game_structures_void_storage_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles_stored_resources" ADD CONSTRAINT "game_vehicles_stored_resources_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles_stored_resources" ADD CONSTRAINT "game_vehicles_stored_resources_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_vehicles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles_void_storage" ADD CONSTRAINT "game_vehicles_void_storage_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles_void_storage" ADD CONSTRAINT "game_vehicles_void_storage_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_vehicles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles" ADD CONSTRAINT "game_vehicles_type_id_vehicles_id_fk" FOREIGN KEY ("type_id") REFERENCES "public"."vehicles"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles" ADD CONSTRAINT "game_vehicles_deployed_at_id_game_structures_id_fk" FOREIGN KEY ("deployed_at_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_vehicles" ADD CONSTRAINT "game_vehicles_faction_id_factions_id_fk" FOREIGN KEY ("faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_npcs" ADD CONSTRAINT "game_npcs_location_id_game_structures_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_npcs" ADD CONSTRAINT "game_npcs_faction_id_factions_id_fk" FOREIGN KEY ("faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_npcs_rels" ADD CONSTRAINT "game_npcs_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."game_npcs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_npcs_rels" ADD CONSTRAINT "game_npcs_rels_assets_fk" FOREIGN KEY ("assets_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_event_logs" ADD CONSTRAINT "game_event_logs_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_event_logs" ADD CONSTRAINT "game_event_logs_structure_id_game_structures_id_fk" FOREIGN KEY ("structure_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "users_preferences_notifications_muted_types" ADD CONSTRAINT "users_preferences_notifications_muted_types_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "users_preferences_discord_muted_types" ADD CONSTRAINT "users_preferences_discord_muted_types_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "user_notifications" ADD CONSTRAINT "user_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "mission_attendances" ADD CONSTRAINT "mission_attendances_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "mission_attendances" ADD CONSTRAINT "mission_attendances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "factions_relationships" ADD CONSTRAINT "factions_relationships_target_faction_id_factions_id_fk" FOREIGN KEY ("target_faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "factions_relationships" ADD CONSTRAINT "factions_relationships_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."factions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "assets_skin_configuration_applies_to_asset_types" ADD CONSTRAINT "assets_skin_configuration_applies_to_asset_types_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "shipments_cargo" ADD CONSTRAINT "shipments_cargo_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."shipments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_origin_id_game_structures_id_fk" FOREIGN KEY ("origin_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_destination_id_game_structures_id_fk" FOREIGN KEY ("destination_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_transport_vehicle_id_game_vehicles_id_fk" FOREIGN KEY ("transport_vehicle_id") REFERENCES "public"."game_vehicles"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "shipments_rels" ADD CONSTRAINT "shipments_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."shipments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "shipments_rels" ADD CONSTRAINT "shipments_rels_resources_fk" FOREIGN KEY ("resources_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "shipments_rels" ADD CONSTRAINT "shipments_rels_assets_fk" FOREIGN KEY ("assets_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "shipments_rels" ADD CONSTRAINT "shipments_rels_vehicles_fk" FOREIGN KEY ("vehicles_id") REFERENCES "public"."vehicles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "bank_accounts" ADD CONSTRAINT "bank_accounts_owner_faction_id_factions_id_fk" FOREIGN KEY ("owner_faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_accounts" ADD CONSTRAINT "bank_accounts_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_accounts" ADD CONSTRAINT "bank_accounts_currency_id_resources_id_fk" FOREIGN KEY ("currency_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_transactions" ADD CONSTRAINT "bank_transactions_from_account_id_bank_accounts_id_fk" FOREIGN KEY ("from_account_id") REFERENCES "public"."bank_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_transactions" ADD CONSTRAINT "bank_transactions_to_account_id_bank_accounts_id_fk" FOREIGN KEY ("to_account_id") REFERENCES "public"."bank_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_transactions" ADD CONSTRAINT "bank_transactions_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "bank_transactions" ADD CONSTRAINT "bank_transactions_reference_id_bank_transactions_id_fk" FOREIGN KEY ("reference_id") REFERENCES "public"."bank_transactions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_account_id_bank_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."bank_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_transaction_id_bank_transactions_id_fk" FOREIGN KEY ("transaction_id") REFERENCES "public"."bank_transactions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages_items_equipment_attachments" ADD CONSTRAINT "locker_storages_items_equipment_attachments_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages_items_equipment_attachments" ADD CONSTRAINT "locker_storages_items_equipment_attachments_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."locker_storages_items"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages_items" ADD CONSTRAINT "locker_storages_items_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages_items" ADD CONSTRAINT "locker_storages_items_equipment_skin_id_assets_id_fk" FOREIGN KEY ("equipment_skin_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages_items" ADD CONSTRAINT "locker_storages_items_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."locker_storages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "locker_storages" ADD CONSTRAINT "locker_storages_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_primary_id_assets_id_fk" FOREIGN KEY ("slots_primary_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_secondary_id_assets_id_fk" FOREIGN KEY ("slots_secondary_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_launcher_id_assets_id_fk" FOREIGN KEY ("slots_launcher_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_uniform_id_assets_id_fk" FOREIGN KEY ("slots_uniform_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_vest_id_assets_id_fk" FOREIGN KEY ("slots_vest_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_backpack_id_assets_id_fk" FOREIGN KEY ("slots_backpack_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_helmet_id_assets_id_fk" FOREIGN KEY ("slots_helmet_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_facewear_id_assets_id_fk" FOREIGN KEY ("slots_facewear_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_nvg_id_assets_id_fk" FOREIGN KEY ("slots_nvg_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_radio_id_assets_id_fk" FOREIGN KEY ("slots_radio_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_optic_id_assets_id_fk" FOREIGN KEY ("slots_optic_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_muzzle_id_assets_id_fk" FOREIGN KEY ("slots_muzzle_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "loadouts" ADD CONSTRAINT "loadouts_slots_bipod_id_assets_id_fk" FOREIGN KEY ("slots_bipod_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_listings" ADD CONSTRAINT "market_listings_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_listings" ADD CONSTRAINT "market_listings_seller_id_users_id_fk" FOREIGN KEY ("seller_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_listings" ADD CONSTRAINT "market_listings_npc_id_game_npcs_id_fk" FOREIGN KEY ("npc_id") REFERENCES "public"."game_npcs"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_listings" ADD CONSTRAINT "market_listings_currency_id_resources_id_fk" FOREIGN KEY ("currency_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_listings" ADD CONSTRAINT "market_listings_buyer_id_users_id_fk" FOREIGN KEY ("buyer_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_negotiations_history" ADD CONSTRAINT "market_negotiations_history_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."market_negotiations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "market_negotiations" ADD CONSTRAINT "market_negotiations_listing_id_market_listings_id_fk" FOREIGN KEY ("listing_id") REFERENCES "public"."market_listings"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "market_negotiations" ADD CONSTRAINT "market_negotiations_buyer_id_users_id_fk" FOREIGN KEY ("buyer_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "tickets_activity" ADD CONSTRAINT "tickets_activity_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "tickets_activity" ADD CONSTRAINT "tickets_activity_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "game_rules_rels" ADD CONSTRAINT "game_rules_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."game_rules"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_rules_rels" ADD CONSTRAINT "game_rules_rels_assignments_fk" FOREIGN KEY ("assignments_id") REFERENCES "public"."assignments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "game_structures_void_storage_order_idx" ON "game_structures_void_storage" USING btree ("_order");
|
||||
CREATE INDEX "game_structures_void_storage_parent_id_idx" ON "game_structures_void_storage" USING btree ("_parent_id");
|
||||
CREATE INDEX "game_structures_void_storage_resource_idx" ON "game_structures_void_storage" USING btree ("resource_id");
|
||||
CREATE INDEX "game_vehicles_stored_resources_order_idx" ON "game_vehicles_stored_resources" USING btree ("_order");
|
||||
CREATE INDEX "game_vehicles_stored_resources_parent_id_idx" ON "game_vehicles_stored_resources" USING btree ("_parent_id");
|
||||
CREATE INDEX "game_vehicles_stored_resources_resource_idx" ON "game_vehicles_stored_resources" USING btree ("resource_id");
|
||||
CREATE INDEX "game_vehicles_void_storage_order_idx" ON "game_vehicles_void_storage" USING btree ("_order");
|
||||
CREATE INDEX "game_vehicles_void_storage_parent_id_idx" ON "game_vehicles_void_storage" USING btree ("_parent_id");
|
||||
CREATE INDEX "game_vehicles_void_storage_resource_idx" ON "game_vehicles_void_storage" USING btree ("resource_id");
|
||||
CREATE INDEX "game_vehicles_type_idx" ON "game_vehicles" USING btree ("type_id");
|
||||
CREATE INDEX "game_vehicles_deployed_at_idx" ON "game_vehicles" USING btree ("deployed_at_id");
|
||||
CREATE INDEX "game_vehicles_faction_idx" ON "game_vehicles" USING btree ("faction_id");
|
||||
CREATE INDEX "game_vehicles_updated_at_idx" ON "game_vehicles" USING btree ("updated_at");
|
||||
CREATE INDEX "game_vehicles_created_at_idx" ON "game_vehicles" USING btree ("created_at");
|
||||
CREATE INDEX "game_npcs_location_idx" ON "game_npcs" USING btree ("location_id");
|
||||
CREATE INDEX "game_npcs_faction_idx" ON "game_npcs" USING btree ("faction_id");
|
||||
CREATE INDEX "game_npcs_updated_at_idx" ON "game_npcs" USING btree ("updated_at");
|
||||
CREATE INDEX "game_npcs_created_at_idx" ON "game_npcs" USING btree ("created_at");
|
||||
CREATE INDEX "game_npcs_rels_order_idx" ON "game_npcs_rels" USING btree ("order");
|
||||
CREATE INDEX "game_npcs_rels_parent_idx" ON "game_npcs_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "game_npcs_rels_path_idx" ON "game_npcs_rels" USING btree ("path");
|
||||
CREATE INDEX "game_npcs_rels_assets_id_idx" ON "game_npcs_rels" USING btree ("assets_id");
|
||||
CREATE INDEX "game_event_logs_type_idx" ON "game_event_logs" USING btree ("type");
|
||||
CREATE INDEX "game_event_logs_actor_idx" ON "game_event_logs" USING btree ("actor_id");
|
||||
CREATE INDEX "game_event_logs_structure_idx" ON "game_event_logs" USING btree ("structure_id");
|
||||
CREATE INDEX "game_event_logs_updated_at_idx" ON "game_event_logs" USING btree ("updated_at");
|
||||
CREATE INDEX "game_event_logs_created_at_idx" ON "game_event_logs" USING btree ("created_at");
|
||||
CREATE INDEX "users_preferences_notifications_muted_types_order_idx" ON "users_preferences_notifications_muted_types" USING btree ("order");
|
||||
CREATE INDEX "users_preferences_notifications_muted_types_parent_idx" ON "users_preferences_notifications_muted_types" USING btree ("parent_id");
|
||||
CREATE INDEX "users_preferences_discord_muted_types_order_idx" ON "users_preferences_discord_muted_types" USING btree ("order");
|
||||
CREATE INDEX "users_preferences_discord_muted_types_parent_idx" ON "users_preferences_discord_muted_types" USING btree ("parent_id");
|
||||
CREATE INDEX "user_notifications_user_idx" ON "user_notifications" USING btree ("user_id");
|
||||
CREATE INDEX "user_notifications_read_idx" ON "user_notifications" USING btree ("read");
|
||||
CREATE INDEX "user_notifications_updated_at_idx" ON "user_notifications" USING btree ("updated_at");
|
||||
CREATE INDEX "user_notifications_created_at_idx" ON "user_notifications" USING btree ("created_at");
|
||||
CREATE INDEX "mission_attendances_mission_idx" ON "mission_attendances" USING btree ("mission_id");
|
||||
CREATE INDEX "mission_attendances_user_idx" ON "mission_attendances" USING btree ("user_id");
|
||||
CREATE INDEX "mission_attendances_updated_at_idx" ON "mission_attendances" USING btree ("updated_at");
|
||||
CREATE INDEX "mission_attendances_created_at_idx" ON "mission_attendances" USING btree ("created_at");
|
||||
CREATE INDEX "factions_relationships_order_idx" ON "factions_relationships" USING btree ("_order");
|
||||
CREATE INDEX "factions_relationships_parent_id_idx" ON "factions_relationships" USING btree ("_parent_id");
|
||||
CREATE INDEX "factions_relationships_target_faction_idx" ON "factions_relationships" USING btree ("target_faction_id");
|
||||
CREATE INDEX "assets_skin_configuration_applies_to_asset_types_order_idx" ON "assets_skin_configuration_applies_to_asset_types" USING btree ("order");
|
||||
CREATE INDEX "assets_skin_configuration_applies_to_asset_types_parent_idx" ON "assets_skin_configuration_applies_to_asset_types" USING btree ("parent_id");
|
||||
CREATE INDEX "shipments_cargo_order_idx" ON "shipments_cargo" USING btree ("_order");
|
||||
CREATE INDEX "shipments_cargo_parent_id_idx" ON "shipments_cargo" USING btree ("_parent_id");
|
||||
CREATE INDEX "shipments_origin_idx" ON "shipments" USING btree ("origin_id");
|
||||
CREATE INDEX "shipments_destination_idx" ON "shipments" USING btree ("destination_id");
|
||||
CREATE INDEX "shipments_transport_vehicle_idx" ON "shipments" USING btree ("transport_vehicle_id");
|
||||
CREATE INDEX "shipments_created_by_idx" ON "shipments" USING btree ("created_by_id");
|
||||
CREATE INDEX "shipments_updated_at_idx" ON "shipments" USING btree ("updated_at");
|
||||
CREATE INDEX "shipments_created_at_idx" ON "shipments" USING btree ("created_at");
|
||||
CREATE INDEX "shipments_rels_order_idx" ON "shipments_rels" USING btree ("order");
|
||||
CREATE INDEX "shipments_rels_parent_idx" ON "shipments_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "shipments_rels_path_idx" ON "shipments_rels" USING btree ("path");
|
||||
CREATE INDEX "shipments_rels_resources_id_idx" ON "shipments_rels" USING btree ("resources_id");
|
||||
CREATE INDEX "shipments_rels_assets_id_idx" ON "shipments_rels" USING btree ("assets_id");
|
||||
CREATE INDEX "shipments_rels_vehicles_id_idx" ON "shipments_rels" USING btree ("vehicles_id");
|
||||
CREATE INDEX "bank_accounts_owner_faction_idx" ON "bank_accounts" USING btree ("owner_faction_id");
|
||||
CREATE INDEX "bank_accounts_owner_user_idx" ON "bank_accounts" USING btree ("owner_user_id");
|
||||
CREATE INDEX "bank_accounts_currency_idx" ON "bank_accounts" USING btree ("currency_id");
|
||||
CREATE INDEX "bank_accounts_updated_at_idx" ON "bank_accounts" USING btree ("updated_at");
|
||||
CREATE UNIQUE INDEX "bank_transactions_transaction_number_idx" ON "bank_transactions" USING btree ("transaction_number");
|
||||
CREATE INDEX "bank_transactions_from_account_idx" ON "bank_transactions" USING btree ("from_account_id");
|
||||
CREATE INDEX "bank_transactions_to_account_idx" ON "bank_transactions" USING btree ("to_account_id");
|
||||
CREATE INDEX "bank_transactions_actor_idx" ON "bank_transactions" USING btree ("actor_id");
|
||||
CREATE INDEX "bank_transactions_reference_idx" ON "bank_transactions" USING btree ("reference_id");
|
||||
CREATE INDEX "bank_transactions_updated_at_idx" ON "bank_transactions" USING btree ("updated_at");
|
||||
CREATE INDEX "bank_transactions_created_at_idx" ON "bank_transactions" USING btree ("created_at");
|
||||
CREATE INDEX "ledger_entries_account_idx" ON "ledger_entries" USING btree ("account_id");
|
||||
CREATE INDEX "ledger_entries_transaction_idx" ON "ledger_entries" USING btree ("transaction_id");
|
||||
CREATE INDEX "ledger_entries_updated_at_idx" ON "ledger_entries" USING btree ("updated_at");
|
||||
CREATE INDEX "ledger_entries_created_at_idx" ON "ledger_entries" USING btree ("created_at");
|
||||
CREATE INDEX "locker_storages_items_equipment_attachments_order_idx" ON "locker_storages_items_equipment_attachments" USING btree ("_order");
|
||||
CREATE INDEX "locker_storages_items_equipment_attachments_parent_id_idx" ON "locker_storages_items_equipment_attachments" USING btree ("_parent_id");
|
||||
CREATE INDEX "locker_storages_items_equipment_attachments_asset_idx" ON "locker_storages_items_equipment_attachments" USING btree ("asset_id");
|
||||
CREATE INDEX "locker_storages_items_order_idx" ON "locker_storages_items" USING btree ("_order");
|
||||
CREATE INDEX "locker_storages_items_parent_id_idx" ON "locker_storages_items" USING btree ("_parent_id");
|
||||
CREATE INDEX "locker_storages_items_asset_idx" ON "locker_storages_items" USING btree ("asset_id");
|
||||
CREATE INDEX "locker_storages_items_equipment_equipment_skin_idx" ON "locker_storages_items" USING btree ("equipment_skin_id");
|
||||
CREATE INDEX "locker_storages_owner_user_idx" ON "locker_storages" USING btree ("owner_user_id");
|
||||
CREATE INDEX "locker_storages_updated_at_idx" ON "locker_storages" USING btree ("updated_at");
|
||||
CREATE INDEX "locker_storages_created_at_idx" ON "locker_storages" USING btree ("created_at");
|
||||
CREATE INDEX "loadouts_owner_user_idx" ON "loadouts" USING btree ("owner_user_id");
|
||||
CREATE INDEX "loadouts_slots_slots_primary_idx" ON "loadouts" USING btree ("slots_primary_id");
|
||||
CREATE INDEX "loadouts_slots_slots_secondary_idx" ON "loadouts" USING btree ("slots_secondary_id");
|
||||
CREATE INDEX "loadouts_slots_slots_launcher_idx" ON "loadouts" USING btree ("slots_launcher_id");
|
||||
CREATE INDEX "loadouts_slots_slots_uniform_idx" ON "loadouts" USING btree ("slots_uniform_id");
|
||||
CREATE INDEX "loadouts_slots_slots_vest_idx" ON "loadouts" USING btree ("slots_vest_id");
|
||||
CREATE INDEX "loadouts_slots_slots_backpack_idx" ON "loadouts" USING btree ("slots_backpack_id");
|
||||
CREATE INDEX "loadouts_slots_slots_helmet_idx" ON "loadouts" USING btree ("slots_helmet_id");
|
||||
CREATE INDEX "loadouts_slots_slots_facewear_idx" ON "loadouts" USING btree ("slots_facewear_id");
|
||||
CREATE INDEX "loadouts_slots_slots_nvg_idx" ON "loadouts" USING btree ("slots_nvg_id");
|
||||
CREATE INDEX "loadouts_slots_slots_radio_idx" ON "loadouts" USING btree ("slots_radio_id");
|
||||
CREATE INDEX "loadouts_slots_slots_optic_idx" ON "loadouts" USING btree ("slots_optic_id");
|
||||
CREATE INDEX "loadouts_slots_slots_muzzle_idx" ON "loadouts" USING btree ("slots_muzzle_id");
|
||||
CREATE INDEX "loadouts_slots_slots_bipod_idx" ON "loadouts" USING btree ("slots_bipod_id");
|
||||
CREATE INDEX "loadouts_updated_at_idx" ON "loadouts" USING btree ("updated_at");
|
||||
CREATE INDEX "loadouts_created_at_idx" ON "loadouts" USING btree ("created_at");
|
||||
CREATE INDEX "market_listings_asset_idx" ON "market_listings" USING btree ("asset_id");
|
||||
CREATE INDEX "market_listings_seller_idx" ON "market_listings" USING btree ("seller_id");
|
||||
CREATE INDEX "market_listings_npc_idx" ON "market_listings" USING btree ("npc_id");
|
||||
CREATE INDEX "market_listings_currency_idx" ON "market_listings" USING btree ("currency_id");
|
||||
CREATE INDEX "market_listings_status_idx" ON "market_listings" USING btree ("status");
|
||||
CREATE INDEX "market_listings_buyer_idx" ON "market_listings" USING btree ("buyer_id");
|
||||
CREATE INDEX "market_listings_updated_at_idx" ON "market_listings" USING btree ("updated_at");
|
||||
CREATE INDEX "market_listings_created_at_idx" ON "market_listings" USING btree ("created_at");
|
||||
CREATE INDEX "market_negotiations_history_order_idx" ON "market_negotiations_history" USING btree ("_order");
|
||||
CREATE INDEX "market_negotiations_history_parent_id_idx" ON "market_negotiations_history" USING btree ("_parent_id");
|
||||
CREATE INDEX "market_negotiations_listing_idx" ON "market_negotiations" USING btree ("listing_id");
|
||||
CREATE INDEX "market_negotiations_buyer_idx" ON "market_negotiations" USING btree ("buyer_id");
|
||||
CREATE INDEX "market_negotiations_status_idx" ON "market_negotiations" USING btree ("status");
|
||||
CREATE INDEX "market_negotiations_updated_at_idx" ON "market_negotiations" USING btree ("updated_at");
|
||||
CREATE INDEX "market_negotiations_created_at_idx" ON "market_negotiations" USING btree ("created_at");
|
||||
CREATE INDEX "tickets_activity_order_idx" ON "tickets_activity" USING btree ("_order");
|
||||
CREATE INDEX "tickets_activity_parent_id_idx" ON "tickets_activity" USING btree ("_parent_id");
|
||||
CREATE INDEX "tickets_activity_author_idx" ON "tickets_activity" USING btree ("author_id");
|
||||
CREATE INDEX "tickets_category_idx" ON "tickets" USING btree ("category");
|
||||
CREATE INDEX "tickets_priority_idx" ON "tickets" USING btree ("priority");
|
||||
CREATE INDEX "tickets_status_idx" ON "tickets" USING btree ("status");
|
||||
CREATE INDEX "tickets_reporter_idx" ON "tickets" USING btree ("reporter_id");
|
||||
CREATE INDEX "tickets_assignee_idx" ON "tickets" USING btree ("assignee_id");
|
||||
CREATE INDEX "tickets_updated_at_idx" ON "tickets" USING btree ("updated_at");
|
||||
CREATE INDEX "tickets_created_at_idx" ON "tickets" USING btree ("created_at");
|
||||
CREATE INDEX "game_rules_rels_order_idx" ON "game_rules_rels" USING btree ("order");
|
||||
CREATE INDEX "game_rules_rels_parent_idx" ON "game_rules_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "game_rules_rels_path_idx" ON "game_rules_rels" USING btree ("path");
|
||||
CREATE INDEX "game_rules_rels_assignments_id_idx" ON "game_rules_rels" USING btree ("assignments_id");
|
||||
ALTER TABLE "game_structures" ADD CONSTRAINT "game_structures_faction_id_factions_id_fk" FOREIGN KEY ("faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "profiles_rels" ADD CONSTRAINT "profiles_rels_qualifications_fk" FOREIGN KEY ("qualifications_id") REFERENCES "public"."qualifications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "missions_factions_involved" ADD CONSTRAINT "missions_factions_involved_faction_id_factions_id_fk" FOREIGN KEY ("faction_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "missions" ADD CONSTRAINT "missions_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "factions" ADD CONSTRAINT "factions_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_fuel_fuel_type_id_resources_id_fk" FOREIGN KEY ("fuel_fuel_type_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_game_vehicles_fk" FOREIGN KEY ("game_vehicles_id") REFERENCES "public"."game_vehicles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_game_npcs_fk" FOREIGN KEY ("game_npcs_id") REFERENCES "public"."game_npcs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_game_event_logs_fk" FOREIGN KEY ("game_event_logs_id") REFERENCES "public"."game_event_logs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_user_notifications_fk" FOREIGN KEY ("user_notifications_id") REFERENCES "public"."user_notifications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_mission_attendances_fk" FOREIGN KEY ("mission_attendances_id") REFERENCES "public"."mission_attendances"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_shipments_fk" FOREIGN KEY ("shipments_id") REFERENCES "public"."shipments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_bank_accounts_fk" FOREIGN KEY ("bank_accounts_id") REFERENCES "public"."bank_accounts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_bank_transactions_fk" FOREIGN KEY ("bank_transactions_id") REFERENCES "public"."bank_transactions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_ledger_entries_fk" FOREIGN KEY ("ledger_entries_id") REFERENCES "public"."ledger_entries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_locker_storages_fk" FOREIGN KEY ("locker_storages_id") REFERENCES "public"."locker_storages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_loadouts_fk" FOREIGN KEY ("loadouts_id") REFERENCES "public"."loadouts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_market_listings_fk" FOREIGN KEY ("market_listings_id") REFERENCES "public"."market_listings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_market_negotiations_fk" FOREIGN KEY ("market_negotiations_id") REFERENCES "public"."market_negotiations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_tickets_fk" FOREIGN KEY ("tickets_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "game_structures_faction_idx" ON "game_structures" USING btree ("faction_id");
|
||||
CREATE UNIQUE INDEX "users_discord_id_idx" ON "users" USING btree ("discord_id");
|
||||
CREATE INDEX "profiles_rels_qualifications_id_idx" ON "profiles_rels" USING btree ("qualifications_id");
|
||||
CREATE INDEX "missions_factions_involved_faction_idx" ON "missions_factions_involved" USING btree ("faction_id");
|
||||
CREATE INDEX "missions_cover_image_idx" ON "missions" USING btree ("cover_image_id");
|
||||
CREATE INDEX "campaigns_cover_image_idx" ON "campaigns" USING btree ("cover_image_id");
|
||||
CREATE INDEX "factions_cover_image_idx" ON "factions" USING btree ("cover_image_id");
|
||||
CREATE INDEX "vehicles_fuel_fuel_fuel_type_idx" ON "vehicles" USING btree ("fuel_fuel_type_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_game_vehicles_id_idx" ON "payload_locked_documents_rels" USING btree ("game_vehicles_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_game_npcs_id_idx" ON "payload_locked_documents_rels" USING btree ("game_npcs_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_game_event_logs_id_idx" ON "payload_locked_documents_rels" USING btree ("game_event_logs_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_user_notifications_id_idx" ON "payload_locked_documents_rels" USING btree ("user_notifications_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_mission_attendances_id_idx" ON "payload_locked_documents_rels" USING btree ("mission_attendances_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_shipments_id_idx" ON "payload_locked_documents_rels" USING btree ("shipments_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_bank_accounts_id_idx" ON "payload_locked_documents_rels" USING btree ("bank_accounts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_bank_transactions_id_idx" ON "payload_locked_documents_rels" USING btree ("bank_transactions_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_ledger_entries_id_idx" ON "payload_locked_documents_rels" USING btree ("ledger_entries_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_locker_storages_id_idx" ON "payload_locked_documents_rels" USING btree ("locker_storages_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_loadouts_id_idx" ON "payload_locked_documents_rels" USING btree ("loadouts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_market_listings_id_idx" ON "payload_locked_documents_rels" USING btree ("market_listings_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_market_negotiations_id_idx" ON "payload_locked_documents_rels" USING btree ("market_negotiations_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_tickets_id_idx" ON "payload_locked_documents_rels" USING btree ("tickets_id");
|
||||
ALTER TABLE "missions_factions_involved" DROP COLUMN "faction_name";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_structures_void_storage" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_vehicles_stored_resources" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_vehicles_void_storage" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_vehicles" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_npcs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_npcs_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_event_logs" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "users_preferences_notifications_muted_types" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "users_preferences_discord_muted_types" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "user_notifications" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "mission_attendances" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "factions_relationships" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "assets_skin_configuration_applies_to_asset_types" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "shipments_cargo" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "shipments" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "shipments_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "bank_accounts" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "bank_transactions" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "ledger_entries" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "locker_storages_items_equipment_attachments" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "locker_storages_items" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "locker_storages" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "loadouts" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "market_listings" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "market_negotiations_history" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "market_negotiations" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "tickets_activity" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "tickets" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "game_rules_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "game_structures_void_storage" CASCADE;
|
||||
DROP TABLE "game_vehicles_stored_resources" CASCADE;
|
||||
DROP TABLE "game_vehicles_void_storage" CASCADE;
|
||||
DROP TABLE "game_vehicles" CASCADE;
|
||||
DROP TABLE "game_npcs" CASCADE;
|
||||
DROP TABLE "game_npcs_rels" CASCADE;
|
||||
DROP TABLE "game_event_logs" CASCADE;
|
||||
DROP TABLE "users_preferences_notifications_muted_types" CASCADE;
|
||||
DROP TABLE "users_preferences_discord_muted_types" CASCADE;
|
||||
DROP TABLE "user_notifications" CASCADE;
|
||||
DROP TABLE "mission_attendances" CASCADE;
|
||||
DROP TABLE "factions_relationships" CASCADE;
|
||||
DROP TABLE "assets_skin_configuration_applies_to_asset_types" CASCADE;
|
||||
DROP TABLE "shipments_cargo" CASCADE;
|
||||
DROP TABLE "shipments" CASCADE;
|
||||
DROP TABLE "shipments_rels" CASCADE;
|
||||
DROP TABLE "bank_accounts" CASCADE;
|
||||
DROP TABLE "bank_transactions" CASCADE;
|
||||
DROP TABLE "ledger_entries" CASCADE;
|
||||
DROP TABLE "locker_storages_items_equipment_attachments" CASCADE;
|
||||
DROP TABLE "locker_storages_items" CASCADE;
|
||||
DROP TABLE "locker_storages" CASCADE;
|
||||
DROP TABLE "loadouts" CASCADE;
|
||||
DROP TABLE "market_listings" CASCADE;
|
||||
DROP TABLE "market_negotiations_history" CASCADE;
|
||||
DROP TABLE "market_negotiations" CASCADE;
|
||||
DROP TABLE "tickets_activity" CASCADE;
|
||||
DROP TABLE "tickets" CASCADE;
|
||||
DROP TABLE "game_rules_rels" CASCADE;
|
||||
ALTER TABLE "game_structures" DROP CONSTRAINT "game_structures_faction_id_factions_id_fk";
|
||||
|
||||
ALTER TABLE "profiles_rels" DROP CONSTRAINT "profiles_rels_qualifications_fk";
|
||||
|
||||
ALTER TABLE "missions_factions_involved" DROP CONSTRAINT "missions_factions_involved_faction_id_factions_id_fk";
|
||||
|
||||
ALTER TABLE "missions" DROP CONSTRAINT "missions_cover_image_id_media_id_fk";
|
||||
|
||||
ALTER TABLE "campaigns" DROP CONSTRAINT "campaigns_cover_image_id_media_id_fk";
|
||||
|
||||
ALTER TABLE "factions" DROP CONSTRAINT "factions_cover_image_id_media_id_fk";
|
||||
|
||||
ALTER TABLE "vehicles" DROP CONSTRAINT "vehicles_fuel_fuel_type_id_resources_id_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_game_vehicles_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_game_npcs_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_game_event_logs_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_user_notifications_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_mission_attendances_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_shipments_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_bank_accounts_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_bank_transactions_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_ledger_entries_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_locker_storages_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_loadouts_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_market_listings_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_market_negotiations_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_tickets_fk";
|
||||
|
||||
ALTER TABLE "users_roles" ALTER COLUMN "value" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_users_roles";
|
||||
CREATE TYPE "public"."enum_users_roles" AS ENUM('guest', 'user', 'admin', 'developer');
|
||||
ALTER TABLE "users_roles" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_roles" USING "value"::"public"."enum_users_roles";
|
||||
ALTER TABLE "factions" ALTER COLUMN "type" SET DATA TYPE text;
|
||||
ALTER TABLE "factions" ALTER COLUMN "type" SET DEFAULT 'minorFaction'::text;
|
||||
DROP TYPE "public"."enum_factions_type";
|
||||
CREATE TYPE "public"."enum_factions_type" AS ENUM('majorFaction', 'minorFaction', 'corporation', 'special');
|
||||
ALTER TABLE "factions" ALTER COLUMN "type" SET DEFAULT 'minorFaction'::"public"."enum_factions_type";
|
||||
ALTER TABLE "factions" ALTER COLUMN "type" SET DATA TYPE "public"."enum_factions_type" USING "type"::"public"."enum_factions_type";
|
||||
ALTER TABLE "assets" ALTER COLUMN "asset_type" SET DATA TYPE text;
|
||||
ALTER TABLE "assets" ALTER COLUMN "asset_type" SET DEFAULT 'weapon'::text;
|
||||
DROP TYPE "public"."enum_assets_asset_type";
|
||||
CREATE TYPE "public"."enum_assets_asset_type" AS ENUM('weapon', 'magazine', 'optic', 'muzzle', 'bipod', 'uniform', 'vest', 'backpack', 'helmet', 'facewear', 'nvg', 'radio', 'medical', 'tool', 'explosive', 'miscellaneous');
|
||||
ALTER TABLE "assets" ALTER COLUMN "asset_type" SET DEFAULT 'weapon'::"public"."enum_assets_asset_type";
|
||||
ALTER TABLE "assets" ALTER COLUMN "asset_type" SET DATA TYPE "public"."enum_assets_asset_type" USING "asset_type"::"public"."enum_assets_asset_type";
|
||||
DROP INDEX "game_structures_faction_idx";
|
||||
DROP INDEX "users_discord_id_idx";
|
||||
DROP INDEX "profiles_rels_qualifications_id_idx";
|
||||
DROP INDEX "missions_factions_involved_faction_idx";
|
||||
DROP INDEX "missions_cover_image_idx";
|
||||
DROP INDEX "campaigns_cover_image_idx";
|
||||
DROP INDEX "factions_cover_image_idx";
|
||||
DROP INDEX "vehicles_fuel_fuel_fuel_type_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_game_vehicles_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_game_npcs_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_game_event_logs_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_user_notifications_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_mission_attendances_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_shipments_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_bank_accounts_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_bank_transactions_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_ledger_entries_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_locker_storages_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_loadouts_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_market_listings_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_market_negotiations_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_tickets_id_idx";
|
||||
ALTER TABLE "missions" ALTER COLUMN "game_details_game_setup_mission_file_id" SET NOT NULL;
|
||||
ALTER TABLE "structures_auto_generated_resources" ALTER COLUMN "min_frequency" SET DEFAULT 1;
|
||||
ALTER TABLE "missions_factions_involved" ADD COLUMN "faction_name" varchar NOT NULL;
|
||||
ALTER TABLE "game_structures_stored_resources" DROP COLUMN "grid_x";
|
||||
ALTER TABLE "game_structures_stored_resources" DROP COLUMN "grid_y";
|
||||
ALTER TABLE "game_structures_stored_resources" DROP COLUMN "rotated";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "faction_id";
|
||||
ALTER TABLE "users" DROP COLUMN "discord_id";
|
||||
ALTER TABLE "users" DROP COLUMN "preferences_notifications_muted_all";
|
||||
ALTER TABLE "users" DROP COLUMN "preferences_display_show_callsign";
|
||||
ALTER TABLE "users" DROP COLUMN "preferences_discord_enabled";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_flappy_bird_games_played";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_flappy_bird_total_passes";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_flappy_bird_best_score";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_flappy_bird_xp_earned";
|
||||
ALTER TABLE "profiles_rels" DROP COLUMN "qualifications_id";
|
||||
ALTER TABLE "awards" DROP COLUMN "ribbon_design";
|
||||
ALTER TABLE "awards" DROP COLUMN "medal_design";
|
||||
ALTER TABLE "missions_factions_involved" DROP COLUMN "faction_id";
|
||||
ALTER TABLE "missions" DROP COLUMN "discord_message_id";
|
||||
ALTER TABLE "missions" DROP COLUMN "discord_attendance_hash";
|
||||
ALTER TABLE "missions" DROP COLUMN "cover_image_id";
|
||||
ALTER TABLE "campaigns" DROP COLUMN "cover_image_id";
|
||||
ALTER TABLE "factions" DROP COLUMN "reputation";
|
||||
ALTER TABLE "factions" DROP COLUMN "cover_image_id";
|
||||
ALTER TABLE "technologies" DROP COLUMN "approval_status";
|
||||
ALTER TABLE "assets" DROP COLUMN "approval_status";
|
||||
ALTER TABLE "assets" DROP COLUMN "storage_dimensions_grid_width";
|
||||
ALTER TABLE "assets" DROP COLUMN "storage_dimensions_grid_height";
|
||||
ALTER TABLE "assets" DROP COLUMN "storage_dimensions_stackable";
|
||||
ALTER TABLE "assets" DROP COLUMN "storage_dimensions_max_stack_size";
|
||||
ALTER TABLE "assets" DROP COLUMN "storage_dimensions_rotatable";
|
||||
ALTER TABLE "resources" DROP COLUMN "approval_status";
|
||||
ALTER TABLE "resources" DROP COLUMN "grid_width";
|
||||
ALTER TABLE "resources" DROP COLUMN "grid_height";
|
||||
ALTER TABLE "resources" DROP COLUMN "stackable";
|
||||
ALTER TABLE "resources" DROP COLUMN "max_stack_size";
|
||||
ALTER TABLE "resources" DROP COLUMN "rotatable";
|
||||
ALTER TABLE "vehicles" DROP COLUMN "approval_status";
|
||||
ALTER TABLE "vehicles" DROP COLUMN "fuel_fuel_type_id";
|
||||
ALTER TABLE "vehicles" DROP COLUMN "fuel_fuel_capacity";
|
||||
ALTER TABLE "vehicles" DROP COLUMN "fuel_fuel_consumption_rate";
|
||||
ALTER TABLE "structures" DROP COLUMN "approval_status";
|
||||
ALTER TABLE "structures" DROP COLUMN "restrict_to_allowed";
|
||||
ALTER TABLE "narrative_events" DROP COLUMN "flow_data";
|
||||
ALTER TABLE "narrative_events" DROP COLUMN "triggers_type";
|
||||
ALTER TABLE "narrative_events" DROP COLUMN "triggers_condition";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "game_vehicles_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "game_npcs_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "game_event_logs_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "user_notifications_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "mission_attendances_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "shipments_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "bank_accounts_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "bank_transactions_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "ledger_entries_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "locker_storages_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "loadouts_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "market_listings_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "market_negotiations_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "tickets_id";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "proximity_threshold";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "game_tick_interval_minutes";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "locker_grid_width";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "locker_grid_height";
|
||||
DROP TYPE "public"."enum_game_vehicles_status";
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
DROP TYPE "public"."enum_users_preferences_notifications_muted_types";
|
||||
DROP TYPE "public"."enum_users_preferences_discord_muted_types";
|
||||
DROP TYPE "public"."enum_mission_attendances_response";
|
||||
DROP TYPE "public"."enum_technologies_approval_status";
|
||||
DROP TYPE "public"."enum_assets_skin_configuration_applies_to_asset_types";
|
||||
DROP TYPE "public"."enum_assets_approval_status";
|
||||
DROP TYPE "public"."enum_resources_approval_status";
|
||||
DROP TYPE "public"."enum_vehicles_approval_status";
|
||||
DROP TYPE "public"."enum_structures_approval_status";
|
||||
DROP TYPE "public"."enum_shipments_status";
|
||||
DROP TYPE "public"."enum_bank_accounts_account_type";
|
||||
DROP TYPE "public"."enum_bank_accounts_status";
|
||||
DROP TYPE "public"."enum_bank_transactions_type";
|
||||
DROP TYPE "public"."enum_bank_transactions_status";
|
||||
DROP TYPE "public"."enum_ledger_entries_type";
|
||||
DROP TYPE "public"."enum_market_listings_status";
|
||||
DROP TYPE "public"."enum_market_negotiations_history_from";
|
||||
DROP TYPE "public"."enum_market_negotiations_status";
|
||||
DROP TYPE "public"."enum_market_negotiations_proposed_by";
|
||||
DROP TYPE "public"."enum_tickets_activity_type";
|
||||
DROP TYPE "public"."enum_tickets_category";
|
||||
DROP TYPE "public"."enum_tickets_priority";
|
||||
DROP TYPE "public"."enum_tickets_status";
|
||||
DROP TYPE "public"."enum_narrative_events_triggers_type";`)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as migration_20251219_061127 from './20251219_061127';
|
|||
import * as migration_20251222_200320 from './20251222_200320';
|
||||
import * as migration_20251223_055859 from './20251223_055859';
|
||||
import * as migration_20260109_065519 from './20260109_065519';
|
||||
import * as migration_20260816_213455 from './20260816_213455';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -34,6 +35,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260109_065519.up,
|
||||
down: migration_20260109_065519.down,
|
||||
name: '20260109_065519'
|
||||
name: '20260109_065519',
|
||||
},
|
||||
{
|
||||
up: migration_20260816_213455.up,
|
||||
down: migration_20260816_213455.down,
|
||||
name: '20260816_213455'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export interface Config {
|
|||
loadouts: Loadout;
|
||||
'market-listings': MarketListing;
|
||||
'market-negotiations': MarketNegotiation;
|
||||
tickets: Ticket;
|
||||
maps: Map;
|
||||
'narrative-events': NarrativeEvent;
|
||||
'mission-files': MissionFile;
|
||||
|
|
@ -147,6 +148,7 @@ export interface Config {
|
|||
loadouts: LoadoutsSelect<false> | LoadoutsSelect<true>;
|
||||
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
||||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
||||
maps: MapsSelect<false> | MapsSelect<true>;
|
||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||
|
|
@ -1227,6 +1229,7 @@ export interface GameEventLog {
|
|||
| 'loadouts'
|
||||
| 'market-listings'
|
||||
| 'market-negotiations'
|
||||
| 'tickets'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -1272,6 +1275,10 @@ export interface User {
|
|||
| 'finance:withdraw'
|
||||
| 'finance:transfer'
|
||||
| 'shipment:completed'
|
||||
| 'ticket:created'
|
||||
| 'ticket:assigned'
|
||||
| 'ticket:reply'
|
||||
| 'ticket:status'
|
||||
)[]
|
||||
| null;
|
||||
};
|
||||
|
|
@ -1295,6 +1302,10 @@ export interface User {
|
|||
| 'finance:withdraw'
|
||||
| 'finance:transfer'
|
||||
| 'shipment:completed'
|
||||
| 'ticket:created'
|
||||
| 'ticket:assigned'
|
||||
| 'ticket:reply'
|
||||
| 'ticket:status'
|
||||
)[]
|
||||
| null;
|
||||
};
|
||||
|
|
@ -2204,6 +2215,90 @@ export interface MarketNegotiation {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tickets".
|
||||
*/
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
/**
|
||||
* Short, descriptive summary of the issue or request.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Full details of the issue or request.
|
||||
*/
|
||||
description: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
category: 'bug_report' | 'feature_request' | 'support_request' | 'other';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
status:
|
||||
| 'open'
|
||||
| 'in_review'
|
||||
| 'planning'
|
||||
| 'in_progress'
|
||||
| 'waiting_on_reporter'
|
||||
| 'in_testing'
|
||||
| 'resolved'
|
||||
| 'canceled'
|
||||
| 'closed';
|
||||
/**
|
||||
* The user who submitted this ticket.
|
||||
*/
|
||||
reporter: number | User;
|
||||
/**
|
||||
* Staff member responsible for this ticket.
|
||||
*/
|
||||
assignee?: (number | null) | User;
|
||||
/**
|
||||
* Append-only activity timeline. Messages between the reporter and staff plus system events.
|
||||
*/
|
||||
activity?:
|
||||
| {
|
||||
type: 'system' | 'agent_message' | 'client_message';
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Who wrote this entry. Omitted for system events.
|
||||
*/
|
||||
author?: (number | null) | User;
|
||||
created_at?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Set automatically when the ticket reaches a closing status.
|
||||
*/
|
||||
closedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "narrative-events".
|
||||
|
|
@ -2393,6 +2488,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'market-negotiations';
|
||||
value: number | MarketNegotiation;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'tickets';
|
||||
value: number | Ticket;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'maps';
|
||||
value: number | Map;
|
||||
|
|
@ -3443,6 +3542,31 @@ export interface MarketNegotiationsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tickets_select".
|
||||
*/
|
||||
export interface TicketsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
description?: T;
|
||||
category?: T;
|
||||
priority?: T;
|
||||
status?: T;
|
||||
reporter?: T;
|
||||
assignee?: T;
|
||||
activity?:
|
||||
| T
|
||||
| {
|
||||
type?: T;
|
||||
content?: T;
|
||||
author?: T;
|
||||
created_at?: T;
|
||||
id?: T;
|
||||
};
|
||||
closedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "maps_select".
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { Loadouts } from "@/collections/locker/Loadouts";
|
|||
import { MarketListings } from "@/collections/market/MarketListings";
|
||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
|
|
@ -124,6 +125,9 @@ export default buildConfig({
|
|||
MarketListings,
|
||||
MarketNegotiations,
|
||||
|
||||
// Helpdesk
|
||||
Tickets,
|
||||
|
||||
// World
|
||||
Maps,
|
||||
NarrativeEvents,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ interface GameEventInput {
|
|||
message: string;
|
||||
actor?: number;
|
||||
structure?: number;
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "game-npcs" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts" | "market-listings" | "market-negotiations";
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "game-npcs" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts" | "market-listings" | "market-negotiations" | "tickets";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ export const EventTypes = {
|
|||
|
||||
// Attendance
|
||||
MissionAttendanceChange: "mission:attendance-change",
|
||||
|
||||
// Helpdesk
|
||||
TicketCreate: "ticket:create",
|
||||
TicketReply: "ticket:reply",
|
||||
TicketStatusChange: "ticket:status-change",
|
||||
TicketAssign: "ticket:assign",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
243
tests/int/tickets.int.spec.ts
Normal file
243
tests/int/tickets.int.spec.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
|
||||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import type { Ticket, User } from "@/payload-types";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
import { getStaffUserIds } from "@/lib/tickets/staff";
|
||||
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `tk-${Date.now().toString(36)}`;
|
||||
|
||||
const makeUser = async (label: string, roles: NonNullable<User["roles"]> = ["user"]): Promise<User> => {
|
||||
const tag = `${RUN}-${label}`;
|
||||
return (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: tag,
|
||||
discordUsername: tag,
|
||||
displayName: label.toUpperCase(),
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
};
|
||||
|
||||
const createTicket = async (reporterId: number, title = `${RUN} Issue`): Promise<Ticket> =>
|
||||
(await payload.create({
|
||||
collection: "tickets",
|
||||
data: {
|
||||
title,
|
||||
description: plaintextToLexical(`${RUN} description with multiple\nlines of detail.`),
|
||||
category: "bug_report",
|
||||
priority: "high",
|
||||
status: "open",
|
||||
reporter: reporterId,
|
||||
activity: [
|
||||
{ type: "system", content: plaintextToLexical("Ticket created"), created_at: new Date().toISOString() },
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Ticket;
|
||||
|
||||
const fetchTicket = async (id: number): Promise<Ticket> =>
|
||||
(await payload.findByID({
|
||||
collection: "tickets",
|
||||
id,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})) as unknown as Ticket;
|
||||
|
||||
const activityTexts = (ticket: Ticket): string[] =>
|
||||
(ticket.activity ?? []).map((entry) => convertLexicalToPlaintext({ data: entry.content }));
|
||||
|
||||
const idOf = (ref: number | { id: number } | null | undefined): number | null => {
|
||||
if (ref == null) return null;
|
||||
return typeof ref === "object" ? ref.id : ref;
|
||||
};
|
||||
|
||||
describe("Tickets", () => {
|
||||
let reporter: User;
|
||||
let assignee: User;
|
||||
let staff: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
reporter = await makeUser("reporter");
|
||||
assignee = await makeUser("assignee");
|
||||
staff = await makeUser("staff", ["admin"]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
|
||||
const tickets = await payload.find({
|
||||
collection: "tickets",
|
||||
where: { title: { contains: RUN } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const ticket of tickets.docs) {
|
||||
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
|
||||
}
|
||||
|
||||
for (const user of [reporter, assignee, staff]) {
|
||||
if (!user) continue;
|
||||
await payload.delete({ collection: "users", id: user.id, overrideAccess: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("plaintextToLexical produces a valid round-trippable lexical state", () => {
|
||||
const state = plaintextToLexical("Hello\nworld\n\nSecond paragraph");
|
||||
|
||||
expect(state.root.type).toBe("root");
|
||||
expect(state.root.children).toHaveLength(2);
|
||||
|
||||
const first = state.root.children[0];
|
||||
expect(first.children).toHaveLength(3); // text, linebreak, text
|
||||
expect(first.children[1]).toMatchObject({ type: "linebreak" });
|
||||
|
||||
const roundTrip = convertLexicalToPlaintext({
|
||||
data: state as unknown as Parameters<typeof convertLexicalToPlaintext>[0]["data"],
|
||||
});
|
||||
expect(roundTrip).toContain("Hello");
|
||||
expect(roundTrip).toContain("Second paragraph");
|
||||
|
||||
// Empty input yields a single empty paragraph.
|
||||
expect(plaintextToLexical("").root.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getStaffUserIds resolves only admin/developer users", async () => {
|
||||
const staffIds = await getStaffUserIds(payload);
|
||||
expect(staffIds).toContain(staff.id);
|
||||
expect(staffIds).not.toContain(reporter.id);
|
||||
expect(staffIds).not.toContain(assignee.id);
|
||||
});
|
||||
|
||||
it("creates a ticket with the initial system activity entry", async () => {
|
||||
const ticket = await createTicket(reporter.id);
|
||||
expect(ticket.status).toBe("open");
|
||||
expect(ticket.reporter).toBe(reporter.id);
|
||||
expect(ticket.closedAt).toBeNull();
|
||||
|
||||
const populated = await fetchTicket(ticket.id);
|
||||
expect(populated.activity ?? []).toHaveLength(1);
|
||||
expect(activityTexts(populated)).toContain("Ticket created");
|
||||
});
|
||||
|
||||
it("appends a system entry and manages closedAt on status transitions", async () => {
|
||||
const ticket = await createTicket(reporter.id);
|
||||
|
||||
// Open → In Progress: system entry, no closedAt.
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: { status: "in_progress" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
let populated = await fetchTicket(ticket.id);
|
||||
expect(activityTexts(populated)).toContain(
|
||||
`Status changed from ${STATUS_LABEL.open} to ${STATUS_LABEL.in_progress}`,
|
||||
);
|
||||
expect(populated.closedAt).toBeNull();
|
||||
|
||||
// In Progress → Resolved: system entry + closedAt set.
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: { status: "resolved" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
populated = await fetchTicket(ticket.id);
|
||||
expect(activityTexts(populated)).toContain(
|
||||
`Status changed from ${STATUS_LABEL.in_progress} to ${STATUS_LABEL.resolved}`,
|
||||
);
|
||||
expect(populated.closedAt).not.toBeNull();
|
||||
|
||||
// Reopening clears closedAt.
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: { status: "open" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
populated = await fetchTicket(ticket.id);
|
||||
expect(populated.closedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("appends a system entry when assigned or unassigned", async () => {
|
||||
const ticket = await createTicket(reporter.id);
|
||||
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: { assignee: assignee.id },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
let populated = await fetchTicket(ticket.id);
|
||||
const texts = activityTexts(populated);
|
||||
expect(texts.some((t) => t.includes("Ticket assigned to"))).toBe(true);
|
||||
expect(idOf(populated.assignee)).toBe(assignee.id);
|
||||
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: { assignee: null },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
populated = await fetchTicket(ticket.id);
|
||||
expect(activityTexts(populated)).toContain("Ticket unassigned");
|
||||
expect(idOf(populated.assignee)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves client/agent messages appended by actions", async () => {
|
||||
const ticket = await createTicket(reporter.id);
|
||||
|
||||
await payload.update({
|
||||
collection: "tickets",
|
||||
id: ticket.id,
|
||||
data: {
|
||||
activity: [
|
||||
...((await fetchTicket(ticket.id)).activity ?? []),
|
||||
{
|
||||
type: "client_message",
|
||||
content: plaintextToLexical("A reporter reply."),
|
||||
author: reporter.id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const populated = await fetchTicket(ticket.id);
|
||||
const client = (populated.activity ?? []).find((entry) => entry.type === "client_message");
|
||||
expect(client).toBeDefined();
|
||||
if (client?.content) {
|
||||
expect(convertLexicalToPlaintext({ data: client.content })).toBe("A reporter reply.");
|
||||
}
|
||||
expect(idOf(client?.author)).toBe(reporter.id);
|
||||
});
|
||||
|
||||
it("exposes every status from TICKET_STATUSES as a valid select option label", () => {
|
||||
for (const status of TICKET_STATUSES) {
|
||||
expect(STATUS_LABEL[status.value]).toBe(status.label);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue