1
0
Fork 0

feat(operations): reservation creation flow

Add the create-reservation dialog and its option sources for todo 3 of
ops-reservations-and-crate-sync.

- src/lib/operations/reservationOptions.ts: server-only option pool
  (listReservationOptions) returning upcoming unit-visible missions, idle
  unreserved vehicles, operational structures, budget accounts (no
  balances), per-origin stock, and a capped roster.
- src/components/frontend/operations/reservations/CreateReservationDialog.tsx:
  client dialog with core fields, personnel/vehicle multi-picks, cargo
  line repeater, budget + expiry; client pre-validation mirrors the
  server validator; engine errors render verbatim.
- Mount the dialog on the reservations list page, server-gated by
  operation-reservations:create + hasLogisticsQualification.
- Integration tests for the option scopes; e2e spec written (run in F3).

tsc clean; 5/5 integration tests pass.
This commit is contained in:
Jason Fraley 2026-09-21 04:39:09 -04:00
parent b483cf910b
commit 42066e9d13
5 changed files with 1671 additions and 0 deletions

View file

@ -0,0 +1,303 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Where } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasPermission } from "@/utils/access-control/hasPermission";
import {
LedgerRow,
NoticeState,
PageShell,
STATUS_TONE_CLASSES,
statusToneFor,
type ReadoutCell,
} from "@/components/frontend/operations";
import type { SurfaceUser } from "@/lib/operations/surface";
import { listReservationOptions } from "@/lib/operations/reservationOptions";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
const RESERVATION_PAGE_SIZE = 20;
export interface ReservationRow {
id: number;
reservationKey: string;
operationId: string;
status: string;
expiresAt: string | null;
}
export interface ReservationFilters {
status?: string;
operationId?: string;
}
export interface ReservationListData {
rows: ReservationRow[];
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
capability: boolean;
page: number;
totalDocs: number;
totalPages: number;
}
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/**
* List of allocation reservations with status/operationId filters and
* pagination. Runs through collection access control (default access + the
* explicit session user, never overrideAccess). Gated behind
* operation-reservations:read: a viewer lacking that permission gets an empty
* result (capability false) so the page can render the permission notice
* instead of a list that looks like "no data".
*/
export async function getReservationList(
payload: PayloadType,
user: SurfaceUser,
filters: ReservationFilters,
requestedPage = 1,
): Promise<ReservationListData> {
const canSee = await hasPermission(payload, user, "operation-reservations:read");
if (!canSee) {
return { rows: [], capability: false, page: 1, totalDocs: 0, totalPages: 0 };
}
const conditions: Where[] = [];
if (filters.status) conditions.push({ status: { equals: filters.status } });
if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } });
const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined;
const found = await payload.find({
collection: "operation-reservations",
where,
sort: "-createdAt",
page: Math.max(1, requestedPage),
limit: RESERVATION_PAGE_SIZE,
depth: 0,
user: user ?? undefined,
});
const rows: ReservationRow[] = found.docs.map((doc) => {
const r = doc as unknown as ReservationRow;
return {
id: r.id,
reservationKey: r.reservationKey,
operationId: r.operationId,
status: r.status,
expiresAt: r.expiresAt ?? null,
};
});
return {
rows,
capability: true,
page: found.page ?? Math.max(1, requestedPage),
totalDocs: found.totalDocs ?? found.docs.length,
totalPages: found.totalPages ?? 1,
};
}
function reservationHref(filters: ReservationFilters, page: number): string {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (filters.operationId) params.set("operationId", filters.operationId);
if (page > 1) params.set("page", String(page));
const qs = params.toString();
return qs ? `/operations/reservations?${qs}` : "/operations/reservations";
}
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "All" },
{ value: "reserved", label: "Reserved" },
{ value: "settled", label: "Settled" },
{ value: "cancelled", label: "Cancelled" },
{ value: "expired", label: "Expired" },
{ value: "no-show", label: "No-Show" },
];
export const metadata = {
title: "Reservations: Polaris Task Force",
};
export default async function ReservationsPage({
searchParams,
}: {
searchParams: Promise<ReservationFilters & { page?: string }>;
}) {
const params = await searchParams;
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 page = Number.parseInt(params.page ?? "1", 10);
const filters: ReservationFilters = {
status: params.status || undefined,
operationId: params.operationId || undefined,
};
const data = await getReservationList(payload, surfaceUser, filters, Number.isNaN(page) ? 1 : page);
// The cancel control is server-gated: only viewers holding
// operation-reservations:update may render it, and only on reserved rows.
const canCancel =
data.capability && user !== null
? await hasPermission(payload, user, "operation-reservations:update")
: false;
// Server-gated create dialog: only operation-reservations:create viewers open it;
// logistics qualification gates the vehicle/cargo/budget sections inside.
const canCreate = user
? await hasPermission(payload, user, "operation-reservations:create")
: false;
const canManageLogistics = user
? await hasLogisticsQualification(payload, user)
: false;
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title="Reservations" meta="ALLOCATION RESERVATIONS // COMMAND VIEW">
<p className="max-w-2xl text-sm text-white/70">
Every allocation of people, vehicles, cargo, and treasury budget against an operation,
with its settlement outcome. Reservations are earmarks only; the settlement record on a
row captures what was consumed or returned.
</p>
<form method="get" className="flex flex-wrap items-end gap-3" data-slot="reservations-filters">
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-status">
Status
</label>
<select
id="reservation-status"
name="status"
defaultValue={filters.status || ""}
className="border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-black">
{option.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-operation">
Operation
</label>
<input
id="reservation-operation"
name="operationId"
defaultValue={filters.operationId || ""}
placeholder="operationId"
className="w-64 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
/>
</div>
<button
type="submit"
className="border border-white/15 bg-black/40 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
>
Apply
</button>
<Link
href="/operations/reservations"
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
>
Clear
</Link>
</form>
</PageShell>
{canCreate && options ? (
<CreateReservationDialog
options={options}
canManageLogistics={canManageLogistics}
canCreate={canCreate}
currentUserId={user!.id as number}
/>
) : null}
{data.capability === false ? (
<NoticeState
variant="error"
message="You do not have permission to view reservations."
hint="Request the operation-reservations:read capability from a command staffer."
/>
) : data.rows.length === 0 ? (
<NoticeState
variant="empty"
message="No reservations match this view."
hint="Reservations appear here once staff allocate assets to an operation."
/>
) : (
<div className="flex flex-col gap-4" data-slot="reservations-list">
{data.rows.map((row) => {
const cells: ReadoutCell[] = [
{ label: "Operation", value: row.operationId },
{ label: "Status", value: row.status },
{ label: "Expires", value: row.expiresAt ?? "n/a" },
];
return (
<div
key={row.id}
data-slot="reservation-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={`/operations/reservations/${row.id}`}
data-slot="reservation-key"
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
>
{row.reservationKey}
</Link>
<span
data-slot="reservation-status"
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(row.status)]}`}
>
{row.status}
</span>
{canCancel && row.status === "reserved" ? (
<div className="shrink-0">
<CancelButton reservationId={row.id} />
</div>
) : null}
</div>
<LedgerRow cells={cells} />
</div>
);
})}
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
Page {data.page} of {Math.max(1, data.totalPages)} ({data.totalDocs} reservations)
</span>
<span className="flex gap-2">
{data.page > 1 && (
<Link
href={reservationHref(filters, data.page - 1)}
className="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"
>
Prev
</Link>
)}
{data.page < data.totalPages && (
<Link
href={reservationHref(filters, data.page + 1)}
className="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"
>
Next
</Link>
)}
</span>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,535 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, ChevronDown, PlusIcon, TrashIcon } from "lucide-react";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { ReservationOptions } from "@/lib/operations/reservationOptions";
import { createReservation } from "@/app/(frontend)/operations/reservations/actions";
interface CreateReservationDialogProps {
options: ReservationOptions;
canManageLogistics: boolean;
currentUserId: number;
/** Trigger only renders for users who hold operation-reservations:create. */
canCreate: boolean;
}
interface CargoLine {
resourceId: string;
amount: string;
}
/**
* Searchable multi-value picker built on the same Command/Popover primitives as
* the site Combobox, which is single-value only. Selections persist in a number
* array; the popover stays as wide as the trigger.
*/
function MultiSelect({
label,
options,
selected,
onChange,
disabled,
placeholder = "None selected",
}: {
label: string;
options: ComboboxOption[];
selected: number[];
onChange: (value: number[]) => void;
disabled?: boolean;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const selectedLabels = useMemo(
() => options.filter((o) => selected.includes(Number(o.value))).map((o) => o.label),
[options, selected],
);
return (
<Popover open={open} onOpenChange={(next) => !disabled && setOpen(next)}>
<PopoverTrigger
disabled={disabled}
role="combobox"
aria-expanded={open}
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="truncate text-left flex-1">
{selectedLabels.length > 0 ? selectedLabels.join(", ") : placeholder}
</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter>
<CommandInput placeholder="Search..." />
<CommandList className="max-h-64">
<CommandEmpty>Nothing found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const value = Number(option.value);
const isSelected = selected.includes(value);
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onChange(
isSelected
? selected.filter((v) => v !== value)
: [...selected, value].sort((a, b) => a - b),
);
}}
>
<Checkbox checked={isSelected} />
<span className="ml-2 min-w-0 block truncate">{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function CreateReservationDialog({
options,
canManageLogistics,
currentUserId,
canCreate,
}: CreateReservationDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reservationKey, setReservationKey] = useState("");
const [operationId, setOperationId] = useState("");
const [missionId, setMissionId] = useState("");
const [personnel, setPersonnel] = useState<number[]>([]);
const [vehicles, setVehicles] = useState<number[]>([]);
const [originId, setOriginId] = useState("");
const [destinationId, setDestinationId] = useState("");
const [cargoLines, setCargoLines] = useState<CargoLine[]>([]);
const [budgetAccountId, setBudgetAccountId] = useState("");
const [budgetAmount, setBudgetAmount] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const missionOptions: ComboboxOption[] = options.upcomingMissions.map((m) => ({
value: String(m.id),
label: m.name + (m.codeName ? ` (${m.codeName})` : ""),
}));
const vehicleOptions: ComboboxOption[] = options.vehicles.map((v) => ({
value: String(v.id),
label: v.name,
hint: `${v.fuelPct}% fuel`,
}));
const rosterOptions: ComboboxOption[] = options.rosterUsers.map((u) => ({
value: String(u.id),
label: u.displayName,
}));
const originOptions: ComboboxOption[] = options.originStructures.map((s) => ({
value: String(s.id),
label: s.name,
}));
const destinationOptions: ComboboxOption[] = options.destinationStructures.map((s) => ({
value: String(s.id),
label: s.name,
}));
const budgetOptions: ComboboxOption[] = options.budgetAccounts.map((a) => ({
value: String(a.id),
label: a.name,
hint: a.type,
}));
const originStock = originId ? options.stockByOrigin[originId] ?? [] : [];
function reset() {
setReservationKey("");
setOperationId("");
setMissionId("");
setPersonnel([]);
setVehicles([]);
setOriginId("");
setDestinationId("");
setCargoLines([]);
setBudgetAccountId("");
setBudgetAmount("");
setExpiresAt("");
setError(null);
}
function updateCargo(index: number, patch: Partial<CargoLine>) {
setCargoLines((lines) =>
lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
);
}
function addCargoLine() {
setCargoLines((lines) => [...lines, { resourceId: "", amount: "" }]);
}
function removeCargoLine(index: number) {
setCargoLines((lines) => lines.filter((_, i) => i !== index));
}
/**
* Client-side sanity checks only. The server action re-validates every limit
* and is the source of truth; these give the form immediate feedback before
* the request goes out.
*/
function clientErrors(): string[] {
const errors: string[] = [];
if (!reservationKey.trim()) errors.push("A reservation key is required.");
if (!operationId.trim()) errors.push("An operation id is required.");
for (const [i, line] of cargoLines.entries()) {
const amount = Number(line.amount);
if (line.resourceId && (!Number.isInteger(amount) || amount <= 0)) {
errors.push(`Cargo amount on line ${i + 1} must be a positive integer.`);
}
}
if (cargoLines.some((l) => l.resourceId) && !originId) {
errors.push("An origin structure is required when cargo is reserved.");
}
if (budgetAmount && (!Number.isInteger(Number(budgetAmount)) || Number(budgetAmount) <= 0)) {
errors.push("Budget amount must be a positive integer.");
}
return errors;
}
async function handleSubmit() {
const problems = clientErrors();
if (problems.length > 0) {
setError(problems.join(" "));
return;
}
setSubmitting(true);
setError(null);
const result = await createReservation({
reservationKey: reservationKey.trim(),
operationId: operationId.trim(),
actorId: currentUserId,
...(missionId ? { missionId: Number(missionId) } : {}),
...(personnel.length ? { personnel: personnel.map((id) => ({ userId: id })) } : {}),
...(vehicles.length ? { vehicleIds: [...vehicles] } : {}),
...(cargoLines
.filter((l) => l.resourceId && Number(l.amount) > 0)
.length
? {
cargo: cargoLines
.filter((l) => l.resourceId && Number(l.amount) > 0)
.map((l) => ({ resourceId: Number(l.resourceId), amount: Number(l.amount) })),
}
: {}),
...(budgetAmount ? { budget: { accountId: Number(budgetAccountId), amount: Number(budgetAmount) } } : {}),
...(originId ? { originId: Number(originId) } : {}),
...(destinationId ? { destinationId: Number(destinationId) } : {}),
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
});
if (result.success) {
setOpen(false);
reset();
router.refresh();
} else {
// Surface the engine's error string verbatim; the server action never
// rewords the allocation-rejected message.
setError(result.error ?? "Failed to create reservation.");
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) reset(); }}>
{canCreate ? (
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon className="size-4" />
New Reservation
</Button>
</DialogTrigger>
) : null}
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Reserve Operation Assets</DialogTitle>
<DialogDescription>
earmark people, vehicles, cargo, and treasury budget against an operation. Capacity and
funds are enforced server-side.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="reservationKey">Reservation key</Label>
<Input
id="reservationKey"
value={reservationKey}
onChange={(e) => setReservationKey(e.target.value)}
placeholder="e.g. op-24-alpha"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="operationId">Operation id</Label>
<Input
id="operationId"
value={operationId}
onChange={(e) => setOperationId(e.target.value)}
placeholder="e.g. operation-id"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>Mission (optional)</Label>
<Combobox
value={missionId}
onValueChange={setMissionId}
placeholder="Attach to a mission"
searchPlaceholder="Search missions..."
emptyMessage="No upcoming unit missions."
options={missionOptions}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Personnel (optional)</Label>
<MultiSelect
label="Personnel"
options={rosterOptions}
selected={personnel}
onChange={setPersonnel}
placeholder="Select unit members"
/>
</div>
{!canManageLogistics ? (
<SectionGated hint="Logistics qualification required to reserve vehicles and cargo.">
<Label>Vehicles</Label>
<MultiSelect
label="Vehicles"
options={vehicleOptions}
selected={vehicles}
onChange={setVehicles}
disabled
placeholder="Request logistics access to reserve vehicles"
/>
</SectionGated>
) : (
<div className="flex flex-col gap-1.5">
<Label>Vehicles (optional)</Label>
<MultiSelect
label="Vehicles"
options={vehicleOptions}
selected={vehicles}
onChange={setVehicles}
placeholder="Select idle vehicles"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="origin">Origin structure (when reserving cargo)</Label>
<Combobox
id="origin"
value={originId}
onValueChange={setOriginId}
placeholder="No origin"
searchPlaceholder="Search structures..."
emptyMessage="No operational structures."
options={originOptions}
/>
</div>
{canManageLogistics ? (
<SectionGated>
<div className="flex items-center justify-between">
<Label>Cargo lines</Label>
<Button type="button" variant="ghost" size="sm" onClick={addCargoLine}>
<PlusIcon className="size-4" />
Add line
</Button>
</div>
{cargoLines.length === 0 ? (
<p className="text-xs text-muted-foreground">No cargo reserved.</p>
) : (
<div className="flex flex-col gap-2">
{cargoLines.map((line, index) => (
<div key={index} className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor={`resource-${index}`}>Resource</Label>
{originStock.length > 0 ? (
<Combobox
value={line.resourceId}
onValueChange={(v) => updateCargo(index, { resourceId: v })}
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored at this origin."
options={originStock.map((s) => ({
value: String(s.resourceId),
label: s.resourceName,
hint: `${s.amount.toLocaleString()} available`,
}))}
/>
) : (
<p className="text-xs text-muted-foreground">
Select an origin to choose resources.
</p>
)}
</div>
<div className="flex w-24 flex-col gap-1.5">
<Label htmlFor={`amount-${index}`}>Amount</Label>
<Input
id={`amount-${index}`}
type="number"
min={1}
value={line.amount}
onChange={(e) => updateCargo(index, { amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeCargoLine(index)}
aria-label="Remove cargo line"
>
<TrashIcon className="size-4" />
</Button>
</div>
))}
</div>
)}
</SectionGated>
) : (
<SectionGated hint="Logistics qualification required to reserve cargo.">
<Label>Cargo lines</Label>
<p className="text-xs text-muted-foreground">Disabled until logistics access is granted.</p>
</SectionGated>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="destination">Destination structure (optional)</Label>
<Combobox
id="destination"
value={destinationId}
onValueChange={setDestinationId}
placeholder="No destination"
searchPlaceholder="Search structures..."
emptyMessage="No operational structures."
options={destinationOptions}
/>
</div>
{canManageLogistics ? (
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="budgetAccount">Budget account (optional)</Label>
<Combobox
value={budgetAccountId}
onValueChange={setBudgetAccountId}
placeholder="No budget"
searchPlaceholder="Search accounts..."
emptyMessage="No accounts available."
options={budgetOptions}
/>
</div>
<div className="flex w-40 flex-col gap-1.5">
<Label htmlFor="budgetAmount">Amount</Label>
<Input
id="budgetAmount"
type="number"
min={1}
value={budgetAmount}
onChange={(e) => setBudgetAmount(e.target.value)}
/>
</div>
</div>
) : (
<SectionGated hint="Logistics qualification required to allocate budget.">
<Label htmlFor="budgetAccount">Budget account (optional)</Label>
<Combobox
id="budgetAccount"
value={budgetAccountId}
onValueChange={setBudgetAccountId}
placeholder="No budget"
searchPlaceholder="Search accounts..."
emptyMessage="No accounts available."
options={budgetOptions}
disabled
/>
</SectionGated>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="expiresAt">Expiry (optional)</Label>
<Input
id="expiresAt"
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-400" role="alert">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Reserving..." : "Reserve assets"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Wrapper that disables a form section for users without logistics access and
* shows why. UX-only gating; the server action is the source of truth.
*/
function SectionGated({
hint,
children,
}: {
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-md border border-dashed border-border/60 px-3 py-2 opacity-60">
{hint ? <p className="mb-2 text-xs text-amber-300">{hint}</p> : null}
<div className="pointer-events-none">{children}</div>
</div>
);
}

View file

@ -0,0 +1,311 @@
/**
* Read-only option pools for the reservation creation form.
*
* Server-only: no JSX, no client imports. The operations page fetches these
* once via `getPayload()` and passes them as props to the client
* `CreateReservationDialog`. Every query runs with `overrideAccess` because the
* pools are authoring helpers, not user-scoped views, and the dialog itself is
* only mounted for users who hold `operation-reservations:create`.
*
* Deliberately does NOT return account balances: the picker shows the account
* name and type only, so a viewer can never read another party's funds.
*/
import type { Payload } from "payload";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
const UPCOMING_MISSION_STATUSES = ["Ready", "Scheduled"] as const;
const MAX_MISSIONS = 50;
const MAX_VEHICLES = 50;
const MAX_STRUCTURES = 100;
const MAX_ROSTER = 200;
const MAX_ACCOUNTS = 100;
export interface MissionOption {
id: number;
name: string;
codeName: string | null;
}
export interface VehicleOption {
id: number;
name: string;
fuelPct: number;
}
export interface StructureOption {
id: number;
name: string;
}
export interface BudgetOption {
id: number;
name: string;
type: "treasury" | "faction" | "personal";
}
export interface StockLine {
resourceId: number;
resourceName: string;
amount: number;
}
export interface RosterUser {
id: number;
displayName: string;
}
export interface ReservationOptions {
upcomingMissions: MissionOption[];
vehicles: VehicleOption[];
originStructures: StructureOption[];
destinationStructures: StructureOption[];
budgetAccounts: BudgetOption[];
/** origin structure id -> available stock lines for that origin. */
stockByOrigin: Record<string, StockLine[]>;
rosterUsers: RosterUser[];
}
interface Depth0Structure {
id: number;
name: string;
constructionStatus?: string;
storedResources?: unknown[];
voidStorage?: unknown[];
}
interface Depth0Vehicle {
id: number;
name: string;
status?: string;
currentFuel?: number | null;
type?: { fuel?: { fuelCapacity?: number | null } | null } | null;
}
async function operationalStructures(
payload: PayloadType,
): Promise<Depth0Structure[]> {
const res = await payload
.find({
collection: "game-structures",
where: { constructionStatus: { equals: "complete" } },
sort: "name",
limit: MAX_STRUCTURES,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
return (res.docs as unknown as Depth0Structure[]).slice(0, MAX_STRUCTURES);
}
async function idleUnreservedVehicles(
payload: PayloadType,
reservedIds: Set<number>,
): Promise<VehicleOption[]> {
const res = await payload
.find({
collection: "game-vehicles",
where: { status: { equals: "idle" } },
sort: "name",
limit: MAX_VEHICLES,
depth: 1,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
const vehicles = (res.docs as unknown as Depth0Vehicle[]).filter(
(v) => !reservedIds.has(v.id),
);
return vehicles.slice(0, MAX_VEHICLES).map((v) => ({
id: v.id,
name: v.name,
fuelPct: fuelPercent(v),
}));
}
function fuelPercent(vehicle: Depth0Vehicle): number {
const current = vehicle.currentFuel ?? 0;
const capacity = vehicle.type?.fuel?.fuelCapacity;
if (typeof capacity !== "number" || capacity <= 0) return 0;
return Math.round((current / capacity) * 100);
}
async function activeReservedVehicleIds(
payload: PayloadType,
): Promise<Set<number>> {
const res = await payload
.find({
collection: "operation-reservations",
where: { status: { equals: "reserved" } },
limit: 1000,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
const ids = new Set<number>();
for (const doc of res.docs) {
const vehicles = (doc as { vehicles?: { vehicle: number | { id: number } }[] | null })
.vehicles;
if (!vehicles) continue;
for (const entry of vehicles) {
const ref = entry.vehicle;
const id = typeof ref === "object" && ref ? ref.id : ref;
if (typeof id === "number") ids.add(id);
}
}
return ids;
}
async function fetchBudgetAccounts(
payload: PayloadType,
userId: number | null,
): Promise<BudgetOption[]> {
const budgetAnd: Array<Record<string, unknown>> = [{ status: { equals: "open" } }];
const accountOr: Array<Record<string, unknown>> = [{ accountType: { equals: "treasury" } }];
if (userId != null) {
accountOr.push({
and: [
{ accountType: { equals: "personal" } },
{ ownerUser: { equals: userId } },
],
});
}
budgetAnd.push({ or: accountOr });
const res = await payload
.find({
collection: "bank-accounts",
where: { and: budgetAnd } as never,
sort: "name",
limit: MAX_ACCOUNTS,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
return (res.docs as unknown as { id: number; name: string; accountType: string }[])
.slice(0, MAX_ACCOUNTS)
.map((a) => ({ id: a.id, name: a.name, type: a.accountType as BudgetOption["type"] }));
}
async function fetchRosterUsers(payload: PayloadType): Promise<RosterUser[]> {
const res = await payload
.find({
collection: "users",
sort: "displayName",
limit: MAX_ROSTER,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
return (res.docs as unknown as { id: number; displayName: string }[])
.slice(0, MAX_ROSTER)
.map((u) => ({ id: u.id, displayName: u.displayName || "Unknown user" }));
}
/**
* Upcoming unit-visible missions: status Ready or Scheduled, visibility unit,
* sorted by earliest start date.
*/
async function fetchUpcomingMissions(payload: PayloadType): Promise<MissionOption[]> {
const res = await payload
.find({
collection: "missions",
where: {
and: [
{ "ownershipAndStatus.status": { in: [...UPCOMING_MISSION_STATUSES] } },
{ "ownershipAndStatus.visibility": { equals: "unit" } },
],
},
sort: "classification.startDateTime",
limit: MAX_MISSIONS,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as unknown[] }));
return (res.docs as unknown as { id: number; name: string; codeName: string | null }[])
.slice(0, MAX_MISSIONS)
.map((m) => ({ id: m.id, name: m.name, codeName: m.codeName ?? null }));
}
/** Aggregate per-origin stock from storedResources + voidStorage, resolving names. */
async function stockByOrigin(
payload: PayloadType,
origins: Depth0Structure[],
): Promise<Record<string, StockLine[]>> {
const nameCache = new Map<number, string>();
const resolveName = async (resourceId: number): Promise<string> => {
const cached = nameCache.get(resourceId);
if (cached) return cached;
const doc = await payload
.findByID({ collection: "resources", id: resourceId, depth: 0, overrideAccess: true })
.catch(() => null);
const name = doc?.name ?? `Resource #${resourceId}`;
nameCache.set(resourceId, name);
return name;
};
const out: Record<string, StockLine[]> = {};
for (const origin of origins) {
const entries = [
...(origin.storedResources ?? []),
...(origin.voidStorage ?? []),
] as Array<{ resource: number | { id: number; name?: string }; amount: number }>;
if (entries.length === 0) continue;
const totals = new Map<number, number>();
for (const entry of entries) {
const resourceId = typeof entry.resource === "object" ? entry.resource.id : entry.resource;
if (typeof resourceId !== "number") continue;
totals.set(resourceId, (totals.get(resourceId) ?? 0) + (entry.amount ?? 0));
}
const lines: StockLine[] = [];
for (const [resourceId, amount] of totals) {
lines.push({ resourceId, resourceName: await resolveName(resourceId), amount });
}
lines.sort((a, b) => a.resourceName.localeCompare(b.resourceName));
out[String(origin.id)] = lines;
}
return out;
}
/**
* All option pools the reservation form needs, in one object.
*
* `userId` is used to scope budget accounts to the viewer's own personal
* account (plus any open treasury accounts); it is never used to read balances.
*/
export async function listReservationOptions(
payload: PayloadType,
user: { id: number | string } | null,
): Promise<ReservationOptions> {
const userId = user ? Number(user.id) : null;
const reservedVehicleIds = await activeReservedVehicleIds(payload);
const origins = await operationalStructures(payload);
const [upcomingMissions, vehicles, budgetAccounts, rosterUsers] = await Promise.all([
fetchUpcomingMissions(payload),
idleUnreservedVehicles(payload, reservedVehicleIds),
fetchBudgetAccounts(payload, userId),
fetchRosterUsers(payload),
]);
const stockByOriginData = await stockByOrigin(payload, origins);
const structureOptions = origins.map((s) => ({ id: s.id, name: s.name }));
return {
upcomingMissions,
vehicles,
originStructures: structureOptions,
destinationStructures: structureOptions,
budgetAccounts,
stockByOrigin: stockByOriginData,
rosterUsers,
};
}
/**
* Re-exported so the dialog can type a stored structure/vehicle doc without
* importing payload-types twice.
*/
export type { GameStructure, GameVehicle } from "@/payload-types";

View file

@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
/**
* Create-reservation dialog on the reservations list page.
*
* 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 dialog is server-gated: only users holding operation-reservations:create
* render the "New Reservation" trigger; logistics qualification gates the
* vehicle/cargo/budget sections inside.
*/
test.describe("Create reservation dialog", () => {
test("guests never see the create-reservation dialog", async ({ page }) => {
await page.goto("/operations/reservations");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force");
});
test("a permitted, logistics-qualified user sees the create dialog with all sections", 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("/operations/reservations");
await expect(page.getByText("Reservations")).toBeVisible();
// The trigger renders for a user holding operation-reservations:create.
await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible();
await page.getByRole("button", { name: "New Reservation" }).click();
// Core fields are present.
await expect(page.getByLabel("Reservation key")).toBeVisible();
await expect(page.getByLabel("Operation id")).toBeVisible();
// Logistics-qualified users get an enabled Vehicles + Cargo + Budget section.
await expect(page.getByText("Vehicles (optional)")).toBeVisible();
await expect(page.getByText("Cargo lines")).toBeVisible();
await expect(page.getByText("Budget account (optional)")).toBeVisible();
});
test("submitting the dialog with the required fields missing shows the client error", 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("/operations/reservations");
await page.getByRole("button", { name: "New Reservation" }).click();
// Empty submit -> client pre-validation surfaces the required-field errors.
await page.getByRole("button", { name: "Reserve assets" }).click();
await expect(
page.getByRole("alert").filter({ hasText: "A reservation key is required." }),
).toBeVisible();
});
test("the list renders and either a reserved row or the empty state is present", 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("/operations/reservations");
// Either a reserved row (with its Cancel control) or the empty state renders.
await expect(
page.getByText("No reservations match this view."),
).toBeVisible();
});
});

View file

@ -0,0 +1,449 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type {
GameStructure,
GameVehicle,
OperationReservation,
Resource,
Structure,
User,
} from "@/payload-types";
import { listReservationOptions } from "@/lib/operations/reservationOptions";
import { createAccount } from "@/lib/banking";
import { storedAmount } from "@/lib/base/storage";
let payload: Payload;
const RUN = `res-options-${Date.now().toString(36)}`;
// ---------------------------------------------------------------------------
// Fixture tracking for cleanup
// ---------------------------------------------------------------------------
const reservationIds: number[] = [];
const userIds: number[] = [];
const roleIds: number[] = [];
const accountIds: number[] = [];
const gameStructureIds: number[] = [];
const blueprintIds: number[] = [];
const gameVehicleIds: number[] = [];
const vehicleBlueprintIds: number[] = [];
const resourceIds: number[] = [];
const missionIds: number[] = [];
const campaignIds: number[] = [];
const mapIds: number[] = [];
let commandUser: User;
let plainUser: User;
let mapId: number;
let campaignId: number;
let resourceAId: number;
let resourceBId: number;
let normalBlueprintId: number;
let vehicleBlueprintId: number;
const LEXICAL_EMPTY = {
root: {
children: [{ text: "" }],
direction: null,
format: "" as const,
indent: 0,
type: "text",
version: 1,
},
};
function relId(value: unknown): number {
return typeof value === "object" && value !== null
? (value as { id: number }).id
: (value as number);
}
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}-${Math.random().toString(36).slice(2, 8)}`,
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;
}
async function makeMission(
status: "Ready" | "Scheduled" | "Completed" | "Cancelled",
visibility: "unit" | "leadership",
): Promise<number> {
const mission = await payload.create({
collection: "missions",
data: {
name: `${RUN} ${status} ${visibility}`,
codeName: `${RUN}-${status}-${visibility}`,
summary: "Options test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [commandUser.id],
status,
visibility,
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
});
missionIds.push(mission.id);
return mission.id;
}
async function makeOrigin(stock: { resourceId: number; amount: number }[]): Promise<number> {
const site = await payload.create({
collection: "game-structures",
data: {
name: `${RUN} origin ${gameStructureIds.length}`,
type: normalBlueprintId,
map: mapId,
coordinates: [100 + gameStructureIds.length, 100],
constructionStatus: "complete",
storedResources: stock.map((s, i) => ({
resource: s.resourceId,
amount: s.amount,
gridX: 0,
gridY: i,
rotated: false,
})),
},
overrideAccess: true,
depth: 0,
});
gameStructureIds.push(site.id);
return site.id;
}
async function makeVehicle(status: "idle" | "assigned"): Promise<number> {
const vehicle = await payload.create({
collection: "game-vehicles",
data: {
name: `${RUN} vic ${gameVehicleIds.length}`,
type: vehicleBlueprintId,
deployedAt: gameStructureIds[0],
status,
currentFuel: 800,
currentHealth: 100,
},
overrideAccess: true,
depth: 0,
});
gameVehicleIds.push(vehicle.id);
return vehicle.id;
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
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 map = await payload.create({
collection: "maps",
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true,
depth: 0,
});
mapId = map.id;
mapIds.push(map.id);
const campaign = await payload.create({
collection: "campaigns",
data: {
name: `${RUN} Campaign`,
summary: "Options test campaign",
status: "concept",
campaignMode: "custom",
},
overrideAccess: true,
depth: 0,
});
campaignId = campaign.id;
campaignIds.push(campaign.id);
const resourceA = await payload.create({
collection: "resources",
data: {
name: `${RUN} Alpha`,
codeName: `${RUN}-alpha`,
approvalStatus: "in_progress",
type: "physical",
baseValue: 1,
rarity: "common",
unitOfMeasure: "kg",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
},
overrideAccess: true,
depth: 0,
});
resourceAId = resourceA.id;
resourceIds.push(resourceA.id);
const resourceB = await payload.create({
collection: "resources",
data: {
name: `${RUN} Beta`,
codeName: `${RUN}-beta`,
approvalStatus: "in_progress",
type: "physical",
baseValue: 1,
rarity: "common",
unitOfMeasure: "kg",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
},
overrideAccess: true,
depth: 0,
});
resourceBId = resourceB.id;
resourceIds.push(resourceB.id);
const normalBlueprint = await payload.create({
collection: "structures",
data: {
name: `${RUN} Depot`,
codeName: `${RUN}-depot`,
approvalStatus: "in_progress",
description: LEXICAL_EMPTY as unknown as Structure["description"],
category: "logistics",
materials: [{ resource: resourceAId, amount: 1 }],
constructionDurationMinutes: 1,
terrainType: "land",
maxHealth: 100,
},
overrideAccess: true,
depth: 0,
});
normalBlueprintId = normalBlueprint.id;
blueprintIds.push(normalBlueprint.id);
const vehicleBlueprint = await payload.create({
collection: "vehicles",
data: {
name: `${RUN} Truck`,
approvalStatus: "in_progress",
transportMode: "ground",
maxSpeedOnRoad: 60,
fuel: { fuelType: resourceAId, fuelCapacity: 1000, fuelConsumptionRate: 0.01 },
},
overrideAccess: true,
depth: 0,
});
vehicleBlueprintId = vehicleBlueprint.id;
vehicleBlueprintIds.push(vehicleBlueprint.id);
});
afterAll(async () => {
if (!payload) return;
for (const id of reservationIds) {
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
}
for (const id of gameVehicleIds) {
await payload.delete({ collection: "game-vehicles", id, overrideAccess: true }).catch(() => {});
}
for (const id of gameStructureIds) {
await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {});
}
for (const id of vehicleBlueprintIds) {
await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {});
}
for (const id of blueprintIds) {
await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {});
}
for (const id of resourceIds) {
await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {});
}
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
for (const id of campaignIds) {
await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {});
}
for (const id of mapIds) {
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
}
for (const id of accountIds) {
const txns = await payload.find({
collection: "bank-transactions",
where: { or: [{ fromAccount: { equals: id } }, { toAccount: { equals: id } }] },
limit: 100,
depth: 0,
overrideAccess: true,
});
for (const txn of txns.docs) {
await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {});
}
await payload.delete({ collection: "bank-accounts", 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(() => {});
}
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("listReservationOptions", () => {
it("returns only Ready/Scheduled unit-visible missions, sorted by start", async () => {
const scheduledId = await makeMission("Scheduled", "unit");
const readyId = await makeMission("Ready", "unit");
const completedId = await makeMission("Completed", "unit");
const leadershipId = await makeMission("Scheduled", "leadership");
const options = await listReservationOptions(payload, commandUser);
const ids = options.upcomingMissions.map((m) => m.id);
expect(ids).toContain(scheduledId);
expect(ids).toContain(readyId);
expect(ids).not.toContain(completedId);
expect(ids).not.toContain(leadershipId);
});
it("excludes non-idle vehicles and vehicles already reserved", async () => {
const idleId = await makeVehicle("idle");
const assignedId = await makeVehicle("assigned");
const reservedId = await makeVehicle("idle");
// Reserve the second idle vehicle via an active (reserved) reservation row.
const row = await payload.create({
collection: "operation-reservations",
data: {
reservationKey: `${RUN}-reserved-veh`,
operationId: `${RUN}-op`,
status: "reserved",
vehicles: [{ vehicle: reservedId }],
createdBy: commandUser.id,
},
overrideAccess: true,
depth: 0,
});
reservationIds.push(row.id);
const options = await listReservationOptions(payload, commandUser);
const vehicleIds = options.vehicles.map((v) => v.id);
expect(vehicleIds).toContain(idleId);
expect(vehicleIds).not.toContain(assignedId);
expect(vehicleIds).not.toContain(reservedId);
});
it("reflects storedAmount per origin in stockByOrigin", async () => {
const originId = await makeOrigin([
{ resourceId: resourceAId, amount: 100 },
{ resourceId: resourceBId, amount: 50 },
]);
const options = await listReservationOptions(payload, commandUser);
const stock = options.stockByOrigin[String(originId)];
expect(stock).toHaveLength(2);
const origin = (await payload
.findByID({ collection: "game-structures", id: originId, depth: 0, overrideAccess: true })
.catch(() => null)) as unknown as (GameStructure & {
storedResources?: unknown[];
voidStorage?: unknown[];
}) | null;
const entries = [
...(origin?.storedResources ?? []),
...(origin?.voidStorage ?? []),
] as Array<{ resource: { id: number }; amount: number }>;
const amountA = storedAmount(entries as never, resourceAId);
const amountB = storedAmount(entries as never, resourceBId);
expect(amountA).toBe(100);
expect(amountB).toBe(50);
const lineA = stock!.find((s) => s.resourceId === resourceAId);
const lineB = stock!.find((s) => s.resourceId === resourceBId);
expect(lineA?.amount).toBe(amountA);
expect(lineB?.amount).toBe(amountB);
expect(lineA?.resourceName).toBeTruthy();
expect(lineB?.resourceName).toBeTruthy();
});
it("caps the roster pool at 200 users", async () => {
for (let i = 0; i < 205; i++) {
await makeUser(`roster${i}`);
}
const options = await listReservationOptions(payload, commandUser);
expect(options.rosterUsers.length).toBe(200);
// Every entry carries a display name and id; nothing else leaks.
for (const user of options.rosterUsers) {
expect(typeof user.id).toBe("number");
expect(user.displayName.length).toBeGreaterThan(0);
}
}, 120000);
it("scopes budget accounts to treasury + this user's personal accounts, no balances", async () => {
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
accountIds.push(treasury.id);
const commandPersonal = await createAccount(payload, {
name: `${RUN} Command Personal`,
accountType: "personal",
ownerUserId: commandUser.id,
});
accountIds.push(commandPersonal.id);
const plainPersonal = await createAccount(payload, {
name: `${RUN} Plain Personal`,
accountType: "personal",
ownerUserId: plainUser.id,
});
accountIds.push(plainPersonal.id);
const options = await listReservationOptions(payload, commandUser);
const ids = options.budgetAccounts.map((a) => a.id);
expect(ids).toContain(treasury.id);
expect(ids).toContain(commandPersonal.id);
// Another user's personal account is out of scope.
expect(ids).not.toContain(plainPersonal.id);
// Only id, name, and type are exposed - balances never leak.
for (const account of options.budgetAccounts) {
const keys = Object.keys(account).sort();
expect(keys).toEqual(["id", "name", "type"]);
}
});
});