1
0
Fork 0

Compare commits

...

13 commits

Author SHA1 Message Date
dad652b1d4 feat(helpdesk): enhance create ticket dialog with critical alert for priority tickets 2026-08-16 18:26:01 -04:00
56b79e142e fix(migrations): backfill grid columns before NOT NULL on data-bearing table
The generated migration added grid_x/grid_y as numeric NOT NULL without a
default, which fails on prod where game_structures_stored_resources already
has rows ('column contains null values'). Add the columns with DEFAULT 0 to
backfill existing rows, then drop the default to match the target schema.
2026-08-16 18:23:33 -04:00
931c60e234 refactor(helpdesk): replace inline hint with Alert in create ticket dialog
Use the shadcn Alert component for the file-sharing hint instead of a bare Message element.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:13:22 -04:00
9ec4555200 feat(migrations): add production migration for tickets and pending schema drift
Add migration 20260816_213455 covering the Tickets collection plus all schema drift accumulated since 20260109 (banking, market, game vehicles/NPCs, attendance, enum extensions).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:59 -04:00
6480b47273 test(helpdesk): add tickets integration tests
Add integration coverage for ticket creation, replies, status changes, and assignment against a live Postgres.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:54 -04:00
5064890452 feat(helpdesk): add helpdesk sidebar entry
Add the Helpdesk nav item with LifeBuoy icon under the core nav group.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:49 -04:00
c80ecf9141 feat(helpdesk): add helpdesk UI components
Add TicketsView, TicketCard, TicketDetail, TicketTimeline, CreateTicketDialog, and TicketBadges components.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:44 -04:00
14d92bf277 feat(helpdesk): add helpdesk pages
Add the helpdesk list page and ticket detail page routes.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:40 -04:00
40f23e8774 feat(helpdesk): add ticket server actions
Add createTicket, replyToTicket, updateTicketStatus, and assignTicket server actions with notification and event-log emission.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:36 -04:00
ff986bf4ec feat(helpdesk): add ticket domain libs
Add shared ticket metadata (labels, styles), staff resolution, and plaintext-to-lexical conversion helpers.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:33 -04:00
e7eff028b7 feat(tickets): add Tickets collection
Add the helpdesk Tickets collection with status/priority/category enums, reporter/assignee relationships, activity timeline, and closedAt lifecycle. Register in payload.config and refresh generated types.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:27 -04:00
94cd7c5626 feat(helpdesk): add ticket notification types
Add ticket:created/assigned/reply/status labels and muteable notification types.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:22 -04:00
6419c9676f feat(helpdesk): add ticket event log wiring
Add ticket:create/reply/status-change/assign event types, extend the emit targetCollection union, and register tickets in GameEventLogs target collections.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-16 18:12:19 -04:00
25 changed files with 18739 additions and 3 deletions

View 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>
);
}

View 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 };
}
}

View 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>
);
}

View file

@ -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 = {

View 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" },
},
},
],
};

View file

@ -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: [
{

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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";
}

View file

@ -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);

View 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
View 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 [];
}
}

View 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",
};

File diff suppressed because it is too large Load diff

View 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";`)
}

View file

@ -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'
},
];

View file

@ -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".

View file

@ -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,

View file

@ -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>;
}

View file

@ -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];

View 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);
}
});
});