Compare commits
10 commits
2f8e8e1b34
...
143a922066
| Author | SHA1 | Date | |
|---|---|---|---|
| 143a922066 | |||
| 65ee082f77 | |||
| 15a5f14ad2 | |||
| d9253e6725 | |||
| 35150e73ac | |||
| a06060eb9f | |||
| d4f9051b17 | |||
| 74564afb2b | |||
| 62573f74e6 | |||
| d43b2ef658 |
72 changed files with 42962 additions and 389 deletions
|
|
@ -22,6 +22,15 @@ APP_URL=http://localhost:3000
|
|||
# Coolify (or to your cron). See DEPLOYMENT.md.
|
||||
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET
|
||||
|
||||
# --- Unsloth AI (Payload AI Compose plugin) ---
|
||||
# Base URL of the Unsloth Studio OpenAI-compatible API that serves the
|
||||
# Ornith 9B model (the "Apex" connection lives on the apex-lnx machine).
|
||||
# Defaults to the Apex box's LAN address; for the deployed site use whatever
|
||||
# host can actually reach that box (VPN / tunnel / reverse proxy).
|
||||
UNSLOTH_URL=http://192.168.1.7:8888/v1
|
||||
# sk-unsloth-... API key generated in Unsloth Studio (Settings -> API).
|
||||
UNSLOTH_API_KEY=YOUR_UNSLOTH_API_KEY
|
||||
|
||||
# --- Bin script file logging ---
|
||||
# Optional. Payload bin scripts (game-tick, market-tick, mission-tick,
|
||||
# generate-mission) tee their logs to daily files. Defaults to <repo>/logs
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -164,7 +164,8 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
|
|||
- **Base tick**: `bun run payload base-tick` — bin registered like the others (needs external cron). Runs `processBaseTick` (`src/lib/base/tick.ts`): pays staff salaries from the structure treasury (banking `applyTransaction` type `salary`), charges structure maintenance, flags upkeep shortages (see Base Management below). Emits `staff:salary-paid`/`staff:salary-unpaid`/`structure:maintenance-paid`/`structure:maintenance-unpaid` events. Notifies clients via the same SSE path as the other ticks (`source: "base-tick"`).
|
||||
- **Economy tick**: `bun run payload economy-tick` — bin registered like the others (needs external cron; cadence hint is GameRules `economy.tickIntervalHintMinutes`). Runs `processEconomyTick` (`src/lib/economy/tick.ts`): ensures a `market-state` ledger doc exists per tradeable resource/asset (copied from GM baselines), decays `realDemand`, drifts `artificialDemand` (deterministic per period via `periodSeed` → mulberry32 rng — a cron retry within the same period recomputes the same values), recomputes `priceModifier` from the demand/supply ratio clamped to the item's band. Master gate: GameRules `economy.enabled` (default **off** — no-op when disabled). Cold-start gate: items with fewer than `coldStartObservations` completed purchases keep `priceModifier = 1`. Period idempotency: docs with `lastComputedAt >= periodStart` are skipped. Emits a batch `economy:tick` event + one `economy:price-change` event per item whose modifier moved ≥ `PRICE_CHANGE_THRESHOLD` (0.05) — target `market-state` (logic: `src/lib/economy/` incl. `events.ts`, tests: `tests/int/economy.int.spec.ts` + `tests/int/economy-events.int.spec.ts`). Nothing consumes the modifier yet (P1d: market-tick new-listing pricing). Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
||||
- **Economy consumption (P1d)**: market-tick prices new NPC listings from the ledger — `price = max(1, round(baseBuyPrice × priceModifier))` (neutral 1 when no market-state doc) then `applyNpcPriceModifier` (vendor's own modifier); the old `autoPrice` ±15% randomness is **removed**. Restock quantity scales down under shortage via `scaleQuantityForShortage` (bounded to a 0.25 factor floor, never below 1). `buyListing` ingests real demand: purchases of NPC/vendor stock (`seller == null`) call `recordPurchase` (fire-and-forget, `src/lib/economy/consumption.ts` — quantity-weighted `realDemand` increment). GM tools: `resetEconomyState` server action (permission `market-state:update`, rebuilds the doc from baselines, emits `economy:reset`; per-listing "Reset economy" button gated by `canResetEconomy` on the market page) and `getReferencePrices` (modifier-applied reference price shown in `CreateListingDialog`'s tip when it differs from the resting valuation). Tests: `tests/int/economy-consumption.int.spec.ts`. Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
||||
- **Mission auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Sweeps missions whose status is `Scheduled`/`Active` and whose scheduled date (`classification.startDateTime`) has fully passed — from the start of the following **server-local** day — and sets them to `Completed`, emitting a `mission:auto-complete` event per mission (logic: `src/lib/intelligence/missionLifecycle.ts`, test: `tests/int/mission-lifecycle.int.spec.ts`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **Mission lifecycle auto-start / auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Each tick runs two sweeps (start first, then complete) in `src/lib/intelligence/missionLifecycle.ts`: `autoStartMissions` flips `Scheduled` missions whose `classification.startDateTime` has arrived to `Active` (emitting `mission:auto-start`), skipping missions whose whole window already elapsed so they don't get a spurious start event; `autoCompleteMissions` then sets missions whose scheduled window (`startDateTime` + `estimatedDuration`, default 240 min when missing/invalid) has fully passed to `Completed` with `completedAt` stamped at the sweep time (emitting `mission:auto-complete`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never auto-started; only `Scheduled`/`Active` are auto-completed. Both sweeps are idempotent. Notifies clients via the same SSE path as the other ticks (tests: `tests/int/mission-lifecycle.int.spec.ts`).
|
||||
- **Side-op workflow (Ready draft → Scheduled committed)**: `createFridaySideOp` creates a side op as a quiet `Ready` draft (no roll-call). The creator commits it with `markSideOpScheduled` (the "Mark as Scheduled" control on the mission page), which flips it to `Scheduled` and emits `mission:commit`. Only `Scheduled` ops post a Discord roll-call (about 3 days out) and are driven by the auto-start/auto-complete lifecycle above. The daily reminder (`readyReminders`, web notification + Discord DM, notification type `mission:ready-reminder`, label "Schedule reminder") nudges the creator to set the op to `Scheduled` while it is still Concept/Planning/Ready; the internal "ready" names are legacy and kept to avoid a migration.
|
||||
- **Server presence**: `bun run payload server-tick` — bin registered like the others (needs external cron, every minute). Flips `game-servers` docs that claim `status: "online"` but whose last heartbeat (`lastSeenAt`) is older than 180s to `offline`, including online docs with no `lastSeenAt` at all; emits a `server:offline` event per flipped server (logic: `src/lib/arma-bridge/presence.ts`, test: `tests/int/server-presence.int.spec.ts`). Idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged.
|
||||
- **GameRules tuning**: `proximityThreshold` and `gameTickIntervalMinutes` live on the global `game-rules` doc.
|
||||
|
|
@ -310,10 +311,10 @@ Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full
|
|||
- **Schema**: `tech-categories` collection (`src/collections/intelligence/TechCategories.ts`: `name` unique, `position` number = timeline order, optional hex `color`) + `Technologies.category` relationship (FK `ON DELETE set null`, so deleting a category just unfiles its techs) + `Technologies.treePosition` group (`x`/`y` numbers; manual canvas position from dragging, empty = automatic). Permissions mirror technologies (`tech-categories:*` + `admin:tech-categories:manage`, registered in `src/permissions/index.ts`); `tech-categories` is in `DIVISION_ADMIN_QUALIFICATIONS`, so Intelligence division members get admin-panel access exactly like `technologies`. Migrations: `20260930_230747_add_tech_categories`, `20261001_000320_add_tech_tree_position`.
|
||||
- **View wiring**: `payload.config.ts` `admin.components.views.techTree` (path `/tech-tree`, component `src/components/admin/tech-tree/TechTreeView.tsx`) and `admin.components.Nav` → `TechTreeNav.tsx`. Custom root views are public by default, so the view gates itself (logged-in + `technologies:read` OR Intelligence qualification). The nav replica injects the entry into the group containing `technologies` and reuses public exports (`NavWrapper`/`NavHamburger`/`DefaultNavClient` from `@payloadcms/next/client`, `groupNavItems` from `@payloadcms/ui/shared`); registered admin components must be **default exports** (importMap convention in this repo).
|
||||
- **Layout** (`layout.ts`, pure): a **propagating left-to-right tree**. A technology WITH prerequisites is placed one column to the right of its parents, at their average row, so linking A -> B drops B immediately beside A and B's dependents continue further right; nothing is teleported to a distant column when its depth changes. Technologies in a category stay pinned to that category's column (the category contract); uncategorized roots fill a catalog grid whose slots come from each tech's rank among ALL uncategorized technologies (`MAX_ROWS_PER_COLUMN` = 10 rows, spilling into more columns) so promoting a tech into the tree leaves an empty slot rather than re-shuffling the grid; uncategorized dependents follow their parents inside the uncategorized zone. Collisions search rows outward from the desired row (dependents may also grow upward, and their columns are not height-capped, so a link from a full column still lands beside its parent rather than far right). Category bands are the bounding box of their members' final positions. Prerequisites placed to the RIGHT of their dependent surface as amber warnings (toolbar chip + node icon + preview panel section).
|
||||
- **Edges**: AND prerequisites solid slate, OR prerequisites dashed cyan with an OR label. Connections are editable on the canvas: drag from a node's right handle onto another node (the target card OR its handle) to add it as an AND prerequisite; the drop-on-card case is resolved by an `onConnectEnd` fallback because React Flow only resolves drops onto a handle. Select an edge and press Backspace to remove the prerequisite. Duplicates, self-references, and cycles (direct or transitive) are rejected by the pure `prereqEdits.ts` helpers with an inline error in the toolbar.
|
||||
- **Interactions**: nodes are draggable; dropping one PATCHes its `treePosition` (persisted, survives reload) and the toolbar shows an "Auto-arrange" button (technologies:update gated) that clears every manual position and restores the ordered layout. A **Save positions** button (technologies:update gated), next to Auto-arrange, commits the current on-screen position of every node to `treePosition` in one pass (matching what React Flow rendered, not just dragged/pinned cards) so the exact arrangement survives a reload; Auto-arrange then clears them. **Connecting does not move anything**: if the ordered layout would relocate the connected technology, the canvas pins it to its current spot (`treePosition` written in the same PATCH as the prerequisite) so the edge appears without the card jumping; the catalog grid also uses stable slots (each tech's rank among all uncategorized technologies, so a tech promoted into the tree leaves its slot empty instead of re-shuffling every other root). Pressing "Auto-arrange" clears those pins and moves everything, including the connected node, to its ordered position. Node click opens the side panel, which is a **lite editor** over the fields it shows (name, type, approval status, summary, category with inline create, prerequisites with AND/OR grouping and add/remove, research costs with resource rows and min/max durations): dirty tracking, Save/Reset, and an "Edit in full view" jump to the standard admin edit route. Final approval states stay superuser-only (`canSetFinalApproval` = `system:admin-access`), mirroring the field access. Quick-create dialog (name, type, summary, category, research durations) creates via REST; the category picker is a native input + suggestion list (existing values selectable, new values creatable inline) because Radix Popover inside the admin Dialog proved layer-unstable. New categories get `position = max + 1`. The canvas uses React Flow's `<Background variant="lines">` so the grid scales with zoom (a CSS-background grid does not, and looked broken at low zoom), and `fitView` is capped at `maxZoom: 0.85`. Panel and toolbar controls carry dark tactical chrome (the admin panel's light shadcn defaults would render as white slabs on the dark surface; bare `<button>` elements in the admin additionally inherit a global light background AND border, so list rows need explicit `bg-transparent` + `border-0`), and the panel's dropdowns use the in-house `PanelSelect` listbox rather than native `<select>`: a native popup is browser/OS chrome that ignores dark styling (only `color-scheme` influences it) and cannot be screenshotted, whereas `PanelSelect` renders its list through a portal as a fixed-position overlay above the panel (so it never pushes the panel's content down), flips upward when there is more room above, closes on scroll/resize so it cannot detach from its trigger, and offers a search field for long lists. Both it and the panel's category picker use a mono header strip, hairline row separators, a cyan left accent on the selected row, and the `.tech-tree-scroll` dark scrollbar rules from `custom.scss`.
|
||||
- **Edges**: AND prerequisites solid slate, OR prerequisites dashed cyan with an OR label. Connections are editable on the canvas: drag from a node's right handle onto another node (the target card OR its handle) to add it as an AND prerequisite; the drop-on-card case is resolved by an `onConnectEnd` fallback because React Flow only resolves drops onto a handle. Select an edge and press Backspace to remove the prerequisite. Duplicates, self-references, and cycles (direct or transitive) are rejected by the pure `prereqEdits.ts` helpers with an inline error in the toolbar. Lines are also manually routable: selecting an edge reveals a "+" handle at each segment's midpoint (click to add a bend), bends drag to move, and double-clicking a bend removes it (`RoutedEdge.tsx` + `edgeRoutes.ts`). Routes are stored per browser in `localStorage`; making them shared would need a persisted field on the target technology. Edges also route around technologies they are not connected to: `edgeRouting.ts` detours each segment around the node rectangles it would cross (inflated by a clearance margin) and `buildRoutePath` rounds every corner. An unrouted edge keeps its automatic out/across/in shape unless that path would cut through a node, in which case it falls back to a direct line the router can bend around.
|
||||
- **Interactions**: nodes are draggable; dropping one PATCHes its `treePosition` (persisted, survives reload) and the toolbar shows an "Auto-arrange" button (technologies:update gated) that clears every manual position and restores the ordered layout. A **Save positions** button (technologies:update gated), next to Auto-arrange, commits the current on-screen position of every node to `treePosition` in one pass (matching what React Flow rendered, not just dragged/pinned cards) so the exact arrangement survives a reload; Auto-arrange then clears them. Dragging one card of a multi-selection moves and persists the whole selection (React Flow hands the full dragged set to `onNodeDragStop`), and a category band is draggable by its header (`dragHandle`), which moves every technology in that band. A **Snap: On/Off** toggle (default on, remembered per browser, 46px grid that divides `COLUMN_STEP`/`ROW_STEP`) snaps dragged cards to the grid. **Connecting does not move anything**: if the ordered layout would relocate the connected technology, the canvas pins it to its current spot (`treePosition` written in the same PATCH as the prerequisite) so the edge appears without the card jumping; the catalog grid also uses stable slots (each tech's rank among all uncategorized technologies, so a tech promoted into the tree leaves its slot empty instead of re-shuffling every other root). Pressing "Auto-arrange" clears those pins and moves everything, including the connected node, to its ordered position. Node click opens the side panel, which is a **lite editor** over the fields it shows (name, type, approval status, summary, category with inline create, prerequisites with AND/OR grouping and add/remove, research costs with resource rows and min/max durations): dirty tracking, Save/Reset, and an "Edit in full view" jump to the standard admin edit route. Final approval states stay superuser-only (`canSetFinalApproval` = `system:admin-access`), mirroring the field access. Quick-create dialog (name, type, summary, category, research durations) creates via REST; the category picker is a native input + suggestion list (existing values selectable, new values creatable inline) because Radix Popover inside the admin Dialog proved layer-unstable. New categories get `position = max + 1`. The canvas uses React Flow's `<Background variant="lines">` so the grid scales with zoom (a CSS-background grid does not, and looked broken at low zoom), and `fitView` is capped at `maxZoom: 0.85`. Panel and toolbar controls carry dark tactical chrome (the admin panel's light shadcn defaults would render as white slabs on the dark surface; bare `<button>` elements in the admin additionally inherit a global light background AND border, so list rows need explicit `bg-transparent` + `border-0`), and the panel's dropdowns use the in-house `PanelSelect` listbox rather than native `<select>`: a native popup is browser/OS chrome that ignores dark styling (only `color-scheme` influences it) and cannot be screenshotted, whereas `PanelSelect` renders its list through a portal as a fixed-position overlay above the panel (so it never pushes the panel's content down), flips upward when there is more room above, closes on scroll/resize so it cannot detach from its trigger, and offers a search field for long lists. Both it and the panel's category picker use a mono header strip, hairline row separators, a cyan left accent on the selected row, and the `.tech-tree-scroll` dark scrollbar rules from `custom.scss`.
|
||||
- **Styling**: tactical dossier (dark `#05070a` canvas + 28px grid via inline styles, squared corners, classification strips, mono readouts); shadcn form controls in the dialog stay conventional. React Flow controls/attribution are darkened via `.tech-tree-canvas` rules in `src/app/(payload)/custom.scss`.
|
||||
- **Gotchas**: React Flow in controlled mode needs `useNodesState` AND `useEdgesState` with change handlers that forward everything except `remove` (a no-op edge handler silently breaks edge selection, and a passed-through remove lets Backspace delete nodes that only exist in the data layer); category band nodes must be `pointerEvents: "none"` or they swallow edge/pane clicks; node handles must live OUTSIDE the card's `overflow-hidden` box or they become un-draggable; a second `next dev` for the same directory is impossible (Next 16 lock) so e2e verification either frees port 3000 or uses a copied repo (whose file watcher is unreliable: restart the copied server after every source change). technologies create requires `approvalStatus` + `researchCosts.minimumResearchDuration` (the field's `defaultValue: 0` violates its own `min: 1`, so creates must pass it explicitly); clearing `treePosition` means sending `{ x: null, y: null }` (a group cannot be nulled wholesale). The e2e seeds via cookie-authed REST (`page.request`) after frontend login, so it MUST run against the test DB: a dev server left on port 3000 is silently reused (`reuseExistingServer: true`) and its DEV database 403s every seed (the dev user has no create permission). Free the port first. Tests: `tests/int/tech-tree-layout.int.spec.ts` (pure layout: wrapping, overrides, band bboxes, warnings, cycles), `tests/int/tech-tree-prereq-edits.int.spec.ts` (pure add/remove/switch with cycle rejection), `tests/int/tech-categories.int.spec.ts` (CRUD, RBAC, division gating, FK set-null, position round-trip) + `tests/e2e/tech-tree.e2e.spec.ts` (nav, render, panel, edit nav, both quick-create paths, drag persistence + Auto-arrange, save positions + reload survival, connect/disconnect + lite-editor saves, wrapping at scale). Vivaldi crashes under automation in this suite; run with `E2E_BROWSER_PATH=<playwright chromium>`.
|
||||
- **Gotchas**: React Flow in controlled mode needs `useNodesState` AND `useEdgesState` with change handlers that forward everything except `remove` (a no-op edge handler silently breaks edge selection, and a passed-through remove lets Backspace delete nodes that only exist in the data layer); category band nodes must be `pointerEvents: "none"` or they swallow edge/pane clicks; node handles must live OUTSIDE the card's `overflow-hidden` box or they become un-draggable; a second `next dev` for the same directory is impossible (Next 16 lock) so e2e verification either frees port 3000 or uses a copied repo (whose file watcher is unreliable: restart the copied server after every source change). technologies create requires `approvalStatus` + `researchCosts.minimumResearchDuration` (the field's `defaultValue: 0` violates its own `min: 1`, so creates must pass it explicitly); clearing `treePosition` means sending `{ x: null, y: null }` (a group cannot be nulled wholesale). The e2e seeds via cookie-authed REST (`page.request`) after frontend login, so it MUST run against the test DB: a dev server left on port 3000 is silently reused (`reuseExistingServer: true`) and its DEV database 403s every seed (the dev user has no create permission). Free the port first. Tests: `tests/int/tech-tree-layout.int.spec.ts` (pure layout: wrapping, overrides, band bboxes, warnings, cycles), `tests/int/tech-tree-prereq-edits.int.spec.ts` (pure add/remove/switch with cycle rejection), `tests/int/tech-categories.int.spec.ts` (CRUD, RBAC, division gating, FK set-null, position round-trip), `tests/int/tech-tree-edge-routes.int.spec.ts` (rounded route path + obstacle avoidance) + `tests/e2e/tech-tree.e2e.spec.ts` (nav, render, panel, edit nav, both quick-create paths, drag persistence + Auto-arrange, save positions + reload survival, multi-select drag, category-band drag, snap-to-grid, edge routing, connect/disconnect + lite-editor saves, wrapping at scale). Vivaldi crashes under automation in this suite; run with `E2E_BROWSER_PATH=<playwright chromium>`.
|
||||
|
||||
## Wiki
|
||||
|
||||
|
|
@ -371,7 +372,7 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
|
|||
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
|
||||
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
|
||||
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
|
||||
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Ready`/`Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.
|
||||
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.
|
||||
- **Notifications / announcements (feature 3)**: `notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick) and DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`). `/announce` (staff only) posts an announcement embed. New notify sites partially done: banking emits `finance:deposit`; shipments has no notify site yet.
|
||||
- **Remaining polish**: the web preferences UI does not yet expose the `preferences.discord` toggles — they exist on the Users collection and are read by the bridge, but are currently only settable in the Payload admin panel.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ Your in-game wallet and web bank account now talk to each other, and staff get a
|
|||
|
||||
- **In-game money syncs to your bank account.** Shop purchases in Arma now run through the web banking system: what you spend in game is debited from your personal account and moved to the unit treasury, while refunds and rewards land back in it. The game can also read your web balance, so linked players see one consistent wallet. Retried updates can't double-charge anyone.
|
||||
- **The tech tree got a visual editor (admin).** Staff can arrange technologies on a canvas in the admin panel instead of editing fields by hand: drag technologies into place, connect prerequisites with AND or OR logic, group them into categories, and hit auto-arrange when the layout gets messy. Positions and connections save right from the canvas.
|
||||
- **We added site analytics.** Microsoft Clarity now tracks how the site gets used in production builds, which helps us see which features are worth polishing. Nothing runs locally or in the admin panel.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -97,6 +97,7 @@
|
|||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@payloadcms/ui@3.88.0": "patches/@payloadcms%2Fui@3.88.0.patch",
|
||||
"@ai-stack/payloadcms@3.2.30": "patches/@ai-stack%2Fpayloadcms@3.2.30.patch",
|
||||
},
|
||||
"packages": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.59",
|
||||
"version": "0.3.0",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
|
||||
"import:arma": "bun run src/tools/import/importArmaLibrary.ts",
|
||||
"icons:arma": "bun run src/tools/import/extractArmaIcons.ts",
|
||||
"version:bump": "bun run src/scripts/bumpVersion.ts",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
|
|
@ -131,6 +132,7 @@
|
|||
},
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"patchedDependencies": {
|
||||
"@ai-stack/payloadcms@3.2.30": "patches/@ai-stack%2Fpayloadcms@3.2.30.patch"
|
||||
"@ai-stack/payloadcms@3.2.30": "patches/@ai-stack%2Fpayloadcms@3.2.30.patch",
|
||||
"@payloadcms/ui@3.88.0": "patches/@payloadcms%2Fui@3.88.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
patches/@payloadcms%2Fui@3.88.0.patch
Normal file
44
patches/@payloadcms%2Fui@3.88.0.patch
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,8 @@
|
|||
SELECT
|
||||
'SELECT SETVAL(' ||
|
||||
quote_literal(quote_ident(sequence_namespace.nspname) || '.' || quote_ident(class_sequence.relname)) ||
|
||||
', COALESCE(MAX(' ||quote_ident(pg_attribute.attname)|| '), 1) ) FROM ' ||
|
||||
', COALESCE(MAX(' || quote_ident(pg_attribute.attname) || '), 1), ' ||
|
||||
'MAX(' || quote_ident(pg_attribute.attname) || ') IS NOT NULL) FROM ' ||
|
||||
quote_ident(table_namespace.nspname) || '.' || quote_ident(class_table.relname) || ';'
|
||||
FROM pg_depend
|
||||
INNER JOIN pg_class AS class_sequence
|
||||
|
|
|
|||
|
|
@ -317,13 +317,34 @@ restore_ai_instructions() {
|
|||
|| warn "Failed to reload ${AI_INSTRUCTIONS_IMAGES_TABLE} (non-fatal)"
|
||||
fi
|
||||
|
||||
PGPASSWORD="$TARGET_DB_PASS" psql \
|
||||
ok "AI Compose prompts preserved (no regeneration needed on next boot)"
|
||||
}
|
||||
|
||||
reset_sequences() {
|
||||
info "Resetting sequences..."
|
||||
|
||||
local has_function
|
||||
has_function=$(PGPASSWORD="$TARGET_DB_PASS" psql \
|
||||
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
|
||||
-d "$TARGET_DB_NAME" \
|
||||
-c "SELECT setval(pg_get_serial_sequence('${AI_INSTRUCTIONS_TABLE}', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM ${AI_INSTRUCTIONS_TABLE}), 1), 1));" >/dev/null 2>&1 \
|
||||
|| true
|
||||
-tAc "SELECT to_regclass('maintenance.reset_owned_sequences(text)') IS NOT NULL;" 2>/dev/null)
|
||||
|
||||
ok "AI Compose prompts preserved (no regeneration needed on next boot)"
|
||||
if [[ "$has_function" != "t" ]]; then
|
||||
warn "maintenance.reset_owned_sequences not available (migrations skipped or not applied); skipping sequence reset"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! PGPASSWORD="$TARGET_DB_PASS" psql \
|
||||
-h "$TARGET_DB_HOST" \
|
||||
-p "$TARGET_DB_PORT" \
|
||||
-U "$TARGET_DB_USER" \
|
||||
-d "$TARGET_DB_NAME" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-c "SELECT * FROM maintenance.reset_owned_sequences('public');"; then
|
||||
die "Failed to reset sequences"
|
||||
fi
|
||||
|
||||
ok "Sequences reset"
|
||||
}
|
||||
|
||||
sync_database() {
|
||||
|
|
@ -395,15 +416,8 @@ sync_database() {
|
|||
fi
|
||||
fi
|
||||
|
||||
info "Resetting sequences..."
|
||||
PGPASSWORD="$TARGET_DB_PASS" psql \
|
||||
-h "$TARGET_DB_HOST" \
|
||||
-p "$TARGET_DB_PORT" \
|
||||
-U "$TARGET_DB_USER" \
|
||||
-d "$TARGET_DB_NAME" \
|
||||
-c "SELECT setval(pg_get_serial_sequence(t.table_name, c.column_name), (SELECT COALESCE(MAX(c.column_name::text::bigint), 1) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id')) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id' AND c.column_default LIKE 'nextval%' AND t.table_schema = 'public';" 2>/dev/null || true
|
||||
|
||||
restore_ai_instructions
|
||||
reset_sequences
|
||||
|
||||
ok "Database sync complete: ${TARGET_DB_NAME}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -543,7 +543,11 @@ export async function createFridaySideOp(
|
|||
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
|
||||
createdBy: userId,
|
||||
ownershipAndStatus: {
|
||||
status: "Scheduled",
|
||||
// Side ops are created as a quiet "Ready" draft. The creator commits
|
||||
// them to "Scheduled" (markSideOpScheduled) to lock the Friday slot
|
||||
// and trigger the roll-call; only then does the mission lifecycle
|
||||
// (auto-start -> auto-complete) drive them.
|
||||
status: "Ready",
|
||||
visibility: "unit",
|
||||
authors: [userId],
|
||||
},
|
||||
|
|
@ -823,7 +827,7 @@ export async function cancelFridaySideOp(
|
|||
}
|
||||
}
|
||||
|
||||
export async function markSideOpReady(
|
||||
export async function markSideOpScheduled(
|
||||
missionId: number,
|
||||
): Promise<ActionResult<{ missionId: number }>> {
|
||||
try {
|
||||
|
|
@ -840,21 +844,21 @@ export async function markSideOpReady(
|
|||
if (!canManage && !isSideOpOwner(mission, userId)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only the player who scheduled this side operation can mark it Ready.",
|
||||
error: "Only the player who scheduled this side operation can commit it to the schedule.",
|
||||
};
|
||||
}
|
||||
|
||||
const status = mission.ownershipAndStatus.status;
|
||||
if (status === "Ready") {
|
||||
if (status === "Scheduled") {
|
||||
return { success: true, data: { missionId: mission.id } };
|
||||
}
|
||||
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
|
||||
if (!["Concept", "Planning", "Ready"].includes(status)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
status === "Active"
|
||||
? "This mission is already underway and cannot be marked Ready."
|
||||
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
|
||||
? "This mission is already underway and cannot be scheduled."
|
||||
: `This mission is ${status.toLowerCase()} and can no longer be scheduled.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -865,14 +869,14 @@ export async function markSideOpReady(
|
|||
overrideAccess: true,
|
||||
data: {
|
||||
ownershipAndStatus: {
|
||||
status: "Ready",
|
||||
status: "Scheduled",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MissionReady,
|
||||
message: `${user.displayName || user.username} marked side operation "${missionRef(updated)}" as Ready. Details are locked in and the roll-call can be posted.`,
|
||||
type: EventTypes.MissionCommit,
|
||||
message: `${user.displayName || user.username} committed side operation "${missionRef(updated)}" to the schedule. The roll-call will be posted and the mission hooks delivered.`,
|
||||
actor: userId,
|
||||
targetCollection: "missions",
|
||||
targetId: updated.id,
|
||||
|
|
|
|||
105
src/app/(frontend)/personnel/promotions/actions.ts
Normal file
105
src/app/(frontend)/personnel/promotions/actions.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import {
|
||||
cancelNomination as cancelNominationCore,
|
||||
recordLeaderDecision,
|
||||
resolveNomination as resolveNominationCore,
|
||||
} from "@/lib/promotions/nominations";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
readonly data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
|
||||
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: message === "Unauthorized" ? "You must be logged in." : message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Division leader stage: endorse a nomination that is awaiting endorsement for
|
||||
* a member of one of the leader's divisions. The current-leader check lives in
|
||||
* the service lib (re-fetched at call time).
|
||||
*/
|
||||
export async function endorseNomination(
|
||||
nominationId: number,
|
||||
note?: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await recordLeaderDecision(payload, user, nominationId, {
|
||||
endorse: true,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/** Division leader stage: reject a pending nomination (terminal). */
|
||||
export async function rejectNomination(nominationId: number, note?: string): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await recordLeaderDecision(payload, user, nominationId, {
|
||||
endorse: false,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Final superuser stage: approve (bumps the nominee exactly one rung
|
||||
* transactionally) or reject the nomination.
|
||||
*/
|
||||
export async function resolveNomination(
|
||||
nominationId: number,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await resolveNominationCore(payload, user, nominationId, {
|
||||
approve,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nominator stage: withdraw one of the caller's own pending nominations. */
|
||||
export async function cancelNomination(nominationId: number): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await cancelNominationCore(payload, user, nominationId);
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
131
src/app/(frontend)/personnel/promotions/page.tsx
Normal file
131
src/app/(frontend)/personnel/promotions/page.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
import { PromotionsView } from "@/components/frontend/promotions/PromotionsView";
|
||||
import type { NominationView } from "@/components/frontend/promotions/nominationTypes";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import type { Assignment, PromotionNomination, User } from "@/payload-types";
|
||||
|
||||
export const metadata = {
|
||||
title: "Promotions: Polaris Task Force",
|
||||
};
|
||||
|
||||
function userRefId(ref: number | User | null | undefined): number | null {
|
||||
if (ref == null) return null;
|
||||
return typeof ref === "number" ? ref : ref.id;
|
||||
}
|
||||
|
||||
function userRefLabel(ref: number | User | null | undefined): string | null {
|
||||
if (ref == null || typeof ref === "number") return null;
|
||||
return ref.displayName || ref.username;
|
||||
}
|
||||
|
||||
function toNominationView(doc: PromotionNomination): NominationView {
|
||||
const nomineeId = userRefId(doc.nominee) ?? 0;
|
||||
const nominatorId = userRefId(doc.nominator) ?? 0;
|
||||
return {
|
||||
id: doc.id,
|
||||
nomineeId,
|
||||
nomineeName: userRefLabel(doc.nominee) ?? `User #${nomineeId}`,
|
||||
nominatorId,
|
||||
nominatorName: userRefLabel(doc.nominator) ?? `User #${nominatorId}`,
|
||||
accolades: doc.accolades,
|
||||
status: doc.status,
|
||||
leaderDecision: doc.leaderDecision ?? "pending",
|
||||
leaderDecisionByName: userRefLabel(doc.leaderDecisionBy),
|
||||
leaderNote: doc.leaderNote ?? null,
|
||||
reviewNote: doc.reviewNote ?? null,
|
||||
createdAt: doc.createdAt,
|
||||
leaderDecidedAt: doc.leaderDecidedAt ?? null,
|
||||
reviewedAt: doc.reviewedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!isPayloadUser(authUser)) {
|
||||
redirect("/");
|
||||
}
|
||||
const currentUserId = authUser.id;
|
||||
const superuserViewer = await isSuperuser(payload, authUser);
|
||||
|
||||
// Divisions the viewer leads: their members form the endorsement queue scope.
|
||||
const divisionsLedRes = await payload.find({
|
||||
collection: "assignments",
|
||||
where: {
|
||||
and: [{ type: { equals: "division" } }, { leader: { equals: currentUserId } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const ledDivisionIds = divisionsLedRes.docs.map((doc: Assignment) => doc.id);
|
||||
const ledMemberIds = new Set<number>();
|
||||
for (const division of divisionsLedRes.docs) {
|
||||
for (const member of division.members ?? []) {
|
||||
const memberId = typeof member === "number" ? member : member.id;
|
||||
if (memberId != null) ledMemberIds.add(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
const nominationsRes = await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
sort: "-createdAt",
|
||||
limit: 100,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const nominations = nominationsRes.docs.map(toNominationView);
|
||||
|
||||
const nomineeInLedDivision = (nomination: NominationView) =>
|
||||
ledMemberIds.has(nomination.nomineeId);
|
||||
|
||||
const leaderQueue = nominations.filter(
|
||||
(nomination) =>
|
||||
nomination.status === "pending" &&
|
||||
nomination.leaderDecision === "pending" &&
|
||||
nomineeInLedDivision(nomination),
|
||||
);
|
||||
const finalQueue = superuserViewer
|
||||
? nominations.filter((nomination) => nomination.status === "awaiting_superuser")
|
||||
: [];
|
||||
const mySubmissions = nominations.filter(
|
||||
(nomination) => nomination.nominatorId === currentUserId,
|
||||
);
|
||||
const resolved = nominations.filter((nomination) =>
|
||||
["approved", "rejected", "cancelled"].includes(nomination.status),
|
||||
);
|
||||
const recentlyResolved = superuserViewer ? resolved : resolved.filter(nomineeInLedDivision);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpIcon className="size-5 text-muted-foreground" aria-hidden="true" />
|
||||
<h1 className="text-lg font-semibold">Promotions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nominate members for promotion from their profile, endorse nominations for your division,
|
||||
and handle final approvals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PromotionsView
|
||||
isSuperuserViewer={superuserViewer}
|
||||
isLeader={ledDivisionIds.length > 0}
|
||||
leaderQueue={leaderQueue}
|
||||
finalQueue={finalQueue}
|
||||
mySubmissions={mySubmissions}
|
||||
recentlyResolved={recentlyResolved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ import {
|
|||
PERSONAL_EXCERPT_MAX_LENGTH,
|
||||
} from "@/lib/profile/excerpt-limits";
|
||||
import { promoteMember as promoteMemberService } from "@/lib/promotions";
|
||||
import {
|
||||
cancelNomination as cancelNominationCore,
|
||||
submitNomination,
|
||||
} from "@/lib/promotions/nominations";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -290,3 +294,43 @@ export async function promoteMember(
|
|||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a promotion nomination for the given member (or a self-request when
|
||||
* the nominee is the authenticated user). The division leader endorsement
|
||||
* stage and the open-nomination guards are enforced by the service lib.
|
||||
*/
|
||||
export async function submitPromotionNomination(input: {
|
||||
nomineeUsername: string;
|
||||
accolades: string;
|
||||
}): Promise<ActionResult<{ nominationId: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const nominee = await findUserByUsername(payload, input.nomineeUsername);
|
||||
if (!nominee) {
|
||||
return { success: false, error: "User not found." };
|
||||
}
|
||||
|
||||
const nomination = await submitNomination(payload, user, {
|
||||
nomineeId: nominee.id,
|
||||
accolades: input.accolades,
|
||||
});
|
||||
return { success: true, data: { nominationId: nomination.id } };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Withdraw one of the caller's own pending promotion nominations. */
|
||||
export async function cancelMyNomination(nominationId: number): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await cancelNominationCore(payload, user, nominationId);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
|||
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
||||
import { PromoteButton } from "@/components/frontend/profile/PromoteButton";
|
||||
import { NominateForPromotionButton } from "@/components/frontend/profile/NominateForPromotionButton";
|
||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||
import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions";
|
||||
import { loadRankLadder, rankIndex } from "@/lib/promotions/authority";
|
||||
import type {
|
||||
EvaluationMissionOption,
|
||||
EvaluationSummary,
|
||||
|
|
@ -367,6 +369,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
const allowedTargetRanks = promotionResult
|
||||
? allowedPromotionTargets(promotionResult, rank)
|
||||
: [];
|
||||
// Nomination visibility: any logged-in viewer can nominate (or self-request)
|
||||
// while the member has a next rung to climb and no nomination is open.
|
||||
const nomineeLadder = promotionResult?.ladder ?? (viewer ? await loadRankLadder(payload) : []);
|
||||
const currentRankIndex = rankIndex(nomineeLadder, rank.id);
|
||||
const hasNextRung = currentRankIndex >= 0 && currentRankIndex + 1 < nomineeLadder.length;
|
||||
const openNominationRes = viewer
|
||||
? await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: user.id } },
|
||||
{ status: { in: ["pending", "awaiting_superuser"] } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
: null;
|
||||
const canRequestPromotion =
|
||||
viewer !== null && hasNextRung && (openNominationRes?.docs.length ?? 0) === 0;
|
||||
const awardEntries = (profile.progression?.awards ?? []).filter(
|
||||
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
|
||||
);
|
||||
|
|
@ -621,6 +644,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{canRequestPromotion && (
|
||||
<NominateForPromotionButton
|
||||
username={username}
|
||||
displayName={user.displayName}
|
||||
isOwnProfile={isProfileOwner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Combat Record ──────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
94
src/app/(frontend)/roster/actions.ts
Normal file
94
src/app/(frontend)/roster/actions.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { LeadershipTransfer } from "@/payload-types";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import {
|
||||
cancelLeadershipTransfer as cancelLeadershipTransferCore,
|
||||
requestLeadershipTransfer as requestLeadershipTransferCore,
|
||||
resolveLeadershipTransfer as resolveLeadershipTransferCore,
|
||||
} from "@/lib/transfers/leadership";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
readonly data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web actions for leadership-initiated removals. The roster page's client
|
||||
* components call requestDivisionRemoval/cancelDivisionRemoval; the transfers
|
||||
* page's leadership section calls resolveLeadershipTransfer. The lib re-validates
|
||||
* every guard (leader authority, pending-only transitions, superuser-only
|
||||
* resolution), so these actions only authenticate and delegate.
|
||||
*/
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
|
||||
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: message === "Unauthorized" ? "You must be logged in." : message,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestDivisionRemoval(input: {
|
||||
readonly subjectId: number;
|
||||
readonly fromAssignmentId: number;
|
||||
readonly reason?: string;
|
||||
}): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await requestLeadershipTransferCore(payload, user, {
|
||||
subjectId: input.subjectId,
|
||||
fromAssignmentId: input.fromAssignmentId,
|
||||
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
||||
});
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDivisionRemoval(
|
||||
transferId: number,
|
||||
): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await cancelLeadershipTransferCore(payload, user, transferId);
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveLeadershipTransfer(
|
||||
transferId: number,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await resolveLeadershipTransferCore(payload, user, transferId, {
|
||||
approve,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export default async function RosterPage() {
|
|||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const currentUser = isPayloadUser(authUser) ? authUser : null;
|
||||
|
||||
const [profileRes, assignmentRes] = await Promise.all([
|
||||
const [profileRes, assignmentRes, rules] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "profiles",
|
||||
limit: 500,
|
||||
|
|
@ -65,6 +65,7 @@ export default async function RosterPage() {
|
|||
subAssignments: true,
|
||||
},
|
||||
}),
|
||||
payload.findGlobal({ slug: "game-rules", depth: 0 }),
|
||||
]);
|
||||
|
||||
const profiles = profileRes.docs;
|
||||
|
|
@ -132,6 +133,16 @@ export default async function RosterPage() {
|
|||
.filter(Boolean) as number[],
|
||||
}));
|
||||
|
||||
// The configured Infantry assignment (leadership-initiated removal
|
||||
// destination), resolved to a display name when it exists.
|
||||
const configuredInfantry = rules?.infantryAssignment ?? null;
|
||||
const infantryId =
|
||||
configuredInfantry != null && typeof configuredInfantry === "object"
|
||||
? configuredInfantry.id
|
||||
: configuredInfantry;
|
||||
const infantryName =
|
||||
infantryId != null ? (assignments.find((a) => a.id === infantryId)?.name ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1" data-tour="page-header">
|
||||
|
|
@ -149,6 +160,8 @@ export default async function RosterPage() {
|
|||
members={members}
|
||||
assignments={assignments}
|
||||
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
|
||||
currentUserId={currentUser?.id}
|
||||
infantryName={infantryName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ bot/
|
|||
|
||||
## Feature flow: attendance
|
||||
|
||||
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
|
||||
Bot posts RSVP embeds (Yes/Tentative/No) for future, Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
|
||||
|
||||
## Feature flow: notifications
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
|
|||
|
||||
## Feature flow: imminent op reminder
|
||||
|
||||
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
|
||||
15 minutes before a posted op starts (`visibility: "unit"`, Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
|
||||
|
||||
## Feature flow: Friday open-slot notice
|
||||
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ const missionTitle = (mission: Mission): string =>
|
|||
const memberName = (user: User): string => user.displayName ?? user.username;
|
||||
|
||||
/**
|
||||
* Roll call is sent once: as soon as the mission is Ready, or three days
|
||||
* before a Scheduled mission, whichever happens first.
|
||||
* Roll call is sent once, three days before a Scheduled mission starts. Side
|
||||
* ops are created as a quiet "Ready" draft and only post a roll-call after the
|
||||
* creator commits them to "Scheduled" (markSideOpScheduled).
|
||||
*/
|
||||
export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => {
|
||||
if (mission.discordAttendanceSentAt) return false;
|
||||
if (mission.ownershipAndStatus.status === "Ready") return true;
|
||||
if (mission.ownershipAndStatus.status !== "Scheduled") return false;
|
||||
|
||||
const start = new Date(mission.classification.startDateTime).getTime();
|
||||
|
|
@ -118,12 +118,6 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
|
|||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||
{
|
||||
or: [
|
||||
{
|
||||
and: [
|
||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
and: [
|
||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||
|
|
@ -158,9 +152,9 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Cancelled missions that are live again (status back to Ready/Scheduled) and
|
||||
* still carry the cancellation marker: the reschedule announcement has not
|
||||
* happened yet.
|
||||
* Cancelled missions that are live again (status back to Scheduled) and still
|
||||
* carry the cancellation marker: the reschedule announcement has not happened
|
||||
* yet.
|
||||
*/
|
||||
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||
const res = await payload.find({
|
||||
|
|
@ -172,7 +166,7 @@ const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
|
|||
where: {
|
||||
and: [
|
||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
||||
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
|
||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||
{ discordCancelledAt: { exists: true } },
|
||||
],
|
||||
|
|
@ -516,7 +510,7 @@ export const reconcileMission = async (
|
|||
|
||||
if (
|
||||
mission.ownershipAndStatus.visibility !== "unit" ||
|
||||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||
!["Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -566,7 +560,7 @@ const cancelMissionEmbed = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* A cancelled op came back (status Cancelled -> Ready/Scheduled): announce the
|
||||
* A cancelled op came back (status Cancelled -> Scheduled): announce the
|
||||
* new start time immediately, note when the fresh roll-call goes up (the usual
|
||||
* lead time before the op, or right away when it is already due), then clear
|
||||
* the marker so the reconcile loop posts the roll-call as usual.
|
||||
|
|
@ -579,8 +573,7 @@ const announceReschedule = async (
|
|||
): Promise<void> => {
|
||||
const start = new Date(mission.classification.startDateTime).getTime();
|
||||
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
|
||||
const rollCallDueNow =
|
||||
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
|
||||
const rollCallDueNow = start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
|
||||
const rollCallDueUnix =
|
||||
rollCallAlreadyLive || rollCallDueNow
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import {
|
|||
/**
|
||||
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
|
||||
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
|
||||
* them to finish the op and mark it "Ready" — the status that posts the
|
||||
* roll-call, locks in details, and delivers the mission hooks.
|
||||
* them to finish the op and commit it to "Scheduled" (markSideOpScheduled): the
|
||||
* status that posts the roll-call, locks in details, and delivers the mission
|
||||
* hooks.
|
||||
*
|
||||
* The DM deliberately ignores `preferences.discord.enabled` (the notification
|
||||
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
|
||||
|
|
@ -41,17 +42,17 @@ const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
|
|||
const days = daysUntil(mission.classification.startDateTime, now);
|
||||
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
|
||||
return new EmbedBuilder()
|
||||
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
|
||||
.setTitle(`⏰ Schedule check: ${missionTitle(mission)}`)
|
||||
.setDescription(
|
||||
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
|
||||
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
|
||||
`Your op starts **${when}** and it isn't marked **Scheduled** yet.\n\n` +
|
||||
"Finish up the operation and set the mission status to **Scheduled** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
|
||||
)
|
||||
.setColor(EMBED_COLOR)
|
||||
.addFields({
|
||||
name: "Open the op",
|
||||
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
|
||||
})
|
||||
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
|
||||
.setFooter({ text: "You'll get this reminder once a day until the op is Scheduled." });
|
||||
};
|
||||
|
||||
const processMission = async (
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
|
||||
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
|
||||
{ label: "Ribbon Submissions", value: "ribbon-submissions" },
|
||||
{ label: "Promotion Nominations", value: "promotion-nominations" },
|
||||
{ label: "Leadership Transfers", value: "leadership-transfers" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,16 @@ export const GameRules: GlobalConfig = {
|
|||
"Assignment(s) considered the intelligence division. Members and leaders of these assignments get elevated access to draft missions and intelligence content.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "infantryAssignment",
|
||||
type: "relationship",
|
||||
relationTo: "assignments",
|
||||
hasMany: false,
|
||||
admin: {
|
||||
description:
|
||||
"Assignment that members are moved to when a division leader removes them from their division (the default destination for leadership-initiated transfers).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proximityThreshold",
|
||||
type: "number",
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ export const Missions: CollectionConfig = {
|
|||
type: "date",
|
||||
admin: {
|
||||
description:
|
||||
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
|
||||
"Server-local day the last web 'not Scheduled' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -281,7 +281,7 @@ export const Missions: CollectionConfig = {
|
|||
type: "date",
|
||||
admin: {
|
||||
description:
|
||||
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
|
||||
"Server-local day the last 'not Scheduled' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -421,6 +421,10 @@ export const Missions: CollectionConfig = {
|
|||
],
|
||||
defaultValue: "Concept",
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
"Side operations start as Ready (a quiet draft) and are committed to Scheduled by their creator, which posts the Discord roll-call. Only Scheduled/Active missions are driven by the automatic start/complete lifecycle; Concept/Planning/Ready are drafts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ export const Assets: CollectionConfig = {
|
|||
label: "Bipod",
|
||||
value: "bipod",
|
||||
},
|
||||
{
|
||||
label: "Accessory (flashlight / laser)",
|
||||
value: "accessory",
|
||||
},
|
||||
{
|
||||
label: "Uniform",
|
||||
value: "uniform",
|
||||
|
|
@ -181,7 +185,7 @@ export const Assets: CollectionConfig = {
|
|||
hasMany: true,
|
||||
filterOptions: {
|
||||
assetType: {
|
||||
in: "optic,muzzle,bipod",
|
||||
in: "optic,muzzle,bipod,accessory",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
168
src/collections/users/LeadershipTransfers.ts
Normal file
168
src/collections/users/LeadershipTransfers.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
CollectionConfig,
|
||||
type CollectionBeforeChangeHook,
|
||||
} from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const LEADERSHIP_TRANSFER_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"denied",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type LeadershipTransferStatus = (typeof LEADERSHIP_TRANSFER_STATUSES)[number];
|
||||
|
||||
export const LEADERSHIP_TRANSFER_STATUS_TRANSITIONS: Record<
|
||||
LeadershipTransferStatus,
|
||||
LeadershipTransferStatus[]
|
||||
> = {
|
||||
pending: ["approved", "denied", "cancelled"],
|
||||
approved: [],
|
||||
denied: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const relationId = (value: unknown): number | null | undefined =>
|
||||
typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined);
|
||||
|
||||
/**
|
||||
* A removal request always belongs to its initiator. REST/admin creates are forced
|
||||
* to the authenticated caller; lib writes that run with overrideAccess and no
|
||||
* req.user set initiator explicitly and skip this guard.
|
||||
*/
|
||||
const enforceInitiatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => {
|
||||
if (operation !== "create") return data;
|
||||
if (req.user) {
|
||||
const initiatorId = relationId(data?.initiator);
|
||||
if (initiatorId !== req.user.id) {
|
||||
throw new Error("You can only submit a removal request as yourself.");
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const guardStatusTransition: CollectionBeforeChangeHook = async ({
|
||||
operation,
|
||||
data,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (operation !== "update") return data;
|
||||
const nextStatus = data?.status as LeadershipTransferStatus | undefined;
|
||||
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||
const current = originalDoc?.status as LeadershipTransferStatus | undefined;
|
||||
const allowed = current ? LEADERSHIP_TRANSFER_STATUS_TRANSITIONS[current] : [];
|
||||
if (!allowed.includes(nextStatus)) {
|
||||
throw new Error(`Illegal leadership transfer status transition: ${current} -> ${nextStatus}.`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const LeadershipTransfers: CollectionConfig = {
|
||||
slug: "leadership-transfers",
|
||||
admin: {
|
||||
group: "Users",
|
||||
useAsTitle: "subject",
|
||||
defaultColumns: ["subject", "fromAssignment", "toAssignment", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return { initiator: { equals: user.id } };
|
||||
},
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "initiator",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Division leader requesting the removal.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subject",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member being transferred.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fromAssignment",
|
||||
type: "relationship",
|
||||
relationTo: "assignments",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Division the member is removed from.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "toAssignment",
|
||||
type: "relationship",
|
||||
relationTo: "assignments",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Division the member is transferred to (the Infantry assignment).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reason",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Optional reason for the removal, visible to command.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Approved", value: "approved" },
|
||||
{ label: "Denied", value: "denied" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Reviewer note recorded with the decision.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "denied",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolvedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "denied",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [enforceInitiatorOnCreate, guardStatusTransition],
|
||||
},
|
||||
};
|
||||
200
src/collections/users/PromotionNominations.ts
Normal file
200
src/collections/users/PromotionNominations.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
CollectionConfig,
|
||||
type CollectionBeforeChangeHook,
|
||||
} from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const NOMINATION_STATUSES = [
|
||||
"pending",
|
||||
"awaiting_superuser",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type NominationStatus = (typeof NOMINATION_STATUSES)[number];
|
||||
|
||||
export const NOMINATION_STATUS_TRANSITIONS: Record<NominationStatus, NominationStatus[]> = {
|
||||
pending: ["awaiting_superuser", "rejected", "cancelled"],
|
||||
awaiting_superuser: ["approved", "rejected"],
|
||||
approved: [],
|
||||
rejected: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export const NOMINATION_LEADER_DECISIONS = [
|
||||
"pending",
|
||||
"endorsed",
|
||||
"rejected",
|
||||
"not_required",
|
||||
] as const;
|
||||
export type NominationLeaderDecision = (typeof NOMINATION_LEADER_DECISIONS)[number];
|
||||
|
||||
const relationId = (value: unknown): number | null | undefined =>
|
||||
typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined);
|
||||
|
||||
/**
|
||||
* A submission always belongs to its submitter. REST/admin creates are forced to
|
||||
* the authenticated caller; lib writes that run with overrideAccess and no req.user
|
||||
* set nominator explicitly and skip this guard.
|
||||
*/
|
||||
const enforceNominatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => {
|
||||
if (operation !== "create") return data;
|
||||
if (req.user) {
|
||||
const nominatorId = relationId(data?.nominator);
|
||||
if (nominatorId !== req.user.id) {
|
||||
throw new Error("You can only submit a promotion nomination as yourself.");
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const guardStatusTransition: CollectionBeforeChangeHook = async ({
|
||||
operation,
|
||||
data,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (operation !== "update") return data;
|
||||
const nextStatus = data?.status as NominationStatus | undefined;
|
||||
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||
const current = originalDoc?.status as NominationStatus | undefined;
|
||||
const allowed = current ? NOMINATION_STATUS_TRANSITIONS[current] : [];
|
||||
if (!allowed.includes(nextStatus)) {
|
||||
throw new Error(`Illegal promotion nomination status transition: ${current} -> ${nextStatus}.`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const PromotionNominations: CollectionConfig = {
|
||||
slug: "promotion-nominations",
|
||||
admin: {
|
||||
group: "Users",
|
||||
useAsTitle: "nominee",
|
||||
defaultColumns: ["nominee", "nominator", "status", "leaderDecision", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
// Nominations stay private to the nominator until resolved; the review page
|
||||
// scopes leader visibility server-side to divisions the leader actually leads.
|
||||
return { nominator: { equals: user.id } };
|
||||
},
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "nominee",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member being nominated for promotion.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nominator",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member submitting the nomination (self for self-requests).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accolades",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 4000,
|
||||
admin: {
|
||||
description:
|
||||
"Written examples of performance, accolades, and reasons this member deserves promotion.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending division leader endorsement", value: "pending" },
|
||||
{ label: "Awaiting final approval", value: "awaiting_superuser" },
|
||||
{ label: "Approved", value: "approved" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "leaderDecision",
|
||||
type: "select",
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Endorsed", value: "endorsed" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
{ label: "Not required", value: "not_required" },
|
||||
],
|
||||
admin: {
|
||||
description: "Decision of the nominee's division leader.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderDecisionBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderDecidedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Note from the division leader's endorsement or rejection.",
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Final reviewer feedback shown to the nominator.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [enforceNominatorOnCreate, guardStatusTransition],
|
||||
},
|
||||
};
|
||||
|
|
@ -7,13 +7,17 @@ import type { CategoryBandLayout } from "./types";
|
|||
export type CategoryBandData = {
|
||||
band: CategoryBandLayout;
|
||||
techCount: number;
|
||||
/** Technologies inside this band; dragging the header moves them all. */
|
||||
memberIds: number[];
|
||||
};
|
||||
|
||||
export type CategoryBandNode = Node<CategoryBandData, "techBand">;
|
||||
|
||||
/**
|
||||
* Full-height column band behind a category's technologies. Non-interactive:
|
||||
* it is a backdrop, not a node (no handles, no dragging, no selection).
|
||||
* Full-height column band behind a category's technologies. The body is a
|
||||
* backdrop (pointer events pass through so edges and the pane still work), but
|
||||
* the header strip is a drag handle: dragging it moves the band and every card
|
||||
* inside it. Non-selectable, so it never joins a multi-selection.
|
||||
*/
|
||||
export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
|
||||
const { band, techCount } = data;
|
||||
|
|
@ -29,8 +33,9 @@ export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
|
|||
data-testid={`tech-band-${band.key}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b bg-black/50 px-3 py-2"
|
||||
className="tech-band-drag-handle pointer-events-auto flex cursor-grab items-center justify-between border-b bg-black/50 px-3 py-2 active:cursor-grabbing"
|
||||
style={{ borderColor: `${accent}33` }}
|
||||
title={`Drag to move ${band.label} and its technologies`}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-[9px] tracking-widest uppercase"
|
||||
|
|
|
|||
204
src/components/admin/tech-tree/RoutedEdge.tsx
Normal file
204
src/components/admin/tech-tree/RoutedEdge.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { BaseEdge, EdgeLabelRenderer, useReactFlow, type EdgeProps } from "@xyflow/react";
|
||||
import React from "react";
|
||||
|
||||
import { buildRoutePath, type RouteWaypoint } from "./edgeRoutes";
|
||||
import {
|
||||
routeAroundObstacles,
|
||||
segmentCrossesRect,
|
||||
type RoutePoint,
|
||||
type RouteRect,
|
||||
} from "./edgeRouting";
|
||||
|
||||
export type RoutedEdgeData = {
|
||||
/** User-placed waypoints for this edge (empty = automatic routing). */
|
||||
waypoints: RouteWaypoint[];
|
||||
/** "source:target" key used to persist the route. */
|
||||
routeId: string;
|
||||
/** Technology ids at each end, so the edge may connect to them. */
|
||||
sourceId: number;
|
||||
targetId: number;
|
||||
/** Node rectangles the edge should route around. */
|
||||
obstacles: RouteRect[];
|
||||
/** Where the automatic vertical run sits (0..1). */
|
||||
stepPosition: number;
|
||||
editable: boolean;
|
||||
onRouteChange: (routeId: string, waypoints: RouteWaypoint[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A prerequisite edge. It draws a polyline from the source handle through any
|
||||
* user waypoints to the target handle, bending around technologies it is not
|
||||
* connected to, with rounded corners at each bend.
|
||||
*
|
||||
* Interaction (only when `editable`):
|
||||
* - Select the edge to reveal "+" handles at each segment's midpoint; click one
|
||||
* to add a bend there.
|
||||
* - Drag a waypoint dot to move it.
|
||||
* - Double-click a waypoint dot to remove it.
|
||||
*/
|
||||
export function RoutedEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerEnd,
|
||||
style,
|
||||
data,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const route = data as unknown as RoutedEdgeData | undefined;
|
||||
const waypoints = route?.waypoints ?? [];
|
||||
const editable = route?.editable ?? false;
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const dragIndexRef = React.useRef<number | null>(null);
|
||||
|
||||
const obstacles = (route?.obstacles ?? []).filter(
|
||||
(r) => r.id !== route?.sourceId && r.id !== route?.targetId,
|
||||
);
|
||||
|
||||
// Preferred shape: the user's waypoints if routed, else the automatic
|
||||
// "out, across, in" path. If that path would cut through a node, fall back to
|
||||
// the direct line so the router can bend around the obstacles (a midpoint
|
||||
// sitting inside a box leaves the router nothing to work with).
|
||||
const start: RoutePoint = { x: sourceX, y: sourceY };
|
||||
const end: RoutePoint = { x: targetX, y: targetY };
|
||||
const stepX = sourceX + (targetX - sourceX) * (route?.stepPosition ?? 0.5);
|
||||
const orthogonal: RoutePoint[] = [
|
||||
start,
|
||||
{ x: stepX, y: sourceY },
|
||||
{ x: stepX, y: targetY },
|
||||
end,
|
||||
];
|
||||
const hasCrossing = (pts: RoutePoint[]) =>
|
||||
pts.some(
|
||||
(p, i) => i < pts.length - 1 && obstacles.some((r) => segmentCrossesRect(p, pts[i + 1], r)),
|
||||
);
|
||||
|
||||
const base: RoutePoint[] =
|
||||
waypoints.length > 0
|
||||
? [start, ...waypoints, end]
|
||||
: hasCrossing(orthogonal)
|
||||
? [start, end]
|
||||
: orthogonal;
|
||||
|
||||
const routed = routeAroundObstacles(base, obstacles);
|
||||
const path = routed.length >= 2 ? buildRoutePath(routed) : "";
|
||||
|
||||
const commit = (next: RouteWaypoint[]) => route?.onRouteChange(route.routeId, next);
|
||||
|
||||
const onHandlePointerDown =
|
||||
(index: number) => (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!editable) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dragIndexRef.current = index;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const index = dragIndexRef.current;
|
||||
if (index === null) return;
|
||||
const point = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
commit(
|
||||
waypoints.map((wp, i) =>
|
||||
i === index ? { x: Math.round(point.x), y: Math.round(point.y) } : wp,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragIndexRef.current === null) return;
|
||||
dragIndexRef.current = null;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// capture may already be released
|
||||
}
|
||||
};
|
||||
|
||||
const onHandleDoubleClick = (index: number) => (event: React.MouseEvent) => {
|
||||
if (!editable) return;
|
||||
event.stopPropagation();
|
||||
commit(waypoints.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Midpoints of the UNROUTED segments, for the "add a bend" handles (a routed
|
||||
// detour is not a place the user can insert a waypoint at).
|
||||
const midpoints: { x: number; y: number; insertAt: number }[] = [];
|
||||
for (let i = 0; i < base.length - 1; i += 1) {
|
||||
midpoints.push({
|
||||
x: (base[i].x + base[i + 1].x) / 2,
|
||||
y: (base[i].y + base[i + 1].y) / 2,
|
||||
insertAt: i,
|
||||
});
|
||||
}
|
||||
|
||||
const addAt = (index: number, point: RouteWaypoint) => {
|
||||
const next = [...waypoints];
|
||||
next.splice(index, 0, point);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} />
|
||||
<EdgeLabelRenderer>
|
||||
{selected && editable
|
||||
? midpoints.map((mid, i) => (
|
||||
<div
|
||||
key={`${route?.routeId}-mid-${i}`}
|
||||
className="nodrag nopan"
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${mid.x}px, ${mid.y}px)`,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add route bend"
|
||||
title="Add a bend here"
|
||||
data-testid="tech-edge-add-bend"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addAt(mid.insertAt, { x: Math.round(mid.x), y: Math.round(mid.y) });
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-[#a5f3fc]/70 bg-black/80 font-mono text-[10px] leading-none text-[#a5f3fc] hover:border-[#a5f3fc] hover:bg-[#22d3ee]/25"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{waypoints.map((wp, index) => (
|
||||
<div
|
||||
key={`${route?.routeId}-wp-${index}`}
|
||||
className="nodrag nopan"
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${wp.x}px, ${wp.y}px)`,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
aria-label="Route waypoint"
|
||||
title="Drag to move, double-click to remove"
|
||||
data-testid="tech-edge-waypoint"
|
||||
onPointerDown={onHandlePointerDown(index)}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onDoubleClick={onHandleDoubleClick(index)}
|
||||
className="h-3 w-3 rounded-full border border-[#a5f3fc]/80 bg-[#22d3ee]/90 shadow-[0_0_6px_rgba(34,211,238,0.7)]"
|
||||
style={{ cursor: "grab" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,25 +10,35 @@ import {
|
|||
useNodesState,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type EdgeTypes,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
type NodeTypes,
|
||||
ReactFlowProvider,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { LayoutGridIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { LayoutGridIcon, MagnetIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { CategoryBand, type CategoryBandData, type CategoryBandNode } from "./CategoryBand";
|
||||
import {
|
||||
loadEdgeRoutes,
|
||||
routeKey,
|
||||
saveEdgeRoutes,
|
||||
type EdgeRouteMap,
|
||||
type RouteWaypoint,
|
||||
} from "./edgeRoutes";
|
||||
import { type RouteRect } from "./edgeRouting";
|
||||
import { edgeVisuals, type EdgeRef } from "./edgeVisuals";
|
||||
import { applyPins, pinsToFreeze, type CanvasPosition } from "./freeze";
|
||||
import { computeLayout } from "./layout";
|
||||
import { mapCategoryDoc, mapTechDoc } from "./mappers";
|
||||
import { addPrerequisite, buildPrereqRows, removePrerequisite, withPrereqs } from "./prereqEdits";
|
||||
import { QuickCreateDialog, type QuickCreateResult } from "./QuickCreateDialog";
|
||||
import { RoutedEdge } from "./RoutedEdge";
|
||||
import { TechNodeCard, type TechNodeData, type TechNodeModel } from "./TechNode";
|
||||
import { TechPreviewPanel } from "./TechPreviewPanel";
|
||||
import {
|
||||
|
|
@ -55,11 +65,22 @@ export interface TechTreeData {
|
|||
const TACTICAL_BUTTON =
|
||||
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
|
||||
|
||||
/**
|
||||
* Snap-to-grid size (px). Divides COLUMN_STEP (368) and ROW_STEP (184), so a
|
||||
* snapped card lines up with the automatic layout instead of drifting off it.
|
||||
*/
|
||||
const SNAP_GRID = 46;
|
||||
const SNAP_STORAGE_KEY = "ptf:tech-tree:snap-to-grid";
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
techCard: TechNodeCard,
|
||||
techBand: CategoryBand,
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
routed: RoutedEdge,
|
||||
};
|
||||
|
||||
function refLabel(id: number): string {
|
||||
return `REF ${id.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
|
@ -77,22 +98,29 @@ function buildFlowNodes(
|
|||
const categoryById = new Map(categories.map((c) => [c.id, c]));
|
||||
|
||||
const bandNodes: CategoryBandNode[] = layout.bands.map((band) => {
|
||||
const techCount =
|
||||
const memberIds =
|
||||
band.key === "uncategorized"
|
||||
? techs.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)).length
|
||||
: techs.filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))).length;
|
||||
? techs
|
||||
.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId))
|
||||
.map((t) => t.id)
|
||||
: techs
|
||||
.filter((t) => t.categoryId === Number(band.key.replace("cat-", "")))
|
||||
.map((t) => t.id);
|
||||
return {
|
||||
id: band.key,
|
||||
type: "techBand",
|
||||
position: { x: band.x, y: band.y },
|
||||
draggable: false,
|
||||
// Draggable only from its header (dragHandle), so the band body stays a
|
||||
// click-through backdrop while the header moves the whole category.
|
||||
draggable: true,
|
||||
dragHandle: ".tech-band-drag-handle",
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
zIndex: 0,
|
||||
// Bands are pure backdrops: they must never swallow pointer events,
|
||||
// otherwise edges under a band cannot be selected and pane clicks die.
|
||||
// The band body must never swallow pointer events, otherwise edges under a
|
||||
// band cannot be selected and pane clicks die. The header re-enables them.
|
||||
style: { width: band.width, height: band.height, pointerEvents: "none" },
|
||||
data: { band, techCount },
|
||||
data: { band, techCount: memberIds.length, memberIds },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -135,6 +163,9 @@ function buildFlowEdges(
|
|||
techs: TreeTech[],
|
||||
layout: ReturnType<typeof computeLayout>,
|
||||
selectedId: string | null,
|
||||
edgeRoutes: EdgeRouteMap,
|
||||
editable: boolean,
|
||||
onRouteChange: (routeId: string, waypoints: RouteWaypoint[]) => void,
|
||||
): Edge[] {
|
||||
const known = new Set(techs.map((t) => t.id));
|
||||
const refs: EdgeRef[] = [];
|
||||
|
|
@ -157,18 +188,27 @@ function buildFlowEdges(
|
|||
const visuals = edgeVisuals(techs, refs, positions, COLUMN_STEP);
|
||||
const focus = selectedId ? Number(selectedId) : null;
|
||||
|
||||
// Node rectangles for obstacle avoidance; each edge excludes its own ends.
|
||||
const obstacles: RouteRect[] = layout.nodes.map((n) => ({
|
||||
id: n.techId,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
}));
|
||||
|
||||
return refs.map((ref) => {
|
||||
const visual = visuals.get(ref.id)!;
|
||||
const or = isOr.has(ref.id);
|
||||
const related = focus === null || ref.source === focus || ref.target === focus;
|
||||
const alpha = focus === null ? 0.8 : related ? 1 : 0.12;
|
||||
const stroke = withAlpha(visual.color, alpha);
|
||||
const routeId = routeKey(ref.source, ref.target);
|
||||
return {
|
||||
id: ref.id,
|
||||
source: String(ref.source),
|
||||
target: String(ref.target),
|
||||
type: "smoothstep",
|
||||
pathOptions: { stepPosition: visual.stepPosition, borderRadius: 8 },
|
||||
type: "routed",
|
||||
animated: or,
|
||||
zIndex: focus !== null && related ? 2 : 0,
|
||||
style: {
|
||||
|
|
@ -184,7 +224,17 @@ function buildFlowEdges(
|
|||
labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 },
|
||||
}
|
||||
: {}),
|
||||
data: { pathRoot: visual.pathRoot },
|
||||
data: {
|
||||
pathRoot: visual.pathRoot,
|
||||
waypoints: edgeRoutes[routeId] ?? [],
|
||||
routeId,
|
||||
sourceId: ref.source,
|
||||
targetId: ref.target,
|
||||
obstacles,
|
||||
stepPosition: visual.stepPosition,
|
||||
editable,
|
||||
onRouteChange,
|
||||
},
|
||||
} as Edge;
|
||||
});
|
||||
}
|
||||
|
|
@ -207,14 +257,34 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [arranging, setArranging] = React.useState(false);
|
||||
|
||||
// Manual edge routing, loaded from this browser's storage on mount. Empty =
|
||||
// the edge draws its automatic path.
|
||||
const [edgeRoutes, setEdgeRoutes] = React.useState<EdgeRouteMap>({});
|
||||
React.useEffect(() => {
|
||||
setEdgeRoutes(loadEdgeRoutes());
|
||||
}, []);
|
||||
const handleRouteChange = React.useCallback(
|
||||
(routeId: string, waypoints: RouteWaypoint[]) => {
|
||||
setEdgeRoutes((prev) => {
|
||||
const next = { ...prev };
|
||||
if (waypoints.length === 0) delete next[routeId];
|
||||
else next[routeId] = waypoints;
|
||||
saveEdgeRoutes(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const layout = React.useMemo(() => computeLayout(techs, categories), [techs, categories]);
|
||||
const flowNodes = React.useMemo(
|
||||
() => buildFlowNodes(techs, categories, layout, data.canUpdate),
|
||||
[techs, categories, layout, data.canUpdate],
|
||||
);
|
||||
const flowEdges = React.useMemo(
|
||||
() => buildFlowEdges(techs, layout, selectedId),
|
||||
[techs, layout, selectedId],
|
||||
() =>
|
||||
buildFlowEdges(techs, layout, selectedId, edgeRoutes, data.canUpdate, handleRouteChange),
|
||||
[techs, layout, selectedId, edgeRoutes, data.canUpdate, handleRouteChange],
|
||||
);
|
||||
|
||||
// Controlled nodes/edges: React Flow needs the change handlers to apply
|
||||
|
|
@ -295,6 +365,33 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
return () => window.clearTimeout(timer);
|
||||
}, [saveStatus]);
|
||||
|
||||
// Snap-to-grid setting (default on). Persisted per browser so it survives a
|
||||
// reload; read in an effect to avoid an SSR/client hydration mismatch.
|
||||
const [snapToGrid, setSnapToGrid] = React.useState(true);
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SNAP_STORAGE_KEY);
|
||||
if (stored !== null) setSnapToGrid(stored === "true");
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode); keep the default.
|
||||
}
|
||||
}, []);
|
||||
const toggleSnapToGrid = React.useCallback(() => {
|
||||
setSnapToGrid((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(SNAP_STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const snapValue = React.useCallback(
|
||||
(v: number) => (snapToGrid ? Math.round(v / SNAP_GRID) * SNAP_GRID : Math.round(v)),
|
||||
[snapToGrid],
|
||||
);
|
||||
|
||||
const patchPrereqs = React.useCallback(
|
||||
async (
|
||||
targetId: number,
|
||||
|
|
@ -377,21 +474,114 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
[techs, categories, patchPrereqs, currentPositions, persistPins],
|
||||
);
|
||||
|
||||
const persistPosition = React.useCallback(async (techId: number, x: number, y: number) => {
|
||||
/**
|
||||
* Persist one or more card positions to `treePosition` (optimistic local
|
||||
* update first, then PATCH each). Used by card drags (a multi-selection
|
||||
* moves several cards at once) and by category-band drags (every member).
|
||||
*/
|
||||
const persistPositions = React.useCallback(
|
||||
async (positions: { id: number; x: number; y: number }[]) => {
|
||||
if (positions.length === 0) return;
|
||||
const byId = new Map(positions.map((p) => [p.id, p]));
|
||||
setTechs((prev) =>
|
||||
prev.map((t) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)),
|
||||
prev.map((t) => {
|
||||
const p = byId.get(t.id);
|
||||
return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await fetch(`/api/technologies/${techId}`, {
|
||||
await Promise.all(
|
||||
positions.map((p) =>
|
||||
fetch(`/api/technologies/${p.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ treePosition: { x, y } }),
|
||||
});
|
||||
} catch {
|
||||
body: JSON.stringify({ treePosition: { x: p.x, y: p.y } }),
|
||||
}).catch(() => {
|
||||
// Optimistic position stays locally; a reload restores the stored value.
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Live session for a category-band drag: the header moves the band and every
|
||||
// member card moves with it (positions are committed on drag stop).
|
||||
const bandDragRef = React.useRef<{
|
||||
bandId: string;
|
||||
start: { x: number; y: number };
|
||||
members: Map<number, { x: number; y: number }>;
|
||||
} | null>(null);
|
||||
|
||||
const handleNodeDragStart = React.useCallback((node: Node) => {
|
||||
if (node.type !== "techBand") {
|
||||
bandDragRef.current = null;
|
||||
return;
|
||||
}
|
||||
// The drag callbacks only hand us the DRAGGED set (just this band), so read
|
||||
// the member cards' start positions from the full rendered node list.
|
||||
const memberIds = (node.data as CategoryBandData).memberIds ?? [];
|
||||
const members = new Map<number, { x: number; y: number }>();
|
||||
for (const id of memberIds) {
|
||||
const card = nodesRef.current.find((candidate) => candidate.id === String(id));
|
||||
if (card) members.set(id, { x: card.position.x, y: card.position.y });
|
||||
}
|
||||
bandDragRef.current = {
|
||||
bandId: node.id,
|
||||
start: { x: node.position.x, y: node.position.y },
|
||||
members,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNodeDrag = React.useCallback(
|
||||
(node: Node) => {
|
||||
const session = bandDragRef.current;
|
||||
if (!session || node.type !== "techBand" || session.bandId !== node.id) return;
|
||||
const dx = node.position.x - session.start.x;
|
||||
const dy = node.position.y - session.start.y;
|
||||
setNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.type !== "techCard") return n;
|
||||
const start = session.members.get(Number(n.id));
|
||||
if (!start) return n;
|
||||
return { ...n, position: { x: start.x + dx, y: start.y + dy } };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const handleNodeDragStop = React.useCallback(
|
||||
(node: Node, allNodes: Node[]) => {
|
||||
if (node.type === "techBand") {
|
||||
const session = bandDragRef.current;
|
||||
bandDragRef.current = null;
|
||||
if (!session) return;
|
||||
const dx = node.position.x - session.start.x;
|
||||
const dy = node.position.y - session.start.y;
|
||||
void persistPositions(
|
||||
[...session.members].map(([id, start]) => ({
|
||||
id,
|
||||
x: snapValue(start.x + dx),
|
||||
y: snapValue(start.y + dy),
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A card drag can move a whole multi-selection: persist every dragged card
|
||||
// (React Flow hands us the full set, not just the one under the pointer).
|
||||
const cards = allNodes.filter((n) => n.type === "techCard");
|
||||
if (cards.length === 0) return;
|
||||
void persistPositions(
|
||||
cards.map((n) => ({
|
||||
id: Number(n.id),
|
||||
x: snapValue(n.position.x),
|
||||
y: snapValue(n.position.y),
|
||||
})),
|
||||
);
|
||||
},
|
||||
[persistPositions, snapValue],
|
||||
);
|
||||
|
||||
const handleSavePositions = React.useCallback(async () => {
|
||||
// Commit the current on-screen position of every node to treePosition, so
|
||||
// the exact arrangement survives a reload. Unlike a drag (one node) or a
|
||||
|
|
@ -507,9 +697,12 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
|
||||
minZoom={0.08}
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={[SNAP_GRID, SNAP_GRID]}
|
||||
nodesConnectable={data.canUpdate}
|
||||
onConnect={(connection) => {
|
||||
void handleConnect(connection);
|
||||
|
|
@ -534,19 +727,21 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
onEdgesDelete={(deleted) => {
|
||||
void handleEdgesDelete(deleted);
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
onNodeClick={(event, node) => {
|
||||
// Ctrl/Cmd-click is a multi-select gesture, not an edit gesture: do not
|
||||
// pop the side panel, which would cover the canvas mid-selection.
|
||||
if (event.ctrlKey || event.metaKey) return;
|
||||
if (node.type === "techCard") setSelectedId(node.id);
|
||||
}}
|
||||
onNodeDragStop={(_, node) => {
|
||||
if (node.type !== "techCard") return;
|
||||
void persistPosition(Number(node.id), Math.round(node.position.x), Math.round(node.position.y));
|
||||
}}
|
||||
onNodeDragStart={(_, node) => handleNodeDragStart(node)}
|
||||
onNodeDrag={(_, node) => handleNodeDrag(node)}
|
||||
onNodeDragStop={(_, node, draggedNodes) => handleNodeDragStop(node, draggedNodes)}
|
||||
onPaneClick={() => setSelectedId(null)}
|
||||
proOptions={{ hideAttribution: false }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Lines}
|
||||
gap={28}
|
||||
gap={SNAP_GRID}
|
||||
size={1}
|
||||
color="rgba(255,255,255,0.055)"
|
||||
/>
|
||||
|
|
@ -584,6 +779,24 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
|||
{saveStatus}
|
||||
</span>
|
||||
) : null}
|
||||
{data.canUpdate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={toggleSnapToGrid}
|
||||
aria-pressed={snapToGrid}
|
||||
title="Snap dragged cards to the grid so they line up with the layout"
|
||||
data-testid="tech-tree-snap-toggle"
|
||||
className={
|
||||
snapToGrid
|
||||
? `${TACTICAL_BUTTON} border-[#22d3ee]/60 bg-[#22d3ee]/15 text-[#a5f3fc] hover:border-[#22d3ee]/80 hover:bg-[#22d3ee]/20 hover:text-[#cffafe]`
|
||||
: TACTICAL_BUTTON
|
||||
}
|
||||
>
|
||||
<MagnetIcon className="size-3.5" />
|
||||
Snap: {snapToGrid ? "On" : "Off"}
|
||||
</Button>
|
||||
) : null}
|
||||
{data.canUpdate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
85
src/components/admin/tech-tree/edgeRoutes.ts
Normal file
85
src/components/admin/tech-tree/edgeRoutes.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Manual routing for Tech Tree connection lines.
|
||||
*
|
||||
* A prerequisite edge (source -> target) can carry a list of waypoints the
|
||||
* user has placed on it. The edge renders as a polyline through those points
|
||||
* instead of the automatic smoothstep path.
|
||||
*
|
||||
* Storage is per-browser (localStorage): routing is a personal viewing aid
|
||||
* rather than shared tree data. Promoting it to a persisted field on the
|
||||
* target technology is a small change once the schema settles.
|
||||
*/
|
||||
|
||||
export type RouteWaypoint = { x: number; y: number };
|
||||
export type EdgeRouteMap = Record<string, RouteWaypoint[]>;
|
||||
|
||||
const STORAGE_KEY = "ptf:tech-tree:edge-routes";
|
||||
|
||||
/** Stable key for the edge between two technologies. */
|
||||
export function routeKey(source: number | string, target: number | string): string {
|
||||
return `${source}:${target}`;
|
||||
}
|
||||
|
||||
export function loadEdgeRoutes(): EdgeRouteMap {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as EdgeRouteMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveEdgeRoutes(routes: EdgeRouteMap): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(routes));
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode); routing just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** Corner rounding radius (px) at each waypoint. */
|
||||
export const ROUTE_CORNER_RADIUS = 14;
|
||||
|
||||
function distance(a: RouteWaypoint, b: RouteWaypoint): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG path through the given points. Each interior corner is cut with a
|
||||
* quadratic curve so bends read as smooth corners instead of hard angles; the
|
||||
* radius shrinks automatically on short segments so curves never overshoot.
|
||||
*/
|
||||
export function buildRoutePath(points: RouteWaypoint[], radius = ROUTE_CORNER_RADIUS): string {
|
||||
if (points.length < 2) return "";
|
||||
const start = points[0];
|
||||
let d = `M ${start.x},${start.y}`;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const next = points[i + 1];
|
||||
const inLen = distance(prev, cur);
|
||||
const outLen = distance(cur, next);
|
||||
if (inLen === 0 || outLen === 0) {
|
||||
d += ` L ${cur.x},${cur.y}`;
|
||||
continue;
|
||||
}
|
||||
const r = Math.min(radius, inLen / 2, outLen / 2);
|
||||
const a = {
|
||||
x: cur.x - ((cur.x - prev.x) / inLen) * r,
|
||||
y: cur.y - ((cur.y - prev.y) / inLen) * r,
|
||||
};
|
||||
const b = {
|
||||
x: cur.x + ((next.x - cur.x) / outLen) * r,
|
||||
y: cur.y + ((next.y - cur.y) / outLen) * r,
|
||||
};
|
||||
d += ` L ${a.x},${a.y} Q ${cur.x},${cur.y} ${b.x},${b.y}`;
|
||||
}
|
||||
|
||||
const end = points[points.length - 1];
|
||||
return `${d} L ${end.x},${end.y}`;
|
||||
}
|
||||
133
src/components/admin/tech-tree/edgeRouting.ts
Normal file
133
src/components/admin/tech-tree/edgeRouting.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Obstacle-avoiding routing for Tech Tree edges.
|
||||
*
|
||||
* A prerequisite edge should not cut through technologies it is not connected
|
||||
* to. Given the polyline an edge would take (source -> optional waypoints ->
|
||||
* target) and the rectangles of every OTHER node, this inserts detour points
|
||||
* so each segment swings around the boxes it would otherwise cross.
|
||||
*
|
||||
* The detour is greedy: for the first box a segment hits, it tries that box's
|
||||
* four (inflated) corners and picks the one with the shortest total bend,
|
||||
* recursing on the two halves. Bounded depth keeps it cheap and terminating.
|
||||
*/
|
||||
|
||||
export type RouteRect = { id: number; x: number; y: number; width: number; height: number };
|
||||
export type RoutePoint = { x: number; y: number };
|
||||
|
||||
/** Clearance kept around a node when routing around it. */
|
||||
export const ROUTE_CLEARANCE = 22;
|
||||
const MAX_DEPTH = 3;
|
||||
const EPS = 1;
|
||||
|
||||
/** Does segment a->b cross the interior of rect r? (exported for tests) */
|
||||
export function segmentCrossesRect(a: RoutePoint, b: RoutePoint, r: RouteRect): boolean {
|
||||
const x1 = r.x + EPS;
|
||||
const y1 = r.y + EPS;
|
||||
const x2 = r.x + r.width - EPS;
|
||||
const y2 = r.y + r.height - EPS;
|
||||
if (x2 <= x1 || y2 <= y1) return false;
|
||||
|
||||
// Liang-Barsky clip of the segment against the (slightly inset) box.
|
||||
let t0 = 0;
|
||||
let t1 = 1;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const p = [-dx, dx, -dy, dy];
|
||||
const q = [a.x - x1, x2 - a.x, a.y - y1, y2 - a.y];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
if (p[i] === 0) {
|
||||
if (q[i] < 0) return false;
|
||||
} else {
|
||||
const t = q[i] / p[i];
|
||||
if (p[i] < 0) {
|
||||
if (t > t1) return false;
|
||||
if (t > t0) t0 = t;
|
||||
} else {
|
||||
if (t < t0) return false;
|
||||
if (t < t1) t1 = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const crosses = segmentCrossesRect;
|
||||
|
||||
function corners(r: RouteRect, margin: number): RoutePoint[] {
|
||||
const x1 = r.x - margin;
|
||||
const y1 = r.y - margin;
|
||||
const x2 = r.x + r.width + margin;
|
||||
const y2 = r.y + r.height + margin;
|
||||
return [
|
||||
{ x: x1, y: y1 },
|
||||
{ x: x2, y: y1 },
|
||||
{ x: x2, y: y2 },
|
||||
{ x: x1, y: y2 },
|
||||
];
|
||||
}
|
||||
|
||||
function inside(p: RoutePoint, r: RouteRect, margin: number): boolean {
|
||||
return (
|
||||
p.x > r.x - margin && p.x < r.x + r.width + margin && p.y > r.y - margin && p.y < r.y + r.height + margin
|
||||
);
|
||||
}
|
||||
|
||||
function dist(a: RoutePoint, b: RoutePoint): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/** Detour points from a to b (excluding a, ending with b). */
|
||||
function detour(
|
||||
a: RoutePoint,
|
||||
b: RoutePoint,
|
||||
obstacles: RouteRect[],
|
||||
margin: number,
|
||||
depth: number,
|
||||
): RoutePoint[] {
|
||||
if (depth >= MAX_DEPTH) return [b];
|
||||
const hit = obstacles.find((r) => crosses(a, b, r));
|
||||
if (!hit) return [b];
|
||||
|
||||
let best: RoutePoint | null = null;
|
||||
let bestLen = Number.POSITIVE_INFINITY;
|
||||
for (const c of corners(hit, margin)) {
|
||||
// Skip a corner inside another box, or one not reachable from `a` without
|
||||
// still cutting through the box. The second half is left to the recursion,
|
||||
// which chains a second corner for wide boxes.
|
||||
if (obstacles.some((r) => r.id !== hit.id && inside(c, r, 0))) continue;
|
||||
if (crosses(a, c, hit)) continue;
|
||||
// A corner at the current point or the destination makes no progress.
|
||||
if (dist(a, c) < 1 || dist(c, b) < 1) continue;
|
||||
const len = dist(a, c) + dist(c, b);
|
||||
if (len < bestLen) {
|
||||
bestLen = len;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
if (!best) return [b];
|
||||
|
||||
return [
|
||||
...detour(a, best, obstacles, margin, depth + 1),
|
||||
...detour(best, b, obstacles, margin, depth + 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a polyline around the given obstacle boxes. Returns a new point list;
|
||||
* returns the input unchanged when there is nothing to avoid.
|
||||
*/
|
||||
export function routeAroundObstacles(
|
||||
points: RoutePoint[],
|
||||
obstacles: RouteRect[],
|
||||
margin = ROUTE_CLEARANCE,
|
||||
): RoutePoint[] {
|
||||
if (points.length < 2 || obstacles.length === 0) return points;
|
||||
const out: RoutePoint[] = [points[0]];
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
for (const p of detour(points[i], points[i + 1], obstacles, margin, 0)) {
|
||||
const last = out[out.length - 1];
|
||||
if (Math.abs(p.x - last.x) > 0.5 || Math.abs(p.y - last.y) > 0.5) out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Flag,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Medal,
|
||||
Map,
|
||||
Package,
|
||||
Radar,
|
||||
|
|
@ -153,6 +154,11 @@ export const sidebarData = {
|
|||
url: "/personnel/statistics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: "Promotions",
|
||||
url: "/personnel/promotions",
|
||||
icon: Medal,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
cancelFridaySideOp,
|
||||
markSideOpReady,
|
||||
markSideOpScheduled,
|
||||
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||
import {
|
||||
CreateSideOpDialog,
|
||||
|
|
@ -37,16 +37,16 @@ export function SideOpControls({
|
|||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isTerminal = status === "Completed" || status === "Cancelled";
|
||||
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
|
||||
const canMarkScheduled = ["Concept", "Planning", "Ready"].includes(status);
|
||||
|
||||
function handleMarkReady() {
|
||||
function handleMarkScheduled() {
|
||||
startTransition(async () => {
|
||||
const result = await markSideOpReady(initial.missionId);
|
||||
const result = await markSideOpScheduled(initial.missionId);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
|
||||
toast.error(result.error ?? "Failed to commit the side operation to the schedule.");
|
||||
return;
|
||||
}
|
||||
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
|
||||
toast.success("Side operation scheduled. The roll-call will be posted soon.");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
|
@ -80,10 +80,10 @@ export function SideOpControls({
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canMarkReady && (
|
||||
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
|
||||
{canMarkScheduled && (
|
||||
<Button size="sm" variant="default" onClick={handleMarkScheduled} disabled={isPending}>
|
||||
<CheckCircleIcon className="size-3.5" />
|
||||
Mark as Ready
|
||||
Mark as Scheduled
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
||||
|
|
@ -101,9 +101,9 @@ export function SideOpControls({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Marking the operation Ready locks in the details so the Discord roll-call can be posted
|
||||
and the mission hooks delivered. Editing covers details, campaign, and narrative.
|
||||
Cancelling frees your scheduling slot and updates the Discord embed.
|
||||
Scheduling the operation commits it: the Discord roll-call is posted within three days of
|
||||
the op and the mission hooks are delivered. Editing covers details, campaign, and
|
||||
narrative. Cancelling frees your scheduling slot and updates the Discord embed.
|
||||
</p>
|
||||
</ItemContent>
|
||||
|
||||
|
|
|
|||
150
src/components/frontend/profile/NominateForPromotionButton.tsx
Normal file
150
src/components/frontend/profile/NominateForPromotionButton.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { submitPromotionNomination } from "@/app/(frontend)/profile/[username]/actions";
|
||||
import { Loader2Icon, TrophyIcon } from "lucide-react";
|
||||
|
||||
interface NominateForPromotionButtonProps {
|
||||
readonly username: string;
|
||||
readonly displayName: string;
|
||||
readonly isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
const MIN_ACCOLADES_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Profile-page section that lets any logged-in user nominate the profile owner
|
||||
* for promotion (or self-request on their own profile). The visibility and the
|
||||
* next-rung existence check are computed server-side by the page; this
|
||||
* component only provides the entry point and submits the accolades.
|
||||
*/
|
||||
export function NominateForPromotionButton({
|
||||
username,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
}: NominateForPromotionButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [accolades, setAccolades] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const label = isOwnProfile ? "Request promotion" : "Nominate for promotion";
|
||||
const trimmed = accolades.trim();
|
||||
const tooShort = trimmed.length > 0 && trimmed.length < MIN_ACCOLADES_LENGTH;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const res = await submitPromotionNomination({
|
||||
nomineeUsername: username,
|
||||
accolades: accolades,
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success(isOwnProfile ? "Promotion request submitted" : "Nomination submitted", {
|
||||
description: isOwnProfile
|
||||
? "Your promotion request is on its way up the chain."
|
||||
: `Your nomination for ${displayName} is on its way up the chain.`,
|
||||
});
|
||||
setOpen(false);
|
||||
setAccolades("");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error ?? "Could not submit the nomination.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrophyIcon className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Promotion nomination
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrophyIcon data-icon="inline-start" />
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!submitting) setOpen(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isOwnProfile
|
||||
? "Make the case for your own promotion. It will be routed to command."
|
||||
: `Make the case for ${displayName}'s promotion. It will be routed through their division leader.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-2" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`nomination-accolades-${username}`}>
|
||||
Performance and accolades *
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`nomination-accolades-${username}`}
|
||||
value={accolades}
|
||||
onChange={(event) => setAccolades(event.target.value)}
|
||||
placeholder="Describe their performance, initiative, and conduct with concrete examples."
|
||||
rows={5}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{MIN_ACCOLADES_LENGTH - trimmed.length} more character
|
||||
{MIN_ACCOLADES_LENGTH - trimmed.length !== 1 ? "s" : ""} needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || trimmed.length < MIN_ACCOLADES_LENGTH}>
|
||||
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
Submit nomination
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
265
src/components/frontend/promotions/NominationCard.tsx
Normal file
265
src/components/frontend/promotions/NominationCard.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckIcon, ScrollTextIcon, XIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
endorseNomination,
|
||||
rejectNomination,
|
||||
resolveNomination,
|
||||
cancelNomination,
|
||||
} from "@/app/(frontend)/personnel/promotions/actions";
|
||||
import { NominationNoteDialog } from "@/components/frontend/promotions/NominationNoteDialog";
|
||||
import {
|
||||
NOMINATION_DECISION_LABELS,
|
||||
NOMINATION_STATUS_LABELS,
|
||||
NOMINATION_STATUS_VARIANTS,
|
||||
formatNominationDate,
|
||||
type NominationView,
|
||||
} from "@/components/frontend/promotions/nominationTypes";
|
||||
|
||||
export type NominationCardActions = "leader" | "final" | "withdraw" | "none";
|
||||
|
||||
interface NominationCardProps {
|
||||
readonly nomination: NominationView;
|
||||
readonly actions: NominationCardActions;
|
||||
}
|
||||
|
||||
const ACCOLADES_PREVIEW_LENGTH = 280;
|
||||
|
||||
export function NominationCard({ nomination, actions }: NominationCardProps) {
|
||||
const [dialog, setDialog] = useState<
|
||||
"endorse" | "leader-reject" | "approve" | "final-reject" | null
|
||||
>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [cancelBusy, setCancelBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const accolades = nomination.accolades;
|
||||
const isTruncated = accolades.length > ACCOLADES_PREVIEW_LENGTH;
|
||||
const accoladesPreview =
|
||||
isTruncated && !expanded
|
||||
? `${accolades.slice(0, ACCOLADES_PREVIEW_LENGTH).trimEnd()}...`
|
||||
: accolades;
|
||||
|
||||
const canWithdraw = actions === "withdraw" && nomination.status === "pending";
|
||||
|
||||
async function handleAction(note: string) {
|
||||
let result: Awaited<ReturnType<typeof endorseNomination>>;
|
||||
if (dialog === "endorse") {
|
||||
result = await endorseNomination(nomination.id, note || undefined);
|
||||
} else if (dialog === "leader-reject") {
|
||||
result = await rejectNomination(nomination.id, note || undefined);
|
||||
} else if (dialog === "approve") {
|
||||
result = await resolveNomination(nomination.id, true, note || undefined);
|
||||
} else if (dialog === "final-reject") {
|
||||
result = await resolveNomination(nomination.id, false, note || undefined);
|
||||
} else {
|
||||
return { success: false, error: "Choose a decision first." };
|
||||
}
|
||||
if (result.success) router.refresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleWithdraw() {
|
||||
if (!confirmCancel) {
|
||||
setConfirmCancel(true);
|
||||
return;
|
||||
}
|
||||
setCancelBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await cancelNomination(nomination.id);
|
||||
if (result.success) {
|
||||
setConfirmCancel(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Could not withdraw this nomination.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : "Could not withdraw this nomination.",
|
||||
);
|
||||
} finally {
|
||||
setCancelBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ScrollTextIcon className="size-4 text-muted-foreground" />
|
||||
{nomination.nomineeName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nominated by {nomination.nominatorName} · {formatNominationDate(nomination.createdAt)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={NOMINATION_STATUS_VARIANTS[nomination.status]}>
|
||||
{NOMINATION_STATUS_LABELS[nomination.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Accolades
|
||||
</span>
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground">{accoladesPreview}</p>
|
||||
{isTruncated && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="w-fit px-0"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{NOMINATION_DECISION_LABELS[nomination.leaderDecision]}</Badge>
|
||||
{nomination.leaderDecisionByName && (
|
||||
<span className="text-muted-foreground">
|
||||
by {nomination.leaderDecisionByName} ·{" "}
|
||||
{formatNominationDate(nomination.leaderDecidedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nomination.leaderNote && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Leader note
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{nomination.leaderNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nomination.reviewNote && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Final review note
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{nomination.reviewNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nomination.reviewedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Resolved {formatNominationDate(nomination.reviewedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{(actions === "leader" || actions === "final" || canWithdraw) && (
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
{actions === "leader" && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setDialog("endorse")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Endorse
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDialog("leader-reject")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{actions === "final" && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setDialog("approve")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Approve promotion
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDialog("final-reject")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canWithdraw && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleWithdraw()}
|
||||
disabled={cancelBusy}
|
||||
>
|
||||
{cancelBusy
|
||||
? "Withdrawing..."
|
||||
: confirmCancel
|
||||
? "Confirm withdrawal?"
|
||||
: "Withdraw nomination"}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
{dialog !== null && (
|
||||
<NominationNoteDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDialog(null);
|
||||
}}
|
||||
title={
|
||||
dialog === "endorse"
|
||||
? `Endorse ${nomination.nomineeName}'s nomination`
|
||||
: dialog === "approve"
|
||||
? `Approve promotion for ${nomination.nomineeName}`
|
||||
: `Reject the nomination for ${nomination.nomineeName}`
|
||||
}
|
||||
description={
|
||||
dialog === "endorse"
|
||||
? "Endorsing sends the nomination to command for final approval."
|
||||
: dialog === "approve"
|
||||
? "Approving promotes the member exactly one rung up the rank ladder."
|
||||
: "This is a terminal decision and will be visible to the nominator only."
|
||||
}
|
||||
fieldId={`nomination-note-${nomination.id}`}
|
||||
fieldLabel="Note"
|
||||
placeholder="Add a note for the nominator (optional)."
|
||||
submitLabel={
|
||||
dialog === "endorse"
|
||||
? "Endorse nomination"
|
||||
: dialog === "approve"
|
||||
? "Approve promotion"
|
||||
: "Reject nomination"
|
||||
}
|
||||
successMessage={
|
||||
dialog === "endorse"
|
||||
? "Nomination endorsed"
|
||||
: dialog === "approve"
|
||||
? "Promotion approved"
|
||||
: "Nomination rejected"
|
||||
}
|
||||
required={false}
|
||||
submitVariant={
|
||||
dialog === "leader-reject" || dialog === "final-reject" ? "destructive" : "default"
|
||||
}
|
||||
onSubmit={handleAction}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
139
src/components/frontend/promotions/NominationNoteDialog.tsx
Normal file
139
src/components/frontend/promotions/NominationNoteDialog.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
export type NominationActionResult = {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
};
|
||||
|
||||
interface NominationNoteDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly fieldId: string;
|
||||
readonly fieldLabel: string;
|
||||
readonly placeholder: string;
|
||||
readonly submitLabel: string;
|
||||
readonly successMessage: string;
|
||||
readonly required: boolean;
|
||||
readonly submitVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
readonly onSubmit: (note: string) => Promise<NominationActionResult>;
|
||||
}
|
||||
|
||||
export function NominationNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fieldId,
|
||||
fieldLabel,
|
||||
placeholder,
|
||||
submitLabel,
|
||||
successMessage,
|
||||
required,
|
||||
submitVariant = "default",
|
||||
onSubmit,
|
||||
}: NominationNoteDialogProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setNote("");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedNote = note.trim();
|
||||
if (required && !trimmedNote) {
|
||||
setError("A reason is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await onSubmit(trimmedNote);
|
||||
if (result.success) {
|
||||
toast.success(successMessage);
|
||||
handleOpenChange(false);
|
||||
} else {
|
||||
setError(result.error ?? "Could not complete this action.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : "Could not complete this action.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={fieldId}>
|
||||
{fieldLabel}
|
||||
{required ? " *" : " (optional)"}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
required={required}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant={submitVariant} disabled={pending}>
|
||||
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
{pending ? "Submitting..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
142
src/components/frontend/promotions/PromotionsView.tsx
Normal file
142
src/components/frontend/promotions/PromotionsView.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ClipboardCheckIcon, FileClockIcon, GavelIcon, SendIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
NominationCard,
|
||||
type NominationCardActions,
|
||||
} from "@/components/frontend/promotions/NominationCard";
|
||||
import type { NominationView } from "@/components/frontend/promotions/nominationTypes";
|
||||
|
||||
interface PromotionsViewProps {
|
||||
readonly isSuperuserViewer: boolean;
|
||||
readonly isLeader: boolean;
|
||||
readonly leaderQueue: readonly NominationView[];
|
||||
readonly finalQueue: readonly NominationView[];
|
||||
readonly mySubmissions: readonly NominationView[];
|
||||
readonly recentlyResolved: readonly NominationView[];
|
||||
}
|
||||
|
||||
function NominationSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
readonly icon: LucideIcon;
|
||||
readonly title: string;
|
||||
readonly count: number;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby={`${title}-heading`} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id={`${title}-heading`} className="text-sm font-semibold uppercase tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNominationSection({ message }: { readonly message: string }) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function NominationGrid({
|
||||
nominations,
|
||||
actionFor,
|
||||
}: {
|
||||
readonly nominations: readonly NominationView[];
|
||||
readonly actionFor: (nomination: NominationView) => NominationCardActions;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{nominations.map((nomination) => (
|
||||
<NominationCard
|
||||
key={nomination.id}
|
||||
nomination={nomination}
|
||||
actions={actionFor(nomination)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromotionsView({
|
||||
isSuperuserViewer,
|
||||
isLeader,
|
||||
leaderQueue,
|
||||
finalQueue,
|
||||
mySubmissions,
|
||||
recentlyResolved,
|
||||
}: PromotionsViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{isLeader && (
|
||||
<NominationSection
|
||||
icon={ClipboardCheckIcon}
|
||||
title="Awaiting your endorsement"
|
||||
count={leaderQueue.length}
|
||||
>
|
||||
{leaderQueue.length === 0 ? (
|
||||
<EmptyNominationSection message="No pending nominations need your endorsement." />
|
||||
) : (
|
||||
<NominationGrid nominations={leaderQueue} actionFor={() => "leader"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
|
||||
{isSuperuserViewer && (
|
||||
<NominationSection
|
||||
icon={GavelIcon}
|
||||
title="Awaiting final approval"
|
||||
count={finalQueue.length}
|
||||
>
|
||||
{finalQueue.length === 0 ? (
|
||||
<EmptyNominationSection message="No nominations are awaiting a final decision." />
|
||||
) : (
|
||||
<NominationGrid nominations={finalQueue} actionFor={() => "final"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
|
||||
<NominationSection icon={SendIcon} title="My submissions" count={mySubmissions.length}>
|
||||
{mySubmissions.length === 0 ? (
|
||||
<EmptyNominationSection message="You have not nominated anyone for promotion yet." />
|
||||
) : (
|
||||
<NominationGrid
|
||||
nominations={mySubmissions}
|
||||
actionFor={(nomination) => (nomination.status === "pending" ? "withdraw" : "none")}
|
||||
/>
|
||||
)}
|
||||
</NominationSection>
|
||||
|
||||
{(isSuperuserViewer || isLeader) && (
|
||||
<NominationSection
|
||||
icon={FileClockIcon}
|
||||
title="Recently resolved"
|
||||
count={recentlyResolved.length}
|
||||
>
|
||||
{recentlyResolved.length === 0 ? (
|
||||
<EmptyNominationSection message="No resolved nominations to show." />
|
||||
) : (
|
||||
<NominationGrid nominations={recentlyResolved} actionFor={() => "none"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/frontend/promotions/nominationTypes.ts
Normal file
63
src/components/frontend/promotions/nominationTypes.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
NominationLeaderDecision,
|
||||
NominationStatus,
|
||||
} from "@/collections/users/PromotionNominations";
|
||||
|
||||
export type NominationStatusValue = NominationStatus;
|
||||
export type NominationLeaderDecisionValue = NominationLeaderDecision;
|
||||
|
||||
/**
|
||||
* Serializable projection of a promotion nomination the review page passes to
|
||||
* the client view. Names are resolved server-side; the client never talks to
|
||||
* the REST API for this data.
|
||||
*/
|
||||
export type NominationView = {
|
||||
readonly id: number;
|
||||
readonly nomineeId: number;
|
||||
readonly nomineeName: string;
|
||||
readonly nominatorId: number;
|
||||
readonly nominatorName: string;
|
||||
readonly accolades: string;
|
||||
readonly status: NominationStatusValue;
|
||||
readonly leaderDecision: NominationLeaderDecisionValue;
|
||||
readonly leaderDecisionByName: string | null;
|
||||
readonly leaderNote: string | null;
|
||||
readonly reviewNote: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly leaderDecidedAt: string | null;
|
||||
readonly reviewedAt: string | null;
|
||||
};
|
||||
|
||||
export type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
|
||||
export const NOMINATION_STATUS_LABELS = {
|
||||
pending: "Awaiting endorsement",
|
||||
awaiting_superuser: "Awaiting final approval",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Withdrawn",
|
||||
} as const satisfies Record<NominationStatusValue, string>;
|
||||
|
||||
export const NOMINATION_STATUS_VARIANTS = {
|
||||
pending: "secondary",
|
||||
awaiting_superuser: "outline",
|
||||
approved: "default",
|
||||
rejected: "destructive",
|
||||
cancelled: "secondary",
|
||||
} as const satisfies Record<NominationStatusValue, BadgeVariant>;
|
||||
|
||||
export const NOMINATION_DECISION_LABELS = {
|
||||
pending: "Leader decision pending",
|
||||
endorsed: "Leader endorsed",
|
||||
rejected: "Leader rejected",
|
||||
not_required: "Leader endorsement not required",
|
||||
} as const satisfies Record<NominationLeaderDecisionValue, string>;
|
||||
|
||||
export function formatNominationDate(iso: string | null): string {
|
||||
if (!iso) return "Unknown date";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "Unknown date";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
date,
|
||||
);
|
||||
}
|
||||
147
src/components/frontend/roster/RemoveMemberDialog.tsx
Normal file
147
src/components/frontend/roster/RemoveMemberDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { UserMinusIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { requestDivisionRemoval } from "@/app/(frontend)/roster/actions";
|
||||
|
||||
interface RemoveMemberDialogProps {
|
||||
readonly memberId: number;
|
||||
readonly memberLabel: string;
|
||||
readonly assignmentId: number;
|
||||
readonly assignmentName: string;
|
||||
/** Display name of the configured Infantry assignment, when one exists. */
|
||||
readonly infantryName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader-only removal dialog launched from a roster MemberCard. Honest copy:
|
||||
* submitting creates a transfer request for command approval; nothing moves
|
||||
* until a superuser approves.
|
||||
*/
|
||||
export function RemoveMemberDialog({
|
||||
memberId,
|
||||
memberLabel,
|
||||
assignmentId,
|
||||
assignmentName,
|
||||
infantryName,
|
||||
}: RemoveMemberDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const destinationLabel = infantryName?.trim() ? infantryName : "the Infantry division";
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setReason("");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await requestDivisionRemoval({
|
||||
subjectId: memberId,
|
||||
fromAssignmentId: assignmentId,
|
||||
...(reason.trim() ? { reason: reason.trim() } : {}),
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(`Removal request for ${memberLabel} submitted`);
|
||||
handleOpenChange(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Could not submit the removal request.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not submit the removal request.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Remove ${memberLabel} from ${assignmentName}`}
|
||||
title="Remove from division"
|
||||
>
|
||||
<UserMinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Remove {memberLabel} from {assignmentName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This creates a transfer request for {memberLabel} to {destinationLabel}. It takes
|
||||
effect once command approves.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="remove-member-reason">Reason (optional)</Label>
|
||||
<Textarea
|
||||
id="remove-member-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="Why should command move this member?"
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Submitting..." : "Submit removal request"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
|||
import { cn, pluralize } from "@/lib/utils";
|
||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RemoveMemberDialog } from "@/components/frontend/roster/RemoveMemberDialog";
|
||||
import {
|
||||
Building2Icon,
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -16,6 +17,23 @@ import {
|
|||
|
||||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||||
|
||||
/**
|
||||
* Viewer-scoped data threaded down for the leader-only removal affordance:
|
||||
* the authenticated user's id (null for the typed guest case; the layout gates
|
||||
* the page) and the configured Infantry assignment name for the removal dialog
|
||||
* copy. Kept in a context (like RosterDisplayContext) so the card components
|
||||
* keep their existing prop signatures.
|
||||
*/
|
||||
type RosterViewerContextValue = {
|
||||
readonly currentUserId: number | null;
|
||||
readonly infantryName: string | null;
|
||||
};
|
||||
|
||||
const RosterViewerContext = React.createContext<RosterViewerContextValue>({
|
||||
currentUserId: null,
|
||||
infantryName: null,
|
||||
});
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
division: 0,
|
||||
squad: 1,
|
||||
|
|
@ -218,7 +236,7 @@ function UnitBox({
|
|||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1 mb-1">
|
||||
<CrownIcon className="size-3" /> Commanding
|
||||
</span>
|
||||
<MemberCard member={leader} />
|
||||
<MemberCard member={leader} owningAssignment={node} />
|
||||
<hr className="my-2 w-1/3 mx-auto"/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -226,7 +244,7 @@ function UnitBox({
|
|||
{directMembers.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{directMembers.map((m) => (
|
||||
<MemberCard key={m.profileId} member={m} />
|
||||
<MemberCard key={m.profileId} member={m} owningAssignment={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -286,7 +304,7 @@ function ByAssignmentView({
|
|||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||||
{groupMembers.map((m) => (
|
||||
<MemberCard key={m.profileId} member={m} />
|
||||
<MemberCard key={m.profileId} member={m} owningAssignment={assignment} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -476,8 +494,15 @@ function SeniorityView({ members }: { members: RosterMember[] }) {
|
|||
|
||||
// ─── Member Card ────────────────────────────────────────────────────
|
||||
|
||||
function MemberCard({ member }: { member: RosterMember }) {
|
||||
function MemberCard({
|
||||
member,
|
||||
owningAssignment,
|
||||
}: {
|
||||
member: RosterMember;
|
||||
owningAssignment?: RosterAssignment;
|
||||
}) {
|
||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||
const viewer = React.useContext(RosterViewerContext);
|
||||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||||
const serviceLabel =
|
||||
|
|
@ -487,16 +512,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
? "Less than a year"
|
||||
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
||||
|
||||
// Leader-only removal affordance: the viewer must be the current leader of
|
||||
// the member's division and cannot remove themself. The server action
|
||||
// re-validates every guard, so this is a display gate only. Only shown in
|
||||
// tabs that know the member's containing assignment (org chart, by
|
||||
// assignment). The assignment chip is suppressed while the action is shown
|
||||
// so the two do not collide at the card's right edge.
|
||||
const canRemove =
|
||||
viewer.currentUserId != null &&
|
||||
owningAssignment != null &&
|
||||
owningAssignment.type === "division" &&
|
||||
owningAssignment.leaderId === viewer.currentUserId &&
|
||||
member.user.id !== viewer.currentUserId;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${member.user.username}`}
|
||||
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors">
|
||||
{serviceLabel && (
|
||||
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||||
{serviceLabel} of service
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/profile/${member.user.username}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
{member.rank.insigniaUrl && (
|
||||
<img
|
||||
src={member.rank.insigniaUrl}
|
||||
|
|
@ -510,19 +549,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
{member.rank.abbreviation} · @{member.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{member.isLeader && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
|
||||
{leadLabel}
|
||||
</span>
|
||||
)}
|
||||
{member.assignmentName && (
|
||||
{canRemove ? (
|
||||
<RemoveMemberDialog
|
||||
memberId={member.user.id}
|
||||
memberLabel={member.user.displayName}
|
||||
assignmentId={owningAssignment.id}
|
||||
assignmentName={owningAssignment.name}
|
||||
infantryName={viewer.infantryName}
|
||||
/>
|
||||
) : (
|
||||
member.assignmentName && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
|
||||
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -532,12 +582,22 @@ export function RosterView({
|
|||
members,
|
||||
assignments,
|
||||
leadOrActual = "lead",
|
||||
currentUserId,
|
||||
infantryName,
|
||||
}: {
|
||||
members: RosterMember[];
|
||||
assignments: RosterAssignment[];
|
||||
leadOrActual?: "lead" | "actual";
|
||||
currentUserId?: number;
|
||||
infantryName?: string | null;
|
||||
}) {
|
||||
const viewer = React.useMemo<RosterViewerContextValue>(
|
||||
() => ({ currentUserId: currentUserId ?? null, infantryName: infantryName ?? null }),
|
||||
[currentUserId, infantryName],
|
||||
);
|
||||
|
||||
return (
|
||||
<RosterViewerContext.Provider value={viewer}>
|
||||
<RosterDisplayContext.Provider value={leadOrActual}>
|
||||
<Tabs defaultValue="org">
|
||||
<TabsList variant="line">
|
||||
|
|
@ -580,5 +640,6 @@ export function RosterView({
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</RosterDisplayContext.Provider>
|
||||
</RosterViewerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
132
src/components/frontend/transfers/LeadershipTransferCard.tsx
Normal file
132
src/components/frontend/transfers/LeadershipTransferCard.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, UserMinusIcon, XIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { resolveLeadershipTransfer } from "@/app/(frontend)/roster/actions";
|
||||
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
|
||||
import {
|
||||
assignmentLabel,
|
||||
formatTransferDate,
|
||||
personLabel,
|
||||
} from "@/components/frontend/transfers/transferTypes";
|
||||
import type { Assignment, LeadershipTransfer } from "@/payload-types";
|
||||
|
||||
type ResolutionMode = "approve" | "deny";
|
||||
|
||||
interface LeadershipTransferCardProps {
|
||||
readonly transfer: LeadershipTransfer;
|
||||
readonly onUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
function assignmentRef(value: number | Assignment | null | undefined): string {
|
||||
if (value == null) return "Unknown assignment";
|
||||
if (typeof value === "number") return `Assignment #${value}`;
|
||||
return value.callsign ? `[${value.callsign}] ${value.name}` : value.name;
|
||||
}
|
||||
|
||||
/** Superuser-facing pending removal request card with Approve/Deny + note dialog. */
|
||||
export function LeadershipTransferCard({ transfer, onUpdated }: LeadershipTransferCardProps) {
|
||||
const [resolutionMode, setResolutionMode] = useState<ResolutionMode | null>(null);
|
||||
|
||||
async function handleResolve(note: string) {
|
||||
if (resolutionMode === null) {
|
||||
return { success: false, error: "Choose a resolution first." };
|
||||
}
|
||||
const result = await resolveLeadershipTransfer(
|
||||
transfer.id,
|
||||
resolutionMode === "approve",
|
||||
note || undefined,
|
||||
);
|
||||
if (result.success) void onUpdated();
|
||||
return result;
|
||||
}
|
||||
|
||||
const isApproving = resolutionMode === "approve";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" />
|
||||
Removal #{transfer.id}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{personLabel(transfer.initiator)} · requested {formatTransferDate(transfer.createdAt)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">Pending approval</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Member
|
||||
</span>
|
||||
<span className="truncate text-sm">{personLabel(transfer.subject)}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Moving
|
||||
</span>
|
||||
<span className="truncate text-sm">
|
||||
{assignmentRef(transfer.fromAssignment)} → {assignmentRef(transfer.toAssignment)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transfer.reason && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Stated reason
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{transfer.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => setResolutionMode("approve")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Approve & transfer
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setResolutionMode("deny")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Deny request
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
<TransferNoteDialog
|
||||
open={resolutionMode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResolutionMode(null);
|
||||
}}
|
||||
title={isApproving ? "Approve removal request" : "Deny removal request"}
|
||||
description={
|
||||
isApproving
|
||||
? "This moves the member into the destination division and notifies everyone involved."
|
||||
: "This denies the removal. Only the requesting leader will be notified."
|
||||
}
|
||||
fieldId={`leadership-transfer-resolution-${transfer.id}`}
|
||||
fieldLabel="Review note"
|
||||
placeholder="Add a note for the record (optional)."
|
||||
submitLabel={isApproving ? "Approve & transfer" : "Deny request"}
|
||||
successMessage={isApproving ? "Removal approved" : "Removal denied"}
|
||||
required={false}
|
||||
submitVariant={isApproving ? "default" : "destructive"}
|
||||
onSubmit={handleResolve}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
287
src/components/frontend/transfers/LeadershipTransfersSection.tsx
Normal file
287
src/components/frontend/transfers/LeadershipTransfersSection.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { UserMinusIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cancelDivisionRemoval } from "@/app/(frontend)/roster/actions";
|
||||
import { LeadershipTransferCard } from "@/components/frontend/transfers/LeadershipTransferCard";
|
||||
import { formatTransferDate } from "@/components/frontend/transfers/transferTypes";
|
||||
import type { Assignment, LeadershipTransfer, User } from "@/payload-types";
|
||||
|
||||
interface LeadershipTransfersSectionProps {
|
||||
readonly currentUserId: number;
|
||||
readonly canResolve: boolean;
|
||||
}
|
||||
|
||||
const LEADERSHIP_QUERY = new URLSearchParams({
|
||||
depth: "1",
|
||||
limit: "50",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
|
||||
const STATUS_LABELS: Record<LeadershipTransfer["status"], string> = {
|
||||
pending: "Pending approval",
|
||||
approved: "Approved",
|
||||
denied: "Denied",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const STATUS_VARIANTS: Record<
|
||||
LeadershipTransfer["status"],
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
approved: "default",
|
||||
denied: "destructive",
|
||||
cancelled: "outline",
|
||||
};
|
||||
|
||||
function initiatorId(value: number | User | null | undefined): number | null {
|
||||
if (value == null) return null;
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function personRefLabel(value: number | User | null | undefined, fallback = "Unknown member"): string {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "number") return `User #${value}`;
|
||||
return value.displayName || value.username || fallback;
|
||||
}
|
||||
|
||||
function assignmentRef(value: number | Assignment | null | undefined): string {
|
||||
if (value == null) return "Unknown assignment";
|
||||
if (typeof value === "number") return `Assignment #${value}`;
|
||||
return value.callsign ? `[${value.callsign}] ${value.name}` : value.name;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/** Shallow-shape-check the REST page so a malformed response surfaces as an error. */
|
||||
function parseDocs(data: unknown): LeadershipTransfer[] | null {
|
||||
if (!isRecord(data) || !Array.isArray(data.docs)) return null;
|
||||
return data.docs.filter((doc) => isRecord(doc) && typeof doc.id === "number") as LeadershipTransfer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leadership-initiated removal requests on the transfers page.
|
||||
*
|
||||
* Superusers get the pending queue (Approve/Deny with note) plus a collapsed
|
||||
* history. Regular members only ever see their own requests as status chips;
|
||||
* the whole section stays hidden when they have none (pending removals are
|
||||
* invisible to the unit by design).
|
||||
*/
|
||||
export function LeadershipTransfersSection({
|
||||
currentUserId,
|
||||
canResolve,
|
||||
}: LeadershipTransfersSectionProps) {
|
||||
const [docs, setDocs] = useState<readonly LeadershipTransfer[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/leadership-transfers?${LEADERSHIP_QUERY.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Could not load leadership transfers.");
|
||||
|
||||
const body: unknown = await response.json();
|
||||
const parsed = parseDocs(body);
|
||||
if (!parsed) throw new Error("The leadership transfer response was invalid.");
|
||||
setDocs(parsed);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not load leadership transfers.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const pending = useMemo(() => (docs ?? []).filter((doc) => doc.status === "pending"), [docs]);
|
||||
const mine = useMemo(
|
||||
() => (docs ?? []).filter((doc) => initiatorId(doc.initiator) === currentUserId),
|
||||
[currentUserId, docs],
|
||||
);
|
||||
const resolved = useMemo(() => (docs ?? []).filter((doc) => doc.status !== "pending"), [docs]);
|
||||
|
||||
async function handleCancel(transferId: number) {
|
||||
setCancellingId(transferId);
|
||||
try {
|
||||
const result = await cancelDivisionRemoval(transferId);
|
||||
if (result.success) {
|
||||
toast.success("Removal request cancelled");
|
||||
void load();
|
||||
} else {
|
||||
toast.error(result.error ?? "Could not cancel the removal request.");
|
||||
}
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular members: hide the section entirely while loading, on fetch errors,
|
||||
// and whenever they have no requests of their own.
|
||||
if (!canResolve) {
|
||||
if (docs === null || mine.length === 0) return null;
|
||||
return (
|
||||
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2
|
||||
id="leadership-transfers-heading"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Leadership transfers
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mine.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mine.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
|
||||
<span>
|
||||
Removal of <span className="font-medium">{personRefLabel(doc.subject)}</span> from{" "}
|
||||
{assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
requested {formatTransferDate(doc.createdAt)}
|
||||
</span>
|
||||
{doc.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={() => void handleCancel(doc.id)}
|
||||
disabled={cancellingId === doc.id}
|
||||
>
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (docs === null && loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Loading leadership transfers">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (docs === null && error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Leadership transfers unavailable</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
<span>{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2
|
||||
id="leadership-transfers-heading"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Leadership transfers
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pending.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not refresh leadership transfers</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
<span>{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pending.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>No pending removal requests need a final call.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{pending.map((doc) => (
|
||||
<LeadershipTransferCard key={doc.id} transfer={doc} onUpdated={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<Collapsible
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-fit">
|
||||
History ({resolved.length})
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="flex flex-col gap-1.5">
|
||||
{resolved.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-border/60 pb-1.5 text-sm"
|
||||
>
|
||||
<span className="font-medium">{personRefLabel(doc.initiator)}</span>
|
||||
<span className="text-muted-foreground">removed</span>
|
||||
<span>{personRefLabel(doc.subject)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
from {assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
|
||||
{doc.resolvedAt && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{formatTransferDate(doc.resolvedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
|
||||
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
|
||||
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
|
||||
import { LeadershipTransfersSection } from "@/components/frontend/transfers/LeadershipTransfersSection";
|
||||
import {
|
||||
relationId,
|
||||
assignmentLeaderId,
|
||||
|
|
@ -158,6 +159,7 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
|
||||
if (!requests && error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Transfer requests unavailable</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
|
|
@ -168,6 +170,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +251,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
)}
|
||||
</TransferSection>
|
||||
)}
|
||||
|
||||
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
* approvalStatus and human-edited "Virtual Data" are never reset.
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Payload } from "payload";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
|
|
@ -35,7 +35,7 @@ import { plaintextToLexical } from "@/lib/tickets/lexical";
|
|||
export const ASSET_TYPES = [
|
||||
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
||||
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
||||
"explosive", "skin", "miscellaneous",
|
||||
"explosive", "skin", "accessory", "miscellaneous",
|
||||
] as const;
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
export type TransportMode = "ground" | "air" | "sea";
|
||||
|
|
@ -60,6 +60,8 @@ export interface CatalogRecord {
|
|||
mass?: number;
|
||||
/** Transport mode for vehicles, round count for magazines, otherwise "". */
|
||||
extra?: number | string;
|
||||
/** Weapons only: slot plus classnames of compatible records in the same catalog. */
|
||||
compat?: { slot?: string; magazines?: unknown; attachments?: unknown };
|
||||
}
|
||||
|
||||
export interface PlanMedia {
|
||||
|
|
@ -82,12 +84,50 @@ export interface ImportPlan {
|
|||
transportMode: TransportMode;
|
||||
/** Magazines only: round count from `extra`. */
|
||||
roundCapacity?: number;
|
||||
/** Weapons only: compatibility references (classnames), linked in a second pass. */
|
||||
compat?: WeaponCompat;
|
||||
mod: string;
|
||||
/** Icon-map ref or rec.picture, used to resolve the .paa thumbnail. */
|
||||
pictureRef: string;
|
||||
media: PlanMedia;
|
||||
}
|
||||
|
||||
export type WeaponSlot = "primary" | "secondary" | "launcher";
|
||||
|
||||
export interface WeaponCompat {
|
||||
slot?: WeaponSlot;
|
||||
/** Deduplicated (case-insensitive) classnames. */
|
||||
magazines: string[];
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
/** Classnames from an untrusted JSON list: strings only, trimmed, case-insensitive unique. */
|
||||
function classList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") continue;
|
||||
const name = item.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseCompat(raw: CatalogRecord["compat"]): WeaponCompat | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const slot = ["primary", "secondary", "launcher"].includes(String(raw.slot))
|
||||
? (raw.slot as WeaponSlot)
|
||||
: undefined;
|
||||
const magazines = classList(raw.magazines);
|
||||
const attachments = classList(raw.attachments);
|
||||
if (!slot && magazines.length === 0 && attachments.length === 0) return undefined;
|
||||
return { ...(slot ? { slot } : {}), magazines, attachments };
|
||||
}
|
||||
|
||||
export interface PlanLookups {
|
||||
/** icon_map JSON: classname -> .paa ref (takes priority over rec.picture). */
|
||||
icons?: Record<string, unknown>;
|
||||
|
|
@ -171,6 +211,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
|
|||
|
||||
const assetType = classifyAssetType(className, rawType);
|
||||
const roundCapacity = assetType === "magazine" ? numericExtra(rec.extra) : undefined;
|
||||
const compat = assetType === "weapon" ? parseCompat(rec.compat) : undefined;
|
||||
|
||||
return {
|
||||
kind: "asset",
|
||||
|
|
@ -180,6 +221,7 @@ export function planRecord(rec: CatalogRecord, lookups: PlanLookups = {}): Impor
|
|||
assetType,
|
||||
transportMode: "ground",
|
||||
...(roundCapacity !== undefined ? { roundCapacity } : {}),
|
||||
...(compat ? { compat } : {}),
|
||||
mod,
|
||||
pictureRef,
|
||||
media: { paaPath: "", pngPath: "", screenshotPath: "" },
|
||||
|
|
@ -232,10 +274,12 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
|
|||
if (paaPath && convert) {
|
||||
plan.media.pngPath = convertPaaToPng(env.hemtt, paaPath, env.outDir) ?? "";
|
||||
}
|
||||
// Skip 0-byte images: Arma's screenshot command writes empty files when the
|
||||
// game is misconfigured (HDR below 16-bit), and uploading one fails.
|
||||
plan.media.screenshotPath = env.screenshots
|
||||
? (["png", "webp", "jpg"]
|
||||
.map((ext) => join(env.screenshots, plan.className + "." + ext))
|
||||
.find(existsSync) ?? "")
|
||||
.find(hasContent) ?? "")
|
||||
: "";
|
||||
}
|
||||
|
||||
|
|
@ -243,16 +287,45 @@ export function enrichPlanMedia(plan: ImportPlan, env: MediaEnv, convert = true)
|
|||
* Database writes (Payload local API)
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
/** Resolve the "Fuel" resource id required by Vehicles.fuel.fuelType. */
|
||||
export async function resolveFuelConfig(payload: Payload): Promise<number> {
|
||||
const r = await payload.find({
|
||||
/** A file that exists and actually has bytes in it. */
|
||||
function hasContent(path: string): boolean {
|
||||
if (!existsSync(path)) return false;
|
||||
try {
|
||||
return statSync(path).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fuel resource id required by Vehicles.fuel.fuelType.
|
||||
* Looked up by code name first (`res_fuel` by default, which is stable even
|
||||
* when the display name is something like "Gasoline"), then by a name equal
|
||||
* to "Fuel" in any casing. Pass a different code name to use another resource.
|
||||
*/
|
||||
export async function resolveFuelConfig(payload: Payload, codeName = "res_fuel"): Promise<number> {
|
||||
const byCode = await payload.find({
|
||||
collection: "resources",
|
||||
limit: 50,
|
||||
where: { name: { equals: "Fuel" } },
|
||||
where: { codeName: { equals: codeName } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const fuel = r.docs.find((d) => String(d.name).toLowerCase() === "fuel");
|
||||
if (!fuel) throw new Error("No resource named 'Fuel' found. Cannot set vehicle fuel fields");
|
||||
if (byCode.docs[0]) return Number(byCode.docs[0].id);
|
||||
|
||||
const byName = await payload.find({
|
||||
collection: "resources",
|
||||
where: { name: { like: "fuel" } },
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const fuel = byName.docs.find((d) => String(d.name).trim().toLowerCase() === "fuel");
|
||||
if (!fuel) {
|
||||
throw new Error(
|
||||
`No fuel resource found (code name '${codeName}' or name 'Fuel'). Cannot set vehicle fuel fields; pass --fuel <codeName>.`,
|
||||
);
|
||||
}
|
||||
return Number(fuel.id);
|
||||
}
|
||||
|
||||
|
|
@ -351,11 +424,15 @@ async function upsertAsset(payload: Payload, plan: ImportPlan): Promise<UpsertRe
|
|||
"Asset Metadata": assetMetadata(plan),
|
||||
};
|
||||
const imagePath = planImagePath(plan);
|
||||
if (imagePath) {
|
||||
// Attach an image whenever the record has none. That covers importing the
|
||||
// catalog first and adding screenshots in a later run, and repairs records
|
||||
// whose image never landed. An image that is already set is never replaced
|
||||
// (it may be a human's choice), and resolveMedia reuses an existing media
|
||||
// doc, so repeated runs do not pile up duplicates.
|
||||
const current = existing.docs[0] as unknown as { "Asset Metadata"?: { thumbnail?: unknown } };
|
||||
if (imagePath && !current["Asset Metadata"]?.thumbnail) {
|
||||
const media = await resolveMedia(payload, imagePath);
|
||||
// Only attach when this run produced a new media doc — never stomp a
|
||||
// human-chosen thumbnail with a re-discovered one.
|
||||
if (media?.created) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
|
||||
if (media) (data["Asset Metadata"] as Record<string, unknown>).thumbnail = media.id;
|
||||
}
|
||||
await payload.update({ collection: "assets", id, data, overrideAccess: true });
|
||||
return { action: "updated", collection: "assets", id };
|
||||
|
|
@ -408,9 +485,11 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
|
|||
};
|
||||
if (plan.description) data.description = plaintextToLexical(plan.description);
|
||||
const imagePath = planImagePath(plan);
|
||||
if (imagePath) {
|
||||
// Same rule as assets: fill an empty image, never replace one that is set.
|
||||
const current = existing.docs[0] as unknown as { image?: unknown };
|
||||
if (imagePath && !current.image) {
|
||||
const media = await resolveMedia(payload, imagePath);
|
||||
if (media?.created) data.image = media.id;
|
||||
if (media) data.image = media.id;
|
||||
}
|
||||
await payload.update({ collection: "vehicles", id, data, overrideAccess: true });
|
||||
return { action: "updated", collection: "vehicles", id };
|
||||
|
|
@ -438,3 +517,125 @@ async function upsertVehicle(payload: Payload, plan: ImportPlan, fuelId: number
|
|||
});
|
||||
return { action: "created", collection: "vehicles", id: Number(created.id) };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Weapon compatibility (second pass, after every record has been upserted)
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
/** Asset types each Weapon Configuration relationship accepts (its filterOptions). */
|
||||
const MAGAZINE_TYPES = new Set<string>(["magazine"]);
|
||||
const ATTACHMENT_TYPES = new Set<string>(["optic", "muzzle", "bipod", "accessory"]);
|
||||
|
||||
export interface CompatLinkResult {
|
||||
/** Weapons whose relationships or slot changed. */
|
||||
updated: number;
|
||||
/** Weapons already up to date. */
|
||||
unchanged: number;
|
||||
/** Weapon classnames not found in the assets collection. */
|
||||
missingWeapons: number;
|
||||
/** References to classnames that are not imported assets. */
|
||||
unresolved: number;
|
||||
/** References to assets of a type the relationship does not accept
|
||||
* (e.g. rail lights/lasers, which import as "miscellaneous"). */
|
||||
skippedType: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function relationIds(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((v) => (typeof v === "number" ? v : typeof v === "object" && v && "id" in v ? Number((v as { id: unknown }).id) : NaN))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Links each weapon to its compatible magazines and attachments. These are
|
||||
* relationships to the asset rows that were imported, so nothing is copied
|
||||
* or duplicated. Links are MERGED with what is already on the weapon (links
|
||||
* added by hand are kept) and deduplicated; the slot is only filled when it
|
||||
* is empty. Run after upserting the whole catalog, so references to records
|
||||
* later in the file resolve.
|
||||
*/
|
||||
export async function linkWeaponCompatibility(
|
||||
payload: Payload,
|
||||
plans: ImportPlan[],
|
||||
log: (line: string) => void = () => {},
|
||||
): Promise<CompatLinkResult> {
|
||||
const result: CompatLinkResult = { updated: 0, unchanged: 0, missingWeapons: 0, unresolved: 0, skippedType: 0, errors: 0 };
|
||||
const weapons = plans.filter((p) => p.kind === "asset" && p.compat);
|
||||
if (weapons.length === 0) return result;
|
||||
|
||||
// One lookup table for every classname involved: weapons + their references.
|
||||
const wanted = new Set<string>();
|
||||
for (const w of weapons) {
|
||||
wanted.add(w.className);
|
||||
for (const c of [...w.compat!.magazines, ...w.compat!.attachments]) wanted.add(c);
|
||||
}
|
||||
const byLower = new Map<string, { id: number; assetType: string }>();
|
||||
const names = [...wanted];
|
||||
for (let i = 0; i < names.length; i += 500) {
|
||||
const chunk = names.slice(i, i + 500);
|
||||
const found = await payload.find({
|
||||
collection: "assets",
|
||||
where: { className: { in: chunk } },
|
||||
limit: chunk.length * 2,
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const doc of found.docs) {
|
||||
const key = String(doc.className).toLowerCase();
|
||||
if (!byLower.has(key)) byLower.set(key, { id: Number(doc.id), assetType: String(doc.assetType) });
|
||||
}
|
||||
}
|
||||
// The exporter writes each reference with the referenced record's own
|
||||
// classname, so the exact-match query above finds it; the map is keyed in
|
||||
// lower case only so duplicates that differ in case collapse to one id.
|
||||
const resolve = (name: string, allowed: Set<string>, into: number[]) => {
|
||||
const hit = byLower.get(name.toLowerCase());
|
||||
if (!hit) { result.unresolved++; return; }
|
||||
if (!allowed.has(hit.assetType)) { result.skippedType++; return; }
|
||||
if (!into.includes(hit.id)) into.push(hit.id);
|
||||
};
|
||||
|
||||
for (const weapon of weapons) {
|
||||
const self = byLower.get(weapon.className.toLowerCase());
|
||||
if (!self) { result.missingWeapons++; continue; }
|
||||
try {
|
||||
const doc = await payload.findByID({ collection: "assets", id: self.id, depth: 0, overrideAccess: true });
|
||||
const config = (doc as unknown as { "Weapon Configuration"?: Record<string, unknown> })["Weapon Configuration"] ?? {};
|
||||
const currentMags = relationIds(config.compatibleMagazines);
|
||||
const currentAtts = relationIds(config.compatibleAttachments);
|
||||
|
||||
const mags = [...currentMags];
|
||||
const atts = [...currentAtts];
|
||||
for (const m of weapon.compat!.magazines) resolve(m, MAGAZINE_TYPES, mags);
|
||||
for (const a of weapon.compat!.attachments) resolve(a, ATTACHMENT_TYPES, atts);
|
||||
const slot = config.slotType || weapon.compat!.slot || undefined;
|
||||
|
||||
const changed = mags.length !== currentMags.length
|
||||
|| atts.length !== currentAtts.length
|
||||
|| (slot ?? null) !== (config.slotType ?? null);
|
||||
if (!changed) { result.unchanged++; continue; }
|
||||
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: self.id,
|
||||
data: {
|
||||
"Weapon Configuration": {
|
||||
...(slot ? { slotType: slot } : {}),
|
||||
compatibleMagazines: mags,
|
||||
compatibleAttachments: atts,
|
||||
},
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
result.updated++;
|
||||
log(`Linked ${weapon.className}: ${mags.length} magazines, ${atts.length} attachments`);
|
||||
} catch (e) {
|
||||
result.errors++;
|
||||
log(`ERR linking ${weapon.className}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
|
|||
export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Ops due for the imminent reminder: unit-visible, Ready/Scheduled, with a
|
||||
* live roll-call message, starting within the reminder window (or just past
|
||||
* it, within the grace bound), and without the one-shot marker yet.
|
||||
* Ops due for the imminent reminder: unit-visible, Scheduled, with a live
|
||||
* roll-call message, starting within the reminder window (or just past it,
|
||||
* within the grace bound), and without the one-shot marker yet.
|
||||
*/
|
||||
export const getImminentReminderMissions = async (
|
||||
payload: Payload,
|
||||
|
|
@ -45,7 +45,7 @@ export const getImminentReminderMissions = async (
|
|||
where: {
|
||||
and: [
|
||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
||||
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
|
||||
{ discordMessageId: { exists: true } },
|
||||
{ imminentReminderSentAt: { exists: false } },
|
||||
{ "classification.startDateTime": { greater_than: windowStartIso } },
|
||||
|
|
|
|||
|
|
@ -6,52 +6,113 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
|||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
/**
|
||||
* Statuses that count as "committed to run" and are auto-completed once the
|
||||
* calendar day of their scheduled start has fully passed. Draft statuses
|
||||
* (Concept/Planning/Ready) are left untouched — a mission that never ran
|
||||
* should not silently become Completed — and terminal statuses
|
||||
* Statuses that are committed to run and are auto-started (flipped to Active)
|
||||
* the moment their scheduled start time arrives. Draft statuses
|
||||
* (Concept/Planning/Ready) are left untouched — a mission that was never
|
||||
* scheduled should not silently become Active.
|
||||
*/
|
||||
const AUTO_STARTABLE_STATUSES = ["Scheduled"] as const;
|
||||
|
||||
/**
|
||||
* Statuses that count as "committed to run" and are auto-completed once their
|
||||
* scheduled window (start + estimated duration) has fully passed. Draft
|
||||
* statuses (Concept/Planning/Ready) are left untouched — a mission that never
|
||||
* ran should not silently become Completed — and terminal statuses
|
||||
* (Completed/Cancelled) are never re-processed.
|
||||
*/
|
||||
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
|
||||
|
||||
export interface MissionAutoCompleteSummary {
|
||||
completed: number;
|
||||
missions: { id: number; name: string; codeName: string | null }[];
|
||||
/**
|
||||
* Fallback duration (minutes) when a mission's `estimatedDuration` is missing
|
||||
* or invalid. Mirrors the field's own default so a malformed mission still
|
||||
* reaches a terminal state instead of being stuck Active forever.
|
||||
*/
|
||||
export const DEFAULT_MISSION_DURATION_MINUTES = 240;
|
||||
|
||||
export interface MissionLifecycleEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
codeName: string | null;
|
||||
}
|
||||
|
||||
export interface MissionAutoCompleteOptions {
|
||||
export interface MissionAutoStartSummary {
|
||||
started: number;
|
||||
missions: MissionLifecycleEntry[];
|
||||
}
|
||||
|
||||
export interface MissionAutoCompleteSummary {
|
||||
completed: number;
|
||||
missions: MissionLifecycleEntry[];
|
||||
}
|
||||
|
||||
export interface MissionLifecycleOptions {
|
||||
/** Restrict the sweep to specific mission ids (lets tests stay hermetic). */
|
||||
missionIds?: number[];
|
||||
}
|
||||
|
||||
/** Start of `now`'s calendar day in the server's local timezone. */
|
||||
export function startOfToday(now: Date = new Date()): Date {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return start;
|
||||
/**
|
||||
* Resolve a mission's end time (start + estimated duration), or null when the
|
||||
* start timestamp is missing/unparseable. A missing or non-positive duration
|
||||
* falls back to DEFAULT_MISSION_DURATION_MINUTES.
|
||||
*/
|
||||
export function missionEndTime(
|
||||
startDateTime: string | null | undefined,
|
||||
estimatedDurationMinutes: number | null | undefined,
|
||||
): Date | null {
|
||||
const start = new Date(startDateTime ?? "");
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
const minutes =
|
||||
typeof estimatedDurationMinutes === "number" &&
|
||||
Number.isFinite(estimatedDurationMinutes) &&
|
||||
estimatedDurationMinutes > 0
|
||||
? estimatedDurationMinutes
|
||||
: DEFAULT_MISSION_DURATION_MINUTES;
|
||||
return new Date(start.getTime() + minutes * 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the calendar day of `startDateTime` (server-local) has fully
|
||||
* ended — i.e. from the start of the day AFTER the scheduled date. A mission
|
||||
* scheduled for today never counts, even after its start time has passed.
|
||||
* Unparseable dates fall closed so malformed data is never auto-completed.
|
||||
* True once the mission's scheduled start time has arrived. Unparseable dates
|
||||
* fall closed so malformed data is never auto-started.
|
||||
*/
|
||||
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean {
|
||||
const start = new Date(startDateTime);
|
||||
export function hasMissionStarted(
|
||||
startDateTime: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
const start = new Date(startDateTime ?? "");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start.getTime() < startOfToday(now).getTime();
|
||||
return start.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
export async function autoCompleteMissions(
|
||||
/**
|
||||
* True once the mission's scheduled window (start + estimated duration) has
|
||||
* fully passed. Unparseable dates fall closed so malformed data is never
|
||||
* auto-completed.
|
||||
*/
|
||||
export function isMissionWindowComplete(
|
||||
startDateTime: string | null | undefined,
|
||||
estimatedDurationMinutes: number | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
const end = missionEndTime(startDateTime, estimatedDurationMinutes);
|
||||
if (!end) return false;
|
||||
return end.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep missions that are committed to run and whose scheduled start time has
|
||||
* arrived, flipping them to Active. Missions whose entire window has already
|
||||
* elapsed are skipped here (auto-complete owns those) so an op that ran while
|
||||
* the tick was down does not emit a spurious "started" event on its way to
|
||||
* Completed. Idempotent: once Active, a mission is no longer startable.
|
||||
*/
|
||||
export async function autoStartMissions(
|
||||
payload: PayloadType,
|
||||
now: Date = new Date(),
|
||||
options: MissionAutoCompleteOptions = {},
|
||||
): Promise<MissionAutoCompleteSummary> {
|
||||
const cutoff = startOfToday(now).toISOString();
|
||||
options: MissionLifecycleOptions = {},
|
||||
): Promise<MissionAutoStartSummary> {
|
||||
const conditions: Where[] = [
|
||||
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
|
||||
{ "classification.startDateTime": { less_than: cutoff } },
|
||||
{ "ownershipAndStatus.status": { in: [...AUTO_STARTABLE_STATUSES] } },
|
||||
{ "classification.startDateTime": { less_than_equal: now.toISOString() } },
|
||||
];
|
||||
if (options.missionIds) {
|
||||
conditions.push({ id: { in: options.missionIds } });
|
||||
|
|
@ -66,13 +127,91 @@ export async function autoCompleteMissions(
|
|||
overrideAccess: true,
|
||||
})) as unknown as { docs: Mission[] };
|
||||
|
||||
const completed: MissionAutoCompleteSummary["missions"] = [];
|
||||
const started: MissionLifecycleEntry[] = [];
|
||||
for (const mission of due.docs) {
|
||||
// A window that already fully elapsed belongs to auto-complete; starting it
|
||||
// first would emit a misleading "started" event for an op that is over.
|
||||
if (
|
||||
isMissionWindowComplete(
|
||||
mission.classification?.startDateTime,
|
||||
mission.classification?.estimatedDuration,
|
||||
now,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { ownershipAndStatus: { status: "Active" } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MissionAutoStart,
|
||||
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} is now active: the scheduled start time has arrived.`,
|
||||
targetCollection: "missions",
|
||||
targetId: mission.id,
|
||||
data: { previousStatus },
|
||||
});
|
||||
started.push({ id: mission.id, name: mission.name, codeName: mission.codeName ?? null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(
|
||||
`[Mission Tick] Failed to auto-start mission ${mission.id} (${mission.name}): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { started: started.length, missions: started };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep missions whose scheduled window (start + estimated duration) has fully
|
||||
* passed and mark them Completed. Passing the sweep's `now` explicitly keeps
|
||||
* the completion timestamp deterministic for tests; the collection's
|
||||
* beforeChange hook preserves it because it only stamps when empty.
|
||||
* Idempotent: Completed/Cancelled are never re-processed.
|
||||
*/
|
||||
export async function autoCompleteMissions(
|
||||
payload: PayloadType,
|
||||
now: Date = new Date(),
|
||||
options: MissionLifecycleOptions = {},
|
||||
): Promise<MissionAutoCompleteSummary> {
|
||||
const conditions: Where[] = [
|
||||
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
|
||||
// Coarse prefilter: end <= now implies start < now, since duration > 0.
|
||||
{ "classification.startDateTime": { less_than: now.toISOString() } },
|
||||
];
|
||||
if (options.missionIds) {
|
||||
conditions.push({ id: { in: options.missionIds } });
|
||||
}
|
||||
|
||||
const due = (await payload.find({
|
||||
collection: "missions",
|
||||
where: { and: conditions },
|
||||
sort: "classification.startDateTime",
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Mission[] };
|
||||
|
||||
const completed: MissionLifecycleEntry[] = [];
|
||||
for (const mission of due.docs) {
|
||||
if (
|
||||
!isMissionWindowComplete(
|
||||
mission.classification?.startDateTime,
|
||||
mission.classification?.estimatedDuration,
|
||||
now,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||
try {
|
||||
// Pass the sweep's `now` explicitly so the completion timestamp is the
|
||||
// exact tick time (deterministic for tests); the collection's
|
||||
// beforeChange hook preserves it because it only stamps when empty.
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
|
|
@ -81,7 +220,7 @@ export async function autoCompleteMissions(
|
|||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MissionAutoComplete,
|
||||
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled day has passed.`,
|
||||
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled window has ended.`,
|
||||
targetCollection: "missions",
|
||||
targetId: mission.id,
|
||||
data: { previousStatus },
|
||||
|
|
|
|||
|
|
@ -7,18 +7,26 @@ import { notifyUser } from "@/lib/notifications";
|
|||
*
|
||||
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
|
||||
* creator gets a once-per-day reminder (web notification + Discord DM) to
|
||||
* finish the op and mark it "Ready" — the status that triggers the roll-call
|
||||
* embed, locks in details, and delivers the mission hooks. Reminders stop the
|
||||
* moment the op is Ready (or cancelled/completed/active), and a marker field
|
||||
* records the last server-local day each channel was sent so the cadence is
|
||||
* exactly one reminder per day per channel.
|
||||
* finish the op and commit it to "Scheduled" (markSideOpScheduled): the status
|
||||
* that triggers the roll-call embed, locks in details, and delivers the mission
|
||||
* hooks. Reminders stop the moment the op is Scheduled (or
|
||||
* cancelled/completed/active), and a marker field records the last server-local
|
||||
* day each channel was sent so the cadence is exactly one reminder per day per
|
||||
* channel.
|
||||
*
|
||||
* Side ops are created as a quiet "Ready" draft (createFridaySideOp), so a
|
||||
* reminder is due while the op is still Concept/Planning/Ready.
|
||||
*
|
||||
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
|
||||
* calendar — always `operationType: "side"`). Staff/admin-created missions are
|
||||
* calendar, always `operationType: "side"`). Staff/admin-created missions are
|
||||
* never reminded.
|
||||
*
|
||||
* Shared by the mission-tick bin (web notifications) and the Discord bot's
|
||||
* readyReminders service (DMs) — keep this module free of discord.js imports.
|
||||
* readyReminders service (DMs): keep this module free of discord.js imports.
|
||||
*
|
||||
* Note: the module name, notification type ("mission:ready-reminder"), and
|
||||
* marker fields keep their legacy "ready" spelling to avoid a data migration;
|
||||
* only the user-facing copy talks about scheduling.
|
||||
*/
|
||||
|
||||
/** How many days before the op the daily reminders begin. */
|
||||
|
|
@ -28,11 +36,11 @@ export const READY_REMINDER_LEAD_DAYS = 5;
|
|||
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
|
||||
|
||||
/**
|
||||
* Statuses that still need the creator's attention. "Ready" is the committed
|
||||
* state the reminders push toward; Active/Completed/Cancelled are terminal or
|
||||
* already underway.
|
||||
* Statuses that still need the creator's attention. "Scheduled" is the
|
||||
* committed state the reminders push toward; Active/Completed/Cancelled are
|
||||
* terminal or already underway.
|
||||
*/
|
||||
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
|
||||
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Ready"] as const;
|
||||
|
||||
export const missionTitle = (mission: Mission): string =>
|
||||
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
||||
|
|
@ -46,8 +54,8 @@ export const dayKey = (date: Date): string =>
|
|||
/**
|
||||
* A channel is due when it has never sent, or last sent on a previous
|
||||
* server-local day. Day-based (not 24h-based) so retries within the same day
|
||||
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
|
||||
* next day instead of being suppressed forever.
|
||||
* stay deduped, and a status flapping Scheduled back to Ready resumes reminders
|
||||
* the next day instead of being suppressed forever.
|
||||
*/
|
||||
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
|
||||
if (!marker) return true;
|
||||
|
|
@ -60,10 +68,10 @@ export const daysUntil = (startIso: string, now: Date): number =>
|
|||
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
|
||||
|
||||
/**
|
||||
* Ops due for reminders: player-created (createdBy set), still not Ready /
|
||||
* Ops due for reminders: player-created (createdBy set), still not Scheduled /
|
||||
* terminal, and starting within the lead window (future start, at most
|
||||
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
|
||||
* the caller via isReminderDue — the "not sent today" condition can't be
|
||||
* the caller via isReminderDue: the "not sent today" condition can't be
|
||||
* expressed in a single where clause.
|
||||
*/
|
||||
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
|
||||
|
|
@ -122,8 +130,8 @@ export const sendWebReadyReminders = async (
|
|||
await notifyUser(payload, {
|
||||
userId: creatorId,
|
||||
type: READY_REMINDER_NOTIFICATION_TYPE,
|
||||
title: `Ready check: ${missionTitle(mission)}`,
|
||||
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
|
||||
title: `Schedule check: ${missionTitle(mission)}`,
|
||||
message: `Your op starts ${when}. Finish up the details and set the mission status to Scheduled so the roll-call can be posted and the mission hooks delivered.`,
|
||||
link: `/intelligence/missions/${mission.id}`,
|
||||
});
|
||||
await payload.update({
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export function getAssetTypeLabel(assetType: Asset["assetType"]): string {
|
|||
return "Muzzle";
|
||||
case "bipod":
|
||||
return "Bipod";
|
||||
case "accessory":
|
||||
return "Accessory";
|
||||
case "uniform":
|
||||
return "Uniform";
|
||||
case "vest":
|
||||
|
|
@ -133,6 +135,7 @@ const ASSET_TYPE_COLORS: Record<Asset["assetType"], string> = {
|
|||
optic: "oklch(0.45 0.1 250)",
|
||||
muzzle: "oklch(0.45 0.1 200)",
|
||||
bipod: "oklch(0.45 0.1 160)",
|
||||
accessory: "oklch(0.5 0.13 60)",
|
||||
uniform: "oklch(0.4 0.09 130)",
|
||||
vest: "oklch(0.42 0.09 110)",
|
||||
backpack: "oklch(0.42 0.09 75)",
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function notificationLabel(type?: string | null): string {
|
|||
case "ticket:status":
|
||||
return "Ticket updated";
|
||||
case "mission:ready-reminder":
|
||||
return "Ready reminder";
|
||||
return "Schedule reminder";
|
||||
case "assignment-transfer:request":
|
||||
return "Transfer request";
|
||||
case "assignment-transfer:approved":
|
||||
|
|
@ -117,6 +117,24 @@ export function notificationLabel(type?: string | null): string {
|
|||
return "Ribbon not approved";
|
||||
case "personnel:promoted":
|
||||
return "Promotion";
|
||||
case "personnel:transferred":
|
||||
return "Transferred";
|
||||
case "promotion:nomination":
|
||||
return "Promotion nomination";
|
||||
case "promotion:endorsed":
|
||||
return "Nomination endorsed";
|
||||
case "promotion:approved":
|
||||
return "Promotion approved";
|
||||
case "promotion:rejected":
|
||||
return "Promotion declined";
|
||||
case "assignment:leadership-transfer-requested":
|
||||
return "Removal request";
|
||||
case "assignment:leadership-transfer":
|
||||
return "Leadership transfer";
|
||||
case "assignment:leadership-transfer-approved":
|
||||
return "Removal approved";
|
||||
case "assignment:leadership-transfer-denied":
|
||||
return "Removal denied";
|
||||
case "mission:role-promoted":
|
||||
return "Role slot granted";
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { Payload } from "payload";
|
||||
import type { Payload, PayloadRequest } from "payload";
|
||||
import type { Rank, User } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
compareRanks,
|
||||
isAdminOrDeveloper,
|
||||
resolvePromotionAuthority,
|
||||
resolveRankFromLadder,
|
||||
} from "./authority";
|
||||
|
|
@ -46,17 +48,30 @@ async function findProfileByUserId(payload: Payload, userId: number) {
|
|||
* own rank's promotionCeiling (clamped below their own rank), or unbounded for
|
||||
* admins/developers. Promotions only: the target rank must be strictly above
|
||||
* the member's current rank and strictly below the actor's own rank. Emits a
|
||||
* `personnel:promoted` event and notifies the promoted member.
|
||||
* `personnel:promoted` event and notifies the promoted member. Pass `req` to
|
||||
* join the rank write into an outer transaction (e.g. the nomination
|
||||
* approval flow).
|
||||
*
|
||||
* `ignoreActorRankLimit` lets an admin/developer or roleDocs superuser act as
|
||||
* unit command rather than as a rank-holder: the "strictly below the actor's
|
||||
* own rank" bound and their promotion ceiling stop applying, and the actor may
|
||||
* hold no ranked profile at all. Reserved for flows where the final decision
|
||||
* is gated elsewhere (promotion nomination approval).
|
||||
*/
|
||||
export async function promoteMember(
|
||||
payload: Payload,
|
||||
actor: User,
|
||||
username: string,
|
||||
newRankId: number,
|
||||
options?: { req?: Partial<PayloadRequest>; ignoreActorRankLimit?: boolean },
|
||||
): Promise<PromotionResult> {
|
||||
const { authority, ladder, actorRank } = await resolvePromotionAuthority(payload, actor);
|
||||
|
||||
if (!authority || !actorRank) {
|
||||
const commandBypass =
|
||||
options?.ignoreActorRankLimit === true &&
|
||||
(isAdminOrDeveloper(actor) || (await isSuperuser(payload, actor)));
|
||||
|
||||
if ((!authority || !actorRank) && !commandBypass) {
|
||||
throw new Error("You do not have promotion authority.");
|
||||
}
|
||||
|
||||
|
|
@ -85,13 +100,18 @@ export async function promoteMember(
|
|||
throw new Error("Promotions only: the new rank must be above the member's current rank.");
|
||||
}
|
||||
|
||||
// Never at or above the actor's own rank.
|
||||
if (compareRanks(ladder, toRank, actorRank) >= 0) {
|
||||
// Never at or above the actor's own rank. Command authority (superuser
|
||||
// approval flows) acts for the unit and bypasses this bound.
|
||||
if (!commandBypass && actorRank && compareRanks(ladder, toRank, actorRank) >= 0) {
|
||||
throw new Error("You cannot promote to a rank at or above your own.");
|
||||
}
|
||||
|
||||
// Ceiling bound for non-admin authority.
|
||||
if (authority.kind === "ceiling" && compareRanks(ladder, toRank, authority.maxRank) > 0) {
|
||||
if (
|
||||
!commandBypass &&
|
||||
authority?.kind === "ceiling" &&
|
||||
compareRanks(ladder, toRank, authority.maxRank) > 0
|
||||
) {
|
||||
throw new Error(`You cannot promote above ${authority.maxRank.name}.`);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +120,7 @@ export async function promoteMember(
|
|||
id: targetProfile.id,
|
||||
data: { rank: toRank.id },
|
||||
overrideAccess: true,
|
||||
...(options?.req ? { req: options.req } : {}),
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
|
|
|
|||
510
src/lib/promotions/nominations.ts
Normal file
510
src/lib/promotions/nominations.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
import type { Payload, PayloadRequest, Where } from "payload";
|
||||
import type { Assignment, PromotionNomination, User } from "@/payload-types";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { loadRankLadder, rankIndex, resolveRankFromLadder } from "./authority";
|
||||
import { promoteMember } from "./index";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants + internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOMINATION_LINK = "/personnel/promotions";
|
||||
const MIN_ACCOLADES_LENGTH = 20;
|
||||
const OPEN_NOMINATION_STATUSES = ["pending", "awaiting_superuser"] as const;
|
||||
|
||||
/** Label for a possibly-unpopulated user reference. */
|
||||
function userLabelFromRef(ref: number | User | null | undefined): string | null {
|
||||
if (ref == null || typeof ref !== "object") return null;
|
||||
return ref.displayName || ref.username;
|
||||
}
|
||||
|
||||
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
|
||||
function relId(value: number | User | null | undefined): number | null {
|
||||
if (value == null) return null;
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function userLabel(user: Pick<User, "displayName" | "username">): string {
|
||||
return user.displayName || user.username;
|
||||
}
|
||||
|
||||
async function fetchUser(payload: Payload, userId: number): Promise<User | null> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return doc ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare-and-set update on a nomination. Returns null when the where clause
|
||||
* matches nothing (the state changed underneath us); callers must throw a
|
||||
* descriptive error in that case.
|
||||
*/
|
||||
async function casUpdate(
|
||||
payload: Payload,
|
||||
where: Where,
|
||||
data: Partial<PromotionNomination>,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<PromotionNomination | null> {
|
||||
const res = await payload.update({
|
||||
collection: "promotion-nominations",
|
||||
where,
|
||||
data,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
...(req ? { req } : {}),
|
||||
});
|
||||
if (res.docs.length === 0) return null;
|
||||
return res.docs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open (not yet resolved) division-style assignment a user belongs to
|
||||
* as a member or leader, with the leader relationship populated.
|
||||
*/
|
||||
export async function resolveMemberDivision(
|
||||
payload: Payload,
|
||||
userId: number,
|
||||
): Promise<Assignment | null> {
|
||||
const res = await payload.find({
|
||||
collection: "assignments",
|
||||
where: {
|
||||
and: [
|
||||
{ type: { equals: "division" } },
|
||||
{
|
||||
or: [{ members: { equals: userId } }, { leader: { equals: userId } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify every admin/developer about a nomination event. Mirrors the transfers
|
||||
* helper but lives here so the promotions lib owns no cross-domain import.
|
||||
*/
|
||||
async function notifySuperusersLocal(
|
||||
payload: Payload,
|
||||
opts: { type: string; title: string; message?: string; link?: string },
|
||||
): Promise<void> {
|
||||
const res = await payload.find({
|
||||
collection: "users",
|
||||
limit: 200,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const superuserIds = res.docs
|
||||
.filter((u) => u.roles?.some((role) => role === "admin" || role === "developer") === true)
|
||||
.map((u) => u.id);
|
||||
for (const id of superuserIds) {
|
||||
await notifyUser(payload, {
|
||||
userId: id,
|
||||
type: opts.type,
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
link: opts.link ?? NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: single source of truth for the web actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Submit a promotion nomination. Guards: nominee exists and has a profile rank;
|
||||
* accolades carry at least a sentence; no already-open nomination for the
|
||||
* nominee; nominee is not at the top of the rank ladder. The nominee's division
|
||||
* leader endorses first; leaderless divisions (or a nomination by the leader
|
||||
* themselves) skip straight to final approval. Submissions stay private: no
|
||||
* public event is emitted for them.
|
||||
*/
|
||||
export async function submitNomination(
|
||||
payload: Payload,
|
||||
nominator: User,
|
||||
opts: { nomineeId: number; accolades: string },
|
||||
): Promise<PromotionNomination> {
|
||||
const accolades = opts.accolades.trim();
|
||||
if (accolades.length < MIN_ACCOLADES_LENGTH) {
|
||||
throw new Error("Provide at least a sentence describing their performance.");
|
||||
}
|
||||
|
||||
const nominee = await fetchUser(payload, opts.nomineeId);
|
||||
if (!nominee) {
|
||||
throw new Error("Nominee not found.");
|
||||
}
|
||||
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: nominee.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const profile = profileRes.docs[0] ?? null;
|
||||
if (!profile?.rank) {
|
||||
throw new Error("This member does not have a rank on file.");
|
||||
}
|
||||
|
||||
const open = await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [{ nominee: { equals: nominee.id } }, { status: { in: [...OPEN_NOMINATION_STATUSES] } }],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (open.docs.length > 0) {
|
||||
throw new Error("This member already has an open promotion nomination.");
|
||||
}
|
||||
|
||||
const ladder = await loadRankLadder(payload);
|
||||
const currentRank = resolveRankFromLadder(ladder, profile.rank);
|
||||
if (!currentRank) {
|
||||
throw new Error("This member does not have a rank on file.");
|
||||
}
|
||||
const currentIndex = rankIndex(ladder, currentRank.id);
|
||||
if (currentIndex + 1 >= ladder.length) {
|
||||
throw new Error(`${userLabel(nominee)} already holds the highest rank.`);
|
||||
}
|
||||
|
||||
const division = await resolveMemberDivision(payload, nominee.id);
|
||||
const divisionLeaderId = relId(division?.leader ?? null);
|
||||
const leaderMustDecide = divisionLeaderId != null && divisionLeaderId !== nominator.id;
|
||||
|
||||
const nominatorLabel = userLabel(nominator);
|
||||
const nomineeLabel = userLabel(nominee);
|
||||
|
||||
const created = await payload.create({
|
||||
collection: "promotion-nominations",
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
data: {
|
||||
nominee: nominee.id,
|
||||
nominator: nominator.id,
|
||||
accolades,
|
||||
status: leaderMustDecide ? "pending" : "awaiting_superuser",
|
||||
leaderDecision: leaderMustDecide ? "pending" : "not_required",
|
||||
},
|
||||
});
|
||||
|
||||
const message = `${nominatorLabel} nominated ${nomineeLabel} for promotion.`;
|
||||
if (leaderMustDecide && divisionLeaderId != null) {
|
||||
await notifyUser(payload, {
|
||||
userId: divisionLeaderId,
|
||||
type: "promotion:nomination",
|
||||
title: "Promotion nomination",
|
||||
message,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
} else {
|
||||
await notifySuperusersLocal(payload, {
|
||||
type: "promotion:nomination",
|
||||
title: "Promotion nomination",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the division leader's decision on a pending nomination. The actor
|
||||
* must be the nominee's CURRENT division leader (superuser bypass is for the
|
||||
* admin panel only). Endorsement moves the nomination to awaiting_superuser;
|
||||
* rejection is terminal. The nominator is notified either way.
|
||||
*/
|
||||
export async function recordLeaderDecision(
|
||||
payload: Payload,
|
||||
leader: User,
|
||||
nominationId: number,
|
||||
opts: { endorse: boolean; note?: string },
|
||||
): Promise<PromotionNomination> {
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "pending" || nomination.leaderDecision !== "pending") {
|
||||
throw new Error("This nomination is not awaiting endorsement.");
|
||||
}
|
||||
|
||||
const nomineeId = relId(nomination.nominee);
|
||||
if (nomineeId == null) {
|
||||
throw new Error("This nomination has no nominee on file.");
|
||||
}
|
||||
const division = await resolveMemberDivision(payload, nomineeId);
|
||||
const currentLeaderId = relId(division?.leader ?? null);
|
||||
const actorIsSuperuser = await isSuperuser(payload, leader);
|
||||
if (!actorIsSuperuser && currentLeaderId !== leader.id) {
|
||||
throw new Error("Only the nominee's division leader can decide this nomination.");
|
||||
}
|
||||
|
||||
const trimmedNote = opts.note?.trim();
|
||||
const decidedAt = new Date().toISOString();
|
||||
const updated = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [
|
||||
{ id: { equals: nomination.id } },
|
||||
{ status: { equals: "pending" } },
|
||||
{ leaderDecision: { equals: "pending" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: opts.endorse ? "awaiting_superuser" : "rejected",
|
||||
leaderDecision: opts.endorse ? "endorsed" : "rejected",
|
||||
leaderDecisionBy: leader.id,
|
||||
leaderDecidedAt: decidedAt,
|
||||
...(trimmedNote ? { leaderNote: trimmedNote } : {}),
|
||||
},
|
||||
);
|
||||
if (!updated) {
|
||||
throw new Error("This nomination changed state before the decision could be recorded.");
|
||||
}
|
||||
|
||||
const nominatorId = relId(updated.nominator);
|
||||
if (nominatorId == null) {
|
||||
throw new Error("This nomination has no nominator on file.");
|
||||
}
|
||||
const nominatorDoc = await fetchUser(payload, nominatorId);
|
||||
const nominatorLabel = nominatorDoc ? userLabel(nominatorDoc) : `user #${nominatorId}`;
|
||||
const nomineeLabel = userLabelFromRef(nomination.nominee) ?? `user #${nomineeId}`;
|
||||
const leaderLabel = userLabel(leader);
|
||||
const noteSuffix = trimmedNote ? ` Note: "${trimmedNote}"` : "";
|
||||
|
||||
if (opts.endorse) {
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:endorsed",
|
||||
title: "Nomination endorsed",
|
||||
message: `${leaderLabel} endorsed your promotion nomination for ${nomineeLabel}.${noteSuffix}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
} else {
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:rejected",
|
||||
title: "Nomination declined",
|
||||
message: `${leaderLabel} declined the promotion nomination for ${nomineeLabel}.${noteSuffix}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final superuser call on an endorsed nomination. Approval bumps the nominee
|
||||
* exactly one rung up the ladder inside a single transaction (the nomination
|
||||
* update and the rank write commit together); a promoteMember failure rolls
|
||||
* the whole thing back and the nomination stays awaiting_superuser. Rejection
|
||||
* is terminal. Only the approval emits a public event; rejections stay private.
|
||||
*/
|
||||
export async function resolveNomination(
|
||||
payload: Payload,
|
||||
reviewer: User,
|
||||
nominationId: number,
|
||||
opts: { approve: boolean; note?: string },
|
||||
): Promise<PromotionNomination> {
|
||||
if (!(await isSuperuser(payload, reviewer))) {
|
||||
throw new Error("Only a superuser can resolve a promotion nomination.");
|
||||
}
|
||||
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "awaiting_superuser") {
|
||||
throw new Error(
|
||||
`Only nominations awaiting final approval can be resolved (current status: ${nomination.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedNote = opts.note?.trim();
|
||||
const nomineeId = relId(nomination.nominee);
|
||||
const nominatorId = relId(nomination.nominator);
|
||||
if (nomineeId == null) {
|
||||
throw new Error("This nomination has no nominee on file.");
|
||||
}
|
||||
if (nominatorId == null) {
|
||||
throw new Error("This nomination has no nominator on file.");
|
||||
}
|
||||
|
||||
const nomineeDoc = await fetchUser(payload, nomineeId);
|
||||
const nominatorDoc = await fetchUser(payload, nominatorId);
|
||||
const nomineeLabel = nomineeDoc ? userLabel(nomineeDoc) : `user #${nomineeId}`;
|
||||
const nominatorLabel = nominatorDoc ? userLabel(nominatorDoc) : `user #${nominatorId}`;
|
||||
|
||||
if (!opts.approve) {
|
||||
const updated = await payload.update({
|
||||
collection: "promotion-nominations",
|
||||
id: nomination.id,
|
||||
data: {
|
||||
status: "rejected",
|
||||
reviewedBy: reviewer.id,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
...(trimmedNote ? { reviewNote: trimmedNote } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:rejected",
|
||||
title: "Promotion declined",
|
||||
message: `The promotion nomination for ${nomineeLabel} was declined by final review.${
|
||||
trimmedNote ? ` Note: "${trimmedNote}"` : ""
|
||||
}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
const txId = await payload.db.beginTransaction();
|
||||
if (txId == null) throw new Error("Failed to begin database transaction.");
|
||||
const req: Partial<PayloadRequest> = { transactionID: txId };
|
||||
let rolledBack = false;
|
||||
try {
|
||||
const approved = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [{ id: { equals: nomination.id } }, { status: { equals: "awaiting_superuser" } }],
|
||||
},
|
||||
{
|
||||
status: "approved",
|
||||
reviewedBy: reviewer.id,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
...(trimmedNote ? { reviewNote: trimmedNote } : {}),
|
||||
},
|
||||
req,
|
||||
);
|
||||
if (!approved) {
|
||||
throw new Error("This nomination changed state before final approval could be recorded.");
|
||||
}
|
||||
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: nomineeId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
});
|
||||
const profile = profileRes.docs[0] ?? null;
|
||||
const ladder = await loadRankLadder(payload);
|
||||
const currentRank = resolveRankFromLadder(ladder, profile?.rank);
|
||||
if (!profile?.rank || !currentRank) {
|
||||
throw new Error("The nominee does not have a rank on file.");
|
||||
}
|
||||
const nextRank = ladder[rankIndex(ladder, currentRank.id) + 1] ?? null;
|
||||
if (!nextRank) {
|
||||
throw new Error(
|
||||
"The nominee already holds the highest rank, so the approval cannot be applied.",
|
||||
);
|
||||
}
|
||||
if (!nomineeDoc) {
|
||||
throw new Error("The nominee could not be loaded for the promotion.");
|
||||
}
|
||||
|
||||
await promoteMember(payload, reviewer, nomineeDoc.username, nextRank.id, {
|
||||
req,
|
||||
ignoreActorRankLimit: true,
|
||||
});
|
||||
|
||||
await payload.db.commitTransaction(txId);
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.PromotionNominationApproved,
|
||||
message: `${nomineeLabel} was promoted to ${nextRank.name} via nomination by ${nominatorLabel}, approved by ${userLabel(reviewer)}.`,
|
||||
actor: reviewer.id,
|
||||
targetCollection: "promotion-nominations",
|
||||
targetId: nomination.id,
|
||||
data: {
|
||||
nominationId: nomination.id,
|
||||
nomineeId,
|
||||
nominatorId,
|
||||
fromRankId: currentRank.id,
|
||||
toRankId: nextRank.id,
|
||||
},
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:approved",
|
||||
title: "Promotion approved",
|
||||
message: `Your promotion nomination for ${nomineeLabel} was approved: they now hold ${nextRank.name}.`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
|
||||
return approved;
|
||||
} catch (err) {
|
||||
if (!rolledBack) await payload.db.rollbackTransaction(txId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a nomination. Only the nominator, only while it is still awaiting
|
||||
* the division leader's endorsement. Silent by design: only the leader knew,
|
||||
* and a withdrawn nomination leaves no unit-visible trace.
|
||||
*/
|
||||
export async function cancelNomination(
|
||||
payload: Payload,
|
||||
nominator: User,
|
||||
nominationId: number,
|
||||
): Promise<PromotionNomination> {
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "pending") {
|
||||
throw new Error("Only pending nominations can be withdrawn.");
|
||||
}
|
||||
const nominationNominatorId = relId(nomination.nominator);
|
||||
if (nominationNominatorId !== nominator.id) {
|
||||
throw new Error("Only your own pending nominations can be withdrawn.");
|
||||
}
|
||||
|
||||
const updated = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [{ id: { equals: nomination.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
{ status: "cancelled" },
|
||||
);
|
||||
if (!updated) {
|
||||
throw new Error("This nomination changed state before it could be withdrawn.");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
|
@ -31,11 +31,15 @@ async function fetchTransfer(payload: Payload, requestId: number): Promise<Assig
|
|||
return doc;
|
||||
}
|
||||
|
||||
async function fetchAssignment(
|
||||
/**
|
||||
* Fetch an assignment's id/name/type/leader/members. Exported for the
|
||||
* leadership-transfer service, which shares the same re-validation needs.
|
||||
*/
|
||||
export async function fetchAssignment(
|
||||
payload: Payload,
|
||||
id: number,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<{ id: number; name: string; leader: number | null; members: number[] }> {
|
||||
): Promise<{ id: number; name: string; type: string; leader: number | null; members: number[] }> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "assignments",
|
||||
id,
|
||||
|
|
@ -46,6 +50,7 @@ async function fetchAssignment(
|
|||
return {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
type: doc.type,
|
||||
leader: (doc.leader as number | null) ?? null,
|
||||
members: (doc.members as number[]) ?? [],
|
||||
};
|
||||
|
|
@ -65,7 +70,7 @@ async function superuserIds(payload: Payload): Promise<number[]> {
|
|||
return res.docs.filter((u) => hasRoles(["admin", "developer"], u)).map((u) => u.id);
|
||||
}
|
||||
|
||||
async function notifySuperusers(
|
||||
export async function notifySuperusers(
|
||||
payload: Payload,
|
||||
type: string,
|
||||
title: string,
|
||||
|
|
@ -106,6 +111,54 @@ async function casUpdate(
|
|||
return res.docs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional member-move core shared by the assignment-transfer execution
|
||||
* path (executeTransfer) and the leadership-transfer service. Must be called
|
||||
* inside the caller's transaction (thread its `req`): re-reads both
|
||||
* assignments inside that transaction, removes the subject from the source
|
||||
* members array, and appends them to the destination members array (a no-op
|
||||
* append when the subject is already there).
|
||||
*/
|
||||
export async function moveMemberBetweenAssignments(
|
||||
payload: Payload,
|
||||
opts: {
|
||||
req: Partial<PayloadRequest>;
|
||||
subjectId: number;
|
||||
fromAssignmentId: number;
|
||||
toAssignmentId: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { req, subjectId, fromAssignmentId, toAssignmentId } = opts;
|
||||
const [fromAssignment, toAssignment] = await Promise.all([
|
||||
fetchAssignment(payload, fromAssignmentId, req),
|
||||
fetchAssignment(payload, toAssignmentId, req),
|
||||
]);
|
||||
|
||||
if (!fromAssignment.members.includes(subjectId)) {
|
||||
throw new Error("The subject is no longer a member of the source assignment.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: fromAssignment.id,
|
||||
data: { members: fromAssignment.members.filter((id) => id !== subjectId) },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
|
||||
if (!toAssignment.members.includes(subjectId)) {
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: toAssignment.id,
|
||||
data: { members: [...toAssignment.members, subjectId] },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isRequired(doc: AssignmentTransfer, side: TransferSide): boolean {
|
||||
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
|
||||
return decision !== "not_required";
|
||||
|
|
@ -233,24 +286,15 @@ async function executeTransfer(
|
|||
throw new Error("The requester is no longer a member of the source assignment.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: fromAssignment.id,
|
||||
data: { members: fromAssignment.members.filter((id) => id !== fresh.requester) },
|
||||
overrideAccess: true,
|
||||
// The shared move core re-reads both assignments inside this transaction
|
||||
// and performs the same member-array splice this function used to inline.
|
||||
await moveMemberBetweenAssignments(payload, {
|
||||
req,
|
||||
subjectId: relId(fresh.requester),
|
||||
fromAssignmentId: fromAssignment.id,
|
||||
toAssignmentId: toAssignment.id,
|
||||
});
|
||||
|
||||
if (!toAssignment.members.includes(relId(fresh.requester))) {
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: toAssignment.id,
|
||||
data: { members: [...toAssignment.members, fresh.requester] },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
const completionData: Partial<AssignmentTransfer> = { status: "completed" };
|
||||
if (opts.viaSuperuser) {
|
||||
completionData.resolvedBy = opts.resolvedBy;
|
||||
|
|
|
|||
341
src/lib/transfers/leadership.ts
Normal file
341
src/lib/transfers/leadership.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import type { Payload, PayloadRequest } from "payload";
|
||||
import type { LeadershipTransfer, User } from "@/payload-types";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { fetchAssignment, moveMemberBetweenAssignments, notifySuperusers } from "./index";
|
||||
|
||||
/**
|
||||
* Notification type strings for the leadership-initiated removal flow. Defined
|
||||
* once here so the lib and any future consumers reference the same values; the
|
||||
* in-app label map in src/lib/notifications/index.ts already labels all four.
|
||||
* These deliberately bypass user mutes (removals are workflow-critical).
|
||||
*/
|
||||
export const LEADERSHIP_TRANSFER_NOTIFICATION_TYPES = {
|
||||
requested: "assignment:leadership-transfer-requested",
|
||||
transferred: "assignment:leadership-transfer",
|
||||
approved: "assignment:leadership-transfer-approved",
|
||||
denied: "assignment:leadership-transfer-denied",
|
||||
} as const;
|
||||
|
||||
const LT = LEADERSHIP_TRANSFER_NOTIFICATION_TYPES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
|
||||
function relId(value: number | { id: number }): number {
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function userLabel(
|
||||
user: Pick<User, "displayName" | "username"> | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!user) return fallback;
|
||||
return user.displayName || user.username || fallback;
|
||||
}
|
||||
|
||||
async function fetchLeadershipTransfer(
|
||||
payload: Payload,
|
||||
transferId: number,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<LeadershipTransfer> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "leadership-transfers",
|
||||
id: transferId,
|
||||
depth: 0,
|
||||
...(req ? { req } : {}),
|
||||
});
|
||||
if (!doc) throw new Error("Removal request not found.");
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Infantry assignment id configured on the Game Rules global.
|
||||
* Returns null when command has not configured a destination yet.
|
||||
*/
|
||||
async function resolveInfantryAssignmentId(payload: Payload): Promise<number | null> {
|
||||
const rules = await payload.findGlobal({ slug: "game-rules", depth: 0 });
|
||||
const configured = rules?.infantryAssignment;
|
||||
if (configured == null) return null;
|
||||
return relId(configured);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A division leader requests removing a member from their division. The
|
||||
* destination is always the Infantry assignment configured on Game Rules.
|
||||
* Every removal takes effect only after a superuser approves.
|
||||
*
|
||||
* Guards: the initiator is the CURRENT leader of a type-"division" assignment;
|
||||
* the subject is one of its members and not the initiator; the Infantry
|
||||
* destination is configured and is not the source itself (Infantry-origin
|
||||
* removals are out of scope); no other pending request exists for the same
|
||||
* subject + source.
|
||||
*
|
||||
* Nothing public happens here: no subject notification and no event-log
|
||||
* entry while the request is pending. Only the superusers are notified.
|
||||
*/
|
||||
export async function requestLeadershipTransfer(
|
||||
payload: Payload,
|
||||
initiator: User,
|
||||
opts: { subjectId: number; fromAssignmentId: number; reason?: string },
|
||||
): Promise<LeadershipTransfer> {
|
||||
const { subjectId, fromAssignmentId } = opts;
|
||||
const reason = opts.reason?.trim();
|
||||
|
||||
const fromAssignment = await fetchAssignment(payload, fromAssignmentId);
|
||||
|
||||
if (fromAssignment.leader !== initiator.id) {
|
||||
throw new Error(`Only the current leader of ${fromAssignment.name} can request this removal.`);
|
||||
}
|
||||
if (fromAssignment.type !== "division") {
|
||||
throw new Error("Only divisions can remove members this way.");
|
||||
}
|
||||
if (subjectId === initiator.id) {
|
||||
throw new Error("You cannot remove yourself.");
|
||||
}
|
||||
if (!fromAssignment.members.includes(subjectId)) {
|
||||
throw new Error("That member is not a member of your division.");
|
||||
}
|
||||
|
||||
const infantryId = await resolveInfantryAssignmentId(payload);
|
||||
if (infantryId == null) {
|
||||
throw new Error("Command must configure the default transfer destination first (Game Rules).");
|
||||
}
|
||||
if (infantryId === fromAssignmentId) {
|
||||
throw new Error("Infantry-origin removals are not supported yet.");
|
||||
}
|
||||
|
||||
const open = await payload.find({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [
|
||||
{ subject: { equals: subjectId } },
|
||||
{ fromAssignment: { equals: fromAssignmentId } },
|
||||
{ status: { equals: "pending" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (open.docs.length > 0) {
|
||||
throw new Error("A removal request for this member is already pending.");
|
||||
}
|
||||
|
||||
const [subject, infantry] = await Promise.all([
|
||||
payload.findByID({ collection: "users", id: subjectId, depth: 0 }),
|
||||
fetchAssignment(payload, infantryId),
|
||||
]);
|
||||
|
||||
const created = await payload.create({
|
||||
collection: "leadership-transfers",
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
data: {
|
||||
initiator: initiator.id,
|
||||
subject: subjectId,
|
||||
fromAssignment: fromAssignmentId,
|
||||
toAssignment: infantryId,
|
||||
status: "pending",
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await notifySuperusers(
|
||||
payload,
|
||||
LT.requested,
|
||||
"Removal request",
|
||||
`${userLabel(initiator, `user #${initiator.id}`)} requested transferring ${userLabel(subject, `user #${subjectId}`)} from ${fromAssignment.name} to ${infantry.name}`,
|
||||
);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Superuser decision on a pending removal request.
|
||||
*
|
||||
* Approve: atomically (one transaction) stamps the request approved via a
|
||||
* compare-and-set update (status pending -> approved, with reviewedBy,
|
||||
* resolvedAt and the optional review note) and moves the member from the
|
||||
* source division into the configured Infantry assignment. Any failure rolls
|
||||
* the whole transaction back and the request stays pending. After the commit
|
||||
* the subject and the initiator are notified and a public event is emitted:
|
||||
* the user-facing attribution is always the LEADER (the initiator), never the
|
||||
* approving superuser, whose id is recorded in the event data for audit only.
|
||||
*
|
||||
* Deny: a plain terminal update; only the initiator is notified. The subject
|
||||
* learns nothing and no public event is emitted.
|
||||
*/
|
||||
export async function resolveLeadershipTransfer(
|
||||
payload: Payload,
|
||||
resolver: User,
|
||||
transferId: number,
|
||||
opts: { approve: boolean; note?: string },
|
||||
): Promise<LeadershipTransfer> {
|
||||
if (!(await isSuperuser(payload, resolver))) {
|
||||
throw new Error("Only a superuser can resolve removal requests.");
|
||||
}
|
||||
|
||||
const doc = await fetchLeadershipTransfer(payload, transferId);
|
||||
if (doc.status !== "pending") {
|
||||
throw new Error(`Only pending removal requests can be resolved (current status: ${doc.status}).`);
|
||||
}
|
||||
|
||||
const note = opts.note?.trim();
|
||||
|
||||
if (!opts.approve) {
|
||||
const denied = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
id: doc.id,
|
||||
data: {
|
||||
status: "denied",
|
||||
reviewedBy: resolver.id,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
...(note ? { reviewNote: note } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const subject = await payload.findByID({
|
||||
collection: "users",
|
||||
id: relId(doc.subject),
|
||||
depth: 0,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.initiator),
|
||||
type: LT.denied,
|
||||
title: "Removal denied",
|
||||
message: `Your removal request for ${userLabel(subject, `user #${relId(doc.subject)}`)} was denied.${note ? ` Note: "${note}"` : ""}`,
|
||||
link: "/transfers",
|
||||
bypassMute: true,
|
||||
});
|
||||
|
||||
return denied;
|
||||
}
|
||||
|
||||
const txId = await payload.db.beginTransaction();
|
||||
if (txId == null) throw new Error("Failed to begin database transaction.");
|
||||
const req: Partial<PayloadRequest> = { transactionID: txId };
|
||||
let approvedDoc: LeadershipTransfer;
|
||||
try {
|
||||
const approved = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
data: {
|
||||
status: "approved",
|
||||
reviewedBy: resolver.id,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
...(note ? { reviewNote: note } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
if (approved.docs.length === 0) {
|
||||
throw new Error("The removal request changed state while it was being resolved.");
|
||||
}
|
||||
|
||||
await moveMemberBetweenAssignments(payload, {
|
||||
req,
|
||||
subjectId: relId(doc.subject),
|
||||
fromAssignmentId: relId(doc.fromAssignment),
|
||||
toAssignmentId: relId(doc.toAssignment),
|
||||
});
|
||||
|
||||
await payload.db.commitTransaction(txId);
|
||||
approvedDoc = approved.docs[0];
|
||||
} catch (err) {
|
||||
await payload.db.rollbackTransaction(txId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Post-commit side effects. Attribution is the leader, never the resolver;
|
||||
// failures here must not roll the committed transfer back.
|
||||
const [initiator, subject, fromAssignment, toAssignment] = await Promise.all([
|
||||
payload.findByID({ collection: "users", id: relId(doc.initiator), depth: 0 }),
|
||||
payload.findByID({ collection: "users", id: relId(doc.subject), depth: 0 }),
|
||||
fetchAssignment(payload, relId(doc.fromAssignment)),
|
||||
fetchAssignment(payload, relId(doc.toAssignment)),
|
||||
]);
|
||||
const leaderLabel = userLabel(initiator, `user #${relId(doc.initiator)}`);
|
||||
const subjectLabel = userLabel(subject, `user #${relId(doc.subject)}`);
|
||||
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.subject),
|
||||
type: LT.transferred,
|
||||
title: "Transferred",
|
||||
message: `You have been transferred from ${fromAssignment.name} to ${toAssignment.name} by ${leaderLabel}.`,
|
||||
link: "/roster",
|
||||
bypassMute: true,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.initiator),
|
||||
type: LT.approved,
|
||||
title: "Removal approved",
|
||||
message: `Your removal request for ${subjectLabel} was approved. ${subjectLabel} has been transferred to ${toAssignment.name}.`,
|
||||
link: "/transfers",
|
||||
bypassMute: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.PersonnelTransferred,
|
||||
message: `${leaderLabel} transferred ${subjectLabel} from ${fromAssignment.name} to ${toAssignment.name}.`,
|
||||
actor: relId(doc.initiator),
|
||||
targetCollection: "leadership-transfers",
|
||||
targetId: doc.id,
|
||||
data: {
|
||||
initiatorId: relId(doc.initiator),
|
||||
approvedById: resolver.id,
|
||||
subjectId: relId(doc.subject),
|
||||
fromAssignmentId: relId(doc.fromAssignment),
|
||||
toAssignmentId: relId(doc.toAssignment),
|
||||
reason: doc.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return approvedDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending removal request. Only the initiator, only while pending.
|
||||
* No notifications and no public event: a cancelled removal never happened as
|
||||
* far as anyone else is concerned.
|
||||
*/
|
||||
export async function cancelLeadershipTransfer(
|
||||
payload: Payload,
|
||||
initiator: User,
|
||||
transferId: number,
|
||||
): Promise<LeadershipTransfer> {
|
||||
const doc = await fetchLeadershipTransfer(payload, transferId);
|
||||
if (relId(doc.initiator) !== initiator.id) {
|
||||
throw new Error("Only your own pending requests can be cancelled.");
|
||||
}
|
||||
if (doc.status !== "pending") {
|
||||
throw new Error("Only your own pending requests can be cancelled.");
|
||||
}
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
data: { status: "cancelled" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
if (updated.docs.length === 0) {
|
||||
throw new Error("The removal request changed state while it was being cancelled.");
|
||||
}
|
||||
|
||||
return updated.docs[0];
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE SCHEMA IF NOT EXISTS maintenance;
|
||||
|
||||
CREATE OR REPLACE FUNCTION maintenance.reset_owned_sequences(
|
||||
target_schema text DEFAULT 'public'
|
||||
)
|
||||
RETURNS TABLE (
|
||||
sequence_name text,
|
||||
table_name text,
|
||||
column_name text,
|
||||
previous_value bigint,
|
||||
new_value bigint,
|
||||
is_called boolean
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
sequence_record record;
|
||||
max_value bigint;
|
||||
prior_value bigint;
|
||||
should_be_called boolean;
|
||||
BEGIN
|
||||
FOR sequence_record IN
|
||||
SELECT
|
||||
sequence_namespace.nspname AS sequence_schema,
|
||||
class_sequence.relname AS sequence_name,
|
||||
table_namespace.nspname AS table_schema,
|
||||
class_table.relname AS table_name,
|
||||
pg_attribute.attname AS column_name
|
||||
FROM pg_depend
|
||||
INNER JOIN pg_class AS class_sequence
|
||||
ON class_sequence.oid = pg_depend.objid
|
||||
AND class_sequence.relkind = 'S'
|
||||
INNER JOIN pg_class AS class_table
|
||||
ON class_table.oid = pg_depend.refobjid
|
||||
INNER JOIN pg_attribute
|
||||
ON pg_attribute.attrelid = class_table.oid
|
||||
AND pg_depend.refobjsubid = pg_attribute.attnum
|
||||
INNER JOIN pg_namespace AS table_namespace
|
||||
ON table_namespace.oid = class_table.relnamespace
|
||||
INNER JOIN pg_namespace AS sequence_namespace
|
||||
ON sequence_namespace.oid = class_sequence.relnamespace
|
||||
WHERE table_namespace.nspname = target_schema
|
||||
AND pg_depend.deptype IN ('a', 'i')
|
||||
ORDER BY sequence_namespace.nspname, class_sequence.relname
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'SELECT COALESCE(MAX(%I), 1), MAX(%I) IS NOT NULL FROM %I.%I',
|
||||
sequence_record.column_name,
|
||||
sequence_record.column_name,
|
||||
sequence_record.table_schema,
|
||||
sequence_record.table_name
|
||||
)
|
||||
INTO max_value, should_be_called;
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT last_value FROM %I.%I',
|
||||
sequence_record.sequence_schema,
|
||||
sequence_record.sequence_name
|
||||
)
|
||||
INTO prior_value;
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT setval(%L, %s, %L)',
|
||||
format('%I.%I', sequence_record.sequence_schema, sequence_record.sequence_name),
|
||||
max_value,
|
||||
should_be_called
|
||||
);
|
||||
|
||||
sequence_name := format('%I.%I', sequence_record.sequence_schema, sequence_record.sequence_name);
|
||||
table_name := format('%I.%I', sequence_record.table_schema, sequence_record.table_name);
|
||||
column_name := sequence_record.column_name;
|
||||
previous_value := prior_value;
|
||||
new_value := max_value;
|
||||
is_called := should_be_called;
|
||||
|
||||
RETURN NEXT;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP FUNCTION IF EXISTS maintenance.reset_owned_sequences(text);
|
||||
`)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,116 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_promotion_nominations_status" AS ENUM('pending', 'awaiting_superuser', 'approved', 'rejected', 'cancelled');
|
||||
CREATE TYPE "public"."enum_promotion_nominations_leader_decision" AS ENUM('pending', 'endorsed', 'rejected', 'not_required');
|
||||
CREATE TYPE "public"."enum_leadership_transfers_status" AS ENUM('pending', 'approved', 'denied', 'cancelled');
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'promotion-nominations';
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'leadership-transfers';
|
||||
CREATE TABLE "promotion_nominations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"nominee_id" integer NOT NULL,
|
||||
"nominator_id" integer NOT NULL,
|
||||
"accolades" varchar NOT NULL,
|
||||
"status" "enum_promotion_nominations_status" DEFAULT 'pending' NOT NULL,
|
||||
"leader_decision" "enum_promotion_nominations_leader_decision" DEFAULT 'pending',
|
||||
"leader_decision_by_id" integer,
|
||||
"leader_decided_at" timestamp(3) with time zone,
|
||||
"leader_note" varchar,
|
||||
"review_note" varchar,
|
||||
"reviewed_by_id" integer,
|
||||
"reviewed_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "leadership_transfers" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"initiator_id" integer NOT NULL,
|
||||
"subject_id" integer NOT NULL,
|
||||
"from_assignment_id" integer NOT NULL,
|
||||
"to_assignment_id" integer NOT NULL,
|
||||
"reason" varchar,
|
||||
"status" "enum_leadership_transfers_status" DEFAULT 'pending' NOT NULL,
|
||||
"review_note" varchar,
|
||||
"reviewed_by_id" integer,
|
||||
"resolved_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_find" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_create" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_update" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_delete" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_find" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_create" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_update" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_delete" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "promotion_nominations_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "leadership_transfers_id" integer;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "infantry_assignment_id" integer;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_nominee_id_users_id_fk" FOREIGN KEY ("nominee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_nominator_id_users_id_fk" FOREIGN KEY ("nominator_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_leader_decision_by_id_users_id_fk" FOREIGN KEY ("leader_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_initiator_id_users_id_fk" FOREIGN KEY ("initiator_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_subject_id_users_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_from_assignment_id_assignments_id_fk" FOREIGN KEY ("from_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_to_assignment_id_assignments_id_fk" FOREIGN KEY ("to_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "promotion_nominations_nominee_idx" ON "promotion_nominations" USING btree ("nominee_id");
|
||||
CREATE INDEX "promotion_nominations_nominator_idx" ON "promotion_nominations" USING btree ("nominator_id");
|
||||
CREATE INDEX "promotion_nominations_leader_decision_by_idx" ON "promotion_nominations" USING btree ("leader_decision_by_id");
|
||||
CREATE INDEX "promotion_nominations_reviewed_by_idx" ON "promotion_nominations" USING btree ("reviewed_by_id");
|
||||
CREATE INDEX "promotion_nominations_updated_at_idx" ON "promotion_nominations" USING btree ("updated_at");
|
||||
CREATE INDEX "promotion_nominations_created_at_idx" ON "promotion_nominations" USING btree ("created_at");
|
||||
CREATE INDEX "leadership_transfers_initiator_idx" ON "leadership_transfers" USING btree ("initiator_id");
|
||||
CREATE INDEX "leadership_transfers_subject_idx" ON "leadership_transfers" USING btree ("subject_id");
|
||||
CREATE INDEX "leadership_transfers_from_assignment_idx" ON "leadership_transfers" USING btree ("from_assignment_id");
|
||||
CREATE INDEX "leadership_transfers_to_assignment_idx" ON "leadership_transfers" USING btree ("to_assignment_id");
|
||||
CREATE INDEX "leadership_transfers_reviewed_by_idx" ON "leadership_transfers" USING btree ("reviewed_by_id");
|
||||
CREATE INDEX "leadership_transfers_updated_at_idx" ON "leadership_transfers" USING btree ("updated_at");
|
||||
CREATE INDEX "leadership_transfers_created_at_idx" ON "leadership_transfers" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_promotion_nominations_fk" FOREIGN KEY ("promotion_nominations_id") REFERENCES "public"."promotion_nominations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_leadership_transfers_fk" FOREIGN KEY ("leadership_transfers_id") REFERENCES "public"."leadership_transfers"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_rules" ADD CONSTRAINT "game_rules_infantry_assignment_id_assignments_id_fk" FOREIGN KEY ("infantry_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_promotion_nominations_id_idx" ON "payload_locked_documents_rels" USING btree ("promotion_nominations_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_leadership_transfers_id_idx" ON "payload_locked_documents_rels" USING btree ("leadership_transfers_id");
|
||||
CREATE INDEX "game_rules_infantry_assignment_idx" ON "game_rules" USING btree ("infantry_assignment_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "promotion_nominations" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "leadership_transfers" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "promotion_nominations" CASCADE;
|
||||
DROP TABLE "leadership_transfers" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_promotion_nominations_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_leadership_transfers_fk";
|
||||
|
||||
ALTER TABLE "game_rules" DROP CONSTRAINT "game_rules_infantry_assignment_id_assignments_id_fk";
|
||||
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers', 'voice-scripts', 'voice-submissions', 'story-beats', 'story-beat-states', 'custom-minefields', 'custom-minefield-scores', 'custom-radio-tests', 'custom-radio-test-scores', 'ribbon-submissions');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "payload_locked_documents_rels_promotion_nominations_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_leadership_transfers_id_idx";
|
||||
DROP INDEX "game_rules_infantry_assignment_idx";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_find";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_create";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_update";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_delete";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_find";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_create";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_update";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_delete";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "promotion_nominations_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "leadership_transfers_id";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "infantry_assignment_id";
|
||||
DROP TYPE "public"."enum_promotion_nominations_status";
|
||||
DROP TYPE "public"."enum_promotion_nominations_leader_decision";
|
||||
DROP TYPE "public"."enum_leadership_transfers_status";`)
|
||||
}
|
||||
20
src/migrations/20261002_180000_add_asset_type_accessory.ts
Normal file
20
src/migrations/20261002_180000_add_asset_type_accessory.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
/**
|
||||
* Adds the "accessory" asset type (rail flashlights and lasers such as
|
||||
* acc_flashlight / acc_pointer_IR). These import as their own type so the
|
||||
* Weapon Configuration > compatibleAttachments relationship can link them,
|
||||
* instead of leaving them as "miscellaneous" and skipping them.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_assets_asset_type"
|
||||
ADD VALUE IF NOT EXISTS 'accessory' BEFORE 'uniform';
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Postgres cannot remove a value from an enum type; reversing this would
|
||||
// mean recreating the type and every dependent column.
|
||||
void db;
|
||||
}
|
||||
23
src/migrations/20261003_060154_change_ai_model_to_ornith.ts
Normal file
23
src/migrations/20261003_060154_change_ai_model_to_ornith.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "plugin_ai_instructions" RENAME COLUMN "gemma_4_settings_model" TO "ornith_9b_settings_model";
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "ornith_9b_settings_model" SET DEFAULT 'unsloth/Ornith-1.0-9B-GGUF';
|
||||
UPDATE "plugin_ai_instructions" SET "ornith_9b_settings_model" = 'unsloth/Ornith-1.0-9B-GGUF' WHERE "ornith_9b_settings_model" IS DISTINCT FROM 'unsloth/Ornith-1.0-9B-GGUF';
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "model_id" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_plugin_ai_instructions_model_id";
|
||||
CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth/Ornith-1.0-9B-GGUF');
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "model_id" SET DATA TYPE "public"."enum_plugin_ai_instructions_model_id" USING CASE WHEN "model_id" IS NULL THEN NULL ELSE 'unsloth/Ornith-1.0-9B-GGUF' END::"public"."enum_plugin_ai_instructions_model_id";`);
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "model_id" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_plugin_ai_instructions_model_id";
|
||||
CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth/gemma-4-26B-A4B-it-qat-GGUF');
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "model_id" SET DATA TYPE "public"."enum_plugin_ai_instructions_model_id" USING CASE WHEN "model_id" IS NULL THEN NULL ELSE 'unsloth/gemma-4-26B-A4B-it-qat-GGUF' END::"public"."enum_plugin_ai_instructions_model_id";
|
||||
UPDATE "plugin_ai_instructions" SET "ornith_9b_settings_model" = 'unsloth/gemma-4-26B-A4B-it-qat-GGUF' WHERE "ornith_9b_settings_model" IS DISTINCT FROM 'unsloth/gemma-4-26B-A4B-it-qat-GGUF';
|
||||
ALTER TABLE "plugin_ai_instructions" ALTER COLUMN "ornith_9b_settings_model" SET DEFAULT 'unsloth/gemma-4-26B-A4B-it-qat-GGUF';
|
||||
ALTER TABLE "plugin_ai_instructions" RENAME COLUMN "ornith_9b_settings_model" TO "gemma_4_settings_model";`);
|
||||
}
|
||||
|
|
@ -97,6 +97,10 @@ import * as migration_20260928_021138_change_ai_model from './20260928_021138_ch
|
|||
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
|
||||
import * as migration_20260930_230747_add_tech_categories from './20260930_230747_add_tech_categories';
|
||||
import * as migration_20261001_000320_add_tech_tree_position from './20261001_000320_add_tech_tree_position';
|
||||
import * as migration_20261001_000400_add_reset_owned_sequences_function from './20261001_000400_add_reset_owned_sequences_function';
|
||||
import * as migration_20261002_031917_add_promotion_nominations_and_leadership_transfers from './20261002_031917_add_promotion_nominations_and_leadership_transfers';
|
||||
import * as migration_20261002_180000_add_asset_type_accessory from './20261002_180000_add_asset_type_accessory';
|
||||
import * as migration_20261003_060154_change_ai_model_to_ornith from './20261003_060154_change_ai_model_to_ornith';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -592,6 +596,26 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20261001_000320_add_tech_tree_position.up,
|
||||
down: migration_20261001_000320_add_tech_tree_position.down,
|
||||
name: '20261001_000320_add_tech_tree_position'
|
||||
name: '20261001_000320_add_tech_tree_position',
|
||||
},
|
||||
{
|
||||
up: migration_20261001_000400_add_reset_owned_sequences_function.up,
|
||||
down: migration_20261001_000400_add_reset_owned_sequences_function.down,
|
||||
name: '20261001_000400_add_reset_owned_sequences_function',
|
||||
},
|
||||
{
|
||||
up: migration_20261002_031917_add_promotion_nominations_and_leadership_transfers.up,
|
||||
down: migration_20261002_031917_add_promotion_nominations_and_leadership_transfers.down,
|
||||
name: '20261002_031917_add_promotion_nominations_and_leadership_transfers'
|
||||
},
|
||||
{
|
||||
up: migration_20261002_180000_add_asset_type_accessory.up,
|
||||
down: migration_20261002_180000_add_asset_type_accessory.down,
|
||||
name: '20261002_180000_add_asset_type_accessory'
|
||||
},
|
||||
{
|
||||
up: migration_20261003_060154_change_ai_model_to_ornith.up,
|
||||
down: migration_20261003_060154_change_ai_model_to_ornith.down,
|
||||
name: '20261003_060154_change_ai_model_to_ornith'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export const enum_game_event_logs_target_collection = pgEnum(
|
|||
"custom-radio-tests",
|
||||
"custom-radio-test-scores",
|
||||
"ribbon-submissions",
|
||||
"promotion-nominations",
|
||||
"leadership-transfers",
|
||||
],
|
||||
);
|
||||
export const enum_users_roles = pgEnum("enum_users_roles", [
|
||||
|
|
@ -575,6 +577,23 @@ export const enum_ribbon_submissions_recipient_scope = pgEnum(
|
|||
"enum_ribbon_submissions_recipient_scope",
|
||||
["same_command", "whitelist", "anyone"],
|
||||
);
|
||||
export const enum_promotion_nominations_status = pgEnum("enum_promotion_nominations_status", [
|
||||
"pending",
|
||||
"awaiting_superuser",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]);
|
||||
export const enum_promotion_nominations_leader_decision = pgEnum(
|
||||
"enum_promotion_nominations_leader_decision",
|
||||
["pending", "endorsed", "rejected", "not_required"],
|
||||
);
|
||||
export const enum_leadership_transfers_status = pgEnum("enum_leadership_transfers_status", [
|
||||
"pending",
|
||||
"approved",
|
||||
"denied",
|
||||
"cancelled",
|
||||
]);
|
||||
export const enum_missions_factions_involved_threat_level = pgEnum(
|
||||
"enum_missions_factions_involved_threat_level",
|
||||
["ally", "friendly", "neutral", "hostile"],
|
||||
|
|
@ -1031,7 +1050,7 @@ export const enum_plugin_ai_instructions_field_type = pgEnum(
|
|||
["text", "textarea", "upload", "richText"],
|
||||
);
|
||||
export const enum_plugin_ai_instructions_model_id = pgEnum("enum_plugin_ai_instructions_model_id", [
|
||||
"unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
||||
"unsloth/Ornith-1.0-9B-GGUF",
|
||||
]);
|
||||
export const enum_shims_shims_targeting_roles = pgEnum("enum_shims_shims_targeting_roles", [
|
||||
"guest",
|
||||
|
|
@ -2741,6 +2760,104 @@ export const ribbon_submissions = pgTable(
|
|||
],
|
||||
);
|
||||
|
||||
export const promotion_nominations = pgTable(
|
||||
"promotion_nominations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
nominee: integer("nominee_id")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
nominator: integer("nominator_id")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
accolades: varchar("accolades").notNull(),
|
||||
status: enum_promotion_nominations_status("status").notNull().default("pending"),
|
||||
leaderDecision:
|
||||
enum_promotion_nominations_leader_decision("leader_decision").default("pending"),
|
||||
leaderDecisionBy: integer("leader_decision_by_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
leaderDecidedAt: timestamp("leader_decided_at", {
|
||||
mode: "string",
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
leaderNote: varchar("leader_note"),
|
||||
reviewNote: varchar("review_note"),
|
||||
reviewedBy: integer("reviewed_by_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
reviewedAt: timestamp("reviewed_at", { mode: "string", withTimezone: true, precision: 3 }),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index("promotion_nominations_nominee_idx").on(columns.nominee),
|
||||
index("promotion_nominations_nominator_idx").on(columns.nominator),
|
||||
index("promotion_nominations_leader_decision_by_idx").on(columns.leaderDecisionBy),
|
||||
index("promotion_nominations_reviewed_by_idx").on(columns.reviewedBy),
|
||||
index("promotion_nominations_updated_at_idx").on(columns.updatedAt),
|
||||
index("promotion_nominations_created_at_idx").on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const leadership_transfers = pgTable(
|
||||
"leadership_transfers",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
initiator: integer("initiator_id")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
subject: integer("subject_id")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
fromAssignment: integer("from_assignment_id")
|
||||
.notNull()
|
||||
.references(() => assignments.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
toAssignment: integer("to_assignment_id")
|
||||
.notNull()
|
||||
.references(() => assignments.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
reason: varchar("reason"),
|
||||
status: enum_leadership_transfers_status("status").notNull().default("pending"),
|
||||
reviewNote: varchar("review_note"),
|
||||
reviewedBy: integer("reviewed_by_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
resolvedAt: timestamp("resolved_at", { mode: "string", withTimezone: true, precision: 3 }),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(columns) => [
|
||||
index("leadership_transfers_initiator_idx").on(columns.initiator),
|
||||
index("leadership_transfers_subject_idx").on(columns.subject),
|
||||
index("leadership_transfers_from_assignment_idx").on(columns.fromAssignment),
|
||||
index("leadership_transfers_to_assignment_idx").on(columns.toAssignment),
|
||||
index("leadership_transfers_reviewed_by_idx").on(columns.reviewedBy),
|
||||
index("leadership_transfers_updated_at_idx").on(columns.updatedAt),
|
||||
index("leadership_transfers_created_at_idx").on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const missions_mission_roles_provided_roles = pgTable(
|
||||
"missions_mission_roles_provided_roles",
|
||||
{
|
||||
|
|
@ -6752,8 +6869,8 @@ export const plugin_ai_instructions = pgTable(
|
|||
layout: varchar("layout").default(
|
||||
'[paragraph] - Write a concise introduction (2-3 sentences) that outlines the main topic.\n[horizontalrule] - Insert a horizontal rule to separate the introduction from the main content.\n[list] - Create a list with 3-5 items. Each list item should contain:\n a. [heading] - A brief, descriptive heading (up to 5 words)\n b. [paragraph] - A short explanation or elaboration (1-2 sentences)\n[horizontalrule] - Insert another horizontal rule to separate the main content from the conclusion.\n[paragraph] - Compose a brief conclusion (2-3 sentences) summarizing the key points.\n[quote] - Include a relevant quote from a famous person, directly related to the topic. Format: "Quote text." - Author Name',
|
||||
),
|
||||
"gemma-4-settings_model": varchar("gemma_4_settings_model").default(
|
||||
"unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
||||
"ornith-9b-settings_model": varchar("ornith_9b_settings_model").default(
|
||||
"unsloth/Ornith-1.0-9B-GGUF",
|
||||
),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
|
|
@ -6864,6 +6981,14 @@ export const payload_mcp_api_keys = pgTable(
|
|||
ribbonSubmissions_create: boolean("ribbon_submissions_create").default(false),
|
||||
ribbonSubmissions_update: boolean("ribbon_submissions_update").default(false),
|
||||
ribbonSubmissions_delete: boolean("ribbon_submissions_delete").default(false),
|
||||
promotionNominations_find: boolean("promotion_nominations_find").default(false),
|
||||
promotionNominations_create: boolean("promotion_nominations_create").default(false),
|
||||
promotionNominations_update: boolean("promotion_nominations_update").default(false),
|
||||
promotionNominations_delete: boolean("promotion_nominations_delete").default(false),
|
||||
leadershipTransfers_find: boolean("leadership_transfers_find").default(false),
|
||||
leadershipTransfers_create: boolean("leadership_transfers_create").default(false),
|
||||
leadershipTransfers_update: boolean("leadership_transfers_update").default(false),
|
||||
leadershipTransfers_delete: boolean("leadership_transfers_delete").default(false),
|
||||
missions_find: boolean("missions_find").default(false),
|
||||
missions_create: boolean("missions_create").default(false),
|
||||
missions_update: boolean("missions_update").default(false),
|
||||
|
|
@ -7158,6 +7283,8 @@ export const payload_locked_documents_rels = pgTable(
|
|||
"user-notificationsID": integer("user_notifications_id"),
|
||||
evaluationsID: integer("evaluations_id"),
|
||||
"ribbon-submissionsID": integer("ribbon_submissions_id"),
|
||||
"promotion-nominationsID": integer("promotion_nominations_id"),
|
||||
"leadership-transfersID": integer("leadership_transfers_id"),
|
||||
missionsID: integer("missions_id"),
|
||||
"mission-attendancesID": integer("mission_attendances_id"),
|
||||
"mission-role-signupsID": integer("mission_role_signups_id"),
|
||||
|
|
@ -7253,6 +7380,12 @@ export const payload_locked_documents_rels = pgTable(
|
|||
index("payload_locked_documents_rels_ribbon_submissions_id_idx").on(
|
||||
columns["ribbon-submissionsID"],
|
||||
),
|
||||
index("payload_locked_documents_rels_promotion_nominations_id_idx").on(
|
||||
columns["promotion-nominationsID"],
|
||||
),
|
||||
index("payload_locked_documents_rels_leadership_transfers_id_idx").on(
|
||||
columns["leadership-transfersID"],
|
||||
),
|
||||
index("payload_locked_documents_rels_missions_id_idx").on(columns.missionsID),
|
||||
index("payload_locked_documents_rels_mission_attendances_id_idx").on(
|
||||
columns["mission-attendancesID"],
|
||||
|
|
@ -7462,6 +7595,16 @@ export const payload_locked_documents_rels = pgTable(
|
|||
foreignColumns: [ribbon_submissions.id],
|
||||
name: "payload_locked_documents_rels_ribbon_submissions_fk",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [columns["promotion-nominationsID"]],
|
||||
foreignColumns: [promotion_nominations.id],
|
||||
name: "payload_locked_documents_rels_promotion_nominations_fk",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [columns["leadership-transfersID"]],
|
||||
foreignColumns: [leadership_transfers.id],
|
||||
name: "payload_locked_documents_rels_leadership_transfers_fk",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [columns["missionsID"]],
|
||||
foreignColumns: [missions.id],
|
||||
|
|
@ -7851,6 +7994,9 @@ export const game_rules = pgTable(
|
|||
currencySingular: varchar("currency_singular"),
|
||||
currencyPlural: varchar("currency_plural"),
|
||||
currencyDenomination: varchar("currency_denomination"),
|
||||
infantryAssignment: integer("infantry_assignment_id").references(() => assignments.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
proximityThreshold: numeric("proximity_threshold", { mode: "number" }).default(1000),
|
||||
gameTickIntervalMinutes: numeric("game_tick_interval_minutes", { mode: "number" }).default(10),
|
||||
lockerGridWidth: numeric("locker_grid_width", { mode: "number" }).default(10),
|
||||
|
|
@ -7883,6 +8029,7 @@ export const game_rules = pgTable(
|
|||
(columns) => [
|
||||
index("game_rules_main_currency_idx").on(columns.mainCurrency),
|
||||
index("game_rules_currency_symbol_idx").on(columns.currencySymbol),
|
||||
index("game_rules_infantry_assignment_idx").on(columns.infantryAssignment),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -8640,6 +8787,55 @@ export const relations_ribbon_submissions = relations(ribbon_submissions, ({ one
|
|||
relationName: "grantorTypes",
|
||||
}),
|
||||
}));
|
||||
export const relations_promotion_nominations = relations(promotion_nominations, ({ one }) => ({
|
||||
nominee: one(users, {
|
||||
fields: [promotion_nominations.nominee],
|
||||
references: [users.id],
|
||||
relationName: "nominee",
|
||||
}),
|
||||
nominator: one(users, {
|
||||
fields: [promotion_nominations.nominator],
|
||||
references: [users.id],
|
||||
relationName: "nominator",
|
||||
}),
|
||||
leaderDecisionBy: one(users, {
|
||||
fields: [promotion_nominations.leaderDecisionBy],
|
||||
references: [users.id],
|
||||
relationName: "leaderDecisionBy",
|
||||
}),
|
||||
reviewedBy: one(users, {
|
||||
fields: [promotion_nominations.reviewedBy],
|
||||
references: [users.id],
|
||||
relationName: "reviewedBy",
|
||||
}),
|
||||
}));
|
||||
export const relations_leadership_transfers = relations(leadership_transfers, ({ one }) => ({
|
||||
initiator: one(users, {
|
||||
fields: [leadership_transfers.initiator],
|
||||
references: [users.id],
|
||||
relationName: "initiator",
|
||||
}),
|
||||
subject: one(users, {
|
||||
fields: [leadership_transfers.subject],
|
||||
references: [users.id],
|
||||
relationName: "subject",
|
||||
}),
|
||||
fromAssignment: one(assignments, {
|
||||
fields: [leadership_transfers.fromAssignment],
|
||||
references: [assignments.id],
|
||||
relationName: "fromAssignment",
|
||||
}),
|
||||
toAssignment: one(assignments, {
|
||||
fields: [leadership_transfers.toAssignment],
|
||||
references: [assignments.id],
|
||||
relationName: "toAssignment",
|
||||
}),
|
||||
reviewedBy: one(users, {
|
||||
fields: [leadership_transfers.reviewedBy],
|
||||
references: [users.id],
|
||||
relationName: "reviewedBy",
|
||||
}),
|
||||
}));
|
||||
export const relations_missions_mission_roles_provided_roles = relations(
|
||||
missions_mission_roles_provided_roles,
|
||||
({ one }) => ({
|
||||
|
|
@ -10535,6 +10731,16 @@ export const relations_payload_locked_documents_rels = relations(
|
|||
references: [ribbon_submissions.id],
|
||||
relationName: "ribbon-submissions",
|
||||
}),
|
||||
"promotion-nominationsID": one(promotion_nominations, {
|
||||
fields: [payload_locked_documents_rels["promotion-nominationsID"]],
|
||||
references: [promotion_nominations.id],
|
||||
relationName: "promotion-nominations",
|
||||
}),
|
||||
"leadership-transfersID": one(leadership_transfers, {
|
||||
fields: [payload_locked_documents_rels["leadership-transfersID"]],
|
||||
references: [leadership_transfers.id],
|
||||
relationName: "leadership-transfers",
|
||||
}),
|
||||
missionsID: one(missions, {
|
||||
fields: [payload_locked_documents_rels.missionsID],
|
||||
references: [missions.id],
|
||||
|
|
@ -10889,6 +11095,11 @@ export const relations_game_rules = relations(game_rules, ({ one, many }) => ({
|
|||
references: [media.id],
|
||||
relationName: "currencySymbol",
|
||||
}),
|
||||
infantryAssignment: one(assignments, {
|
||||
fields: [game_rules.infantryAssignment],
|
||||
references: [assignments.id],
|
||||
relationName: "infantryAssignment",
|
||||
}),
|
||||
_rels: many(game_rules_rels, {
|
||||
relationName: "_rels",
|
||||
}),
|
||||
|
|
@ -10974,6 +11185,9 @@ type DatabaseSchema = {
|
|||
enum_ribbon_submissions_grantor_types: typeof enum_ribbon_submissions_grantor_types;
|
||||
enum_ribbon_submissions_status: typeof enum_ribbon_submissions_status;
|
||||
enum_ribbon_submissions_recipient_scope: typeof enum_ribbon_submissions_recipient_scope;
|
||||
enum_promotion_nominations_status: typeof enum_promotion_nominations_status;
|
||||
enum_promotion_nominations_leader_decision: typeof enum_promotion_nominations_leader_decision;
|
||||
enum_leadership_transfers_status: typeof enum_leadership_transfers_status;
|
||||
enum_missions_factions_involved_threat_level: typeof enum_missions_factions_involved_threat_level;
|
||||
enum_missions_logistics_and_support_insertion: typeof enum_missions_logistics_and_support_insertion;
|
||||
enum_missions_logistics_and_support_extraction: typeof enum_missions_logistics_and_support_extraction;
|
||||
|
|
@ -11109,6 +11323,8 @@ type DatabaseSchema = {
|
|||
evaluations: typeof evaluations;
|
||||
ribbon_submissions_grantor_types: typeof ribbon_submissions_grantor_types;
|
||||
ribbon_submissions: typeof ribbon_submissions;
|
||||
promotion_nominations: typeof promotion_nominations;
|
||||
leadership_transfers: typeof leadership_transfers;
|
||||
missions_mission_roles_provided_roles: typeof missions_mission_roles_provided_roles;
|
||||
missions_briefing: typeof missions_briefing;
|
||||
missions_mission_objectives: typeof missions_mission_objectives;
|
||||
|
|
@ -11299,6 +11515,8 @@ type DatabaseSchema = {
|
|||
relations_evaluations: typeof relations_evaluations;
|
||||
relations_ribbon_submissions_grantor_types: typeof relations_ribbon_submissions_grantor_types;
|
||||
relations_ribbon_submissions: typeof relations_ribbon_submissions;
|
||||
relations_promotion_nominations: typeof relations_promotion_nominations;
|
||||
relations_leadership_transfers: typeof relations_leadership_transfers;
|
||||
relations_missions_mission_roles_provided_roles: typeof relations_missions_mission_roles_provided_roles;
|
||||
relations_missions_briefing: typeof relations_missions_briefing;
|
||||
relations_missions_mission_objectives: typeof relations_missions_mission_objectives;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export interface Config {
|
|||
'user-notifications': UserNotification;
|
||||
evaluations: Evaluation;
|
||||
'ribbon-submissions': RibbonSubmission;
|
||||
'promotion-nominations': PromotionNomination;
|
||||
'leadership-transfers': LeadershipTransfer;
|
||||
missions: Mission;
|
||||
'mission-attendances': MissionAttendance;
|
||||
'mission-role-signups': MissionRoleSignup;
|
||||
|
|
@ -183,6 +185,8 @@ export interface Config {
|
|||
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
|
||||
evaluations: EvaluationsSelect<false> | EvaluationsSelect<true>;
|
||||
'ribbon-submissions': RibbonSubmissionsSelect<false> | RibbonSubmissionsSelect<true>;
|
||||
'promotion-nominations': PromotionNominationsSelect<false> | PromotionNominationsSelect<true>;
|
||||
'leadership-transfers': LeadershipTransfersSelect<false> | LeadershipTransfersSelect<true>;
|
||||
missions: MissionsSelect<false> | MissionsSelect<true>;
|
||||
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
|
||||
'mission-role-signups': MissionRoleSignupsSelect<false> | MissionRoleSignupsSelect<true>;
|
||||
|
|
@ -1685,6 +1689,7 @@ export interface Asset {
|
|||
| 'optic'
|
||||
| 'muzzle'
|
||||
| 'bipod'
|
||||
| 'accessory'
|
||||
| 'uniform'
|
||||
| 'vest'
|
||||
| 'backpack'
|
||||
|
|
@ -2386,6 +2391,8 @@ export interface GameEventLog {
|
|||
| 'custom-radio-tests'
|
||||
| 'custom-radio-test-scores'
|
||||
| 'ribbon-submissions'
|
||||
| 'promotion-nominations'
|
||||
| 'leadership-transfers'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -2951,11 +2958,11 @@ export interface Mission {
|
|||
*/
|
||||
evaluationRemindersSentAt?: string | null;
|
||||
/**
|
||||
* Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).
|
||||
* Server-local day the last web 'not Scheduled' reminder notification was sent to the op creator (managed by the mission tick, once per day).
|
||||
*/
|
||||
readyReminderWebSentAt?: string | null;
|
||||
/**
|
||||
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
|
||||
* Server-local day the last 'not Scheduled' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
|
||||
*/
|
||||
readyReminderDmSentAt?: string | null;
|
||||
/**
|
||||
|
|
@ -2988,6 +2995,9 @@ export interface Mission {
|
|||
ownershipAndStatus: {
|
||||
authors: (number | User)[];
|
||||
zeus?: (number | User)[] | null;
|
||||
/**
|
||||
* Side operations start as Ready (a quiet draft) and are committed to Scheduled by their creator, which posts the Discord roll-call. Only Scheduled/Active missions are driven by the automatic start/complete lifecycle; Concept/Planning/Ready are drafts.
|
||||
*/
|
||||
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
|
||||
visibility: 'unit' | 'leadership' | 'intel' | 'private';
|
||||
/**
|
||||
|
|
@ -3270,6 +3280,80 @@ export interface RibbonSubmission {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "promotion-nominations".
|
||||
*/
|
||||
export interface PromotionNomination {
|
||||
id: number;
|
||||
/**
|
||||
* Member being nominated for promotion.
|
||||
*/
|
||||
nominee: number | User;
|
||||
/**
|
||||
* Member submitting the nomination (self for self-requests).
|
||||
*/
|
||||
nominator: number | User;
|
||||
/**
|
||||
* Written examples of performance, accolades, and reasons this member deserves promotion.
|
||||
*/
|
||||
accolades: string;
|
||||
status: 'pending' | 'awaiting_superuser' | 'approved' | 'rejected' | 'cancelled';
|
||||
/**
|
||||
* Decision of the nominee's division leader.
|
||||
*/
|
||||
leaderDecision?: ('pending' | 'endorsed' | 'rejected' | 'not_required') | null;
|
||||
leaderDecisionBy?: (number | null) | User;
|
||||
leaderDecidedAt?: string | null;
|
||||
/**
|
||||
* Note from the division leader's endorsement or rejection.
|
||||
*/
|
||||
leaderNote?: string | null;
|
||||
/**
|
||||
* Final reviewer feedback shown to the nominator.
|
||||
*/
|
||||
reviewNote?: string | null;
|
||||
reviewedBy?: (number | null) | User;
|
||||
reviewedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "leadership-transfers".
|
||||
*/
|
||||
export interface LeadershipTransfer {
|
||||
id: number;
|
||||
/**
|
||||
* Division leader requesting the removal.
|
||||
*/
|
||||
initiator: number | User;
|
||||
/**
|
||||
* Member being transferred.
|
||||
*/
|
||||
subject: number | User;
|
||||
/**
|
||||
* Division the member is removed from.
|
||||
*/
|
||||
fromAssignment: number | Assignment;
|
||||
/**
|
||||
* Division the member is transferred to (the Infantry assignment).
|
||||
*/
|
||||
toAssignment: number | Assignment;
|
||||
/**
|
||||
* Optional reason for the removal, visible to command.
|
||||
*/
|
||||
reason?: string | null;
|
||||
status: 'pending' | 'approved' | 'denied' | 'cancelled';
|
||||
/**
|
||||
* Reviewer note recorded with the decision.
|
||||
*/
|
||||
reviewNote?: string | null;
|
||||
reviewedBy?: (number | null) | User;
|
||||
resolvedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "mission-attendances".
|
||||
|
|
@ -5239,7 +5323,7 @@ export interface PluginAiInstruction {
|
|||
*/
|
||||
'field-type'?: ('text' | 'textarea' | 'upload' | 'richText') | null;
|
||||
'relation-to'?: string | null;
|
||||
'model-id'?: 'unsloth/gemma-4-26B-A4B-it-qat-GGUF' | null;
|
||||
'model-id'?: 'unsloth/Ornith-1.0-9B-GGUF' | null;
|
||||
/**
|
||||
* Please reload your collection after applying the changes
|
||||
*/
|
||||
|
|
@ -5259,7 +5343,7 @@ export interface PluginAiInstruction {
|
|||
| null;
|
||||
system?: string | null;
|
||||
layout?: string | null;
|
||||
'gemma-4-settings'?: {
|
||||
'ornith-9b-settings'?: {
|
||||
model?: string | null;
|
||||
};
|
||||
updatedAt: string;
|
||||
|
|
@ -5663,6 +5747,42 @@ export interface PayloadMcpApiKey {
|
|||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
promotionNominations?: {
|
||||
/**
|
||||
* Allow clients to find promotion-nominations.
|
||||
*/
|
||||
find?: boolean | null;
|
||||
/**
|
||||
* Allow clients to create promotion-nominations.
|
||||
*/
|
||||
create?: boolean | null;
|
||||
/**
|
||||
* Allow clients to update promotion-nominations.
|
||||
*/
|
||||
update?: boolean | null;
|
||||
/**
|
||||
* Allow clients to delete promotion-nominations.
|
||||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
leadershipTransfers?: {
|
||||
/**
|
||||
* Allow clients to find leadership-transfers.
|
||||
*/
|
||||
find?: boolean | null;
|
||||
/**
|
||||
* Allow clients to create leadership-transfers.
|
||||
*/
|
||||
create?: boolean | null;
|
||||
/**
|
||||
* Allow clients to update leadership-transfers.
|
||||
*/
|
||||
update?: boolean | null;
|
||||
/**
|
||||
* Allow clients to delete leadership-transfers.
|
||||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
missions?: {
|
||||
/**
|
||||
* Allow clients to find missions.
|
||||
|
|
@ -6768,6 +6888,14 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'ribbon-submissions';
|
||||
value: number | RibbonSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'promotion-nominations';
|
||||
value: number | PromotionNomination;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'leadership-transfers';
|
||||
value: number | LeadershipTransfer;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'missions';
|
||||
value: number | Mission;
|
||||
|
|
@ -7696,6 +7824,42 @@ export interface RibbonSubmissionsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "promotion-nominations_select".
|
||||
*/
|
||||
export interface PromotionNominationsSelect<T extends boolean = true> {
|
||||
nominee?: T;
|
||||
nominator?: T;
|
||||
accolades?: T;
|
||||
status?: T;
|
||||
leaderDecision?: T;
|
||||
leaderDecisionBy?: T;
|
||||
leaderDecidedAt?: T;
|
||||
leaderNote?: T;
|
||||
reviewNote?: T;
|
||||
reviewedBy?: T;
|
||||
reviewedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "leadership-transfers_select".
|
||||
*/
|
||||
export interface LeadershipTransfersSelect<T extends boolean = true> {
|
||||
initiator?: T;
|
||||
subject?: T;
|
||||
fromAssignment?: T;
|
||||
toAssignment?: T;
|
||||
reason?: T;
|
||||
status?: T;
|
||||
reviewNote?: T;
|
||||
reviewedBy?: T;
|
||||
resolvedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "missions_select".
|
||||
|
|
@ -9360,7 +9524,7 @@ export interface PluginAiInstructionsSelect<T extends boolean = true> {
|
|||
};
|
||||
system?: T;
|
||||
layout?: T;
|
||||
'gemma-4-settings'?:
|
||||
'ornith-9b-settings'?:
|
||||
| T
|
||||
| {
|
||||
model?: T;
|
||||
|
|
@ -9544,6 +9708,22 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
|||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
promotionNominations?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
leadershipTransfers?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
missions?:
|
||||
| T
|
||||
| {
|
||||
|
|
@ -10068,6 +10248,10 @@ export interface GameRule {
|
|||
* Assignment(s) considered the intelligence division. Members and leaders of these assignments get elevated access to draft missions and intelligence content.
|
||||
*/
|
||||
intelAssignments?: (number | Assignment)[] | null;
|
||||
/**
|
||||
* Assignment that members are moved to when a division leader removes them from their division (the default destination for leadership-initiated transfers).
|
||||
*/
|
||||
infantryAssignment?: (number | null) | Assignment;
|
||||
/**
|
||||
* Distance threshold in real meters for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.
|
||||
*/
|
||||
|
|
@ -10212,6 +10396,7 @@ export interface GameRulesSelect<T extends boolean = true> {
|
|||
currencyDenomination?: T;
|
||||
commandAssignments?: T;
|
||||
intelAssignments?: T;
|
||||
infantryAssignment?: T;
|
||||
proximityThreshold?: T;
|
||||
gameTickIntervalMinutes?: T;
|
||||
lockerGridWidth?: T;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldSc
|
|||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
||||
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
||||
import { PromotionNominations } from "@/collections/users/PromotionNominations";
|
||||
import { LeadershipTransfers } from "@/collections/users/LeadershipTransfers";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||
|
|
@ -97,12 +99,24 @@ import { createOpenAI } from "@ai-sdk/openai";
|
|||
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
||||
import type { User } from "@/payload-types";
|
||||
|
||||
// Ornith-9B is served by the "Apex" Unsloth connection (a separate machine,
|
||||
// apex-lnx / 192.168.1.7), reached through Unsloth Studio's OpenAI-compatible
|
||||
// API. Point UNSLOTH_URL at that instance; the default is the LAN address of
|
||||
// the Apex box.
|
||||
const unslothProvider = createOpenAI({
|
||||
apiKey: process.env.UNSLOTH_API_KEY,
|
||||
baseURL: process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1",
|
||||
baseURL: process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1",
|
||||
});
|
||||
|
||||
const DEFAULT_MODEL = "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
|
||||
const DEFAULT_MODEL = "unsloth/Ornith-1.0-9B-GGUF";
|
||||
|
||||
// Ornith is a reasoning model: it emits a long `reasoning_content` block before
|
||||
// the actual answer, and with a small token budget the reasoning consumes the
|
||||
// whole allowance, leaving `content` empty. Unsloth Studio (llama.cpp) accepts
|
||||
// `reasoning_effort: "none"` to turn thinking off for the request, so the model
|
||||
// behaves like a plain instruct model and small budgets still produce output.
|
||||
// Sent on every request; harmless on models that do not support it.
|
||||
const REASONING_DISABLED = { reasoning_effort: "none" } as const;
|
||||
|
||||
// AI Compose allowlist: the plugin's default init makes an LLM call per field
|
||||
// to seed prompts. Returning false here skips a field (no instruction row, no
|
||||
|
|
@ -210,7 +224,7 @@ const stripCodeFences = (text: string): string =>
|
|||
.trim();
|
||||
|
||||
async function generatePlainText(prompt: string, system: string, model: string): Promise<string> {
|
||||
const baseUrl = process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1";
|
||||
const baseUrl = process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1";
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -224,6 +238,7 @@ async function generatePlainText(prompt: string, system: string, model: string):
|
|||
{ role: "user", content: prompt },
|
||||
],
|
||||
stream: false,
|
||||
...REASONING_DISABLED,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -268,6 +283,8 @@ const collections = [
|
|||
UserNotifications,
|
||||
Evaluations,
|
||||
RibbonSubmissions,
|
||||
PromotionNominations,
|
||||
LeadershipTransfers,
|
||||
|
||||
// Intel
|
||||
Missions,
|
||||
|
|
@ -577,8 +594,8 @@ export default buildConfig({
|
|||
},
|
||||
generationModels: [
|
||||
{
|
||||
id: "unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
||||
name: "Gemma 4 26B A4B",
|
||||
id: "unsloth/Ornith-1.0-9B-GGUF",
|
||||
name: "Ornith 1.0 9B",
|
||||
fields: ["text", "textarea", "richText"],
|
||||
handler: async (
|
||||
prompt: string,
|
||||
|
|
@ -623,6 +640,7 @@ export default buildConfig({
|
|||
openai: {
|
||||
strictJsonSchema: true,
|
||||
structuredOutputs: true,
|
||||
...REASONING_DISABLED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -650,11 +668,11 @@ export default buildConfig({
|
|||
},
|
||||
output: "text",
|
||||
settings: {
|
||||
name: "gemma-4-settings",
|
||||
name: "ornith-9b-settings",
|
||||
type: "group",
|
||||
admin: {
|
||||
condition(data) {
|
||||
return data["model-id"] === "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
|
||||
return data["model-id"] === "unsloth/Ornith-1.0-9B-GGUF";
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
|
|
@ -665,7 +683,7 @@ export default buildConfig({
|
|||
label: "Model",
|
||||
},
|
||||
],
|
||||
label: "Gemma 4 Settings",
|
||||
label: "Ornith 9B Settings",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
||||
import { autoCompleteMissions, autoStartMissions } from "@/lib/intelligence/missionLifecycle";
|
||||
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
|
|
@ -12,6 +12,9 @@ export const script = async (config: SanitizedConfig) => {
|
|||
log.info("[Mission Tick] Starting mission tick...");
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
// Start before complete so a mission whose whole window elapsed between
|
||||
// ticks is still driven through Active on its way to Completed.
|
||||
const startResult = await autoStartMissions(payload);
|
||||
const result = await autoCompleteMissions(payload);
|
||||
|
||||
const reminders = await sendWebReadyReminders(payload);
|
||||
|
|
@ -38,7 +41,9 @@ export const script = async (config: SanitizedConfig) => {
|
|||
log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
|
||||
}
|
||||
|
||||
log.info(`[Mission Tick] Complete. Auto-completed ${result.completed} mission(s).`);
|
||||
log.info(
|
||||
`[Mission Tick] Complete. Auto-started ${startResult.started} mission(s), auto-completed ${result.completed} mission(s).`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
* game's packed PBOs into a --paa-root tree shaped like the reference paths,
|
||||
* so importArmaLibrary.ts's resolvePaaPath() can find them for PAA->PNG.
|
||||
*
|
||||
* Resolution is ref-driven: the second path segment names the PBO (vanilla
|
||||
* refs look like "\a3\weapons_f\Rifles\MX\data\UI\gear_mx_cqc_M_CA.paa"), so we
|
||||
* locate that PBO by basename and inspect it on demand (cached), then map the
|
||||
* ref to a file inside it. A fabricated/unresolvable ref reports MISS rather
|
||||
* Resolution is prefix-driven: every PBO's "prefix" header is read up front
|
||||
* (cheap: first few KB of each file), and each ref is matched to the PBO with
|
||||
* the longest prefix it starts with (e.g. "\a3\weapons_f\..." -> prefix
|
||||
* a3\weapons_f; CUP refs map to their per-weapon PBOs). That PBO's file list
|
||||
* is then read once via `hemtt utils pbo inspect` (cached). Vanilla DLC PBOs in
|
||||
* <game>/<dlc>/addons are scanned too. An unresolvable ref reports MISS rather
|
||||
* than failing the run.
|
||||
*
|
||||
* CLI:
|
||||
|
|
@ -20,7 +22,7 @@
|
|||
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
interface CliOpts {
|
||||
|
|
@ -61,6 +63,14 @@ interface PboInfo {
|
|||
|
||||
function discoverPbos(game: string, mods: string[]): string[] {
|
||||
const dirs = [join(game, "Addons")];
|
||||
// Vanilla DLC/expansion PBOs live in <game>/<dlc>/addons (enoch, expansion, jets, ...).
|
||||
for (const entry of readdirSync(game, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
for (const sub of ["addons", "Addons"]) {
|
||||
const cand = join(game, entry.name, sub);
|
||||
if (existsSync(cand) && !dirs.includes(cand)) dirs.push(cand);
|
||||
}
|
||||
}
|
||||
for (const m of mods) {
|
||||
const cand = join(m, "addons");
|
||||
if (existsSync(cand)) dirs.push(cand);
|
||||
|
|
@ -78,15 +88,6 @@ function discoverPbos(game: string, mods: string[]): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
function pbosByName(pbos: string[]): Map<string, string> {
|
||||
const byName = new Map<string, string>();
|
||||
for (const p of pbos) {
|
||||
const base = p.split(/[\\/]/).pop()!.replace(/\.pbo$/i, "").toLowerCase();
|
||||
if (!byName.has(base)) byName.set(base, p);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: string[] } | undefined {
|
||||
const r = spawnSync(
|
||||
hemtt,
|
||||
|
|
@ -94,18 +95,60 @@ function inspectPbo(hemtt: string, pboPath: string): { prefix: string; files: st
|
|||
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (r.status !== 0 || !r.stdout) return undefined;
|
||||
let data: { files?: { name?: string }[]; header?: { properties?: Record<string, string> } };
|
||||
// hemtt prints a text summary ("- prefix: a3\weapons_f", ...) followed by
|
||||
// the file list as a JSON array; it is not a single JSON document.
|
||||
const prefixLine = r.stdout.match(/^\s*-\s*prefix:\s*(.+)$/im);
|
||||
const prefix = (prefixLine?.[1] ?? readPboPrefix(pboPath)).trim().toLowerCase().replace(/\\/g, "/");
|
||||
const arrayStart = r.stdout.indexOf("[{");
|
||||
if (arrayStart < 0) return { prefix, files: [] };
|
||||
let entries: { filename?: string; name?: string }[];
|
||||
try {
|
||||
data = JSON.parse(r.stdout);
|
||||
entries = JSON.parse(r.stdout.slice(arrayStart, r.stdout.lastIndexOf("]") + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const files = (data.files ?? []).map((f) => f.name ?? "").filter(Boolean);
|
||||
const props = data.header?.properties ?? {};
|
||||
const prefix = (props.prefix ?? "").trim().toLowerCase().replace(/\\/g, "/");
|
||||
const files = entries.map((f) => f.filename ?? f.name ?? "").filter(Boolean);
|
||||
return { prefix, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the "prefix" header property straight from the PBO file (first entry
|
||||
* is a "Vers" product entry followed by key/value C strings). Cheap enough to
|
||||
* run on every PBO up front, which lets refs be matched to PBOs by prefix
|
||||
* instead of guessing from the PBO file name.
|
||||
*/
|
||||
function readPboPrefix(pboPath: string): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(pboPath, "r");
|
||||
const buf = Buffer.alloc(64 * 1024);
|
||||
const len = readSync(fd, buf, 0, buf.length, 0);
|
||||
let pos = 0;
|
||||
const cstr = (): string => {
|
||||
const end = buf.indexOf(0, pos);
|
||||
if (end < 0 || end >= len) throw new Error("truncated");
|
||||
const v = buf.toString("latin1", pos, end);
|
||||
pos = end + 1;
|
||||
return v;
|
||||
};
|
||||
cstr(); // empty entry name
|
||||
const mime = buf.readUInt32LE(pos);
|
||||
pos += 20;
|
||||
if (mime !== 0x56657273) return "";
|
||||
for (;;) {
|
||||
const key = cstr();
|
||||
if (!key) break;
|
||||
const value = cstr();
|
||||
if (key.toLowerCase() === "prefix") return value;
|
||||
}
|
||||
} catch {
|
||||
// not a readable PBO header
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensurePbo(
|
||||
hemtt: string,
|
||||
pboPath: string,
|
||||
|
|
@ -125,37 +168,31 @@ function normalizeRef(src: string): string {
|
|||
return src.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function buildPrefixIndex(pbos: string[]): { prefix: string; pboPath: string }[] {
|
||||
const index: { prefix: string; pboPath: string }[] = [];
|
||||
for (const pboPath of pbos) {
|
||||
const prefix = readPboPrefix(pboPath).trim().toLowerCase().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (prefix) index.push({ prefix, pboPath });
|
||||
}
|
||||
// Longest prefix first, so "cup/weapons/cup_weapons_m16" beats "cup/weapons".
|
||||
return index.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
}
|
||||
|
||||
function resolveRef(
|
||||
norm: string,
|
||||
pbos: string[],
|
||||
byName: Map<string, string>,
|
||||
prefixIndex: { prefix: string; pboPath: string }[],
|
||||
inspected: Map<string, PboInfo>,
|
||||
hemtt: string,
|
||||
): { pbo: PboInfo; relFile: string } | undefined {
|
||||
const seg = norm.split("/").filter(Boolean);
|
||||
const pboName = seg[1]?.toLowerCase();
|
||||
if (pboName && byName.has(pboName)) {
|
||||
const pbo = ensurePbo(hemtt, byName.get(pboName)!, inspected);
|
||||
if (pbo) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash)) {
|
||||
const relLower = norm.slice(pbo.prefix.length + 1).replace(/\//g, "\\");
|
||||
for (const entry of prefixIndex) {
|
||||
if (!norm.startsWith(entry.prefix + "/")) continue;
|
||||
const pbo = ensurePbo(hemtt, entry.pboPath, inspected);
|
||||
if (!pbo) continue;
|
||||
const relLower = norm.slice(entry.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = pbo.files.get(relLower);
|
||||
if (exact) return { pbo, relFile: exact };
|
||||
}
|
||||
}
|
||||
}
|
||||
let best: { prefix: string; pbo: PboInfo } | undefined;
|
||||
for (const pbo of inspected.values()) {
|
||||
const prefixSlash = pbo.prefix + "/";
|
||||
if (norm.startsWith(prefixSlash) && (!best || pbo.prefix.length > best.prefix.length)) {
|
||||
best = { prefix: pbo.prefix, pbo };
|
||||
}
|
||||
}
|
||||
if (!best) return undefined;
|
||||
const relLower = norm.slice(best.prefix.length + 1).replace(/\//g, "\\");
|
||||
const exact = best.pbo.files.get(relLower);
|
||||
return exact ? { pbo: best.pbo, relFile: exact } : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -170,7 +207,8 @@ async function main() {
|
|||
|
||||
const pbos = discoverPbos(game, opts.mod ?? []);
|
||||
console.log(`Discovered ${pbos.length} PBOs across ${1 + (opts.mod?.length ?? 0)} roots.`);
|
||||
const byName = pbosByName(pbos);
|
||||
const prefixIndex = buildPrefixIndex(pbos);
|
||||
console.log(`Indexed ${prefixIndex.length} PBO prefixes.`);
|
||||
const inspected = new Map<string, PboInfo>();
|
||||
|
||||
interface Ref { src: string; origin: string; }
|
||||
|
|
@ -202,7 +240,7 @@ async function main() {
|
|||
ok++;
|
||||
continue;
|
||||
}
|
||||
const res = resolveRef(norm, pbos, byName, inspected, hemtt);
|
||||
const res = resolveRef(norm, prefixIndex, inspected, hemtt);
|
||||
if (!res) {
|
||||
console.log(` MISS ${ref.origin}\t${ref.src} (not in any indexed PBO)`);
|
||||
report.push(`MISS\t${ref.origin}\t${ref.src}`);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* [--approve] apply to the DB (default: proposal only)
|
||||
* [--dry-run] plan + print, never touch the DB or filesystem
|
||||
* [--limit <n>] import at most n records
|
||||
* [--fuel <codeName>] fuel resource for vehicles (default: res_fuel)
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
|
@ -29,6 +30,7 @@ import config from "@payload-config";
|
|||
import {
|
||||
enrichPlanMedia,
|
||||
planRecord,
|
||||
linkWeaponCompatibility,
|
||||
resolveFuelConfig,
|
||||
upsertPlan,
|
||||
type CatalogRecord,
|
||||
|
|
@ -45,6 +47,7 @@ interface CliOpts {
|
|||
approve?: boolean;
|
||||
"dry-run"?: boolean;
|
||||
limit?: string[];
|
||||
fuel?: string[];
|
||||
}
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {};
|
||||
|
|
@ -106,14 +109,17 @@ async function main() {
|
|||
}
|
||||
|
||||
if (dryRun) {
|
||||
const withCompat = plans.filter((p) => p.compat);
|
||||
const refs = withCompat.reduce((n, p) => n + p.compat!.magazines.length + p.compat!.attachments.length, 0);
|
||||
console.log(`\nDry run: ${plans.length} would import, 0 applied.`);
|
||||
console.log(`Compatibility: ${withCompat.length} weapons carry ${refs} magazine/attachment references.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
// Fuel is only needed for vehicle records; resolve lazily so asset-only
|
||||
// catalogs import even without a Fuel resource.
|
||||
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload) : undefined;
|
||||
const fuelId = plans.some((p) => p.kind === "vehicle") ? await resolveFuelConfig(payload, opts.fuel?.[0] ?? "res_fuel") : undefined;
|
||||
|
||||
let created = 0, updated = 0, errors = 0;
|
||||
for (const plan of plans) {
|
||||
|
|
@ -127,6 +133,15 @@ async function main() {
|
|||
}
|
||||
}
|
||||
console.log(`Done: ${created} created, ${updated} updated, ${errors} errors.`);
|
||||
|
||||
// Second pass: link weapons to their magazines/attachments (references to
|
||||
// the rows imported above, never copies).
|
||||
const link = await linkWeaponCompatibility(payload, plans, (line) => console.log(line));
|
||||
console.log(
|
||||
`Compatibility: ${link.updated} weapons linked, ${link.unchanged} already up to date, ` +
|
||||
`${link.unresolved} references not in the app, ${link.skippedType} of an unsupported type ` +
|
||||
`(e.g. rail lights/lasers), ${link.missingWeapons} weapons missing, ${link.errors} errors.`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ interface GameEventInput {
|
|||
| "custom-minefield-scores"
|
||||
| "custom-radio-tests"
|
||||
| "custom-radio-test-scores"
|
||||
| "ribbon-submissions";
|
||||
| "ribbon-submissions"
|
||||
| "promotion-nominations"
|
||||
| "leadership-transfers";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ export const EventTypes = {
|
|||
|
||||
// Personnel
|
||||
PersonnelPromoted: "personnel:promoted",
|
||||
PersonnelTransferred: "personnel:transferred",
|
||||
PromotionNominationApproved: "promotion:approved",
|
||||
|
||||
// Awards
|
||||
AwardGrant: "award:grant",
|
||||
|
|
@ -148,10 +150,11 @@ export const EventTypes = {
|
|||
AwardRibbonRejected: "award:ribbon-rejected",
|
||||
|
||||
// Missions
|
||||
MissionAutoStart: "mission:auto-start",
|
||||
MissionAutoComplete: "mission:auto-complete",
|
||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||
MissionSchedule: "mission:schedule",
|
||||
MissionReady: "mission:ready",
|
||||
MissionCommit: "mission:commit",
|
||||
MissionUpdate: "mission:update",
|
||||
MissionCancel: "mission:cancel",
|
||||
MissionMoveCampaign: "mission:move-campaign",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,26 @@ test.describe("Tech Tree admin view", () => {
|
|||
await expect(page.getByTestId("tech-tree-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every technology and category so a test starts from a predictable,
|
||||
* empty tree. The suite shares one database and only truncates at global
|
||||
* setup, so without this the drag tests inherit a large tree from earlier
|
||||
* tests and their targets land off-screen or under the side panel.
|
||||
*/
|
||||
async function clearTree(page: import("@playwright/test").Page) {
|
||||
const techRes = await page.request.get("/api/technologies?limit=0&depth=0");
|
||||
const techs = ((await techRes.json()).docs ?? []) as { id: number }[];
|
||||
// Two passes: a prerequisite deleted before its dependents can fail, so a
|
||||
// second pass removes whatever survived the first.
|
||||
for (const pass of [0, 1]) {
|
||||
for (const t of techs) await page.request.delete(`/api/technologies/${t.id}`);
|
||||
void pass;
|
||||
}
|
||||
const catRes = await page.request.get("/api/tech-categories?limit=0&depth=0");
|
||||
const cats = ((await catRes.json()).docs ?? []) as { id: number }[];
|
||||
for (const c of cats) await page.request.delete(`/api/tech-categories/${c.id}`);
|
||||
}
|
||||
|
||||
test("nav entry, tree render, preview panel, edit navigation, quick-create", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -205,6 +225,10 @@ test.describe("Tech Tree admin view", () => {
|
|||
const storedRes = await page.request.get(`/api/technologies/${draggable.id}`);
|
||||
const stored = (await storedRes.json()).treePosition as { x: number; y: number };
|
||||
expect(stored.y).toBeGreaterThan(0);
|
||||
// Snap-to-grid is on by default, so the stored position lands on the 46px
|
||||
// grid (which divides the layout steps, keeping cards aligned).
|
||||
expect(stored.x % 46).toBe(0);
|
||||
expect(stored.y % 46).toBe(0);
|
||||
|
||||
// A reload renders the node at the stored coordinates.
|
||||
await gotoTree(page);
|
||||
|
|
@ -309,6 +333,223 @@ test.describe("Tech Tree admin view", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("dragging a multi-selection persists every moved node", async ({ page }) => {
|
||||
await login(page);
|
||||
await clearTree(page);
|
||||
|
||||
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||
const a = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Multi A",
|
||||
type: "upgrade",
|
||||
summary: "Multi-drag A.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
const b = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Multi B",
|
||||
type: "upgrade",
|
||||
summary: "Multi-drag B.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
|
||||
await gotoTree(page);
|
||||
const nodeA = page.getByTestId(`tech-node-${a.id}`);
|
||||
const nodeB = page.getByTestId(`tech-node-${b.id}`);
|
||||
await expect(nodeA).toBeVisible();
|
||||
await expect(nodeB).toBeVisible();
|
||||
|
||||
const transform = (id: number) =>
|
||||
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
|
||||
const m = (el as HTMLElement).style.transform.match(
|
||||
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
|
||||
);
|
||||
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
|
||||
});
|
||||
const beforeA = (await transform(a.id))!;
|
||||
const beforeB = (await transform(b.id))!;
|
||||
|
||||
// Multi-select with the platform multi-select key (Control on Linux).
|
||||
await nodeA.click({ modifiers: ["Control"] });
|
||||
await nodeB.click({ modifiers: ["Control"] });
|
||||
await expect(page.locator(".react-flow__node.selected")).toHaveCount(2);
|
||||
|
||||
// Drag one selected node; React Flow moves the whole selection.
|
||||
const boxA = (await nodeA.boundingBox())!;
|
||||
await page.mouse.move(boxA.x + boxA.width / 2, boxA.y + boxA.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(boxA.x + boxA.width / 2 + 220, boxA.y + boxA.height / 2 + 140, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
// Both cards moved on screen...
|
||||
await expect.poll(async () => (await transform(a.id))!.x, { timeout: 10_000 }).not.toBe(beforeA.x);
|
||||
await expect.poll(async () => (await transform(b.id))!.x, { timeout: 10_000 }).not.toBe(beforeB.x);
|
||||
|
||||
// ...and both persisted (the bug was that only the node under the pointer did).
|
||||
for (const id of [a.id, b.id]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
|
||||
return (await res.json())?.treePosition?.x ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
}
|
||||
|
||||
for (const id of [a.id, b.id]) {
|
||||
const del = await page.request.delete(`/api/technologies/${id}`);
|
||||
expect(del.ok()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("dragging a category header moves all of its technologies", async ({ page }) => {
|
||||
await login(page);
|
||||
await clearTree(page);
|
||||
|
||||
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||
const cat = await createDoc(page, "tech-categories", { name: "Band Drag (E2E)", position: 9 });
|
||||
const a = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Band A",
|
||||
type: "upgrade",
|
||||
summary: "Band-dragged A.",
|
||||
category: cat.id,
|
||||
researchCosts: costs,
|
||||
});
|
||||
const b = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Band B",
|
||||
type: "upgrade",
|
||||
summary: "Band-dragged B.",
|
||||
category: cat.id,
|
||||
researchCosts: costs,
|
||||
});
|
||||
|
||||
await gotoTree(page);
|
||||
await expect(page.getByTestId(`tech-node-${a.id}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`tech-node-${b.id}`)).toBeVisible();
|
||||
|
||||
const transform = (id: number) =>
|
||||
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
|
||||
const m = (el as HTMLElement).style.transform.match(
|
||||
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
|
||||
);
|
||||
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
|
||||
});
|
||||
const beforeA = (await transform(a.id))!;
|
||||
const beforeB = (await transform(b.id))!;
|
||||
|
||||
// Drag the band's header (its drag handle); the whole category moves with it.
|
||||
const header = page.locator(
|
||||
`.react-flow__node[data-id="cat-${cat.id}"] .tech-band-drag-handle`,
|
||||
);
|
||||
await expect(header).toBeVisible();
|
||||
const box = (await header.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 180, box.y + box.height / 2 + 120, {
|
||||
steps: 15,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
// Both member cards shifted by (about) the same delta.
|
||||
await expect
|
||||
.poll(async () => (await transform(a.id))!.x - beforeA.x, { timeout: 10_000 })
|
||||
.toBeGreaterThan(50);
|
||||
await expect
|
||||
.poll(async () => (await transform(b.id))!.x - beforeB.x, { timeout: 10_000 })
|
||||
.toBeGreaterThan(50);
|
||||
const afterA = (await transform(a.id))!;
|
||||
const afterB = (await transform(b.id))!;
|
||||
expect(Math.abs(afterA.x - beforeA.x - (afterB.x - beforeB.x))).toBeLessThanOrEqual(2);
|
||||
|
||||
// Positions persisted.
|
||||
for (const id of [a.id, b.id]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
|
||||
return (await res.json())?.treePosition?.x ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
}
|
||||
|
||||
for (const id of [a.id, b.id]) {
|
||||
await page.request.delete(`/api/technologies/${id}`);
|
||||
}
|
||||
const delCat = await page.request.delete(`/api/tech-categories/${cat.id}`);
|
||||
expect(delCat.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test("adjusting a connection line adds a draggable bend that persists", async ({ page }) => {
|
||||
await login(page);
|
||||
await clearTree(page);
|
||||
|
||||
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||
const a = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Route A",
|
||||
type: "upgrade",
|
||||
summary: "Route source.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
const b = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Route B",
|
||||
type: "upgrade",
|
||||
summary: "Route target.",
|
||||
category: null,
|
||||
requirements: { prerequisiteTechnologies: [{ requiresAnd: { technology: [a.id] } }] },
|
||||
researchCosts: costs,
|
||||
});
|
||||
|
||||
await gotoTree(page);
|
||||
const edgeId = `and-${a.id}-${b.id}`;
|
||||
const edgePath = () =>
|
||||
page
|
||||
.locator(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`)
|
||||
.getAttribute("d");
|
||||
const before = await edgePath();
|
||||
|
||||
// Select the edge (click its path) to reveal the "+" bend handles.
|
||||
const point = await page.evaluate((id) => {
|
||||
const p = document.querySelector(
|
||||
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
|
||||
) as SVGPathElement | null;
|
||||
if (!p) return null;
|
||||
const local = p.getPointAtLength(p.getTotalLength() / 2);
|
||||
const svg = p.ownerSVGElement!;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = local.x;
|
||||
pt.y = local.y;
|
||||
const screen = pt.matrixTransform(p.getScreenCTM()!);
|
||||
return { x: screen.x, y: screen.y };
|
||||
}, edgeId);
|
||||
expect(point).not.toBeNull();
|
||||
await page.mouse.click(point!.x, point!.y);
|
||||
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"].selected`)).toHaveCount(1);
|
||||
|
||||
// Add a bend, then drag it to reroute the line.
|
||||
await page.getByTestId("tech-edge-add-bend").first().click();
|
||||
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
|
||||
const wp = page.getByTestId("tech-edge-waypoint").first();
|
||||
const box = (await wp.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2 - 90, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(edgePath, { timeout: 10_000 }).not.toBe(before);
|
||||
|
||||
// The route is stored for this browser and survives a reload.
|
||||
await gotoTree(page);
|
||||
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
|
||||
|
||||
for (const id of [a.id, b.id]) await page.request.delete(`/api/technologies/${id}`);
|
||||
});
|
||||
|
||||
test("connections and the lite editor persist through the REST API", async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
ASSET_TYPES,
|
||||
enrichPlanMedia,
|
||||
linkWeaponCompatibility,
|
||||
planRecord,
|
||||
upsertPlan,
|
||||
type CatalogRecord,
|
||||
type MediaEnv,
|
||||
} from "@/lib/arma-import/catalog";
|
||||
|
||||
let payload: Payload;
|
||||
|
|
@ -15,6 +21,16 @@ let fuelId: number;
|
|||
let createdFuel = false;
|
||||
const assetIds: number[] = [];
|
||||
const vehicleIds: number[] = [];
|
||||
const mediaIds: number[] = [];
|
||||
const SHOT_DIR = join(tmpdir(), `ptf-shots-${RUN}`);
|
||||
// 1x1 PNG: enough for the upload path, tiny enough to inline.
|
||||
const PNG_1PX = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
function mediaEnv(screenshots = ""): MediaEnv {
|
||||
return { paaRoot: "", screenshots, outDir: SHOT_DIR, hemtt: "" };
|
||||
}
|
||||
|
||||
describe("Arma catalog import", () => {
|
||||
beforeAll(async () => {
|
||||
|
|
@ -63,6 +79,10 @@ describe("Arma catalog import", () => {
|
|||
for (const id of assetIds) {
|
||||
await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of mediaIds) {
|
||||
await payload.delete({ collection: "media", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
rmSync(SHOT_DIR, { recursive: true, force: true });
|
||||
if (createdFuel && fuelId) {
|
||||
await payload.delete({ collection: "resources", id: fuelId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
|
@ -228,6 +248,157 @@ describe("Arma catalog import", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("adding images in a later import pass", () => {
|
||||
const lateClassName = `${RUN}_late_image_rifle`;
|
||||
|
||||
function planFor(screenshot: string) {
|
||||
const plan = planRecord({
|
||||
classname: lateClassName,
|
||||
type: "weapon",
|
||||
displayName: `${RUN} Late Image Rifle`,
|
||||
picture: "",
|
||||
mod: "PTF_Test_Mod",
|
||||
description: "",
|
||||
mass: 1,
|
||||
extra: "",
|
||||
})!;
|
||||
enrichPlanMedia(plan, mediaEnv(screenshot));
|
||||
return plan;
|
||||
}
|
||||
|
||||
it("imports the catalog with no image, then attaches one on a later run", async () => {
|
||||
// Pass 1: data only (no --screenshots).
|
||||
const first = await upsertPlan(payload, planFor(""));
|
||||
expect(first.action).toBe("created");
|
||||
assetIds.push(first.id);
|
||||
let doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
||||
expect(doc["Asset Metadata"]?.thumbnail).toBeFalsy();
|
||||
|
||||
// Pass 2: the screenshot now exists on disk.
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
writeFileSync(join(SHOT_DIR, `${lateClassName}.png`), PNG_1PX);
|
||||
|
||||
const second = await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
expect(second.action).toBe("updated");
|
||||
expect(second.id).toBe(first.id); // upsert, not a duplicate
|
||||
|
||||
doc = await payload.findByID({ collection: "assets", id: first.id, depth: 0, overrideAccess: true });
|
||||
const thumbnail = doc["Asset Metadata"]?.thumbnail;
|
||||
expect(thumbnail).toBeTruthy();
|
||||
if (typeof thumbnail === "number") mediaIds.push(thumbnail);
|
||||
else if (thumbnail && typeof thumbnail === "object") mediaIds.push(thumbnail.id);
|
||||
|
||||
const count = await payload.count({
|
||||
collection: "assets",
|
||||
where: { className: { equals: lateClassName } },
|
||||
});
|
||||
expect(count.totalDocs).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the image when re-run, and repairs a cleared one", async () => {
|
||||
const assetId = assetIds[assetIds.length - 1];
|
||||
const before = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
const originalId = typeof before["Asset Metadata"]?.thumbnail === "number"
|
||||
? (before["Asset Metadata"]!.thumbnail as number)
|
||||
: (before["Asset Metadata"]!.thumbnail as { id: number }).id;
|
||||
|
||||
// Re-run: the media doc already exists, so it must not be duplicated.
|
||||
await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
const after = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
const afterId = typeof after["Asset Metadata"]?.thumbnail === "number"
|
||||
? (after["Asset Metadata"]!.thumbnail as number)
|
||||
: (after["Asset Metadata"]!.thumbnail as { id: number }).id;
|
||||
expect(afterId).toBe(originalId);
|
||||
|
||||
// A cleared thumbnail is repaired by the next run (media already exists).
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: assetId,
|
||||
data: { "Asset Metadata": { thumbnail: null } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await upsertPlan(payload, planFor(SHOT_DIR));
|
||||
const repaired = await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true });
|
||||
expect(repaired["Asset Metadata"]?.thumbnail).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("weapon compatibility", () => {
|
||||
const rifle = `${RUN}_compat_rifle`;
|
||||
const mag = `${RUN}_compat_mag`;
|
||||
const optic = `${RUN}_compat_optic`;
|
||||
const light = `${RUN}_compat_light`;
|
||||
|
||||
function catalog() {
|
||||
return [
|
||||
{ classname: rifle, type: "weapon", displayName: "Compat Rifle", compat: {
|
||||
slot: "primary",
|
||||
// duplicate + different case + a ref that was never imported
|
||||
magazines: [mag, mag.toUpperCase(), `${RUN}_not_in_app`],
|
||||
attachments: [optic, light],
|
||||
} },
|
||||
{ classname: mag, type: "magazine", displayName: "Compat Mag", extra: 30 },
|
||||
{ classname: optic, type: "optic", displayName: "Compat Optic" },
|
||||
{ classname: light, type: "miscellaneous", displayName: "Compat Light" },
|
||||
].map((r) => planRecord(r as CatalogRecord)!);
|
||||
}
|
||||
|
||||
it("parses compat on weapons only, deduplicating classnames", () => {
|
||||
const plans = catalog();
|
||||
expect(plans[0].compat).toEqual({
|
||||
slot: "primary",
|
||||
magazines: [mag, `${RUN}_not_in_app`],
|
||||
attachments: [optic, light],
|
||||
});
|
||||
expect(plans[1].compat).toBeUndefined();
|
||||
expect(planRecord({ classname: "x", type: "magazine", compat: { magazines: ["y"] } })!.compat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("links magazines/attachments as references, merges on re-run, never duplicates", async () => {
|
||||
const plans = catalog();
|
||||
const ids: Record<string, number> = {};
|
||||
for (const plan of plans) {
|
||||
const res = await upsertPlan(payload, plan);
|
||||
assetIds.push(res.id);
|
||||
ids[plan.className] = res.id;
|
||||
}
|
||||
|
||||
const first = await linkWeaponCompatibility(payload, plans);
|
||||
expect(first.updated).toBe(1);
|
||||
expect(first.unresolved).toBe(1); // the never-imported magazine
|
||||
expect(first.skippedType).toBe(1); // rail light is "miscellaneous"
|
||||
|
||||
let doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
||||
const wc = () => (doc as unknown as { "Weapon Configuration": Record<string, unknown> })["Weapon Configuration"];
|
||||
expect(wc().slotType).toBe("primary");
|
||||
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
||||
expect(wc().compatibleAttachments).toEqual([ids[optic]]);
|
||||
|
||||
// A link added by hand survives, and re-running adds nothing twice.
|
||||
const extraOptic = await upsertPlan(payload, planRecord({ classname: `${RUN}_hand_optic`, type: "optic" })!);
|
||||
assetIds.push(extraOptic.id);
|
||||
await payload.update({
|
||||
collection: "assets",
|
||||
id: ids[rifle],
|
||||
data: { "Weapon Configuration": { compatibleAttachments: [ids[optic], extraOptic.id] } } as never,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const second = await linkWeaponCompatibility(payload, plans);
|
||||
expect(second.updated).toBe(0);
|
||||
expect(second.unchanged).toBe(1);
|
||||
|
||||
doc = await payload.findByID({ collection: "assets", id: ids[rifle], depth: 0, overrideAccess: true });
|
||||
expect(wc().compatibleMagazines).toEqual([ids[mag]]);
|
||||
expect(wc().compatibleAttachments).toEqual([ids[optic], extraOptic.id]);
|
||||
|
||||
// Each referenced item still exists exactly once.
|
||||
for (const name of [mag, optic]) {
|
||||
const count = await payload.count({ collection: "assets", where: { className: { equals: name } } });
|
||||
expect(count.totalDocs).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertPlan vehicles", () => {
|
||||
const vehicleClassname = `${RUN}_heli_ghost`;
|
||||
|
||||
|
|
|
|||
|
|
@ -149,15 +149,16 @@ describe("Imminent-op reminder query", () => {
|
|||
return mission;
|
||||
};
|
||||
|
||||
it("finds posted Ready/Scheduled ops starting inside the 15 minute window", async () => {
|
||||
it("finds posted Scheduled ops starting inside the 15 minute window, not Ready drafts", async () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
|
||||
const ready = await makeMission("in-window-ready", { status: "Ready", start });
|
||||
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
|
||||
|
||||
const found = await getImminentReminderMissions(payload, now);
|
||||
expect(found.map((m) => m.id)).toContain(ready.id);
|
||||
expect(found.map((m) => m.id)).toContain(scheduled.id);
|
||||
// Ready is now a quiet draft (no roll-call), so it is never imminent-reminded.
|
||||
expect(found.map((m) => m.id)).not.toContain(ready.id);
|
||||
});
|
||||
|
||||
it("includes the exact window edge and the grace zone just past the start", async () => {
|
||||
|
|
|
|||
471
tests/int/leadership-transfers.int.spec.ts
Normal file
471
tests/int/leadership-transfers.int.spec.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { LeadershipTransfer, Role, User } from "@/payload-types";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
import {
|
||||
cancelLeadershipTransfer,
|
||||
requestLeadershipTransfer,
|
||||
resolveLeadershipTransfer,
|
||||
} from "@/lib/transfers/leadership";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `leadxfer-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
describe("Leadership-initiated removals (leadership-transfers)", () => {
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const assignmentIds: number[] = [];
|
||||
const transferIds: number[] = [];
|
||||
|
||||
const users: Record<string, User> = {};
|
||||
const assignmentIdsByName: Record<string, number> = {};
|
||||
|
||||
let pendingDoc: LeadershipTransfer | null = null;
|
||||
|
||||
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
||||
const role = (await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Role;
|
||||
roleIds.push(role.id);
|
||||
return role;
|
||||
};
|
||||
|
||||
const makeUser = async (label: string, extra: Partial<User> = {}): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}`,
|
||||
displayName: label.toUpperCase(),
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
...extra,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
const makeAssignment = async (
|
||||
label: string,
|
||||
type: "division" | "squad",
|
||||
leader: number | null,
|
||||
members: number[],
|
||||
): Promise<number> => {
|
||||
const doc = await payload.create({
|
||||
collection: "assignments",
|
||||
data: { name: `${RUN} ${label}`, type, leader, members },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
assignmentIds.push(doc.id);
|
||||
assignmentIdsByName[label] = doc.id;
|
||||
return doc.id;
|
||||
};
|
||||
|
||||
const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => {
|
||||
const doc = await payload.findByID({ collection: "assignments", id, depth: 0 });
|
||||
return {
|
||||
leader: (doc.leader as number | null) ?? null,
|
||||
members: (doc.members as number[]) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const notificationsFor = async (userId: number, type?: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "user-notifications",
|
||||
where: type
|
||||
? { and: [{ user: { equals: userId } }, { type: { equals: type } }] }
|
||||
: { user: { equals: userId } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
const leadershipEventsFor = async (transferId: number) => {
|
||||
const res = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
and: [
|
||||
{ targetCollection: { equals: "leadership-transfers" } },
|
||||
{ targetId: { equals: transferId } },
|
||||
],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
// The per-user permission cache is keyed by numeric id, which can collide
|
||||
// with users from previously run spec files in the same serialized run.
|
||||
invalidatePermissionCache();
|
||||
|
||||
// The superuser needs BOTH sides of the RBAC split: the dynamic
|
||||
// isSuperuser role (roleDocs) for resolveLeadershipTransfer's guard and the
|
||||
// legacy admin role for the transfers lib's hasRoles-based notifySuperusers.
|
||||
const superRole = await makeRole("super", { isSuperuser: true });
|
||||
users.boss = await makeUser("boss-su", { roles: ["admin"], roleDocs: [superRole.id] });
|
||||
|
||||
// Make sure the destination starts unconfigured for the unset-error test,
|
||||
// regardless of what earlier files in the same serialized run left behind.
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
users.leaderX = await makeUser("lead-x");
|
||||
users.leaderY = await makeUser("lead-y");
|
||||
users.member1 = await makeUser("member-1");
|
||||
users.member2 = await makeUser("member-2");
|
||||
users.member3 = await makeUser("member-3");
|
||||
users.memberInf = await makeUser("member-inf");
|
||||
users.infantryLead = await makeUser("inf-lead");
|
||||
users.outsider = await makeUser("outsider");
|
||||
|
||||
await makeAssignment("Division X", "division", users.leaderX.id, [
|
||||
users.member1.id,
|
||||
users.member2.id,
|
||||
users.member3.id,
|
||||
]);
|
||||
await makeAssignment("Division Y", "division", users.leaderY.id, []);
|
||||
await makeAssignment("Squad S", "squad", users.leaderX.id, [users.member1.id]);
|
||||
await makeAssignment("Infantry", "division", users.infantryLead.id, [users.memberInf.id]);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
|
||||
// Restore the global so other spec files in the same run are unaffected.
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const notifs = await payload
|
||||
.find({
|
||||
collection: "user-notifications",
|
||||
where: { user: { in: userIds } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const n of notifs?.docs ?? []) {
|
||||
await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const events = await payload
|
||||
.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
or: [
|
||||
{ actor: { in: userIds } },
|
||||
{ targetCollection: { equals: "leadership-transfers" } },
|
||||
],
|
||||
},
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const e of events?.docs ?? []) {
|
||||
await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const id of transferIds) {
|
||||
await payload.delete({ collection: "leadership-transfers", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of assignmentIds) {
|
||||
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
const profiles = await payload
|
||||
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const p of profiles?.docs ?? []) {
|
||||
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
const accounts = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const a of accounts?.docs ?? []) {
|
||||
await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces creation guards", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
// Non-leader initiator.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.outsider, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("Only the current leader of");
|
||||
|
||||
// Leader of a different assignment.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderY, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("Only the current leader of");
|
||||
|
||||
// Squad-type source (leader is right, assignment type is wrong).
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Squad S"],
|
||||
}),
|
||||
).rejects.toThrow("Only divisions can remove members this way.");
|
||||
|
||||
// Self-removal.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.leaderX.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("You cannot remove yourself.");
|
||||
|
||||
// Subject is not a member of the division.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.outsider.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("That member is not a member of your division.");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("rejects requests while the Infantry destination is not configured", async () => {
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
reason: "presence",
|
||||
}),
|
||||
).rejects.toThrow("Command must configure the default transfer destination first (Game Rules).");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("rejects infantry-origin removals", async () => {
|
||||
const infantry = assignmentIdsByName["Infantry"];
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: infantry },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.infantryLead, {
|
||||
subjectId: users.memberInf.id,
|
||||
fromAssignmentId: infantry,
|
||||
}),
|
||||
).rejects.toThrow("Infantry-origin removals are not supported yet.");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("creates a pending request, notifies only superusers, and emits no event", async () => {
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
reason: "presence",
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
expect(doc.status).toBe("pending");
|
||||
expect(doc.toAssignment).toBe(assignmentIdsByName["Infantry"]);
|
||||
expect(doc.reason).toBe("presence");
|
||||
|
||||
const bossNotified = await notificationsFor(users.boss.id, "assignment:leadership-transfer-requested");
|
||||
expect(bossNotified.length).toBeGreaterThan(0);
|
||||
expect(bossNotified[0]?.title).toBe("Removal request");
|
||||
|
||||
// The subject learns nothing while the request is pending.
|
||||
const subjectNotified = await notificationsFor(users.member1.id);
|
||||
expect(subjectNotified.length).toBe(0);
|
||||
|
||||
// No public event-log entry while pending.
|
||||
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
||||
|
||||
// A second open request for the same member + division is rejected.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
}),
|
||||
).rejects.toThrow("A removal request for this member is already pending.");
|
||||
|
||||
// Stash for the approval test.
|
||||
pendingDoc = doc;
|
||||
}, TIMEOUT);
|
||||
|
||||
it("superuser approval moves the member and attributes everything to the leader", async () => {
|
||||
const doc = pendingDoc!;
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
const infantry = assignmentIdsByName["Infantry"];
|
||||
|
||||
// Non-superusers cannot resolve.
|
||||
await expect(
|
||||
resolveLeadershipTransfer(payload, users.leaderY, doc.id, { approve: true }),
|
||||
).rejects.toThrow("Only a superuser can resolve removal requests.");
|
||||
|
||||
const approved = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
||||
approve: true,
|
||||
note: "agreed",
|
||||
});
|
||||
expect(approved.status).toBe("approved");
|
||||
expect(approved.reviewedBy).toBe(users.boss.id);
|
||||
expect(approved.resolvedAt).toBeTruthy();
|
||||
expect(approved.reviewNote).toBe("agreed");
|
||||
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(infantry);
|
||||
expect(from.members).not.toContain(users.member1.id);
|
||||
expect(to.members).toContain(users.member1.id);
|
||||
|
||||
// The subject's notification names the LEADER, never the approving superuser.
|
||||
const subjectNotified = await notificationsFor(users.member1.id, "assignment:leadership-transfer");
|
||||
expect(subjectNotified.length).toBeGreaterThan(0);
|
||||
const subjectMessage = subjectNotified[0]?.message ?? "";
|
||||
expect(subjectMessage).toContain(users.leaderX.displayName);
|
||||
expect(subjectMessage).not.toContain(users.boss.displayName);
|
||||
|
||||
const initiatorNotified = await notificationsFor(
|
||||
users.leaderX.id,
|
||||
"assignment:leadership-transfer-approved",
|
||||
);
|
||||
expect(initiatorNotified.length).toBeGreaterThan(0);
|
||||
|
||||
// Exactly one public event, visibly authored by the leader; the resolver's
|
||||
// id lives in data for audit only.
|
||||
const events = await leadershipEventsFor(doc.id);
|
||||
expect(events.length).toBe(1);
|
||||
const event = events[0]!;
|
||||
expect(event.type).toBe(EventTypes.PersonnelTransferred);
|
||||
expect(event.actor).toBe(users.leaderX.id);
|
||||
const data = event.data as Record<string, unknown>;
|
||||
expect(data.initiatorId).toBe(users.leaderX.id);
|
||||
expect(data.approvedById).toBe(users.boss.id);
|
||||
expect(data.subjectId).toBe(users.member1.id);
|
||||
expect(data.fromAssignmentId).toBe(divisionX);
|
||||
expect(data.toAssignmentId).toBe(infantry);
|
||||
expect(data.reason).toBe("presence");
|
||||
|
||||
// A second resolution attempt is rejected (no longer pending).
|
||||
await expect(
|
||||
resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: true }),
|
||||
).rejects.toThrow("Only pending removal requests can be resolved");
|
||||
|
||||
// The collection hook rejects illegal transitions out of the terminal state.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: "leadership-transfers",
|
||||
id: doc.id,
|
||||
data: { status: "pending" },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow("Illegal leadership transfer status transition");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("denial moves nothing, notifies only the initiator, and emits no event", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member2.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
const denied = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
||||
approve: false,
|
||||
note: "keep them",
|
||||
});
|
||||
expect(denied.status).toBe("denied");
|
||||
expect(denied.reviewedBy).toBe(users.boss.id);
|
||||
expect(denied.resolvedAt).toBeTruthy();
|
||||
expect(denied.reviewNote).toBe("keep them");
|
||||
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
||||
expect(from.members).toContain(users.member2.id);
|
||||
expect(to.members).not.toContain(users.member2.id);
|
||||
|
||||
const initiatorNotified = await notificationsFor(
|
||||
users.leaderX.id,
|
||||
"assignment:leadership-transfer-denied",
|
||||
);
|
||||
expect(initiatorNotified.length).toBeGreaterThan(0);
|
||||
expect(initiatorNotified[0]?.message).toContain("keep them");
|
||||
|
||||
// The subject is not told about a denial, and nothing goes public.
|
||||
expect((await notificationsFor(users.member2.id, "assignment:leadership-transfer")).length).toBe(0);
|
||||
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("cancels own pending requests only", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member3.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
// Another leader cannot cancel someone else's request.
|
||||
await expect(
|
||||
cancelLeadershipTransfer(payload, users.leaderY, doc.id),
|
||||
).rejects.toThrow("Only your own pending requests can be cancelled.");
|
||||
|
||||
const cancelled = await cancelLeadershipTransfer(payload, users.leaderX, doc.id);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
|
||||
// Cancelled is terminal: no second cancellation, and no membership moved.
|
||||
await expect(cancelLeadershipTransfer(payload, users.leaderX, doc.id)).rejects.toThrow(
|
||||
"Only your own pending requests can be cancelled.",
|
||||
);
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
||||
expect(from.members).toContain(users.member3.id);
|
||||
expect(to.members).not.toContain(users.member3.id);
|
||||
|
||||
// A cancelled request is not "open": a fresh one for the same member is allowed.
|
||||
const second = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member3.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(second.id);
|
||||
expect(second.status).toBe("pending");
|
||||
await cancelLeadershipTransfer(payload, users.leaderX, second.id);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
|
|
@ -4,20 +4,26 @@ import config from "@/payload.config";
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
||||
import {
|
||||
DEFAULT_MISSION_DURATION_MINUTES,
|
||||
autoCompleteMissions,
|
||||
isPastScheduledDay,
|
||||
startOfToday,
|
||||
autoStartMissions,
|
||||
hasMissionStarted,
|
||||
isMissionWindowComplete,
|
||||
missionEndTime,
|
||||
} from "@/lib/intelligence/missionLifecycle";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `mlc-${Date.now().toString(36)}`;
|
||||
|
||||
describe("Mission auto-complete", () => {
|
||||
describe("Mission lifecycle (auto-start + auto-complete)", () => {
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let authorId: number;
|
||||
const missionIds: number[] = [];
|
||||
// Ids completed by the window sweep, so the idempotency check can scope to
|
||||
// exactly those (the suite deliberately leaves other due missions behind).
|
||||
const completedBySweep: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
|
|
@ -102,20 +108,21 @@ describe("Mission auto-complete", () => {
|
|||
label: string,
|
||||
status: Mission["ownershipAndStatus"]["status"],
|
||||
start: Date,
|
||||
estimatedDuration = 60,
|
||||
): Promise<Mission> => {
|
||||
const mission = (await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} ${label}`,
|
||||
codeName: `${RUN}-${label}`,
|
||||
summary: "Auto-complete boundary test mission",
|
||||
summary: "Mission lifecycle boundary test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: start.toISOString(),
|
||||
estimatedDuration: 60,
|
||||
estimatedDuration,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [authorId],
|
||||
|
|
@ -148,41 +155,37 @@ describe("Mission auto-complete", () => {
|
|||
overrideAccess: true,
|
||||
}) as unknown as Promise<Mission>;
|
||||
|
||||
it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => {
|
||||
it("auto-starts only Scheduled missions whose start has arrived and whose window is still open", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
const at = (dayOffset: number, hour: number): Date => {
|
||||
const at = (hourOffset: number): Date => {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + dayOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
d.setHours(now.getHours() + hourOffset);
|
||||
return d;
|
||||
};
|
||||
|
||||
const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20));
|
||||
const dueActive = await makeMission("due-active", "Active", at(-3, 20));
|
||||
const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23));
|
||||
const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8));
|
||||
const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13));
|
||||
const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20));
|
||||
const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20));
|
||||
const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240);
|
||||
const future = await makeMission("start-future", "Scheduled", at(1), 240);
|
||||
const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60);
|
||||
const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240);
|
||||
const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240);
|
||||
const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id];
|
||||
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
const result = await autoStartMissions(payload, now, { missionIds: ids });
|
||||
|
||||
expect(result.missions.map((m) => m.id).sort()).toEqual(
|
||||
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(),
|
||||
);
|
||||
expect(result.started).toBe(1);
|
||||
expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]);
|
||||
|
||||
expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready");
|
||||
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
|
||||
expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
// Fully elapsed windows are auto-complete's job, not a spurious start.
|
||||
expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active");
|
||||
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
|
||||
|
||||
// The partial group update must not wipe sibling fields.
|
||||
const updated = await fetchMission(dueScheduled.id);
|
||||
const updated = await fetchMission(inWindow.id);
|
||||
expect(updated.ownershipAndStatus.visibility).toBe("unit");
|
||||
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
|
||||
? updated.ownershipAndStatus.authors
|
||||
|
|
@ -190,10 +193,49 @@ describe("Mission auto-complete", () => {
|
|||
expect(authorRefs).toContain(authorId);
|
||||
});
|
||||
|
||||
it("is idempotent — a second sweep finds nothing left to complete", async () => {
|
||||
it("is idempotent — a second auto-start sweep finds nothing left to start", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
const result = await autoStartMissions(payload, now, { missionIds });
|
||||
expect(result.started).toBe(0);
|
||||
expect(result.missions).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-completes Scheduled and Active missions once start + estimatedDuration has passed", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
const at = (hourOffset: number): Date => {
|
||||
const d = new Date(now);
|
||||
d.setHours(now.getHours() + hourOffset);
|
||||
return d;
|
||||
};
|
||||
|
||||
const fromScheduled = await makeMission("done-from-scheduled", "Scheduled", at(-6), 240);
|
||||
const fromActive = await makeMission("done-from-active", "Active", at(-5), 60);
|
||||
const inWindow = await makeMission("done-in-window", "Active", at(-1), 240);
|
||||
const readyPast = await makeMission("done-ready", "Ready", at(-5), 60);
|
||||
const cancelledPast = await makeMission("done-cancelled", "Cancelled", at(-5), 60);
|
||||
const ids = [fromScheduled.id, fromActive.id, inWindow.id, readyPast.id, cancelledPast.id];
|
||||
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds: ids });
|
||||
|
||||
expect(result.completed).toBe(2);
|
||||
expect(result.missions.map((m) => m.id).sort()).toEqual([fromScheduled.id, fromActive.id].sort());
|
||||
completedBySweep.push(...result.missions.map((m) => m.id));
|
||||
|
||||
expect((await fetchMission(fromScheduled.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(fromActive.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
|
||||
// Draft and terminal statuses are never auto-completed.
|
||||
expect((await fetchMission(readyPast.id)).ownershipAndStatus.status).toBe("Ready");
|
||||
expect((await fetchMission(cancelledPast.id)).ownershipAndStatus.status).toBe("Cancelled");
|
||||
});
|
||||
|
||||
it("is idempotent — a second auto-complete sweep finds nothing left to complete", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds: completedBySweep });
|
||||
expect(result.completed).toBe(0);
|
||||
expect(result.missions).toEqual([]);
|
||||
});
|
||||
|
|
@ -202,10 +244,10 @@ describe("Mission auto-complete", () => {
|
|||
const now = new Date();
|
||||
now.setHours(15, 0, 0, 0);
|
||||
const start = new Date(now);
|
||||
start.setDate(now.getDate() - 1);
|
||||
start.setDate(now.getDate() - 2);
|
||||
start.setHours(20, 0, 0, 0);
|
||||
|
||||
const due = await makeMission("timestamp-due", "Scheduled", start);
|
||||
const due = await makeMission("timestamp-due", "Scheduled", start, 60);
|
||||
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
|
||||
expect(result.completed).toBe(1);
|
||||
|
|
@ -217,11 +259,11 @@ describe("Mission auto-complete", () => {
|
|||
|
||||
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
|
||||
const start = new Date();
|
||||
// Future start date — the auto-complete sweep must never touch this mission.
|
||||
// Future start date — neither sweep must ever touch this mission.
|
||||
start.setDate(start.getDate() + 1);
|
||||
start.setHours(20, 0, 0, 0);
|
||||
|
||||
const mission = await makeMission("manual-complete", "Scheduled", start);
|
||||
const mission = await makeMission("manual-complete", "Scheduled", start, 60);
|
||||
expect(mission.ownershipAndStatus.completedAt).toBeNull();
|
||||
|
||||
// Non-terminal transitions must not stamp a completion time.
|
||||
|
|
@ -271,13 +313,41 @@ describe("Mission auto-complete", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("classifies day boundaries deterministically", () => {
|
||||
const now = new Date(2026, 7, 24, 12, 0, 0);
|
||||
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
|
||||
expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true);
|
||||
expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("not-a-date", now)).toBe(false);
|
||||
it("classifies start and window boundaries deterministically", () => {
|
||||
// missionEndTime adds the duration in minutes to the start.
|
||||
expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe(
|
||||
"2026-08-25T00:00:00.000Z",
|
||||
);
|
||||
// Missing / invalid durations fall back to the default.
|
||||
expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe(
|
||||
new Date(
|
||||
new Date("2026-08-24T20:00:00.000Z").getTime() +
|
||||
DEFAULT_MISSION_DURATION_MINUTES * 60_000,
|
||||
).toISOString(),
|
||||
);
|
||||
expect(missionEndTime("not-a-date", 60)).toBeNull();
|
||||
|
||||
const now = new Date("2026-08-24T20:00:00.000Z");
|
||||
expect(hasMissionStarted("2026-08-24T19:59:59.000Z", now)).toBe(true);
|
||||
expect(hasMissionStarted("2026-08-24T20:00:00.000Z", now)).toBe(true);
|
||||
expect(hasMissionStarted("2026-08-24T20:00:01.000Z", now)).toBe(false);
|
||||
expect(hasMissionStarted("not-a-date", now)).toBe(false);
|
||||
|
||||
expect(isMissionWindowComplete("2026-08-24T20:00:00.000Z", 240, now)).toBe(false);
|
||||
expect(
|
||||
isMissionWindowComplete(
|
||||
"2026-08-24T20:00:00.000Z",
|
||||
240,
|
||||
new Date("2026-08-25T00:00:00.000Z"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMissionWindowComplete(
|
||||
"2026-08-24T20:00:00.000Z",
|
||||
240,
|
||||
new Date("2026-08-24T23:59:59.999Z"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isMissionWindowComplete("not-a-date", 240, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
599
tests/int/promotion-nominations.int.spec.ts
Normal file
599
tests/int/promotion-nominations.int.spec.ts
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Assignment, Profile, Rank, Role, User } from "@/payload-types";
|
||||
import {
|
||||
cancelNomination,
|
||||
recordLeaderDecision,
|
||||
resolveNomination,
|
||||
submitNomination,
|
||||
} from "@/lib/promotions/nominations";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `promnom-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
const ACCOLADES = "Consistently leads the squad under fire and never misses a rally point.";
|
||||
|
||||
/**
|
||||
* User deletion trips FK constraints unless the hook-provisioned personal bank
|
||||
* account and profile are removed first, and event/notification rows must go
|
||||
* before the users they reference.
|
||||
*/
|
||||
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
|
||||
const accounts = await pg
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await pg
|
||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
const profiles = await pg
|
||||
.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await pg
|
||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
};
|
||||
|
||||
describe("Promotion nominations", () => {
|
||||
const rankIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const assignmentIds: number[] = [];
|
||||
const nominationIds: number[] = [];
|
||||
|
||||
let pvt: Rank;
|
||||
let cpl: Rank;
|
||||
let sgt: Rank;
|
||||
let colonel: Rank;
|
||||
|
||||
let superuser: User;
|
||||
let leader: User;
|
||||
let outsider: User;
|
||||
let selfNominee: User;
|
||||
let divisionlessNominee: User;
|
||||
let topRankUser: User;
|
||||
let leaderNominee: User;
|
||||
let leaderRejectNominee: User;
|
||||
let cancelNominee: User;
|
||||
let hookNominee: User;
|
||||
let restNominee: User;
|
||||
|
||||
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username,
|
||||
discordUsername: username,
|
||||
displayName: username,
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
const setProfileRank = async (userId: number, rankId: number): Promise<void> => {
|
||||
const profiles = (await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: userId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(profiles.docs.length).toBeGreaterThan(0);
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profiles.docs[0].id,
|
||||
data: { rank: rankId },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const getProfile = async (userId: number): Promise<Profile> => {
|
||||
const profiles = (await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: userId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Profile[] };
|
||||
return profiles.docs[0];
|
||||
};
|
||||
|
||||
const notificationsFor = async (userId: number, type: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "user-notifications",
|
||||
where: {
|
||||
and: [{ user: { equals: userId } }, { type: { equals: type } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
const eventsOfType = async (type: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { equals: type } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
invalidatePermissionCache();
|
||||
|
||||
// Self-contained 4-rank ladder: Pvt < Cpl < Sgt < Col (orderable _order).
|
||||
for (const [name, abbreviation] of [
|
||||
["Pvt", "pvt"],
|
||||
["Cpl", "cpl"],
|
||||
["Sgt", "sgt"],
|
||||
["Col", "col"],
|
||||
] as const) {
|
||||
const rank = (await payload.create({
|
||||
collection: "ranks",
|
||||
data: {
|
||||
name: `${RUN} ${name}`,
|
||||
abbreviation: `${RUN}-${abbreviation}`,
|
||||
description: `${RUN} test rank`,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Rank;
|
||||
rankIds.push(rank.id);
|
||||
if (name === "Pvt") pvt = rank;
|
||||
if (name === "Cpl") cpl = rank;
|
||||
if (name === "Sgt") sgt = rank;
|
||||
if (name === "Col") colonel = rank;
|
||||
}
|
||||
|
||||
const superRole = (await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN}-super`, slug: `${RUN}-super`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Role;
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
superuser = await makeUser(`${RUN}-superuser`, ["admin"]);
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: superuser.id,
|
||||
data: { roleDocs: [superRole.id] },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
// promoteMember resolves the actor's authority from their profile rank;
|
||||
// a legacy admin gets unbounded authority but still needs a rank above the
|
||||
// granted rank.
|
||||
await setProfileRank(superuser.id, colonel.id);
|
||||
|
||||
leader = await makeUser(`${RUN}-leader`);
|
||||
outsider = await makeUser(`${RUN}-outsider`);
|
||||
selfNominee = await makeUser(`${RUN}-self`);
|
||||
divisionlessNominee = await makeUser(`${RUN}-divless`);
|
||||
topRankUser = await makeUser(`${RUN}-top`);
|
||||
leaderNominee = await makeUser(`${RUN}-leadnom`);
|
||||
leaderRejectNominee = await makeUser(`${RUN}-lerej`);
|
||||
cancelNominee = await makeUser(`${RUN}-cancel`);
|
||||
hookNominee = await makeUser(`${RUN}-hook`);
|
||||
restNominee = await makeUser(`${RUN}-rest`);
|
||||
|
||||
await setProfileRank(selfNominee.id, pvt.id);
|
||||
await setProfileRank(divisionlessNominee.id, pvt.id);
|
||||
await setProfileRank(topRankUser.id, colonel.id);
|
||||
await setProfileRank(leaderNominee.id, pvt.id);
|
||||
await setProfileRank(leaderRejectNominee.id, pvt.id);
|
||||
await setProfileRank(cancelNominee.id, pvt.id);
|
||||
await setProfileRank(hookNominee.id, pvt.id);
|
||||
await setProfileRank(restNominee.id, pvt.id);
|
||||
|
||||
const division = (await payload.create({
|
||||
collection: "assignments",
|
||||
data: {
|
||||
name: `${RUN} Division`,
|
||||
type: "division",
|
||||
leader: leader.id,
|
||||
members: [
|
||||
selfNominee.id,
|
||||
leaderNominee.id,
|
||||
leaderRejectNominee.id,
|
||||
cancelNominee.id,
|
||||
hookNominee.id,
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Assignment;
|
||||
assignmentIds.push(division.id);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of nominationIds) {
|
||||
await payload
|
||||
.delete({ collection: "promotion-nominations", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
// Events and notifications reference users; remove them before the users.
|
||||
for (const collection of ["game-event-logs", "user-notifications"] as const) {
|
||||
const docs = await payload
|
||||
.find({
|
||||
collection,
|
||||
where: { id: { exists: true } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const doc of docs?.docs ?? []) {
|
||||
const event = doc as unknown as {
|
||||
actor?: number | { id: number } | null;
|
||||
user?: number | { id: number } | null;
|
||||
};
|
||||
const ref = event.actor ?? event.user;
|
||||
const refId = typeof ref === "object" ? ref?.id : ref;
|
||||
if (refId != null && userIds.includes(refId)) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of assignmentIds) {
|
||||
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await deleteUserWithRelations(payload, id);
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of rankIds) {
|
||||
await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
it("guards submissions: short accolades, open duplicate, and top of the ladder", async () => {
|
||||
// Short accolades are refused with the plain-language message.
|
||||
await expect(
|
||||
submitNomination(payload, outsider, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: "too short",
|
||||
}),
|
||||
).rejects.toThrow(/Provide at least a sentence/);
|
||||
|
||||
// A valid submission for a leaderless member skips straight to the superuser.
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
expect(submitted.status).toBe("awaiting_superuser");
|
||||
expect(submitted.leaderDecision).toBe("not_required");
|
||||
|
||||
// Superusers are notified for the leaderless routing.
|
||||
const superuserNotified = (await notificationsFor(superuser.id, "promotion:nomination")).filter(
|
||||
(n) => n.message?.includes(divisionlessNominee.displayName),
|
||||
);
|
||||
expect(superuserNotified).toHaveLength(1);
|
||||
expect(superuserNotified[0].message).toContain(outsider.displayName);
|
||||
|
||||
// A second open nomination for the same nominee is refused.
|
||||
await expect(
|
||||
submitNomination(payload, leader, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
}),
|
||||
).rejects.toThrow(/already has an open promotion nomination/);
|
||||
|
||||
// A member at the top of the ladder cannot be nominated.
|
||||
await expect(
|
||||
submitNomination(payload, outsider, {
|
||||
nomineeId: topRankUser.id,
|
||||
accolades: ACCOLADES,
|
||||
}),
|
||||
).rejects.toThrow(/already holds the highest rank/);
|
||||
});
|
||||
|
||||
it("routes the leader stage: division leader notified and pending, self-request allowed", async () => {
|
||||
// Self-request by a member of a led division: pending with the leader.
|
||||
const selfRequest = await submitNomination(payload, selfNominee, {
|
||||
nomineeId: selfNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(selfRequest.id);
|
||||
expect(selfRequest.status).toBe("pending");
|
||||
expect(selfRequest.leaderDecision).toBe("pending");
|
||||
expect(selfRequest.nominator).toBe(selfRequest.nominee);
|
||||
|
||||
const leaderNotified = await notificationsFor(leader.id, "promotion:nomination");
|
||||
expect(leaderNotified).toHaveLength(1);
|
||||
expect(leaderNotified[0]).toMatchObject({
|
||||
title: "Promotion nomination",
|
||||
link: "/personnel/promotions",
|
||||
});
|
||||
|
||||
// The division leader nominating their own member needs no leader stage.
|
||||
const leaderNominated = await submitNomination(payload, leader, {
|
||||
nomineeId: leaderNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(leaderNominated.id);
|
||||
expect(leaderNominated.status).toBe("awaiting_superuser");
|
||||
expect(leaderNominated.leaderDecision).toBe("not_required");
|
||||
});
|
||||
|
||||
it("authorizes leader decisions and endorses through to the superuser stage", async () => {
|
||||
const selfRequest = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: { nominee: { equals: selfNominee.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
const nominationId = selfRequest.docs[0].id;
|
||||
|
||||
// A member who is not the division leader cannot decide.
|
||||
await expect(
|
||||
recordLeaderDecision(payload, outsider, nominationId, { endorse: true }),
|
||||
).rejects.toThrow(/Only the nominee's division leader/);
|
||||
|
||||
// The current leader endorses: awaiting_superuser + stamped decision.
|
||||
const endorsed = await recordLeaderDecision(payload, leader, nominationId, {
|
||||
endorse: true,
|
||||
note: "Well earned.",
|
||||
});
|
||||
expect(endorsed.status).toBe("awaiting_superuser");
|
||||
expect(endorsed.leaderDecision).toBe("endorsed");
|
||||
expect(endorsed.leaderDecisionBy).toBe(leader.id);
|
||||
expect(endorsed.leaderNote).toBe("Well earned.");
|
||||
|
||||
// The nominator is notified of the endorsement.
|
||||
const endorsedNotes = await notificationsFor(selfNominee.id, "promotion:endorsed");
|
||||
expect(endorsedNotes).toHaveLength(1);
|
||||
expect(endorsedNotes[0].message).toContain(leader.displayName);
|
||||
|
||||
// The decision is one-shot: a second endorsement is refused.
|
||||
await expect(
|
||||
recordLeaderDecision(payload, leader, nominationId, { endorse: true }),
|
||||
).rejects.toThrow(/not awaiting endorsement/);
|
||||
});
|
||||
|
||||
it("rejects a non-superuser final resolution", async () => {
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: selfNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
resolveNomination(payload, leader, awaiting.docs[0].id, { approve: true }),
|
||||
).rejects.toThrow(/Only a superuser/);
|
||||
});
|
||||
|
||||
it("approves through promoteMember: exactly one rung, event, and nominator notification", async () => {
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: selfNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
const nominationId = awaiting.docs[0].id;
|
||||
|
||||
const approved = await resolveNomination(payload, superuser, nominationId, {
|
||||
approve: true,
|
||||
note: "Approved by command.",
|
||||
});
|
||||
expect(approved.status).toBe("approved");
|
||||
expect(approved.reviewedBy).toBe(superuser.id);
|
||||
expect(approved.reviewNote).toBe("Approved by command.");
|
||||
|
||||
// The nominee moved EXACTLY one rung: Pvt -> Cpl.
|
||||
const profile = await getProfile(selfNominee.id);
|
||||
expect(profile.rank).toBe(cpl.id);
|
||||
|
||||
// The public event carries the promotion trace.
|
||||
const events = await eventsOfType("promotion:approved");
|
||||
const nominationEvent = events.find(
|
||||
(e) => (e as unknown as { targetId: number }).targetId === nominationId,
|
||||
);
|
||||
expect(nominationEvent).toBeDefined();
|
||||
const eventData = (nominationEvent as unknown as { data: Record<string, unknown> }).data;
|
||||
expect(eventData).toMatchObject({
|
||||
nominationId,
|
||||
nomineeId: selfNominee.id,
|
||||
nominatorId: selfNominee.id,
|
||||
fromRankId: pvt.id,
|
||||
toRankId: cpl.id,
|
||||
});
|
||||
|
||||
// The nominator is notified with the new rank.
|
||||
const approvedNotes = await notificationsFor(selfNominee.id, "promotion:approved");
|
||||
expect(approvedNotes).toHaveLength(1);
|
||||
expect(approvedNotes[0].message).toContain(cpl.name);
|
||||
});
|
||||
|
||||
it("rejects at final review without touching the nominee's rank", async () => {
|
||||
// The divisionless member's nomination is still awaiting final approval.
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: divisionlessNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
|
||||
const rejected = await resolveNomination(payload, superuser, awaiting.docs[0].id, {
|
||||
approve: false,
|
||||
note: "Not this cycle.",
|
||||
});
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.reviewedBy).toBe(superuser.id);
|
||||
expect(rejected.reviewNote).toBe("Not this cycle.");
|
||||
|
||||
const profile = await getProfile(divisionlessNominee.id);
|
||||
expect(profile.rank).toBe(pvt.id);
|
||||
|
||||
const rejectedNotes = await notificationsFor(outsider.id, "promotion:rejected");
|
||||
expect(rejectedNotes).toHaveLength(1);
|
||||
expect(rejectedNotes[0].message).toContain("Not this cycle.");
|
||||
});
|
||||
|
||||
it("records the leader's rejection as terminal and notifies the nominator", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: leaderRejectNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
expect(submitted.status).toBe("pending");
|
||||
|
||||
const rejected = await recordLeaderDecision(payload, leader, submitted.id, {
|
||||
endorse: false,
|
||||
note: "Needs more time in grade.",
|
||||
});
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.leaderDecision).toBe("rejected");
|
||||
expect(rejected.leaderDecisionBy).toBe(leader.id);
|
||||
|
||||
const rejectedNotes = (await notificationsFor(outsider.id, "promotion:rejected")).filter(
|
||||
(n) => n.message?.includes("Needs more time in grade."),
|
||||
);
|
||||
expect(rejectedNotes).toHaveLength(1);
|
||||
expect(rejectedNotes[0].message).toContain(leader.displayName);
|
||||
|
||||
const profile = await getProfile(leaderRejectNominee.id);
|
||||
expect(profile.rank).toBe(pvt.id);
|
||||
});
|
||||
|
||||
it("lets only the nominator cancel their own pending nomination", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: cancelNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
|
||||
await expect(cancelNomination(payload, leader, submitted.id)).rejects.toThrow(
|
||||
/Only your own pending nominations can be withdrawn/,
|
||||
);
|
||||
|
||||
const cancelled = await cancelNomination(payload, outsider, submitted.id);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
|
||||
await expect(cancelNomination(payload, outsider, submitted.id)).rejects.toThrow(
|
||||
/Only pending nominations can be withdrawn/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects illegal status transitions at the collection hook", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: hookNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
|
||||
// pending -> approved skips the endorsement and final stages: illegal.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: "promotion-nominations",
|
||||
id: submitted.id,
|
||||
data: { status: "approved" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/Illegal promotion nomination status transition/);
|
||||
|
||||
const reread = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: submitted.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(reread.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("forces the nominator to the authenticated caller on a REST-style create", async () => {
|
||||
// A REST create claiming another nominator is refused by the hook.
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: "promotion-nominations",
|
||||
data: {
|
||||
nominee: restNominee.id,
|
||||
nominator: leader.id,
|
||||
accolades: ACCOLADES,
|
||||
status: "pending",
|
||||
},
|
||||
user: outsider,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/only submit a promotion nomination as yourself/);
|
||||
|
||||
// The caller is accepted as the nominator.
|
||||
const created = await payload.create({
|
||||
collection: "promotion-nominations",
|
||||
data: {
|
||||
nominee: restNominee.id,
|
||||
nominator: outsider.id,
|
||||
accolades: ACCOLADES,
|
||||
status: "pending",
|
||||
},
|
||||
user: outsider,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
});
|
||||
nominationIds.push(created.id);
|
||||
expect(created.nominator).toBe(outsider.id);
|
||||
expect(created.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
86
tests/int/tech-tree-edge-routes.int.spec.ts
Normal file
86
tests/int/tech-tree-edge-routes.int.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildRoutePath, ROUTE_CORNER_RADIUS } from "@/components/admin/tech-tree/edgeRoutes";
|
||||
import {
|
||||
routeAroundObstacles,
|
||||
segmentCrossesRect,
|
||||
type RouteRect,
|
||||
} from "@/components/admin/tech-tree/edgeRouting";
|
||||
|
||||
describe("tech tree edge route path", () => {
|
||||
it("returns nothing for fewer than two points", () => {
|
||||
expect(buildRoutePath([])).toBe("");
|
||||
expect(buildRoutePath([{ x: 0, y: 0 }])).toBe("");
|
||||
});
|
||||
|
||||
it("draws a straight line for two points", () => {
|
||||
expect(buildRoutePath([{ x: 0, y: 0 }, { x: 100, y: 0 }])).toBe("M 0,0 L 100,0");
|
||||
});
|
||||
|
||||
it("rounds each interior corner with a quadratic curve", () => {
|
||||
const d = buildRoutePath([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
]);
|
||||
expect(d.startsWith("M 0,0")).toBe(true);
|
||||
expect(d).toContain("Q 100,0");
|
||||
expect(d.endsWith("L 100,100")).toBe(true);
|
||||
// The straight run stops short of the corner by the radius.
|
||||
expect(d).toContain(`L ${100 - ROUTE_CORNER_RADIUS},0`);
|
||||
});
|
||||
|
||||
it("shrinks the radius on short segments so it never overshoots", () => {
|
||||
const d = buildRoutePath([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 10, y: 10 },
|
||||
]);
|
||||
// Half of the 10px segment, not the full default radius.
|
||||
expect(d).toContain("L 5,0");
|
||||
expect(d).toContain("Q 10,0 10,5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tech tree edge obstacle routing", () => {
|
||||
const box = (id: number, x: number, y: number, width: number, height: number): RouteRect => ({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
it("leaves a clear path unchanged", () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 200, y: 0 },
|
||||
];
|
||||
// The box sits well below the line.
|
||||
expect(routeAroundObstacles(points, [box(1, 0, 100, 60, 40)])).toEqual(points);
|
||||
});
|
||||
|
||||
it("returns the input untouched when there are no obstacles", () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 50 },
|
||||
];
|
||||
expect(routeAroundObstacles(points, [])).toBe(points);
|
||||
});
|
||||
|
||||
it("detours around a box that blocks the segment", () => {
|
||||
const obstacle = box(1, 80, -20, 40, 40);
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 200, y: 0 },
|
||||
];
|
||||
const routed = routeAroundObstacles(points, [obstacle]);
|
||||
expect(routed.length).toBeGreaterThan(2);
|
||||
expect(routed[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(routed[routed.length - 1]).toEqual({ x: 200, y: 0 });
|
||||
// Every resulting segment clears the box.
|
||||
for (let i = 0; i < routed.length - 1; i += 1) {
|
||||
expect(segmentCrossesRect(routed[i], routed[i + 1], obstacle)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue