1
0
Fork 0

feat(logistics): supply box management surface

This commit is contained in:
Jason Fraley 2026-09-21 08:19:33 -04:00
parent f895f75317
commit 679a0f7879
7 changed files with 1086 additions and 0 deletions

View file

@ -0,0 +1,281 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import {
LedgerRow,
NoticeState,
PageShell,
ProvenanceChip,
STATUS_TONE_CLASSES,
type ReadoutCell,
type StatusTone,
} from "@/components/frontend/operations";
import { SupplyBoxActions } from "@/components/frontend/logistics/SupplyBoxActions";
import type { ArmaCommand, SupplyBox } from "@/payload-types";
import type { SurfaceUser } from "@/lib/operations/surface";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export const metadata = {
title: "Supply Box Detail: Polaris Task Force",
};
function relationId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
const id = (value as { id: unknown }).id;
return typeof id === "number" ? id : null;
}
return null;
}
function locationFromJson(value: unknown): { x: number; y: number; z: number } | null {
if (typeof value !== "object" || value === null) return null;
const { x, y, z } = value as { x?: unknown; y?: unknown; z?: unknown };
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return null;
return { x, y, z };
}
function locationLabel(value: unknown): string {
const loc = locationFromJson(value);
return loc === null ? "n/a" : `${loc.x}, ${loc.y}, ${loc.z}`;
}
function commandTone(status: string): StatusTone {
if (status === "succeeded") return "success";
if (status === "failed") return "danger";
if (status === "delivered") return "info";
return "neutral";
}
export interface SupplyBoxDetailData {
box: SupplyBox | null;
capability: boolean;
commands: ArmaCommand[];
}
/**
* Full detail for a single supply box plus its recent queued commands, fetched
* through collection access control (default access + the explicit session
* user, never overrideAccess). Returns null for an unknown id so the page can
* render a not-found notice.
*/
export async function getSupplyBoxDetail(
payload: PayloadType,
user: SurfaceUser,
id: string,
): Promise<SupplyBoxDetailData> {
const qualified = await hasLogisticsQualification(payload, user);
if (!qualified) return { box: null, capability: false, commands: [] };
let box: SupplyBox | null = null;
try {
box = await payload.findByID({
collection: "supply-boxes",
id: Number(id),
depth: 2,
user: user ?? undefined,
});
} catch {
box = null;
}
if (box === null) return { box: null, capability: true, commands: [] };
const serverId = relationId(box.server);
const commands =
serverId === null
? []
: (
await payload.find({
collection: "arma-commands",
where: { server: { equals: serverId } },
sort: "-createdAt",
limit: 50,
depth: 0,
user: user ?? undefined,
})
).docs.filter((cmd) => Array.isArray(cmd.payload) && cmd.payload[0] === box.boxId);
return { box, capability: true, commands };
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section data-slot="supply-box-section" className="flex flex-col gap-2">
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
{children}
</section>
);
}
function kvRow(label: string, value: React.ReactNode) {
return (
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">{label}</span>
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
</div>
);
}
export default async function SupplyBoxDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const user = isPayloadUser(authUser) ? authUser : null;
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
const { box, capability, commands } = await getSupplyBoxDetail(payload, surfaceUser, id);
if (capability === false) {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
<NoticeState
variant="error"
message="You do not have the Logistics qualification to manage supply boxes."
hint="Request the Logistics qualification from unit command."
/>
</PageShell>
</div>
);
}
if (box === null) {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
<NoticeState
variant="error"
message="Supply box not found."
hint="The supply box id is unknown or you lack permission to read it."
action={
<Link
href="/logistics/supply-boxes"
className="mt-2 border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
>
Back to supply boxes
</Link>
}
/>
</PageShell>
</div>
);
}
const contents = Array.isArray(box.contents) ? box.contents : [];
const itemCount = contents.reduce<number>((sum, line) => {
if (typeof line !== "object" || line === null) return sum;
const count = (line as { count?: unknown }).count;
return sum + (typeof count === "number" ? count : 0);
}, 0);
const sourceEventId = relationId(box.sourceEvent);
const readoutCells: ReadoutCell[] = [
{ label: "Class", value: box.boxClass ?? "n/a" },
{ label: "Status", value: box.status },
{ label: "Items", value: String(itemCount) },
{ label: "Last Sync", value: box.lastSyncedAt },
];
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title={box.boxId} meta="SUPPLY BOX // DETAIL">
<Link
href="/logistics/supply-boxes"
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
>
Back to supply boxes
</Link>
<LedgerRow cells={readoutCells} />
{sourceEventId !== null ? (
<ProvenanceChip
label={`RAW EVENT #${sourceEventId}`}
href={`/admin/collections/arma-sync-events/${sourceEventId}`}
/>
) : null}
<Section title="Contents">
{contents.length === 0 ? (
<NoticeState variant="empty" message="This crate is empty." />
) : (
<ul className="flex flex-col gap-1">
{contents.map((line, index) => (
<li
key={index}
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
>
<span className="min-w-0 truncate font-mono text-xs text-white/80">
{(line as { class?: unknown }).class as string}
</span>
<span className="font-mono text-xs tabular-nums text-white/70">
{(line as { count?: unknown }).count as number}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Location">
<div className="flex flex-col gap-1">
{kvRow("Position", locationLabel(box.location))}
{kvRow("Session", box.sessionId)}
</div>
</Section>
<Section title="Command Queue">
<SupplyBoxActions
boxId={box.boxId}
boxClass={box.boxClass ?? null}
location={locationFromJson(box.location)}
contents={contents.map((line) => ({
class: (line as { class?: unknown }).class as string,
count: (line as { count?: unknown }).count as number,
}))}
/>
</Section>
<Section title="Recent Commands">
{commands.length === 0 ? (
<NoticeState variant="empty" message="No commands have been queued for this crate." />
) : (
<ul className="flex flex-col gap-1">
{commands.map((cmd) => (
<li
key={cmd.id}
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
>
<span className="min-w-0 truncate font-mono text-xs text-white/80">{cmd.type}</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
>
{cmd.status}
</span>
{cmd.error ? (
<span className="max-w-40 truncate font-mono text-[10px] text-rose-300">
{cmd.error}
</span>
) : null}
</span>
</li>
))}
</ul>
)}
</Section>
</PageShell>
</div>
);
}

View file

@ -0,0 +1,124 @@
"use server";
import { randomBytes } from "crypto";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export interface ActionResult<T> {
success: boolean;
error?: string;
data?: T;
}
export type CrateCommand = "spawn" | "apply" | "despawn";
async function authenticate() {
const { headers } = await import("next/headers");
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await headers(),
canSetHeaders: false,
});
return { payload, user: isPayloadUser(authUser) ? authUser : null };
}
function relId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
const id = (value as { id: unknown }).id;
return typeof id === "number" ? id : null;
}
return null;
}
/**
* Validates the crate command payload against the crate-sync contract
* (docs/arma-bridge/crate-sync.md). The command envelope is built web-side by
* the pull route; the action only stores the row, so the payload must already
* match the shape the Arma bridge expects.
*/
function validateCratePayload(command: CrateCommand, payloadArgs: unknown[]): boolean {
if (command === "spawn") {
if (payloadArgs.length !== 4) return false;
const [boxClass, x, y, z] = payloadArgs;
return (
typeof boxClass === "string" &&
typeof x === "number" &&
typeof y === "number" &&
typeof z === "number"
);
}
if (command === "apply") {
if (payloadArgs.length !== 1) return false;
const contents = payloadArgs[0];
if (!Array.isArray(contents)) return false;
return contents.every(
(line) =>
Array.isArray(line) &&
line.length === 2 &&
typeof line[0] === "string" &&
typeof line[1] === "number",
);
}
// despawn
return payloadArgs.length === 0;
}
/**
* Queues an outbound crate command for a supply box. The command row is
* created with overrideAccess because arma-commands denies direct writes; the
* pull route picks it up and builds the [commandId, commandType, payload]
* envelope for the game server.
*/
export async function queueCrateCommand(
boxId: string,
command: CrateCommand,
payloadArgs: unknown[],
): Promise<ActionResult<{ commandId: string; status: string }>> {
try {
const { payload, user } = await authenticate();
if (!user) return { success: false, error: "Authentication required." };
const qualified = await hasLogisticsQualification(payload, user);
if (!qualified) return { success: false, error: "forbidden" };
if (!validateCratePayload(command, payloadArgs)) {
return { success: false, error: `Invalid payload for crate.${command}.` };
}
const found = await payload.find({
collection: "supply-boxes",
where: { boxId: { equals: boxId } },
limit: 1,
depth: 0,
user: user ?? undefined,
});
const box = found.docs[0];
if (!box) return { success: false, error: "Supply box not found." };
const serverId = relId(box.server);
if (serverId === null) {
return { success: false, error: "Supply box has no owning server." };
}
const commandId = `crate-${randomBytes(12).toString("hex")}`;
const created = await payload.create({
collection: "arma-commands",
data: {
commandId,
server: serverId,
type: `crate.${command}`,
payload: [boxId, ...payloadArgs],
status: "queued",
},
overrideAccess: true,
depth: 0,
});
return { success: true, data: { commandId: created.commandId, status: created.status } };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}

View file

@ -0,0 +1,212 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import {
LedgerRow,
NoticeState,
OperationsRealtimeRefresher,
PageShell,
STATUS_TONE_CLASSES,
type ReadoutCell,
type StatusTone,
} from "@/components/frontend/operations";
import type { SurfaceUser } from "@/lib/operations/surface";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export const metadata = {
title: "Supply Boxes: Polaris Task Force",
};
const SUPPLY_BOX_PAGE_SIZE = 50;
function relationId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
const id = (value as { id: unknown }).id;
return typeof id === "number" ? id : null;
}
return null;
}
function boxStatusTone(status: string): StatusTone {
if (status === "active") return "success";
if (status === "deleted") return "danger";
return "neutral";
}
function syncTone(iso: string): StatusTone {
const ageMs = Date.now() - new Date(iso).getTime();
if (ageMs < 2 * 60_000) return "success";
if (ageMs < 10 * 60_000) return "warning";
return "danger";
}
function syncAgeLabel(iso: string): string {
const ageMs = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function boxItemCount(contents: unknown): number {
if (!Array.isArray(contents)) return 0;
return contents.reduce((sum, line) => {
if (typeof line !== "object" || line === null) return sum;
const count = (line as { count?: unknown }).count;
return sum + (typeof count === "number" ? count : 0);
}, 0);
}
function boxLocationLabel(location: unknown): string {
if (typeof location !== "object" || location === null) return "n/a";
const { x, y, z } = location as { x?: unknown; y?: unknown; z?: unknown };
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return "n/a";
return `${x}, ${y}, ${z}`;
}
export interface SupplyBoxRow {
id: number;
boxId: string;
boxClass: string | null;
itemCount: number;
locationLabel: string;
lastSyncedAt: string;
status: string;
serverName: string | null;
}
export interface SupplyBoxListData {
rows: SupplyBoxRow[];
capability: boolean;
}
/**
* List of supply boxes, fetched through collection access control (default
* access + the explicit session user, never overrideAccess). A server restart
* mints a fresh sessionId, so rows from older generations are stale snapshots;
* only the newest sessionId per server is kept.
*/
export async function getSupplyBoxList(
payload: PayloadType,
user: SurfaceUser,
): Promise<SupplyBoxListData> {
const qualified = await hasLogisticsQualification(payload, user);
if (!qualified) return { rows: [], capability: false };
const found = await payload.find({
collection: "supply-boxes",
sort: "-lastSyncedAt",
limit: SUPPLY_BOX_PAGE_SIZE,
depth: 1,
user: user ?? undefined,
});
const newestSessionByServer = new Map<number, string>();
for (const doc of found.docs) {
const serverId = relationId(doc.server);
if (serverId === null) continue;
const current = newestSessionByServer.get(serverId);
if (current === undefined || doc.sessionId > current) {
newestSessionByServer.set(serverId, doc.sessionId);
}
}
const rows: SupplyBoxRow[] = found.docs
.filter((doc) => {
const serverId = relationId(doc.server);
return serverId !== null && newestSessionByServer.get(serverId) === doc.sessionId;
})
.map((doc) => ({
id: doc.id,
boxId: doc.boxId,
boxClass: doc.boxClass ?? null,
itemCount: boxItemCount(doc.contents),
locationLabel: boxLocationLabel(doc.location),
lastSyncedAt: doc.lastSyncedAt,
status: doc.status,
serverName: typeof doc.server === "object" && doc.server !== null ? doc.server.name : null,
}));
return { rows, capability: true };
}
export default async function SupplyBoxesPage() {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const user = isPayloadUser(authUser) ? authUser : null;
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
const data = await getSupplyBoxList(payload, surfaceUser);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
<p className="max-w-2xl text-sm text-white/70">
Crate inventory synced from the game server through the Arma bridge.
Rows show the newest generation per server; older sessions are filtered out.
</p>
</PageShell>
<OperationsRealtimeRefresher />
{data.capability === false ? (
<NoticeState
variant="error"
message="You do not have the Logistics qualification to manage supply boxes."
hint="Request the Logistics qualification from unit command."
/>
) : data.rows.length === 0 ? (
<NoticeState
variant="empty"
message="No supply boxes have been registered yet."
hint="Boxes appear here once the game server registers them through the Arma bridge."
/>
) : (
<div className="flex flex-col gap-4" data-slot="supply-boxes-list">
{data.rows.map((row) => {
const cells: ReadoutCell[] = [
{ label: "Class", value: row.boxClass ?? "n/a" },
{ label: "Items", value: String(row.itemCount) },
{ label: "Location", value: row.locationLabel },
{ label: "Sync", value: syncAgeLabel(row.lastSyncedAt) },
{ label: "Server", value: row.serverName ?? "n/a" },
];
return (
<div
key={row.id}
data-slot="supply-box-row"
className="flex flex-col gap-2 rounded border border-white/10 bg-card/20"
>
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
<Link
href={`/logistics/supply-boxes/${row.id}`}
data-slot="supply-box-id"
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
>
{row.boxId}
</Link>
<span
data-slot="supply-box-status"
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[boxStatusTone(row.status)]}`}
>
{row.status}
</span>
</div>
<LedgerRow cells={cells} />
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -123,6 +123,11 @@ export const sidebarData = {
url: "/logistics/game-vehicles",
icon: Car,
},
{
title: "Supply Boxes",
url: "/logistics/supply-boxes",
icon: Boxes,
},
],
},
{

View file

@ -0,0 +1,207 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
queueCrateCommand,
type ActionResult,
type CrateCommand,
} from "@/app/(frontend)/logistics/supply-boxes/actions";
export interface SupplyBoxContentLine {
class: string;
count: number;
}
interface SupplyBoxActionsProps {
boxId: string;
boxClass: string | null;
location: { x: number; y: number; z: number } | null;
contents: SupplyBoxContentLine[];
}
type PendingAction = CrateCommand | null;
export function SupplyBoxActions({ boxId, boxClass, location, contents }: SupplyBoxActionsProps) {
const [lines, setLines] = useState<SupplyBoxContentLine[]>(contents);
const [confirm, setConfirm] = useState<PendingAction>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function updateLine(index: number, field: "class" | "count", value: string) {
setLines((prev) =>
prev.map((line, i) =>
i === index
? field === "class"
? { ...line, class: value }
: { ...line, count: Number.parseInt(value, 10) || 0 }
: line,
),
);
}
function addLine() {
setLines((prev) => [...prev, { class: "", count: 1 }]);
}
function removeLine(index: number) {
setLines((prev) => prev.filter((_, i) => i !== index));
}
function openConfirm(action: Exclude<PendingAction, null>) {
setError(null);
setConfirm(action);
}
async function dispatch() {
if (confirm === null) return;
setError(null);
let result: ActionResult<{ commandId: string; status: string }>;
if (confirm === "apply") {
const clean = lines.filter((line) => line.class.trim() !== "" && line.count > 0);
result = await queueCrateCommand(
boxId,
"apply",
[clean.map((line) => [line.class.trim(), line.count])],
);
} else if (confirm === "despawn") {
result = await queueCrateCommand(boxId, "despawn", []);
} else {
if (boxClass === null || location === null) return;
result = await queueCrateCommand(boxId, "spawn", [boxClass, location.x, location.y, location.z]);
}
if (!result.success) {
setError(result.error ?? "Failed to queue command.");
return;
}
setConfirm(null);
router.refresh();
}
const applyLineCount = lines.filter((line) => line.class.trim() !== "" && line.count > 0).length;
return (
<div data-slot="supply-box-actions" className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50">
Contents to apply
</label>
<div className="flex flex-col gap-1">
{lines.map((line, index) => (
<div key={index} className="flex items-center gap-2">
<input
value={line.class}
onChange={(e) => updateLine(index, "class", e.target.value)}
placeholder="itemClass"
className="min-w-0 flex-1 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
/>
<input
value={String(line.count)}
onChange={(e) => updateLine(index, "count", e.target.value)}
type="number"
min={1}
className="w-20 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
/>
<button
type="button"
onClick={() => removeLine(index)}
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/60 hover:border-rose-500/40 hover:text-rose-300"
>
Remove
</button>
</div>
))}
</div>
<button
type="button"
onClick={addLine}
className="self-start border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
>
Add line
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="supply-box-apply"
onClick={() => openConfirm("apply")}
disabled={applyLineCount === 0}
className="border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 transition-colors hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
>
Queue apply
</button>
<button
type="button"
data-slot="supply-box-despawn"
onClick={() => openConfirm("despawn")}
className="border border-rose-500/30 bg-rose-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-rose-300 transition-colors hover:border-rose-500/50 hover:text-rose-200"
>
Despawn
</button>
<button
type="button"
data-slot="supply-box-respawn"
onClick={() => openConfirm("spawn")}
disabled={boxClass === null || location === null}
className="border border-blue-500/30 bg-blue-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-blue-300 transition-colors hover:border-blue-500/50 hover:text-blue-200 disabled:cursor-not-allowed disabled:opacity-50"
>
Respawn
</button>
</div>
{error ? (
<p data-slot="supply-box-actions-error" className="min-w-0 truncate text-xs text-rose-300">
{error}
</p>
) : null}
<Dialog
open={confirm !== null}
onOpenChange={(open) => {
if (!open) setConfirm(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm crate command</DialogTitle>
<DialogDescription>
{confirm === "apply"
? `Queue crate.apply with ${applyLineCount} item line${applyLineCount === 1 ? "" : "s"} to the game server?`
: confirm === "despawn"
? "Remove this crate from the game server? The stored row stays as a deleted snapshot."
: "Spawn this crate at its last known location?"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={() => setConfirm(null)}
className="border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
>
Cancel
</button>
<button
type="button"
data-slot="supply-box-confirm"
onClick={() => startTransition(dispatch)}
disabled={isPending}
className="border border-emerald-500/30 bg-emerald-500/5 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
>
{isPending ? "Queuing..." : "Confirm"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test";
/**
* Supply box management surface.
*
* NOTE: these tests exercise the real dev server and are run in the final
* verification wave (F3) under the dev-server protocol, not on every change.
* The list is server-gated: guests get the landing page; only logged-in users
* see the supply box list (logistics-qualified users additionally get the
* crate command controls on the detail page).
*/
test.describe("Supply box management surface", () => {
test("guests never see the supply box list", async ({ page }) => {
await page.goto("/logistics/supply-boxes");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force");
});
test("an authed user sees the supply box list page", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Username").fill("dev");
await page.getByLabel("Password").fill("Test123");
await page.getByRole("button", { name: "Log in" }).click();
await page.goto("/logistics/supply-boxes");
await expect(page.getByText("Supply Boxes")).toBeVisible();
// Either a registered box row or the empty state renders.
await expect(
page.getByText("No supply boxes have been registered yet."),
).toBeVisible();
});
});

View file

@ -0,0 +1,226 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
import type { ArmaCommand, SupplyBox, User } from "@/payload-types";
import { queueCrateCommand } from "@/app/(frontend)/logistics/supply-boxes/actions";
// Mock next/headers so the action's authenticate() can run outside a request.
vi.mock("next/headers", () => ({
headers: async () => new Headers(),
}));
let payload: Payload;
let authSpy: MockInstance;
const RUN = `sbx-${Date.now().toString(36)}`;
// ---------------------------------------------------------------------------
// Fixture tracking for cleanup
// ---------------------------------------------------------------------------
const userIds: number[] = [];
const roleIds: number[] = [];
const syncEventIds: number[] = [];
const boxIds: number[] = [];
const commandIds: number[] = [];
let serverId: number;
let commandUser: User;
let plainUser: User;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}-discord`,
displayName: `${RUN} ${label}`,
steamId: `${RUN}-steam-${label}`,
password: "Test1234",
roles: ["user"],
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
}
// Creates a supply box directly (the crate-sync handler is covered by
// supply-box-sync.int.spec.ts) with a real arma-sync-events row as the
// required sourceEvent provenance anchor.
async function makeBox(boxId: string): Promise<SupplyBox> {
const raw = await payload.create({
collection: "arma-sync-events",
data: {
eventId: `${RUN}-evt-${boxId}`,
server: serverId,
type: "fixture",
occurredAt: new Date().toISOString(),
payload: [],
},
overrideAccess: true,
depth: 0,
});
syncEventIds.push(raw.id);
const box = (await payload.create({
collection: "supply-boxes",
data: {
boxId,
sessionId: RUN,
server: serverId,
boxClass: "Box_NATO_Wps_F",
displayName: "Ammo Crate",
location: { x: 100, y: 200, z: 0 },
contents: [{ class: "Item_1", count: 10 }],
lastSyncedAt: new Date().toISOString(),
status: "active",
sourceEvent: raw.id,
},
overrideAccess: true,
depth: 0,
})) as unknown as SupplyBox;
boxIds.push(box.id);
return box;
}
async function findCommand(commandId: string): Promise<ArmaCommand | null> {
const found = await payload.find({
collection: "arma-commands",
where: { commandId: { equals: commandId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return (found.docs[0] as ArmaCommand | undefined) ?? null;
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
authSpy = vi.spyOn(payload, "auth");
const superRole = await payload.create({
collection: "roles",
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
overrideAccess: true,
depth: 0,
});
roleIds.push(superRole.id);
commandUser = await makeUser("command", [superRole.id]);
plainUser = await makeUser("plain");
const server = await payload.create({
collection: "game-servers",
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
overrideAccess: true,
depth: 0,
});
serverId = server.id;
});
afterAll(async () => {
if (!payload) return;
for (const id of commandIds) {
await payload.delete({ collection: "arma-commands", id, overrideAccess: true }).catch(() => {});
}
for (const id of boxIds) {
await payload.delete({ collection: "supply-boxes", id, overrideAccess: true }).catch(() => {});
}
for (const id of syncEventIds) {
await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {});
}
for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
if (serverId) {
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("supply box crate command actions", () => {
describe("queueCrateCommand", () => {
it("queues a crate.apply command with the contract payload for a qualified user", async () => {
authSpy.mockResolvedValue({ user: commandUser });
const box = await makeBox(`box:${RUN}:apply`);
const result = await queueCrateCommand(box.boxId, "apply", [
[
["Item_1", 10],
["Item_2", 5],
],
]);
expect(result.success).toBe(true);
expect(result.data?.status).toBe("queued");
expect(result.data?.commandId).toMatch(/^crate-[0-9a-f]{24}$/);
const cmd = await findCommand(result.data!.commandId);
expect(cmd).not.toBeNull();
expect(cmd?.type).toBe("crate.apply");
expect(cmd?.server).toBe(serverId);
expect(cmd?.status).toBe("queued");
expect(cmd?.payload).toEqual([
box.boxId,
[
["Item_1", 10],
["Item_2", 5],
],
]);
commandIds.push(cmd!.id);
});
it("rejects a user without the logistics qualification with a forbidden error", async () => {
authSpy.mockResolvedValue({ user: plainUser });
const box = await makeBox(`box:${RUN}:forbidden`);
const result = await queueCrateCommand(box.boxId, "despawn", []);
expect(result.success).toBe(false);
expect(result.error).toContain("forbidden");
});
it("maps an unknown box id to a not-found error", async () => {
authSpy.mockResolvedValue({ user: commandUser });
const result = await queueCrateCommand(`box:${RUN}:missing`, "despawn", []);
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});
it("mints a unique command id per queue", async () => {
authSpy.mockResolvedValue({ user: commandUser });
const box = await makeBox(`box:${RUN}:unique`);
const first = await queueCrateCommand(box.boxId, "despawn", []);
const second = await queueCrateCommand(box.boxId, "despawn", []);
expect(first.success).toBe(true);
expect(second.success).toBe(true);
expect(first.data?.commandId).not.toBe(second.data?.commandId);
const firstCmd = await findCommand(first.data!.commandId);
const secondCmd = await findCommand(second.data!.commandId);
expect(firstCmd).not.toBeNull();
expect(secondCmd).not.toBeNull();
expect(firstCmd?.id).not.toBe(secondCmd?.id);
expect(firstCmd?.payload).toEqual([box.boxId]);
expect(secondCmd?.payload).toEqual([box.boxId]);
commandIds.push(firstCmd!.id, secondCmd!.id);
});
});
});