feat(realtime): add game-tick SSE notification pipeline
This commit is contained in:
parent
75a1c8ed9a
commit
d3afc6a0c1
10 changed files with 493 additions and 0 deletions
|
|
@ -1,2 +1,8 @@
|
||||||
DATABASE_URI=mongodb://127.0.0.1/your-database-name
|
DATABASE_URI=mongodb://127.0.0.1/your-database-name
|
||||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||||
|
|
||||||
|
# Public URL of the running app (used by the game tick script to notify clients)
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
# Secret that guards the /api/game-tick/notify endpoint. The game tick script
|
||||||
|
# sends this header so connected clients refresh after each tick.
|
||||||
|
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET
|
||||||
|
|
|
||||||
26
src/app/api/game-tick/notify/route.ts
Normal file
26
src/app/api/game-tick/notify/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { broadcast } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const secret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||||
|
const headerSecret = req.headers.get("x-game-tick-secret");
|
||||||
|
if (secret && headerSecret !== secret) {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { processedAt?: string } = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON body
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast("game-tick", {
|
||||||
|
processedAt: body?.processedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
55
src/app/api/realtime/route.ts
Normal file
55
src/app/api/realtime/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import {
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
type SSEEventClient,
|
||||||
|
} from "@/lib/realtime/bus";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
let client: SSEEventClient | null = null;
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (client) unsubscribe(client);
|
||||||
|
client = null;
|
||||||
|
heartbeat = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
client = {
|
||||||
|
send: (data) => controller.enqueue(encoder.encode(data)),
|
||||||
|
};
|
||||||
|
subscribe(client);
|
||||||
|
|
||||||
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||||
|
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, 25_000);
|
||||||
|
|
||||||
|
req.signal.addEventListener("abort", cleanup);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
191
src/components/frontend/logistics/ShipmentToasts.tsx
Normal file
191
src/components/frontend/logistics/ShipmentToasts.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, TruckIcon } from "lucide-react";
|
||||||
|
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
||||||
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||||
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
|
|
||||||
|
type ActiveShipment = Shipment & {
|
||||||
|
origin: GameStructure | number;
|
||||||
|
destination: GameStructure | number;
|
||||||
|
transportVehicle: GameVehicle & { type: Vehicle };
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge: Record<string, string> = {
|
||||||
|
dispatched: "bg-blue-500/20 text-blue-400",
|
||||||
|
in_transit: "bg-amber-500/20 text-amber-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
dispatched: "Dispatched",
|
||||||
|
in_transit: "In Transit",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Module-scoped cache so the toast persists across re-mounts (page navigation
|
||||||
|
// re-mounts this client component; without this it flashes away and back).
|
||||||
|
const store: { shipments: ActiveShipment[]; open: boolean } = {
|
||||||
|
shipments: [],
|
||||||
|
open: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShipmentToasts() {
|
||||||
|
const [shipments, setShipments] = useState<ActiveShipment[]>(store.shipments);
|
||||||
|
const [open, setOpenState] = useState(store.open);
|
||||||
|
|
||||||
|
const setOpen = useCallback((value: boolean | ((v: boolean) => boolean)) => {
|
||||||
|
setOpenState((prev) => {
|
||||||
|
const next = typeof value === "function" ? value(prev) : value;
|
||||||
|
store.open = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchShipments = useCallback(async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("where[status][in][0]", "dispatched");
|
||||||
|
params.append("where[status][in][1]", "in_transit");
|
||||||
|
params.append("sort", "estimatedArrival");
|
||||||
|
params.append("limit", "20");
|
||||||
|
params.append("depth", "2");
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/shipments?${params.toString()}`);
|
||||||
|
const body = await r.json();
|
||||||
|
store.shipments = body.docs ?? [];
|
||||||
|
setShipments(store.shipments);
|
||||||
|
} catch {
|
||||||
|
store.shipments = [];
|
||||||
|
setShipments([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShipments();
|
||||||
|
const interval = setInterval(fetchShipments, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchShipments]);
|
||||||
|
|
||||||
|
useGameTick(() => fetchShipments());
|
||||||
|
|
||||||
|
if (shipments.length === 0) return null;
|
||||||
|
|
||||||
|
const count = shipments.length;
|
||||||
|
const countLabel = `${count} active shipment${count !== 1 ? "s" : ""}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
|
||||||
|
{open && (
|
||||||
|
<div className="w-80 sm:w-96 rounded-xl border border-border bg-background/95 backdrop-blur shadow-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TruckIcon className="size-4 text-primary" />
|
||||||
|
<span className="text-sm font-semibold">Active Shipments</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href="/logistics/shipments"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Collapse shipment tracker"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto divide-y divide-border">
|
||||||
|
{shipments.map((shipment) => {
|
||||||
|
const origin =
|
||||||
|
typeof shipment.origin === "object" && shipment.origin !== null
|
||||||
|
? (shipment.origin as GameStructure)
|
||||||
|
: null;
|
||||||
|
const destination =
|
||||||
|
typeof shipment.destination === "object" &&
|
||||||
|
shipment.destination !== null
|
||||||
|
? (shipment.destination as GameStructure)
|
||||||
|
: null;
|
||||||
|
const vehicle =
|
||||||
|
typeof shipment.transportVehicle === "object" &&
|
||||||
|
shipment.transportVehicle !== null
|
||||||
|
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
|
||||||
|
: null;
|
||||||
|
const vehicleType =
|
||||||
|
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={shipment.id}
|
||||||
|
href={`/logistics/shipments/${shipment.id}`}
|
||||||
|
className="block px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{shipment.name ?? `SHP-${shipment.id}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${
|
||||||
|
statusBadge[shipment.status] ?? "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabel[shipment.status] ?? shipment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">
|
||||||
|
{origin?.name ?? `Structure #${shipment.origin}`}
|
||||||
|
</span>
|
||||||
|
<ArrowRightIcon className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{destination?.name ?? `Structure #${shipment.destination}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{vehicleType && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground/80">
|
||||||
|
{vehicleType.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{shipment.dispatchedAt && shipment.estimatedArrival && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ShipmentProgressBar
|
||||||
|
dispatchedAt={shipment.dispatchedAt}
|
||||||
|
estimatedArrival={shipment.estimatedArrival}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-2 rounded-full border border-border bg-background/95 backdrop-blur px-3.5 py-2 text-sm shadow-lg hover:border-primary/50 hover:scale-[1.02] transition-all"
|
||||||
|
aria-label={open ? "Collapse shipment tracker" : "Show active shipments"}
|
||||||
|
>
|
||||||
|
<span className="relative flex size-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
<TruckIcon className="size-4 text-primary" />
|
||||||
|
<span className="font-medium">{countLabel}</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronUpIcon className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/frontend/realtime/GameTickRealtime.tsx
Normal file
34
src/components/frontend/realtime/GameTickRealtime.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { GAME_TICK_EVENT, type GameTickDetail } from "@/hooks/useGameTick";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a Server-Sent Events connection to the game tick stream.
|
||||||
|
* When a game tick completes, refreshes server-rendered data via
|
||||||
|
* router.refresh() and dispatches a window event that client-side
|
||||||
|
* components (e.g. ShipmentToasts, EventLedger) listen to for refetch.
|
||||||
|
*/
|
||||||
|
export function GameTickRealtime() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource("/api/realtime");
|
||||||
|
source.addEventListener("game-tick", (event) => {
|
||||||
|
let detail: GameTickDetail = {};
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data ?? "{}");
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(GAME_TICK_EVENT, { detail }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => source.close();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
|
||||||
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
|
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
|
||||||
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
|
|
@ -105,6 +106,10 @@ export function EventLedger({
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchEntries]);
|
}, [fetchEntries]);
|
||||||
|
|
||||||
|
useGameTick(() => {
|
||||||
|
if (initialDone.current) fetchEntries();
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
|
||||||
26
src/hooks/useGameTick.ts
Normal file
26
src/hooks/useGameTick.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export const GAME_TICK_EVENT = "ptf:game-tick";
|
||||||
|
|
||||||
|
export interface GameTickDetail {
|
||||||
|
processedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameTick(onTick?: () => void): number {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const callbackRef = useRef(onTick);
|
||||||
|
callbackRef.current = onTick;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
callbackRef.current?.();
|
||||||
|
};
|
||||||
|
window.addEventListener(GAME_TICK_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(GAME_TICK_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
30
src/lib/realtime/bus.ts
Normal file
30
src/lib/realtime/bus.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export interface SSEEventClient {
|
||||||
|
send: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = new Set<SSEEventClient>();
|
||||||
|
|
||||||
|
export function subscribe(client: SSEEventClient): () => void {
|
||||||
|
clients.add(client);
|
||||||
|
return () => clients.delete(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsubscribe(client: SSEEventClient): void {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientCount(): number {
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast(event: string, data: unknown): number {
|
||||||
|
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
client.send(payload);
|
||||||
|
} catch {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||
81
src/scripts/gameTick.ts
Normal file
81
src/scripts/gameTick.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { SanitizedConfig } from "payload";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { Shipment, GameVehicle, GameStructure, Vehicle } from "@/payload-types";
|
||||||
|
import { processShipmentTick } from "./processShipmentTick";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
|
||||||
|
export const script = async (config: SanitizedConfig) => {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
payload.logger.info("[Game Tick] Starting game tick...");
|
||||||
|
|
||||||
|
const activeShipments = await payload.find({
|
||||||
|
collection: "shipments",
|
||||||
|
where: {
|
||||||
|
status: { in: ["dispatched", "in_transit"] },
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
depth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
for (const shipment of activeShipments.docs) {
|
||||||
|
try {
|
||||||
|
await processShipmentTick(
|
||||||
|
payload,
|
||||||
|
shipment as unknown as Shipment & {
|
||||||
|
transportVehicle: GameVehicle & { type: Vehicle };
|
||||||
|
origin: GameStructure;
|
||||||
|
destination: GameStructure;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
processedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
payload.logger.error(
|
||||||
|
`[Game Tick] Error processing shipment ${shipment.id}: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.GameTick,
|
||||||
|
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||||
|
data: {
|
||||||
|
processedCount,
|
||||||
|
totalActive: activeShipments.docs.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
|
||||||
|
/\/$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||||
|
if (notifySecret) {
|
||||||
|
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-game-tick-secret": notifySecret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ processedAt: new Date().toISOString() }),
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
payload.logger.error(
|
||||||
|
`[Game Tick] Failed to notify app clients: ${message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
payload.logger.warn(
|
||||||
|
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.logger.info(
|
||||||
|
`[Game Tick] Complete. Processed ${processedCount} shipments.`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
39
src/utils/access-control/hasLogisticsQualification.ts
Normal file
39
src/utils/access-control/hasLogisticsQualification.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user has the Logistics qualification.
|
||||||
|
* Queries the user's profile for qualifications matching "Logistics" (case-insensitive).
|
||||||
|
* Developers and admins always pass.
|
||||||
|
*/
|
||||||
|
export async function hasLogisticsQualification(
|
||||||
|
payload: Payload,
|
||||||
|
user: { id: number | string; roles?: unknown } | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const roles = user.roles as string[] | undefined;
|
||||||
|
if (roles?.includes("developer") || roles?.includes("admin")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 1,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
qualifications: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) return false;
|
||||||
|
|
||||||
|
const qualifications = profile.progression?.qualifications ?? [];
|
||||||
|
return qualifications.some((q) => {
|
||||||
|
if (typeof q === "object" && q !== null && "name" in q) {
|
||||||
|
return (q.name as string).toLowerCase().includes("logistics");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue