1
0
Fork 0

Compare commits

...

23 commits

Author SHA1 Message Date
53d053b3b2 chore(db): refresh generated schema 2026-08-28 00:20:58 -04:00
450697416b test(auth): cover session token expiry parsing 2026-08-28 00:20:58 -04:00
64babe2608 feat(intelligence): track mission completion and campaign modes 2026-08-28 00:20:47 -04:00
d94da34af2 feat(intelligence): add mission feedback reminders 2026-08-28 00:20:41 -04:00
c2a16120cc feat(auth): handle expiring sessions in frontend 2026-08-28 00:20:35 -04:00
f6d42399d4 fix(bot): refresh roll-call date changes 2026-08-28 00:20:24 -04:00
055db8d0f0 docs(todo): refresh application roadmap
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
ddca600f04 docs(deploy): document Coolify deployment flow
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
a82ab415af build(deploy): remove legacy deploy hook
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
7ca8cd8551 feat(version): expose build metadata
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
7490f34267 feat(version): add build metadata fields
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
d518b13f36 feat(xp): show experience descriptions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
523b19ee52 feat(xp): expose experience descriptions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:11 -04:00
21f61ca33f feat(evaluations): add permissions and event logging
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
22b7343fa8 feat(evaluations): register collection and generated types
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
6bbafc7c5a feat(profile): integrate evaluations into profile pages
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
b8ba036965 feat(profile): add leadership evaluation controls
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
959f65b51e feat(evaluations): add profile evaluation actions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
a21770e54e feat(evaluations): add evaluation service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
15145f613a feat(evaluations): add evaluations collection and migration
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
9955c376f9 feat(evaluations): define performance levels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-27 18:36:04 -04:00
2be1081e92 test(int): fix pre-existing tickets and market spec failures
tickets: makeUser now assigns roleDocs because getStaffUserIds queries the RBAC relationship and the legacy roles select field is not synced at runtime. Both specs: teardown deletes profiles (and user-notifications in market) before users, since those relationships are NOT NULL.
2026-08-27 14:01:16 -04:00
9323a7ce08 feat(notifications): notify users when awards are granted via profile edit
Add a Profiles afterChange hook that diffs progression.awards against previousDoc (row id, falling back to award+date composite key) and sends an award:granted notification including the grant reason and issuer. Register award:granted as a muteable notification type.
2026-08-27 14:01:16 -04:00
65 changed files with 64022 additions and 141 deletions

View file

@ -53,6 +53,7 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
- If a dev server is **already running** when you want to test (port 3000 in use, or Next reports "Another next dev server is already running"), **ask the user whether you should kill the existing server and start a fresh one** before doing anything. A stale `.next/dev/devserver.lock` can also block startup — offer to clear it as part of the same question.
- If the user says **no**, do **not** start a dev server and do **not** attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back.
- Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting.
- For browser/UI verification, make a reasonable attempt with Playwright. If the browser tooling is unavailable or repeatedly unreliable, stop rather than spending excessive effort on it and defer the manual UI check to the user.
## Agent debugging SOP (read before fixing any bug)
@ -292,7 +293,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Deploy
`bun run deploy` bumps patch version (via `bun pm version patch`), builds, then runs `build/deploy.sh`. The `build/` directory is gitignored so the deploy script is not in the repo.
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
## Gotchas

View file

@ -208,9 +208,15 @@ Recommended flow:
4. Coolify waits for `/api/health` to come back 200, then routes traffic.
5. If a new migration shipped with that deploy, run it via `docker exec` or a Scheduled Task (Sec. 7).
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only. Tags, commit date, and commit subject have no Coolify equivalent and stay null on Coolify builds; local builds (`docker build`, `bun run dev`) still use real `git`.
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
>
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
>
> Local builds (`docker build`, `bun run dev`) use real `git` and can populate all fields.
No SSH, no `rsync`, no `systemctl`. The legacy `build/deploy.sh` (gitignored, the old SSH+systemd flow to `jmf-usrv-2404`) is **no longer used** — exclude it from future deploys. `bun run deploy` (the npm script) will still try to run it (it calls `postversion → bash ./build/deploy.sh`); don't run it from your local machine anymore, or remove that script entry from `package.json` once you've moved onto Coolify for good.
> **Version bump ordering.** The `postversion` hook runs the production build after `package.json` has been bumped. This prevents the build from embedding the previous package version.
No SSH, no `rsync`, no `systemctl`. The legacy `build/deploy.sh` (gitignored, the old SSH+systemd flow to `jmf-usrv-2404`) is **no longer used** — exclude it from future deploys. `bun run deploy` bumps the package version and runs the production build; push the resulting version commit to the branch watched by Coolify to deploy it.
---
@ -273,4 +279,4 @@ docker compose exec app sh # shell inside the app container
---
Last updated: 2026-08-12. Verify against the live repo if anything looks stale.
Last updated: 2026-08-12. Verify against the live repo if anything looks stale.

View file

@ -59,7 +59,7 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied).
| `bun run payload market-tick` | Expire listings and refresh NPC vendor stock |
| `bun run payload mission-tick` | Auto-complete missions whose scheduled day has passed |
| `bun run payload server-tick` | Mark game servers without a recent heartbeat offline |
| `bun run deploy` | Bump patch version, build, and deploy |
| `bun run deploy` | Bump patch version and build; push to deploy via Coolify |
| `bun run version:bump -- --base <sha> --head <sha>` | Choose and apply a patch/minor bump from a git range |
Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`.

53
TODO.md
View file

@ -1,49 +1,12 @@
# To-Do Feature List
# Application TODOs
- [x] Personal "locker" for users – A secure space where users can store, equip, and manage their items, gear, weapons, loadout, and other personal assets. (Live at `/locker`.)
- [x] Wardrobe – A virtual representation of the user's character that can be customized with different outfits, accessories, and gear. The wardrobe should reflect the user's current loadout and inventory.
- [x] Equipment editor – A tool that allows users to modify and customize their equipment, including changing attachments, skins, and other visual elements. The editor should provide a user-friendly interface for easy adjustments.
- [x] Inventory management system – A comprehensive system that allows users to view, organize, and manage their items, including sorting, filtering, and categorizing options. (Covered by the Locker: category tabs + grid arrangement via command palette actions.)
- [ ] Item crafting and customization – A feature that enables users to craft new items or customize existing ones, allowing for unique combinations and personalization.
- [ ] Trading and marketplace – A platform where users can trade items with other players or buy/sell items in a virtual marketplace, including features like bidding, auctions, and secure transactions. It should be reminiscent of the flea market in Escape from Tarkov. Automatically generated market entries should be available for items that are not listed by users, allowing for a dynamic and evolving marketplace.
- [x] Core buy/sell marketplace - Users list items from their locker at a fixed price; buyers pay via the banking system and items transfer directly into the buyer's locker. Runs `bun run payload market-tick` to expire listings and top up auto-generated vendor entries.
- [ ] Bidding and auctions – Allow sellers to list items as auctions with timed bidding instead of fixed-price buyouts.
- [ ] Marketplace fees and market price drift – Percentage listing/sale fees and price fluctuations that tend toward each asset's resting `baseBuyPrice`/`baseSellPrice` over time.
- [x] Implement command palette – A feature that provides users with a list of available commands and allows them to execute them with a single keystroke or gesture. The command palette should be accessible from a designated keybind or gesture and should display a list of relevant commands based on the user's current context. (Implemented via `src/components/command-palette/`, mounted in the `(frontend)` layout.)
- [x] Restyle roster leader label radio buttons in PreferencesForm – The native HTML radio buttons work but look out of place; replace with shadcn RadioGroup or SegmentedControl for visual consistency. (Now uses shadcn `RadioGroup`.)
## Full Feature Additions, Top Priority
## MVP – Priority Deadline by 8/15
- [ ] **Leadership evaluations and performance rating system** - Members of the unit should be able to rate the performance of their direct team leaders (full performance rating) and submit opinions on separate leaders (a more limited rating system for non-direct supervisors.) These ratings should appear anonymously on the leader's service record page. The ratings are divided by mission, so that they can accumulate over time to form a more accurate representation of the overall delta of a leader's performance over the course of multiple operations. These ratings appear as bars, red-to-green gradients, and percentage scores on the service record page. A small table showing the latest missions and the leader's overall rating are also visible below this bar. Additionally, leaders should be able to rate the performance of their subordinates. However, these ratings appear in a far more limited fashion on the subordinate's service record page. Instead of a breakdown with bars, percentages, latest missions, etc., they instead just get a text label indicating a summary of their performance from leadership. For example, if a subordinate is rated very highly by their leaders, their text label might say "OPERATOR PERFORMANCE LEVEL: EXCELLENT" in green text while a moderate review may say "OPERATOR PERFORMANCE LEVEL: MODERATE" in grey or blue text and a low rating might say "OPERATOR PERFORMANCE LEVEL: UNDER REVIEW" in red text. Preferably, there'd be at least 5 levels of performance instead of the 3 I used as examples.
- [ ] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
- [ ] **"Community" navigation group** - A "Community" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
[!NOTE] Some pieces of this application were not implemented by this date and have been pushed to the next release.
## Desirable Additions, Medium Priority
This application must be available to my users by 8/15. The minimum viable product is defined as:
- [x] Working authentication (login, logout, forgot password without using email)
- [x] Automatically generated profile with customization options for user on the front-end
- [ ] Full permissions and ACL system for all components
- [ ] ALL ADMIN UI access should be blocked behind a "trusted" role. (`isTrusted` helper exists in `src/utils/access-control/isRole.ts` but is not wired into any access checks yet.)
- [x] Admin UI access to the Intelligence section should be blocked behind an "intelligence" qualification.
- [x] Admin UI access to the Logistics section should be blocked behind a "logistics" qualification.
- [ ] Admin UI access to anything else should be blocked for the "admin" and "developer" roles until an explicit change by the developer at a later date.
- [ ] Proper front-end
- [x] Working dashboard (customizable layout/widgets still open)
- [ ] Customizable dashboard – let users arrange/toggle dashboard widgets.
- [x] Working profile page
- [x] Working roster page
- [x] Working intelligence pages
### Logistics (core of the application)
- [x] Logistics front-end
- [x] Working shipments screen
- [x] Working structures screen
- [x] Working assets screen
- [x] Working vehicles screen
- [x] Banking system
- [x] Working market screen
### To-Dos for later
- [ ] Add division art to profiles
- [ ] Medical stat tracking
- [ ] Community hub for posting videos, screenshots, and other media
- [ ] Player decoration for a "Player of the Week" award
- [ ] **"First obtained by" label for items** - When adding an item to the arsenal for the first time, there should be a tag or some type of text to recognize who acquired it/put it into the arsenal/resource pool for the first time. For example, if someone was the first to add a cigarette to the arsenal, in this web app it'll list who that was.

View file

@ -24,8 +24,7 @@
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"deploy": "bun pm version patch --message \"chore: update to %s\"",
"preversion": "bun run build",
"postversion": "bun run generate:version-info && bash ./build/deploy.sh",
"postversion": "bun run build",
"u:reset-password": "bun run src/tools/cli/reset-password.ts"
},
"dependencies": {

View file

@ -12,7 +12,10 @@ import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
import { getSessionExpMs } from "@/lib/session/token";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner";
@ -54,10 +57,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const sessionExp = getSessionExpMs(headers);
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = {
levelName: "Recruit",
levelDesc: "",
progress: 0,
currentLevelXP: 0,
nextLevelXP: null,
@ -96,6 +101,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
limit: 100,
select: {
name: true,
description: true,
requiredPoints: true,
},
}),
@ -154,13 +160,11 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
)}
{user && <GameTickRealtime />}
{user && <SessionWatchdog sessionExp={sessionExp} />}
{user && <MissionReminderToasts />}
{isLogistics && <ShipmentToasts />}
<Toaster />
<ShimLoader
shims={shimEntries}
userId={user?.id ?? null}
userRoles={user?.roles ?? []}
/>
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
</body>
</html>
);

View file

@ -38,7 +38,7 @@ export default async function HomePage() {
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: { name: true, requiredPoints: true },
select: { name: true, description: true, requiredPoints: true },
}),
payload.find({
collection: "assignments",

View file

@ -0,0 +1,152 @@
"use server";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload";
import type { User } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { isPerformanceLevelId } from "@/lib/evaluations/levels";
import {
evaluateEligibility,
getLatestSubordinateLevel,
getLeaderAggregate,
upsertEvaluation,
} from "@/lib/evaluations";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function findUserByUsername(
payload: Awaited<ReturnType<typeof getPayload>>,
username: string,
) {
const res = await payload.find({
collection: "users",
where: { username: { equals: username } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return (res.docs[0] as { id: number; username: string } | undefined) ?? null;
}
/**
* Submit (or re-submit) a leadership evaluation for the given profile. The kind
* is derived server-side from the rater/ratee/mission relationship — it is never
* accepted from the client. Eligibility is the permission gate here; any logged-in
* user with a qualifying relationship may evaluate.
*/
export async function submitEvaluation(input: {
username: string;
missionId: number;
level: string;
comment?: string;
}): Promise<ActionResult<{ created: boolean }>> {
try {
const { payload, user } = await authenticate();
if (!isPerformanceLevelId(input.level)) {
return { success: false, error: "Unknown performance level." };
}
const ratee = await findUserByUsername(payload, input.username);
if (!ratee) {
return { success: false, error: "User not found." };
}
const eligibility = await evaluateEligibility(payload, {
raterId: user.id as number,
rateeId: ratee.id,
missionId: input.missionId,
});
if (!eligibility.eligible || !eligibility.kind) {
return { success: false, error: eligibility.reason ?? "Not eligible to evaluate." };
}
const { evaluation, created } = await upsertEvaluation(payload, {
raterId: user.id as number,
rateeId: ratee.id,
missionId: input.missionId,
kind: eligibility.kind,
level: input.level,
comment: input.comment,
});
// No actor on the event: the event log is readable by any logged-in user and
// must not leak who rated whom.
await emitGameEvent(payload, {
type: EventTypes.EvaluationSubmit,
message: "A leadership evaluation was submitted.",
targetCollection: "evaluations",
targetId: evaluation.id,
data: { kind: eligibility.kind },
});
return { success: true, data: { created } };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
/**
* Rater-free evaluation summary for a profile page. Any logged-in user may call
* this; the output contains no rater identity or comments — only the anonymous
* leader aggregate (with its visibility threshold) and the latest subordinate
* level.
*/
export async function getEvaluationSummary(
username: string,
): Promise<
| {
success: true;
data: {
leader: Awaited<ReturnType<typeof getLeaderAggregate>>;
subordinate: Awaited<ReturnType<typeof getLatestSubordinateLevel>>;
};
}
| { success: false; error: string }
> {
try {
const { payload } = await authenticate();
const ratee = await findUserByUsername(payload, username);
if (!ratee) {
return { success: false, error: "User not found." };
}
const [leader, subordinate] = await Promise.all([
getLeaderAggregate(payload, ratee.id),
getLatestSubordinateLevel(payload, ratee.id),
]);
return { success: true, data: { leader, subordinate } };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}

View file

@ -1,10 +1,16 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import { getEvaluationSummary } from "./actions";
import {
ArrowLeftIcon,
AwardIcon,
@ -34,14 +40,128 @@ import {
} from "@/components/ui/item";
import AwardCard from "@/components/frontend/account/AwardCard";
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
import type {
EvaluationMissionOption,
EvaluationSummary,
LatestMissionRow,
} from "@/components/frontend/profile/evaluation-types";
interface ProfilePageProps {
params: Promise<{ username: string }>;
}
type Payload = Awaited<ReturnType<typeof getPayload>>;
async function findEligibleEvaluationMissions(
payload: Payload,
viewerId: number,
now: Date,
): Promise<readonly EvaluationMissionOption[]> {
const attendanceRes = await payload.find({
collection: "mission-attendances",
where: {
and: [{ user: { equals: viewerId } }, { response: { equals: "yes" } }],
},
limit: 100,
depth: 0,
select: { mission: true },
overrideAccess: true,
});
const missionIds = [
...new Set(
attendanceRes.docs.map((attendance) =>
typeof attendance.mission === "number" ? attendance.mission : attendance.mission.id,
),
),
];
if (missionIds.length === 0) return [];
const missionRes = await payload.find({
collection: "missions",
where: { id: { in: missionIds } },
limit: missionIds.length,
depth: 0,
select: {
name: true,
classification: { startDateTime: true },
ownershipAndStatus: { status: true },
},
overrideAccess: true,
});
return [...missionRes.docs]
.filter((mission) =>
isMissionEvaluable(
mission.ownershipAndStatus.status,
mission.classification.startDateTime,
now,
),
)
.sort(
(first, second) =>
new Date(second.classification.startDateTime).getTime() -
new Date(first.classification.startDateTime).getTime(),
)
.map((mission) => ({
id: mission.id,
name: mission.name,
startDateTime: mission.classification.startDateTime,
}));
}
async function findLatestLeaderMissionRows(
payload: Payload,
rateeId: number,
): Promise<readonly LatestMissionRow[]> {
const evaluationRes = await payload.find({
collection: "evaluations",
where: {
and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }],
},
sort: "-updatedAt",
limit: 10,
depth: 1,
select: { mission: true, level: true, updatedAt: true },
overrideAccess: true,
});
const missionIds = new Set<number>();
const rows: LatestMissionRow[] = [];
for (const evaluation of evaluationRes.docs) {
if (typeof evaluation.mission === "number") continue;
const level = PERFORMANCE_LEVELS.find((candidate) => candidate.id === evaluation.level);
if (!level || missionIds.has(evaluation.mission.id)) continue;
missionIds.add(evaluation.mission.id);
rows.push({
missionId: evaluation.mission.id,
missionName: evaluation.mission.name,
startDateTime: evaluation.mission.classification.startDateTime,
level: {
id: level.id,
label: getLeadershipDisplayLabel(level),
score: level.score,
color: level.color,
},
});
if (rows.length === 5) break;
}
return rows;
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { username } = await params;
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const viewer = isPayloadUser(authUser) ? authUser : null;
const profileRes = await payload
.find({
@ -70,6 +190,69 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const profile = profileRes.docs[0];
const user = profile.user as User;
const isProfileOwner = viewer?.id === user.id;
const isLegacyPrivileged =
viewer?.roles?.some((role) => role === "admin" || role === "developer") === true;
const evaluationSummaryPromise = viewer ? getEvaluationSummary(username) : Promise.resolve(null);
const eligibleMissionsPromise: Promise<readonly EvaluationMissionOption[]> = viewer
? findEligibleEvaluationMissions(payload, viewer.id, new Date())
: Promise.resolve([]);
const privilegedViewerPromise: Promise<boolean> = viewer
? isSuperuser(payload, viewer)
: Promise.resolve(false);
const directLeaderPromise: Promise<boolean> =
viewer && !isProfileOwner
? isDirectLeaderOf(payload, viewer.id, user.id)
: Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] =
await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
]);
const canViewSubordinate =
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
const latestMissionRows =
evaluationSummaryResult?.success && evaluationSummaryResult.data.leader.visible
? await findLatestLeaderMissionRows(payload, user.id)
: [];
const evaluationSummary: EvaluationSummary | null = evaluationSummaryResult?.success
? {
leader: {
totalRaters: evaluationSummaryResult.data.leader.totalRaters,
visible: evaluationSummaryResult.data.leader.visible,
averageScore: evaluationSummaryResult.data.leader.averageScore,
levels:
evaluationSummaryResult.data.leader.levels?.map((level) => ({
id: level.id,
label: level.label,
score: level.score,
color: level.color,
count: level.count,
percentage: level.percentage,
})) ?? null,
},
subordinate:
canViewSubordinate && evaluationSummaryResult.data.subordinate
? {
level: {
id: evaluationSummaryResult.data.subordinate.level.id,
label: evaluationSummaryResult.data.subordinate.level.label,
score: evaluationSummaryResult.data.subordinate.level.score,
color: evaluationSummaryResult.data.subordinate.level.color,
},
missionId: evaluationSummaryResult.data.subordinate.missionId,
at: evaluationSummaryResult.data.subordinate.at,
}
: null,
}
: null;
const evaluationSummaryError =
evaluationSummaryResult && !evaluationSummaryResult.success
? evaluationSummaryResult.error
: null;
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
@ -87,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: { name: true, requiredPoints: true },
select: { name: true, description: true, requiredPoints: true },
});
const xpLevel = resolveLevel(xp, experienceRes.docs);
@ -236,6 +419,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
)}
</section>
<LeadershipEvaluationSection
username={username}
summary={evaluationSummary}
summaryError={evaluationSummaryError}
eligibleMissions={eligibleMissions}
latestMissionRows={latestMissionRows}
canSubmitEvaluation={canSubmitEvaluation}
canViewSubordinate={canViewSubordinate}
/>
{/* ── Dossier ────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">

View file

@ -29,6 +29,8 @@ type VersionInfo = {
commitHash: string | null;
commitDate: string | null;
commitSubject: string | null;
lastUpdated: string | null;
commitMessage: string | null;
buildTime: string | null;
};

View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload";
import { getMissionReminders } from "@/lib/intelligence/missionReminder";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/mission-reminder
*
* Returns recently completed main operations (within 72h of completion) where the
* authenticated user RSVP'd "yes" and at least one of their assignment-based
* direct leaders also RSVP'd "yes". The client uses this to prompt mission
* feedback. Leader entries carry identity only — no rater/comment data.
*/
export async function GET(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
if (!isPayloadUser(user)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const reminders = await getMissionReminders(payload, user);
return NextResponse.json({ reminders });
} catch (error) {
console.error("mission-reminder: failed to load reminders", error);
return NextResponse.json({ error: "Failed to load mission reminders." }, { status: 500 });
}
}

View file

@ -14,6 +14,8 @@ const EMPTY: VersionInfo = {
commitHash: null,
commitDate: null,
commitSubject: null,
lastUpdated: null,
commitMessage: null,
buildTime: null,
};

View file

@ -66,7 +66,10 @@ export const handleButtonInteraction = async (
await setMissionAttendance(payload, { missionId, userId: user.id, response });
const summary = await getMissionAttendance(payload, missionId);
const hash = attendanceHash(await fetchAttendanceDocs(payload, missionId));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, missionId),
mission.classification.startDateTime,
);
await interaction.message.edit({
embeds: [renderAttendanceEmbed(mission, summary)],

View file

@ -28,12 +28,6 @@ const missionTitle = (mission: Mission): string =>
const memberName = (user: User): string => user.displayName ?? user.username;
const isStillFuture = (mission: Mission): boolean => {
const start = new Date(mission.classification.startDateTime).getTime();
const end = start + mission.classification.estimatedDuration * 60_000;
return end > Date.now();
};
/**
* Roll call is sent once: as soon as the mission is Ready, or three days
* before a Scheduled mission, whichever happens first.
@ -59,17 +53,18 @@ const operationTypeLabel = (type: Mission["operationType"]): string => {
};
/**
* Deterministic fingerprint of the attendance state so reconcile can skip
* embeds that haven't changed (anti-loop).
* Deterministic fingerprint of the attendance state and scheduled start time
* so reconcile can skip embeds that haven't changed (anti-loop), while still
* detecting mission date changes.
*/
export const attendanceHash = (docs: MissionAttendance[]): string => {
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
const rows = docs
.map((doc) => {
const userId = typeof doc.user === "object" ? doc.user.id : doc.user;
return [userId, doc.response] as const;
})
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
return JSON.stringify(rows);
return JSON.stringify({ rows, startDateTime: startDateTime ?? null });
};
export const fetchAttendanceDocs = async (
@ -97,17 +92,23 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
limit: MAX_ELIGIBLE_MISSIONS,
where: {
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{
or: [
{ "ownershipAndStatus.status": { equals: "Ready" } },
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Ready" } },
],
},
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
],
},
{ discordMessageId: { exists: true } },
],
},
],
@ -200,7 +201,10 @@ const postEmbed = async (
mission: Mission,
): Promise<void> => {
const summary = await getMissionAttendance(payload, mission.id);
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, mission.id),
mission.classification.startDateTime,
);
const channel = await getOpsChannel(client, opsChannelId);
const message = await channel.send({
embeds: [renderAttendanceEmbed(mission, summary)],
@ -257,7 +261,10 @@ const manageMission = async (
return;
}
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, mission.id),
mission.classification.startDateTime,
);
if (hash === mission.discordAttendanceHash) {
return;
}
@ -297,7 +304,6 @@ export const reconcileMission = async (
if (
mission.ownershipAndStatus.visibility !== "unit" ||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
!isStillFuture(mission) ||
(!isRollCallDue(mission) && !mission.discordMessageId)
) {
return;
@ -353,7 +359,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
}
const eligible = (await getEligibleMissions(payload)).filter(
(mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
);
for (const mission of eligible) {
try {

View file

@ -23,6 +23,7 @@ const TARGET_COLLECTIONS = [
{ label: "Loadouts", value: "loadouts" },
{ label: "Market Listings", value: "market-listings" },
{ label: "Market Negotiations", value: "market-negotiations" },
{ label: "Evaluations", value: "evaluations" },
{ label: "Tickets", value: "tickets" },
{ label: "Game Servers", value: "game-servers" },
] as const;

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
import type { User } from "@/payload-types";
export const Campaigns: CollectionConfig = {
@ -47,6 +48,30 @@ export const Campaigns: CollectionConfig = {
required: true,
defaultValue: "concept",
},
{
name: "campaignMode",
type: "select",
label: "Campaign Mode",
options: [
{
label: "Official Campaign",
value: "official",
},
{
label: "Custom Campaign",
value: "custom",
},
],
required: true,
defaultValue: "custom",
access: {
create: isDeveloper,
update: isDeveloper,
},
admin: {
description: "Only developers can designate a campaign as official or custom.",
},
},
{
name: "description",
type: "richText",

View file

@ -38,6 +38,29 @@ export const Missions: CollectionConfig = {
group: "Intelligence",
useAsTitle: "name",
},
hooks: {
beforeChange: [
// Loop-safe by construction: this mutates the incoming data of the very
// operation it runs in, so it never triggers a second write. It only
// stamps `ownershipAndStatus.completedAt` when a transition lands on
// "Completed" and no completion time exists yet — auto-complete passes
// its own sweep time explicitly (preserved by the existing-value check),
// and re-completing an already-stamped mission keeps the original stamp.
async ({ data, originalDoc }) => {
if (data?.ownershipAndStatus?.status !== "Completed") return data;
const existing =
data.ownershipAndStatus.completedAt ?? originalDoc?.ownershipAndStatus?.completedAt;
if (existing) return data;
return {
...data,
ownershipAndStatus: {
...data.ownershipAndStatus,
completedAt: new Date().toISOString(),
},
};
},
],
},
access: {
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:create");
@ -305,11 +328,20 @@ export const Missions: CollectionConfig = {
value: "private",
},
],
defaultValue: "leadership",
required: true,
},
],
},
defaultValue: "leadership",
required: true,
},
{
name: "completedAt",
type: "date",
admin: {
description:
"When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.",
disabled: true,
},
},
],
},
{
name: "missionRoles",
type: "group",

View file

@ -0,0 +1,88 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
/**
* Leadership evaluations.
*
* One row per (rater, ratee, mission, kind) — the uniqueness is enforced by the
* service layer (`upsertEvaluation` in `src/lib/evaluations`), not by a DB
* constraint.
*
* Privacy model: raw documents are only readable/writable by users holding the
* `evaluations:*` permissions (admin/developer via role docs). Normal users never
* receive raw rows over REST — the profile UI consumes pre-aggregated, rater-free
* output produced by server actions that call the service with explicit RBAC gating.
* `rater` and `comment` are private fields and must never be surfaced to normal
* users by any read path.
*/
export const Evaluations: CollectionConfig = {
slug: "evaluations",
admin: {
group: "Users",
defaultColumns: ["ratee", "rater", "mission", "kind", "level"],
},
access: {
read: requirePermission("evaluations:read"),
create: requirePermission("evaluations:create"),
update: requirePermission("evaluations:update"),
delete: requirePermission("evaluations:delete"),
},
fields: [
{
name: "rater",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description:
"Who submitted this evaluation. Private — only visible to users with evaluations:read.",
},
},
{
name: "ratee",
type: "relationship",
relationTo: "users",
required: true,
},
{
name: "mission",
type: "relationship",
relationTo: "missions",
required: true,
},
{
name: "kind",
type: "select",
options: [
{
label: "Leader (direct)",
value: "leader-direct",
},
{
label: "Leader (indirect)",
value: "leader-indirect",
},
{
label: "Subordinate",
value: "subordinate",
},
],
required: true,
},
{
name: "level",
type: "select",
options: PERFORMANCE_LEVELS.map((l) => ({ label: l.label, value: l.id })),
required: true,
},
{
name: "comment",
type: "textarea",
admin: {
description:
"Private free-text note. Never shown to normal users; managers only.",
},
},
],
};

View file

@ -1,6 +1,7 @@
import { CollectionConfig } from "payload";
import { Award } from "@/payload-types";
import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyAwardGrants } from "@/lib/awards";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const Profiles: CollectionConfig = {
@ -32,6 +33,9 @@ export const Profiles: CollectionConfig = {
broadcastToUser("profile-update", userId, { profileId: doc.id });
}
},
async ({ doc, previousDoc, req }) => {
await notifyAwardGrants(req.payload, previousDoc, doc);
},
],
},
fields: [

View file

@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
group: "Users",
},
auth: {
tokenExpiration: 43200,
loginWithUsername: {
requireUsername: true,
allowEmailLogin: false,

View file

@ -38,6 +38,7 @@ import XPDisplay from "@/components/frontend/blocks/XPDisplay";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { Rank, User } from "@/payload-types";
import { Progress } from "@/components/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { updatePreferences } from "@/app/(frontend)/account/actions";
const data = {
@ -277,7 +278,16 @@ export function AppSidebar({
<div className="flex items-center justify-between">
{isMiniMode ? (
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground uppercase">{xpLevel.levelName}</div>
<Tooltip>
<TooltipTrigger>
<div className="text-xs text-muted-foreground uppercase">
{xpLevel.levelName}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{xpLevel.levelDesc}</p>
</TooltipContent>
</Tooltip>
<SparkleIcon className="w-4 h-4" />
<span className="text-xs font-bold">{xpLevel.xp.toLocaleString()}</span>
<Progress value={xpLevel.progress} className="w-20 h-1" />

View file

@ -6,9 +6,10 @@ import { Progress } from "@/components/ui/progress";
import { SparkleIcon } from "lucide-react";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { PROFILE_UPDATE_EVENT, type ProfileUpdateDetail } from "@/hooks/useGameTick";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
const { levelName, progress, xp, nextLevelXP } = xpLevel;
const { levelName, levelDesc, progress, xp, nextLevelXP } = xpLevel;
const [xpGain, setXpGain] = useState<number | null>(null);
const [xpGainId, setXpGainId] = useState(0);
const lastGainRef = useRef<{ amount: number; at: number } | null>(null);
@ -44,7 +45,14 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
<ItemTitle className="flex justify-between items-center w-full">
<SparkleIcon />
<div className="flex flex-col items-end">
<span className="uppercase">{levelName}</span>
<Tooltip>
<TooltipTrigger>
<span className="uppercase">{levelName}</span>
</TooltipTrigger>
<TooltipContent>
<p>{levelDesc ?? "Missing content!"}</p>
</TooltipContent>
</Tooltip>
<span className="relative text-2xl font-bold uppercase flex flex-row items-center">
{xp.toLocaleString()}
{xpGain !== null && (

View file

@ -17,6 +17,8 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
@ -182,6 +184,7 @@ export function MinesweeperBoard({
onDifficultyChange: (difficulty: EodDifficulty) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const [difficulty, setDifficulty] = useState<GameMode>("training");
const [customConfig, setCustomConfig] = useState<GameConfig>({
label: "Custom Minefield",
@ -240,6 +243,7 @@ export function MinesweeperBoard({
const reset = useCallback(
(nextDifficulty: GameMode = difficulty) => {
if (submittingRef.current) return;
if (expiredRef.current) return;
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
setDifficulty(nextDifficulty);
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
@ -249,7 +253,7 @@ export function MinesweeperBoard({
setLastRun(null);
setPhaseSafe("ready");
},
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
);
useEffect(() => {
@ -284,6 +288,17 @@ export function MinesweeperBoard({
const recordResult = useCallback(
async (won: boolean, elapsedSeconds: number, flags: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
won,
seconds: elapsedSeconds,
score: 0,
xpEarned: 0,
best: 0,
error: "Your session expired. Result not recorded.",
});
return;
}
if (difficulty === "custom") {
setLastRun({
won,
@ -337,7 +352,7 @@ export function MinesweeperBoard({
});
}
},
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, onGameRecorded, router],
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, expiredRef, onGameRecorded, router],
);
const finishGame = useCallback(
@ -366,6 +381,7 @@ export function MinesweeperBoard({
const reveal = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const existing = board[index];
if (!existing || existing.flagged || existing.revealed) return;
const workingBoard = generatedRef.current ? board : createBoard(config, index);
@ -387,12 +403,13 @@ export function MinesweeperBoard({
setBoard(nextBoard);
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
},
[board, config, finishGame, phase, setPhaseSafe],
[board, config, expiredRef, finishGame, phase, setPhaseSafe],
);
const toggleFlag = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.revealed) return;
playFlag();
@ -402,7 +419,7 @@ export function MinesweeperBoard({
),
);
},
[board, phase],
[board, expiredRef, phase],
);
const clearLongPress = useCallback(() => {
@ -450,7 +467,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
@ -516,6 +533,21 @@ export function MinesweeperBoard({
return (
<div className="flex w-full max-w-5xl flex-col gap-4">
{expired && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-6 text-center">
<span className="text-xl font-black uppercase tracking-widest text-amber-400">
Session Ending Soon
</span>
<span className="max-w-md text-sm text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink>
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
<div className="flex select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
@ -567,7 +599,7 @@ export function MinesweeperBoard({
type="button"
variant={difficulty === key ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === key}
onClick={() => reset(key)}
>
@ -676,7 +708,7 @@ export function MinesweeperBoard({
<Button
type="button"
variant={difficulty === "custom" ? "default" : "outline"}
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === "custom"}
onClick={() => reset("custom")}
>
@ -731,7 +763,7 @@ export function MinesweeperBoard({
type="button"
variant="outline"
size="sm"
disabled={submitting}
disabled={submitting || expired}
onClick={() => reset()}
>
<RotateCcw /> Retry sector
@ -759,7 +791,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&

View file

@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Button } from "@/components/ui/button";
import { playFlap, playHit, playXp } from "./sounds";
const CANVAS_W = 420;
@ -333,6 +336,7 @@ export function FlappyBird({
onPhaseChange?: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const phaseRef = useRef<Phase>("ready");
@ -367,6 +371,7 @@ export function FlappyBird({
}, [onPhaseChange]);
const reset = useCallback(() => {
if (expiredRef.current) return;
const g = gameRef.current;
g.birdY = START_Y;
g.birdVy = 0;
@ -383,6 +388,15 @@ export function FlappyBird({
const handleGameOver = useCallback(
async (finalScore: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
score: finalScore,
xpEarned: 0,
best: statsRef.current.bestScore,
error: "Your session expired. Score not recorded.",
});
return;
}
if (finalScore <= 0) {
setLastRun({
score: finalScore,
@ -423,7 +437,7 @@ export function FlappyBird({
});
}
},
[onGameRecorded, router],
[expiredRef, onGameRecorded, router],
);
const handleGameOverRef = useRef(handleGameOver);
@ -432,6 +446,7 @@ export function FlappyBird({
}, [handleGameOver]);
const flap = useCallback(() => {
if (expiredRef.current) return;
const p = phaseRef.current;
if (p === "ready") {
const g = gameRef.current;
@ -445,7 +460,7 @@ export function FlappyBird({
} else if (p === "over") {
reset();
}
}, [reset, setPhaseSafe]);
}, [expiredRef, reset, setPhaseSafe]);
useEffect(() => {
const canvas = canvasRef.current;
@ -716,6 +731,22 @@ export function FlappyBird({
</span>
</div>
)}
{expired && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center bg-black/70 rounded-xl">
<span className="text-2xl font-black uppercase tracking-widest text-amber-400 drop-shadow-lg">
Session Ending Soon
</span>
<span className="max-w-[260px] text-xs text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink className="mt-1">
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">

View file

@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useGameTick } from "@/hooks/useGameTick";
import {
buildReminderEntries,
parseMissionReminders,
readReminderDismissals,
writeReminderDismissals,
type Dismissals,
type MissionReminder,
type ReminderEntry,
} from "@/components/frontend/intelligence/missionReminderClient";
const POLL_MS = 60_000;
export function MissionReminderToasts() {
const router = useRouter();
const mountedRef = useRef(false);
const fetchInFlightRef = useRef<Promise<void> | null>(null);
const expiryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pumpScheduledRef = useRef(false);
const pumpQueueRef = useRef<() => void>(() => undefined);
const dismissedRef = useRef<Dismissals>({});
const currentRef = useRef(new Map<string, ReminderEntry>());
const queueRef = useRef<ReminderEntry[]>([]);
const queuedKeysRef = useRef(new Set<string>());
const shownKeysRef = useRef(new Set<string>());
const activeToastRef = useRef<{ key: string; toastId: string } | null>(null);
const schedulePump = useCallback(() => {
if (!mountedRef.current || pumpScheduledRef.current) return;
pumpScheduledRef.current = true;
setTimeout(() => {
pumpScheduledRef.current = false;
pumpQueueRef.current();
}, 0);
}, []);
const finishToast = useCallback(
(key: string, toastId: string) => {
const active = activeToastRef.current;
if (!active || active.key !== key || active.toastId !== toastId) return;
activeToastRef.current = null;
schedulePump();
},
[schedulePump],
);
const scheduleExpiryCheck = useCallback(() => {
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
const now = Date.now();
const nextExpiry = [...currentRef.current.values()]
.map((entry) => entry.expiresAt)
.filter((expiresAt) => expiresAt > now)
.sort((first, second) => first - second)[0];
if (nextExpiry === undefined) return;
expiryTimerRef.current = setTimeout(
() => {
expiryTimerRef.current = null;
const currentNow = Date.now();
for (const [key, entry] of currentRef.current) {
if (entry.expiresAt <= currentNow) currentRef.current.delete(key);
}
queueRef.current = queueRef.current.filter((entry) => {
const current = currentRef.current.get(entry.key);
const keep = current?.expiresAt === entry.expiresAt && entry.expiresAt > currentNow;
if (!keep) queuedKeysRef.current.delete(entry.key);
return keep;
});
const active = activeToastRef.current;
if (active && !currentRef.current.has(active.key)) {
activeToastRef.current = null;
toast.dismiss(active.toastId);
}
scheduleExpiryCheck();
schedulePump();
},
Math.max(0, nextExpiry - now + 1),
);
}, [schedulePump]);
const applyReminders = useCallback(
(reminders: readonly MissionReminder[]) => {
const now = Date.now();
const entries = buildReminderEntries(reminders, now);
const next = new Map(entries.map((entry) => [entry.key, entry]));
const active = activeToastRef.current;
if (active && !next.has(active.key)) {
activeToastRef.current = null;
toast.dismiss(active.toastId);
}
currentRef.current = next;
queueRef.current = queueRef.current.filter((entry) => {
const current = next.get(entry.key);
const keep = current?.expiresAt === entry.expiresAt;
if (!keep) queuedKeysRef.current.delete(entry.key);
return keep;
});
for (const entry of entries) {
if (
!shownKeysRef.current.has(entry.key) &&
!queuedKeysRef.current.has(entry.key) &&
dismissedRef.current[entry.key] !== undefined
) {
continue;
}
if (!shownKeysRef.current.has(entry.key) && !queuedKeysRef.current.has(entry.key)) {
queueRef.current.push(entry);
queuedKeysRef.current.add(entry.key);
}
}
scheduleExpiryCheck();
schedulePump();
},
[scheduleExpiryCheck, schedulePump],
);
const fetchReminders = useCallback(async () => {
if (fetchInFlightRef.current) return fetchInFlightRef.current;
const request = (async () => {
try {
const response = await fetch("/api/mission-reminder", { cache: "no-store" });
if (!response.ok || !mountedRef.current) return;
const body: unknown = await response.json();
if (mountedRef.current) applyReminders(parseMissionReminders(body));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
})();
fetchInFlightRef.current = request;
try {
await request;
} finally {
if (fetchInFlightRef.current === request) fetchInFlightRef.current = null;
}
}, [applyReminders]);
const pumpQueue = useCallback(() => {
if (!mountedRef.current || activeToastRef.current) return;
const now = Date.now();
let entry: ReminderEntry | undefined;
while (queueRef.current.length > 0) {
const candidate = queueRef.current.shift();
if (!candidate) break;
queuedKeysRef.current.delete(candidate.key);
const current = currentRef.current.get(candidate.key);
if (
!current ||
current.expiresAt !== candidate.expiresAt ||
candidate.expiresAt <= now ||
shownKeysRef.current.has(candidate.key) ||
dismissedRef.current[candidate.key] !== undefined
) {
continue;
}
entry = candidate;
break;
}
if (!entry) return;
const toastId = `mission-reminder:${entry.key}`;
const leaderName = entry.leader.displayName || entry.leader.username;
const missionName = entry.missionName || entry.missionCodeName;
shownKeysRef.current.add(entry.key);
activeToastRef.current = { key: entry.key, toastId };
toast("Mission feedback", {
id: toastId,
description: `Rate ${leaderName}'s leadership on ${missionName}.`,
action: {
label: "Rate leader",
onClick: () => {
toast.dismiss(toastId);
finishToast(entry.key, toastId);
router.push(`/profile/${encodeURIComponent(entry.leader.username)}`);
},
},
cancel: {
label: "Dismiss reminder",
onClick: () => {
dismissedRef.current[entry.key] = entry.expiresAt;
writeReminderDismissals(dismissedRef.current, Date.now());
toast.dismiss(toastId);
finishToast(entry.key, toastId);
},
},
onAutoClose: () => finishToast(entry.key, toastId),
onDismiss: () => finishToast(entry.key, toastId),
});
}, [finishToast, router]);
pumpQueueRef.current = pumpQueue;
useEffect(() => {
mountedRef.current = true;
const now = Date.now();
dismissedRef.current = readReminderDismissals(now);
writeReminderDismissals(dismissedRef.current, now);
void fetchReminders();
const interval = setInterval(() => void fetchReminders(), POLL_MS);
return () => {
mountedRef.current = false;
clearInterval(interval);
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
const active = activeToastRef.current;
activeToastRef.current = null;
if (active) toast.dismiss(active.toastId);
queueRef.current = [];
queuedKeysRef.current.clear();
currentRef.current.clear();
};
}, [fetchReminders]);
useGameTick(() => {
void fetchReminders();
});
return null;
}

View file

@ -0,0 +1,122 @@
const REMINDER_EXPIRY_MS = 72 * 60 * 60 * 1000;
const DISMISSALS_STORAGE_KEY = "ptf:mission-reminder:dismissals";
export type ReminderLeader = {
readonly id: number;
readonly username: string;
readonly displayName: string;
};
export type MissionReminder = {
readonly missionId: number;
readonly missionName: string;
readonly missionCodeName: string;
readonly completedAt: string;
readonly leaders: readonly ReminderLeader[];
};
export type ReminderEntry = MissionReminder & {
readonly key: string;
readonly leader: ReminderLeader;
readonly expiresAt: number;
};
export type Dismissals = Record<string, number>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isReminderLeader(value: unknown): value is ReminderLeader {
return (
isRecord(value) &&
typeof value.id === "number" &&
typeof value.username === "string" &&
typeof value.displayName === "string"
);
}
function isMissionReminder(value: unknown): value is MissionReminder {
return (
isRecord(value) &&
typeof value.missionId === "number" &&
typeof value.missionName === "string" &&
typeof value.missionCodeName === "string" &&
typeof value.completedAt === "string" &&
Array.isArray(value.leaders) &&
value.leaders.every(isReminderLeader)
);
}
export function parseMissionReminders(value: unknown): readonly MissionReminder[] {
if (!isRecord(value) || !Array.isArray(value.reminders)) return [];
return value.reminders.filter(isMissionReminder);
}
export function missionReminderKey(missionId: number, leaderId: number): string {
return `${missionId}:${leaderId}`;
}
export function buildReminderEntries(
reminders: readonly MissionReminder[],
now: number,
): readonly ReminderEntry[] {
const entries: ReminderEntry[] = [];
const seen = new Set<string>();
for (const reminder of reminders) {
const completedAt = Date.parse(reminder.completedAt);
if (!Number.isFinite(completedAt)) continue;
const expiresAt = completedAt + REMINDER_EXPIRY_MS;
if (expiresAt <= now) continue;
for (const leader of reminder.leaders) {
const key = missionReminderKey(reminder.missionId, leader.id);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ...reminder, key, leader, expiresAt });
}
}
return entries;
}
// Browser storage is read during mount and written during mount or the
// explicit dismissal handler; it is never accessed during render.
export function readReminderDismissals(now: number): Dismissals {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const dismissals: Dismissals = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value) && value > now) {
dismissals[key] = value;
}
}
return dismissals;
} catch (error) {
if (error instanceof Error) return {};
throw error;
}
}
export function writeReminderDismissals(dismissals: Dismissals, now: number): void {
if (typeof window === "undefined") return;
const activeDismissals: Dismissals = {};
for (const [key, expiresAt] of Object.entries(dismissals)) {
if (expiresAt > now) activeDismissals[key] = expiresAt;
}
try {
window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(activeDismissals));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
}

View file

@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon, PencilIcon } from "lucide-react";
import { toast } from "sonner";
import {
getLeadershipDisplayLabel,
PERFORMANCE_LEVELS,
isPerformanceLevelId,
type PerformanceLevelId,
} from "@/lib/evaluations/levels";
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { EvaluationMissionOption } from "./evaluation-types";
interface LeadershipEvaluationDialogProps {
readonly username: string;
readonly missions: readonly EvaluationMissionOption[];
}
function formatMissionDate(value: string): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(value));
}
export function LeadershipEvaluationDialog({
username,
missions,
}: LeadershipEvaluationDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [missionId, setMissionId] = useState("");
const [level, setLevel] = useState<PerformanceLevelId | "">("");
const [comment, setComment] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setMissionId("");
setLevel("");
setComment("");
setPending(false);
setError(null);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const selectedMission = missions.find((mission) => String(mission.id) === missionId);
if (!selectedMission) {
setError("Select a mission to rate.");
return;
}
if (!isPerformanceLevelId(level)) {
setError("Select a performance level.");
return;
}
setPending(true);
setError(null);
try {
const trimmedComment = comment.trim();
const result = await submitEvaluation({
username,
missionId: selectedMission.id,
level,
...(trimmedComment ? { comment: trimmedComment } : {}),
});
if (!result.success) {
setError(result.error ?? "Could not save the rating.");
return;
}
setOpen(false);
reset();
toast.success("Leadership rating saved");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save the rating.");
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && pending) return;
setOpen(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogTrigger asChild>
<Button type="button" size="sm" variant="outline">
<PencilIcon data-icon="inline-start" />
Rate leadership
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rate leadership</DialogTitle>
<DialogDescription>
Submit a new rating or update your existing rating for one completed mission. Your
rating is included anonymously in the leadership aggregate.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-mission">Mission</Label>
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
<SelectValue placeholder="Select a completed mission" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{missions.map((mission) => (
<SelectItem key={mission.id} value={String(mission.id)}>
{mission.name} · {formatMissionDate(mission.startDateTime)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-level">Performance level</Label>
<Select
value={level}
onValueChange={(value) => {
if (isPerformanceLevelId(value)) setLevel(value);
}}
disabled={pending}
>
<SelectTrigger id="evaluation-level" aria-invalid={error !== null}>
<SelectValue placeholder="Select a level" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PERFORMANCE_LEVELS.map((performanceLevel) => (
<SelectItem
key={performanceLevel.id}
value={performanceLevel.id}
style={{ color: performanceLevel.color }}
>
{getLeadershipDisplayLabel(performanceLevel)} · {performanceLevel.score}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-comment">Comment (optional)</Label>
<Textarea
id="evaluation-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Add context for the leadership team."
maxLength={1000}
rows={4}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Saving..." : "Save rating"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,264 @@
"use client";
import { BadgeCheckIcon, ClipboardCheckIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
import { getLeadershipDisplayLabel, PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import { LeadershipEvaluationDialog } from "./LeadershipEvaluationDialog";
import type {
EvaluationMissionOption,
EvaluationSummary,
LatestMissionRow,
} from "./evaluation-types";
interface LeadershipEvaluationSectionProps {
readonly username: string;
readonly summary: EvaluationSummary | null;
readonly summaryError: string | null;
readonly eligibleMissions: readonly EvaluationMissionOption[];
readonly latestMissionRows: readonly LatestMissionRow[];
readonly canSubmitEvaluation: boolean;
readonly canViewSubordinate: boolean;
}
const PERFORMANCE_GRADIENT = `linear-gradient(to right, ${[...PERFORMANCE_LEVELS]
.reverse()
.map((level) => level.color)
.join(", ")})`;
function formatDate(value: string): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(value));
}
function formatPercentage(value: number): string {
return `${value.toFixed(value % 1 === 0 ? 0 : 1)}%`;
}
export function LeadershipEvaluationSection({
username,
summary,
summaryError,
eligibleMissions,
latestMissionRows,
canSubmitEvaluation,
canViewSubordinate,
}: LeadershipEvaluationSectionProps) {
return (
<section className="flex flex-col gap-3" aria-labelledby="leadership-evaluation-heading">
<div className="flex items-center gap-2">
<BadgeCheckIcon className="size-4 text-muted-foreground" />
<h2
id="leadership-evaluation-heading"
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"
>
Leadership Evaluation
</h2>
</div>
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center justify-between gap-3 text-base">
<span>Leadership performance</span>
{canSubmitEvaluation && eligibleMissions.length > 0 && (
<LeadershipEvaluationDialog username={username} missions={eligibleMissions} />
)}
</CardTitle>
<CardDescription>
Anonymous feedback from operators who have served alongside this leader.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{summaryError ? (
<p role="alert" className="text-sm text-destructive">
{summaryError}
</p>
) : summary ? (
<>
{summary.leader.visible &&
summary.leader.averageScore !== null &&
summary.leader.levels !== null ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Average rating
</span>
<span className="font-mono text-4xl font-semibold tracking-tight">
{Math.round(summary.leader.averageScore)}%
</span>
</div>
<span className="font-mono text-xs text-muted-foreground">
{summary.leader.totalRaters} responses
</span>
</div>
<div className="flex flex-col gap-1.5">
<div
role="img"
aria-label={`Average leadership rating: ${Math.round(summary.leader.averageScore)} percent`}
className="relative h-5"
>
<div
className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 overflow-hidden rounded-full"
style={{ background: PERFORMANCE_GRADIENT }}
>
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: PERFORMANCE_GRADIENT,
filter: "grayscale(1)",
maskImage: `linear-gradient(to right, black 0%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, black 100%)`,
WebkitMaskImage: `linear-gradient(to right, black 0%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, transparent ${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%, black 100%)`,
}}
/>
</div>
<span
aria-hidden="true"
className="absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-full bg-foreground ring-2 ring-background"
style={{
left: `${Math.min(100, Math.max(0, Math.round(summary.leader.averageScore)))}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Needs work</span>
<span>Exceptional</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Level distribution</h3>
<span className="text-xs text-muted-foreground">All submitted ratings</span>
</div>
<div className="flex flex-col gap-2.5">
{PERFORMANCE_LEVELS.map((performanceLevel) => {
const levelSummary = summary.leader.levels?.find(
(level) => level.id === performanceLevel.id,
);
const percentage = levelSummary?.percentage ?? 0;
const count = levelSummary?.count ?? 0;
return (
<div key={performanceLevel.id} className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs text-muted-foreground">
{getLeadershipDisplayLabel(performanceLevel)}
</span>
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${percentage}%`,
backgroundColor: performanceLevel.color,
}}
/>
</div>
<span className="w-20 shrink-0 text-right font-mono text-xs text-muted-foreground">
{formatPercentage(percentage)} · {count}
</span>
</div>
);
})}
</div>
</div>
{latestMissionRows.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<ClipboardCheckIcon className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Latest mission ratings</h3>
</div>
<ItemGroup className="gap-2">
{latestMissionRows.map((mission) => (
<Item key={mission.missionId} variant="muted" size="sm">
<ItemMedia variant="icon">
<ClipboardCheckIcon />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full truncate">
{mission.missionName}
</ItemTitle>
<ItemDescription>{formatDate(mission.startDateTime)}</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto">
<Badge
variant="outline"
className="font-mono text-xs"
style={{ color: mission.level.color }}
>
{getLeadershipDisplayLabel(mission.level)}
</Badge>
</ItemActions>
</Item>
))}
</ItemGroup>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-4">
<span className="text-sm font-semibold">Confidential baseline</span>
<p className="text-sm text-muted-foreground">
Leadership ratings remain private until at least three distinct operators have
responded.
</p>
</div>
)}
{canViewSubordinate && summary.subordinate && (
<>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold">Subordinate performance</span>
<span className="text-xs text-muted-foreground">Latest command review</span>
</div>
<Badge
variant="outline"
className="font-mono text-xs"
style={{ color: summary.subordinate.level.color }}
>
{summary.subordinate.level.label}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
Updated {formatDate(summary.subordinate.at)}
</span>
</div>
</>
)}
</>
) : (
<p role="alert" className="text-sm text-muted-foreground">
Evaluation data is unavailable right now.
</p>
)}
{canSubmitEvaluation && eligibleMissions.length === 0 && (
<p className="text-xs text-muted-foreground">
No completed missions with a Yes RSVP are available to rate yet.
</p>
)}
</CardContent>
</Card>
</section>
);
}

View file

@ -0,0 +1,43 @@
export type EvaluationMissionOption = {
readonly id: number;
readonly name: string;
readonly startDateTime: string;
};
export type EvaluationDisplayLevel = {
readonly id: string;
readonly label: string;
readonly score: number;
readonly color: string;
};
export type EvaluationLevelSummary = EvaluationDisplayLevel & {
readonly id: string;
readonly count: number;
readonly percentage: number;
};
export type AnonymousLeaderSummary = {
readonly totalRaters: number;
readonly visible: boolean;
readonly levels: readonly EvaluationLevelSummary[] | null;
readonly averageScore: number | null;
};
export type AnonymousSubordinateSummary = {
readonly level: EvaluationDisplayLevel;
readonly missionId: number;
readonly at: string;
};
export type EvaluationSummary = {
readonly leader: AnonymousLeaderSummary;
readonly subordinate: AnonymousSubordinateSummary | null;
};
export type LatestMissionRow = {
readonly missionId: number;
readonly missionName: string;
readonly startDateTime: string;
readonly level: EvaluationDisplayLevel;
};

View file

@ -0,0 +1,91 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* How early (ms) before the JWT `exp` we lock the client (dispatch the
* `SESSION_EXPIRED_EVENT`). The server is authoritative; this buffer only
* makes the client-side UX hint fire a little ahead of the hard cutoff so the
* user isn't mid-action when the token lapses.
*/
const NEAR_EXPIRY_BUFFER_MS = 30_000;
/**
* How often (ms) the safety check runs. It compares the wall clock against the
* server-provided `sessionExp` — it makes no server requests and reads no
* cookies. It exists to catch a throttled timer (e.g. a backgrounded tab) that
* delayed the one-shot timeout past the real expiry.
*/
const SAFETY_CHECK_INTERVAL_MS = 5 * 60_000;
/**
* Mounted only in the authenticated branch of `(frontend)/layout.tsx`.
*
* The server reads the HttpOnly `payload-token` cookie and passes only the JWT
* `exp` (ms) as `sessionExp`. This component schedules a one-shot lock timer
* just before expiry (dispatches `SESSION_EXPIRED_EVENT` so minigames and other
* consumers can lock themselves) and a refresh timer at the actual expiry
* (`router.refresh()` — the server then re-renders the layout, sees the expired
* token, and swaps the shell for the guest landing page). A lightweight safety
* interval catches throttled timers. Guards prevent duplicate lock/refresh.
*/
export function SessionWatchdog({ sessionExp }: { sessionExp: number | null }) {
const router = useRouter();
const lockFiredRef = useRef(false);
const refreshFiredRef = useRef(false);
useEffect(() => {
if (sessionExp === null) return;
const lock = () => {
if (lockFiredRef.current) return;
lockFiredRef.current = true;
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
};
const refresh = () => {
if (refreshFiredRef.current) return;
refreshFiredRef.current = true;
router.refresh();
};
const now = Date.now();
const lockDelay = sessionExp - NEAR_EXPIRY_BUFFER_MS - now;
const refreshDelay = sessionExp - now;
let lockTimer: number | null = null;
let refreshTimer: number | null = null;
if (refreshDelay <= 0) {
// Already past expiry — lock and refresh immediately.
lock();
refresh();
} else {
if (lockDelay <= 0) {
lock();
} else {
lockTimer = window.setTimeout(lock, lockDelay);
}
refreshTimer = window.setTimeout(refresh, refreshDelay);
}
// Safety net for throttled timers in background tabs.
const safety = window.setInterval(() => {
if (Date.now() >= sessionExp) {
lock();
refresh();
window.clearInterval(safety);
}
}, SAFETY_CHECK_INTERVAL_MS);
return () => {
if (lockTimer !== null) window.clearTimeout(lockTimer);
if (refreshTimer !== null) window.clearTimeout(refreshTimer);
window.clearInterval(safety);
};
}, [router, sessionExp]);
return null;
}

View file

@ -10,6 +10,8 @@ const EMPTY: VersionInfo = {
commitHash: null,
commitDate: null,
commitSubject: null,
lastUpdated: null,
commitMessage: null,
buildTime: null,
};
@ -50,11 +52,14 @@ const VersionOverlay = ({ canSeeCommit }: { canSeeCommit: boolean }) => {
const resolved = info ?? EMPTY;
const versionLabel = resolveVersionLabel(resolved);
const lastUpdated = formatCommitDate(resolved.commitDate);
const lastUpdated = formatCommitDate(
resolved.lastUpdated ?? resolved.commitDate ?? resolved.buildTime,
);
const commitMessage = resolved.commitMessage ?? resolved.commitSubject;
const commitInfo =
resolved.commitHash && resolved.commitSubject
? `${resolved.commitHash} · ${resolved.commitSubject}`
: (resolved.commitHash ?? resolved.commitSubject ?? null);
resolved.commitHash && commitMessage
? `${resolved.commitHash} · ${commitMessage}`
: (resolved.commitHash ?? commitMessage ?? null);
const showSecondary = lastUpdated !== null || commitInfo !== null;
return (

View file

@ -6,6 +6,7 @@ export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export interface GameTickDetail {
processedAt?: string;

View file

@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* Tracks whether the session has expired (per the client-side watchdog).
*
* Returns both a reactive `expired` boolean (for rendering) and an
* `expiredRef` (for use inside callbacks / the game loop without stale
* closures). Consumers should gate start/retry/input and skip server saves
* on `expiredRef.current`.
*/
export function useSessionExpired(): {
expired: boolean;
expiredRef: React.RefObject<boolean>;
} {
const [expired, setExpired] = useState(false);
const expiredRef = useRef(false);
useEffect(() => {
const handler = () => {
expiredRef.current = true;
setExpired(true);
};
window.addEventListener(SESSION_EXPIRED_EVENT, handler);
return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
};
}, []);
return { expired, expiredRef };
}

162
src/lib/awards/index.ts Normal file
View file

@ -0,0 +1,162 @@
import type { Award, Profile } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** A single row in `profile.progression.awards`. */
export interface AwardGrantEntry {
id?: string | null;
award: number | Award;
awardedBy?: number | null | Profile;
awardedAt: string;
awardReason?: string | null;
}
interface ProgressionDoc {
progression?: { awards?: AwardGrantEntry[] | null } | null;
}
/** Extract the numeric award id from a grant entry, populated or not. */
function awardIdOf(entry: AwardGrantEntry): number | null {
const award = entry.award;
if (typeof award === "number") return award;
if (award && typeof award.id === "number") return award.id;
return null;
}
/**
* Stable identity key for a grant row: the generated row id when present,
* otherwise a composite of award id + issue date. The admin UI preserves row
* ids when editing existing rows, so reason-only edits never count as new
* grants; the composite fallback covers docs whose rows carry no id.
*/
function keyOf(entry: AwardGrantEntry): string {
if (entry.id) return `row:${entry.id}`;
return `aw:${awardIdOf(entry) ?? "?"}:${entry.awardedAt}`;
}
function countKeys(entries: AwardGrantEntry[]): Map<string, number> {
const counts = new Map<string, number>();
for (const entry of entries) {
const key = keyOf(entry);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Return the grant rows present in `after` but not in `before`. Multiset
* diff — re-granting the same award twice is two grants.
*/
export function diffGrantedAwards(
before: ProgressionDoc | null | undefined,
after: ProgressionDoc | null | undefined,
): AwardGrantEntry[] {
const beforeCounts = countKeys(before?.progression?.awards ?? []);
const seen = new Map<string, number>();
const granted: AwardGrantEntry[] = [];
for (const entry of after?.progression?.awards ?? []) {
const key = keyOf(entry);
const seenCount = seen.get(key) ?? 0;
seen.set(key, seenCount + 1);
if (seenCount >= (beforeCounts.get(key) ?? 0)) granted.push(entry);
}
return granted;
}
/** Resolve the display name of the profile that issued a grant, if any. */
async function issuerLabel(
payload: PayloadType,
awardedBy: AwardGrantEntry["awardedBy"],
): Promise<string | null> {
let profileId: number | null = null;
if (typeof awardedBy === "number") profileId = awardedBy;
else if (awardedBy && typeof awardedBy === "object" && typeof awardedBy.id === "number") {
profileId = awardedBy.id;
}
if (!profileId) return null;
try {
const issuerProfile = await payload.findByID({
collection: "profiles",
id: profileId,
depth: 1,
overrideAccess: true,
});
const userRef = (issuerProfile as unknown as { user?: number | { id?: number } | null }).user;
const userId = typeof userRef === "object" ? userRef?.id : userRef;
if (typeof userId !== "number") return null;
const issuer = await payload.findByID({
collection: "users",
id: userId,
depth: 0,
overrideAccess: true,
});
const user = issuer as unknown as { displayName?: string | null; username?: string };
return user.displayName || user.username || null;
} catch {
return null;
}
}
/**
* Notify the profile owner about award rows added since the previous save.
* Called from the Profiles afterChange hook; never throws — a notification
* failure must not break the admin's save.
*/
export async function notifyAwardGrants(
payload: PayloadType,
previousDoc: Profile | null | undefined,
doc: Profile,
): Promise<void> {
try {
const newEntries = diffGrantedAwards(previousDoc, doc);
if (newEntries.length === 0) return;
const userId = typeof doc.user === "object" ? doc.user?.id : doc.user;
if (typeof userId !== "number") return;
const awardIds = [
...new Set(newEntries.map(awardIdOf).filter((id): id is number => id !== null)),
];
let awards: Award[] = [];
if (awardIds.length > 0) {
const res = await payload.find({
collection: "awards",
where: { id: { in: awardIds } },
depth: 0,
overrideAccess: true,
});
awards = res.docs;
}
const user = (await payload.findByID({
collection: "users",
id: userId,
depth: 0,
overrideAccess: true,
})) as unknown as { username?: string };
const link = user?.username ? `/profile/${user.username}` : undefined;
for (const entry of newEntries) {
const awardId = awardIdOf(entry);
const award = awards.find((a) => a.id === awardId);
const parts: string[] = [];
if (entry.awardReason) parts.push(entry.awardReason);
const issuer = await issuerLabel(payload, entry.awardedBy);
parts.push(issuer ? `Issued by ${issuer}.` : "On behalf of the unit.");
await notifyUser(payload, {
userId,
type: "award:granted",
title: `Award received: ${award?.name ?? "an award"}`,
message: parts.join(" "),
link,
});
}
} catch (err) {
console.error("[Awards] Failed to send award-grant notifications:", err);
}
}

View file

@ -0,0 +1,396 @@
import {
getPerformanceLevel,
isPerformanceLevelId,
PERFORMANCE_LEVELS,
type PerformanceLevel,
type PerformanceLevelId,
} from "./levels";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** The three evaluation kinds, per the leadership-evaluations feature. */
export type EvaluationKind = "leader-direct" | "leader-indirect" | "subordinate";
/** Shape of an evaluations doc as consumed by this service (depth 0). */
export interface EvaluationDoc {
id: number;
rater: number;
ratee: number;
mission: number;
kind: EvaluationKind;
level: PerformanceLevelId;
comment: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Minimum number of distinct raters before a leader aggregate is visible to anyone.
* Below this threshold the per-level breakdown and average are withheld so a small
* group of raters cannot be identified from the displayed distribution.
*/
export const ANONYMITY_THRESHOLD = 3;
/**
* Pure eligibility check for whether a mission is "completed/past" enough to be
* evaluated. Draft statuses (Concept/Planning/Ready) and Cancelled are never
* eligible. Scheduled/Active missions become eligible once their scheduled start
* has passed.
*/
export function isMissionEvaluable(
status: string,
startDateTimeISO?: string | null,
now: Date = new Date(),
): boolean {
if (status === "Completed") return true;
if (status === "Scheduled" || status === "Active") {
if (!startDateTimeISO) return false;
const start = new Date(startDateTimeISO).getTime();
if (Number.isNaN(start)) return false;
return start < now.getTime();
}
return false;
}
/**
* Documented participation proxy: this codebase has no actual participant list or
* mission-leader field on missions. A "yes" RSVP in `mission-attendances` is the
* evidence we treat as "this user took part in the mission". Used for leader
* ratings (direct and indirect). Subordinate ratings do not require it — the
* command relationship itself is the basis.
*/
export async function hasYesAttendance(
payload: PayloadType,
userId: number,
missionId: number,
): Promise<boolean> {
const res = await payload.find({
collection: "mission-attendances",
where: {
and: [
{ user: { equals: userId } },
{ mission: { equals: missionId } },
{ response: { equals: "yes" } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs.length > 0;
}
function extractIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
const ids: number[] = [];
for (const v of value) {
if (typeof v === "number") ids.push(v);
else if (v && typeof v === "object" && typeof (v as { id?: unknown }).id === "number") {
ids.push((v as { id: number }).id);
}
}
return ids;
}
/** True when `leaderId` leads an assignment whose members include `subordinateId`. */
export async function isDirectLeaderOf(
payload: PayloadType,
leaderId: number,
subordinateId: number,
): Promise<boolean> {
const assignments = await payload.find({
collection: "assignments",
where: { leader: { equals: leaderId } },
depth: 0,
overrideAccess: true,
});
return assignments.docs.some((a) => extractIds(a.members).includes(subordinateId));
}
/** True when `userId` leads at least one assignment. */
export async function isAnyLeader(payload: PayloadType, userId: number): Promise<boolean> {
const res = await payload.find({
collection: "assignments",
where: { leader: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs.length > 0;
}
export interface EligibilityResult {
eligible: boolean;
kind?: EvaluationKind;
reason?: string;
}
/**
* Determines whether `raterId` may evaluate `rateeId` for `missionId`, and under
* which kind. Rules (leadership-evaluations feature):
*
* - You cannot evaluate yourself.
* - The mission must be completed/past (see {@link isMissionEvaluable}).
* - `leader-direct`: the ratee directly leads the rater's assignment AND the rater
* participated in the mission (yes-RSVP proxy).
* - `subordinate`: the rater directly leads the ratee's assignment. No RSVP
* required — the command relationship itself is the basis.
* - `leader-indirect`: the rater participated in the mission and the ratee leads at
* least one assignment, but did not directly lead the rater.
*/
export async function evaluateEligibility(
payload: PayloadType,
input: { raterId: number; rateeId: number; missionId: number },
): Promise<EligibilityResult> {
const { raterId, rateeId, missionId } = input;
if (raterId === rateeId) {
return { eligible: false, reason: "You cannot evaluate yourself." };
}
const missions = await payload.find({
collection: "missions",
where: { id: { equals: missionId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const mission = missions.docs[0] as
| {
ownershipAndStatus?: { status?: string };
classification?: { startDateTime?: string | null };
}
| undefined;
if (!mission) return { eligible: false, reason: "Mission not found." };
const status = mission.ownershipAndStatus?.status ?? "";
const startDateTime = mission.classification?.startDateTime;
if (!isMissionEvaluable(status, startDateTime)) {
return { eligible: false, reason: "This mission is not completed or past yet." };
}
const participated = await hasYesAttendance(payload, raterId, missionId);
const rateeLeadsRater = await isDirectLeaderOf(payload, rateeId, raterId);
if (rateeLeadsRater) {
if (!participated) {
return { eligible: false, reason: "No attendance recorded for this mission." };
}
return { eligible: true, kind: "leader-direct" };
}
const raterLeadsRatee = await isDirectLeaderOf(payload, raterId, rateeId);
if (raterLeadsRatee) {
return { eligible: true, kind: "subordinate" };
}
if (participated && (await isAnyLeader(payload, rateeId))) {
return { eligible: true, kind: "leader-indirect" };
}
return { eligible: false, reason: "No leadership relationship for this mission." };
}
export interface UpsertEvaluationInput {
raterId: number;
rateeId: number;
missionId: number;
kind: EvaluationKind;
level: PerformanceLevelId;
comment?: string;
}
/**
* One row per (rater, ratee, mission, kind). An existing evaluation for that tuple
* is updated in place; otherwise a new one is created. Enforced here in the service
* layer — there is deliberately no DB constraint to hand-maintain.
*/
export async function upsertEvaluation(
payload: PayloadType,
input: UpsertEvaluationInput,
): Promise<{ evaluation: EvaluationDoc; created: boolean }> {
if (!isPerformanceLevelId(input.level)) {
throw new Error(`Unknown performance level: ${String(input.level)}`);
}
const existing = await payload.find({
collection: "evaluations",
where: {
and: [
{ rater: { equals: input.raterId } },
{ ratee: { equals: input.rateeId } },
{ mission: { equals: input.missionId } },
{ kind: { equals: input.kind } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) {
const updated = await payload.update({
collection: "evaluations",
id: existing.docs[0].id,
data: { level: input.level, comment: input.comment ?? null },
depth: 0,
overrideAccess: true,
});
return { evaluation: updated as unknown as EvaluationDoc, created: false };
}
const created = await payload.create({
collection: "evaluations",
data: {
rater: input.raterId,
ratee: input.rateeId,
mission: input.missionId,
kind: input.kind,
level: input.level,
comment: input.comment ?? null,
},
depth: 0,
overrideAccess: true,
});
return { evaluation: created as unknown as EvaluationDoc, created: true };
}
export interface LeaderEvaluationAggregate {
rateeId: number;
/** Distinct raters across all leader-kind evaluations. */
totalRaters: number;
/** False while raters are below ANONYMITY_THRESHOLD — breakdown withheld. */
visible: boolean;
levels: {
id: string;
label: string;
score: number;
color: string;
count: number;
percentage: number;
}[] | null;
averageScore: number | null;
missions: LeaderMissionAggregate[];
}
export interface LeaderMissionAggregate {
missionId: number;
missionName: string;
missionCodeName: string;
startDateTime: string | null;
totalRaters: number;
percentage: number | null;
}
/**
* Anonymous aggregate of leader-kind evaluations (direct + indirect) for a ratee.
* The per-level breakdown and average are only exposed once at least
* ANONYMITY_THRESHOLD distinct raters have evaluated — below that, `visible` is
* false and the numbers are null so no rater can be identified. No rater identity
* or comment ever appears in the output.
*/
export async function getLeaderAggregate(
payload: PayloadType,
rateeId: number,
): Promise<LeaderEvaluationAggregate> {
const res = await payload.find({
collection: "evaluations",
where: {
and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }],
},
depth: 0,
overrideAccess: true,
});
const docs = res.docs as unknown as EvaluationDoc[];
const totalRaters = new Set(docs.map((d) => d.rater)).size;
if (totalRaters < ANONYMITY_THRESHOLD) {
return { rateeId, totalRaters, visible: false, levels: null, averageScore: null, missions: [] };
}
const counts = new Map<string, number>();
for (const d of docs) {
counts.set(d.level, (counts.get(d.level) ?? 0) + 1);
}
const totalDocs = docs.length;
const levels = PERFORMANCE_LEVELS.map((l) => {
const count = counts.get(l.id) ?? 0;
return {
id: l.id,
label: l.label,
score: l.score,
color: l.color,
count,
percentage: totalDocs ? Math.round((count / totalDocs) * 10000) / 100 : 0,
};
}).filter((l) => l.count > 0);
const averageScore = totalDocs
? Math.round(
(docs.reduce((sum, d) => sum + (getPerformanceLevel(d.level)?.score ?? 0), 0) /
totalDocs) *
100,
) / 100
: 0;
const missionIds = [...new Set(docs.map((doc) => doc.mission))];
const missionRows = await Promise.all(
missionIds.map(async (missionId) => {
const missionResult = await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
});
const missionDocs = docs.filter((doc) => doc.mission === missionId);
const missionRaters = new Set(missionDocs.map((doc) => doc.rater)).size;
const missionScore = missionDocs.reduce(
(sum, doc) => sum + (getPerformanceLevel(doc.level)?.score ?? 0),
0,
);
return {
missionId,
missionName: missionResult.name,
missionCodeName: missionResult.codeName,
startDateTime: missionResult.classification?.startDateTime ?? null,
totalRaters: missionRaters,
percentage:
missionRaters >= ANONYMITY_THRESHOLD && missionDocs.length > 0
? Math.round((missionScore / missionDocs.length) * 100) / 100
: null,
} satisfies LeaderMissionAggregate;
}),
);
missionRows.sort((a, b) => {
const dateA = a.startDateTime ? new Date(a.startDateTime).getTime() : 0;
const dateB = b.startDateTime ? new Date(b.startDateTime).getTime() : 0;
return dateB - dateA;
});
return { rateeId, totalRaters, visible: true, levels, averageScore, missions: missionRows.slice(0, 5) };
}
/**
* The most recent subordinate-kind evaluation for a ratee, reduced to the level
* definition plus mission and timestamp. Rater identity and comment are private
* and intentionally absent from this output.
*/
export async function getLatestSubordinateLevel(
payload: PayloadType,
rateeId: number,
): Promise<{ level: PerformanceLevel; missionId: number; at: string } | null> {
const res = await payload.find({
collection: "evaluations",
where: {
and: [{ ratee: { equals: rateeId } }, { kind: { equals: "subordinate" } }],
},
sort: "-updatedAt",
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = res.docs[0] as unknown as EvaluationDoc | undefined;
if (!doc) return null;
const level = getPerformanceLevel(doc.level);
if (!level) return null;
return { level, missionId: doc.mission, at: doc.updatedAt };
}

View file

@ -0,0 +1,51 @@
/**
* Pure performance level definitions for leadership evaluations.
*
* This module has no Payload imports — it is safe to use from server actions,
* integration tests, and (later) frontend components. The UI renders these as
* bar/gradient displays; `score` drives the numeric aggregation and `color`
* the per-level accent.
*/
export interface PerformanceLevel {
/** Stable identifier stored on evaluation docs. */
id: string;
/** Human-readable label, e.g. shown as "OPERATOR PERFORMANCE LEVEL: EXCELLENT". */
label: string;
/** Numeric score (0–100) used for aggregate bar/percentage display. */
score: number;
/** Hex color for UI bars/gradients. */
color: string;
}
/**
* The performance scale, best to worst. At least five levels are required by the
* leadership-evaluations feature; six are defined so the top of the scale has room
* above "EXCELLENT" without collapsing distinctions.
*/
export const PERFORMANCE_LEVELS = [
{ id: "exceptional", label: "EXCEPTIONAL", score: 100, color: "#34d399" },
{ id: "excellent", label: "EXCELLENT", score: 85, color: "#4ade80" },
{ id: "proficient", label: "PROFICIENT", score: 70, color: "#a3e635" },
{ id: "adequate", label: "ADEQUATE", score: 55, color: "#fbbf24" },
{ id: "moderate", label: "MODERATE", score: 40, color: "#94a3b8" },
{ id: "under-review", label: "UNDER REVIEW", score: 25, color: "#f87171" },
] as const satisfies readonly PerformanceLevel[];
export type PerformanceLevelId = (typeof PERFORMANCE_LEVELS)[number]["id"];
const LEVEL_IDS: ReadonlySet<string> = new Set(PERFORMANCE_LEVELS.map((l) => l.id));
/** Type guard for runtime-validated level ids (DB/API input). */
export function isPerformanceLevelId(value: unknown): value is PerformanceLevelId {
return typeof value === "string" && LEVEL_IDS.has(value);
}
/** Resolve a level definition by id, or null when unknown. */
export function getPerformanceLevel(id: string): PerformanceLevel | null {
return PERFORMANCE_LEVELS.find((l) => l.id === id) ?? null;
}
export function getLeadershipDisplayLabel(level: Pick<PerformanceLevel, "id" | "label">): string {
return level.id === "under-review" ? "NEEDS WORK" : level.label;
}

View file

@ -70,10 +70,13 @@ export async function autoCompleteMissions(
for (const mission of due.docs) {
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,
data: { ownershipAndStatus: { status: "Completed" } },
data: { ownershipAndStatus: { status: "Completed", completedAt: now.toISOString() } },
overrideAccess: true,
});
await emitGameEvent(payload, {

View file

@ -0,0 +1,174 @@
import type { Payload } from "payload";
/**
* Mission feedback reminder window: a completed main operation only prompts for
* leader feedback within 72 hours of its completion timestamp.
*/
export const MISSION_REMINDER_WINDOW_MS = 72 * 60 * 60 * 1000;
/** Leader identity, enough to render a link to `/profile/[username]`. */
export interface ReminderLeader {
id: number;
username: string;
displayName: string;
}
/** One eligible mission with the direct leaders who RSVP'd "yes". */
export interface MissionReminder {
missionId: number;
missionName: string;
missionCodeName: string;
completedAt: string;
leaders: ReminderLeader[];
}
function refId(value: number | { id: number } | null | undefined): number | null {
if (typeof value === "number") return value;
if (value && typeof value.id === "number") return value.id;
return null;
}
/**
* The user's direct leaders, using the same assignment model as
* `isDirectLeaderOf` in `@/lib/evaluations`: a leader is anyone who leads an
* assignment whose members include this user.
*/
async function findDirectLeaderIds(payload: Payload, userId: number): Promise<number[]> {
const res = await payload.find({
collection: "assignments",
where: { members: { contains: userId } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const ids = new Set<number>();
for (const assignment of res.docs) {
const leaderId = refId(assignment.leader);
if (leaderId !== null) ids.add(leaderId);
}
return [...ids];
}
/**
* Recently completed main operations that need feedback from `user`: the mission
* is a main operation, transitioned to Completed within the last 72 hours
* (`ownershipAndStatus.completedAt`), `user` RSVP'd "yes", and at least one of
* the user's assignment-based direct leaders also RSVP'd "yes".
*
* Fail-closed: missions with a missing or unparseable completion timestamp are
* never eligible, and `updatedAt` is never used as a completion proxy.
*/
export async function getMissionReminders(
payload: Payload,
user: { id: number },
now: Date = new Date(),
): Promise<MissionReminder[]> {
const cutoff = new Date(now.getTime() - MISSION_REMINDER_WINDOW_MS);
// One query for the window + type + status. Missions without a completion
// timestamp cannot match the date comparison, and are re-checked below anyway.
const missionsRes = await payload.find({
collection: "missions",
where: {
and: [
{ operationType: { equals: "main" } },
{ "ownershipAndStatus.status": { equals: "Completed" } },
{ "ownershipAndStatus.completedAt": { greater_than_equal: cutoff.toISOString() } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
const candidates = missionsRes.docs
.map((mission) => {
const completedAt = mission.ownershipAndStatus?.completedAt;
if (!completedAt || typeof completedAt !== "string") return null; // fail closed on missing
const time = new Date(completedAt).getTime();
if (Number.isNaN(time)) return null; // fail closed on unknown data
if (time < cutoff.getTime()) return null;
return { mission, completedAt };
})
.filter(
(
candidate,
): candidate is { mission: (typeof missionsRes.docs)[number]; completedAt: string } =>
candidate !== null,
);
if (candidates.length === 0) return [];
const missionIds = candidates.map(({ mission }) => mission.id);
// All "yes" RSVPs for the candidate missions in a single query.
const attendanceRes = await payload.find({
collection: "mission-attendances",
where: {
and: [{ mission: { in: missionIds } }, { response: { equals: "yes" } }],
},
limit: 500,
depth: 0,
overrideAccess: true,
});
const yesByMission = new Map<number, Set<number>>();
for (const doc of attendanceRes.docs) {
const missionId = refId(doc.mission);
const userId = refId(doc.user);
if (missionId === null || userId === null) continue;
let yesUsers = yesByMission.get(missionId);
if (!yesUsers) {
yesUsers = new Set<number>();
yesByMission.set(missionId, yesUsers);
}
yesUsers.add(userId);
}
const leaderIds = await findDirectLeaderIds(payload, user.id);
if (leaderIds.length === 0) return [];
const leaderSet = new Set(leaderIds);
const eligible: Array<{
mission: (typeof missionsRes.docs)[number];
completedAt: string;
leaderIds: number[];
}> = [];
for (const { mission, completedAt } of candidates) {
const yesUsers = yesByMission.get(mission.id) ?? new Set<number>();
if (!yesUsers.has(user.id)) continue; // the user must have RSVP'd "yes"
const leaders = [...yesUsers].filter((id) => leaderSet.has(id));
if (leaders.length === 0) continue; // at least one direct leader must have RSVP'd "yes"
eligible.push({ mission, completedAt, leaderIds: leaders });
}
if (eligible.length === 0) return [];
const allLeaderIds = [...new Set(eligible.flatMap((entry) => entry.leaderIds))];
const usersRes = await payload.find({
collection: "users",
where: { id: { in: allLeaderIds } },
limit: allLeaderIds.length,
depth: 0,
overrideAccess: true,
});
const leaderById = new Map<number, ReminderLeader>();
for (const doc of usersRes.docs) {
if (typeof doc.id !== "number") continue;
leaderById.set(doc.id, {
id: doc.id,
username: String(doc.username ?? ""),
displayName: String(doc.displayName ?? ""),
});
}
return eligible.map(({ mission, completedAt, leaderIds }) => ({
missionId: mission.id,
missionName: mission.name,
missionCodeName: mission.codeName,
completedAt,
leaders: leaderIds
.map((id) => leaderById.get(id))
.filter((leader): leader is ReminderLeader => leader !== undefined),
}));
}

View file

@ -67,6 +67,8 @@ export function notificationLabel(type?: string | null): string {
return "Listing sold";
case "market:expired":
return "Listing expired";
case "award:granted":
return "Award received";
case "account:discord-request":
return "Discord request";
case "finance:deposit":

View file

@ -7,6 +7,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
{ label: "Negotiations closed", value: "market:closed" },
{ label: "Listing sold", value: "market:sold" },
{ label: "Listing expired", value: "market:expired" },
{ label: "Award received", value: "award:granted" },
{ label: "Discord requests", value: "account:discord-request" },
{ label: "Bank deposit", value: "finance:deposit" },
{ label: "Bank withdrawal", value: "finance:withdraw" },

44
src/lib/session/token.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Session-token helpers.
*
* The `payload-token` cookie is HttpOnly (set by Payload), so it is invisible
* to client-side JavaScript. The server reads it from the request headers and
* extracts only the JWT `exp` claim, which is passed to the client watchdog as
* a plain number. The server remains the authority on whether a session is
* actually valid — the `exp` is used purely as a client-side UX hint.
*/
/**
* Decode the `exp` claim (seconds since epoch) from a JWT string, or null when
* the token is malformed or carries no numeric `exp`. Pure and side-effect
* free so it is trivially unit-testable on both server and client.
*/
export function decodeTokenExp(token: string): number | null {
const payload = token.split(".")[1];
if (!payload) return null;
let json: unknown;
try {
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
json = JSON.parse(atob(base64));
} catch {
return null;
}
if (typeof json !== "object" || json === null) return null;
const exp = (json as { exp?: unknown }).exp;
return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
}
/**
* Read the `payload-token` cookie from the request headers and return the JWT
* `exp` in milliseconds since epoch, or null when the cookie is absent or the
* token is malformed. Server-only in practice (needs the request headers), but
* takes the headers as a parameter so it stays pure and testable.
*/
export function getSessionExpMs(headers: { get(name: string): string | null }): number | null {
const cookieHeader = headers.get("cookie");
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)payload-token=([^;]+)/);
if (!match) return null;
const exp = decodeTokenExp(match[1]);
return exp === null ? null : exp * 1000;
}

View file

@ -5,5 +5,7 @@ export interface VersionInfo {
commitHash: string | null;
commitDate: string | null;
commitSubject: string | null;
lastUpdated: string | null;
commitMessage: string | null;
buildTime: string | null;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
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_evaluations_kind" AS ENUM('leader-direct', 'leader-indirect', 'subordinate');
CREATE TYPE "public"."enum_evaluations_level" AS ENUM('exceptional', 'excellent', 'proficient', 'adequate', 'moderate', 'under-review');
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'evaluations' BEFORE 'tickets';
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'award:granted' BEFORE 'account:discord-request';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'award:granted' BEFORE 'account:discord-request';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'evaluations:create' BEFORE 'missions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'evaluations:read' BEFORE 'missions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'evaluations:update' BEFORE 'missions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'evaluations:delete' BEFORE 'missions:create';
CREATE TABLE "evaluations" (
"id" serial PRIMARY KEY NOT NULL,
"rater_id" integer NOT NULL,
"ratee_id" integer NOT NULL,
"mission_id" integer NOT NULL,
"kind" "enum_evaluations_kind" NOT NULL,
"level" "enum_evaluations_level" NOT NULL,
"comment" varchar,
"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_locked_documents_rels" ADD COLUMN "evaluations_id" integer;
ALTER TABLE "evaluations" ADD CONSTRAINT "evaluations_rater_id_users_id_fk" FOREIGN KEY ("rater_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "evaluations" ADD CONSTRAINT "evaluations_ratee_id_users_id_fk" FOREIGN KEY ("ratee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "evaluations" ADD CONSTRAINT "evaluations_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "evaluations_rater_idx" ON "evaluations" USING btree ("rater_id");
CREATE INDEX "evaluations_ratee_idx" ON "evaluations" USING btree ("ratee_id");
CREATE INDEX "evaluations_mission_idx" ON "evaluations" USING btree ("mission_id");
CREATE INDEX "evaluations_updated_at_idx" ON "evaluations" USING btree ("updated_at");
CREATE INDEX "evaluations_created_at_idx" ON "evaluations" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_evaluations_fk" FOREIGN KEY ("evaluations_id") REFERENCES "public"."evaluations"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_evaluations_id_idx" ON "payload_locked_documents_rels" USING btree ("evaluations_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "evaluations" DISABLE ROW LEVEL SECURITY;
DROP TABLE "evaluations" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_evaluations_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', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'tickets', 'game-servers');
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";
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_notifications_muted_types";
CREATE TYPE "public"."enum_users_preferences_notifications_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_notifications_muted_types" USING "value"::"public"."enum_users_preferences_notifications_muted_types";
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_discord_muted_types";
CREATE TYPE "public"."enum_users_preferences_discord_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_discord_muted_types" USING "value"::"public"."enum_users_preferences_discord_muted_types";
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_roles_permissions";
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'campaigns:manage', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'projects:create', 'projects:read', 'projects:update', 'projects:delete', 'labels:create', 'labels:read', 'labels:update', 'labels:delete', 'sprints:create', 'sprints:read', 'sprints:update', 'sprints:delete', 'releases:create', 'releases:read', 'releases:update', 'releases:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE "public"."enum_roles_permissions" USING "value"::"public"."enum_roles_permissions";
DROP INDEX "payload_locked_documents_rels_evaluations_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "evaluations_id";
DROP TYPE "public"."enum_evaluations_kind";
DROP TYPE "public"."enum_evaluations_level";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "ownership_and_status_completed_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "ownership_and_status_completed_at";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
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_campaigns_campaign_mode" AS ENUM('official', 'custom');
ALTER TABLE "campaigns" ADD COLUMN "campaign_mode" "enum_campaigns_campaign_mode" DEFAULT 'custom' NOT NULL;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaigns" DROP COLUMN "campaign_mode";
DROP TYPE "public"."enum_campaigns_campaign_mode";`)
}

View file

@ -4,6 +4,9 @@ import * as migration_20260826_050000_repair_shims_auxiliary_tables from './2026
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
export const migrations = [
{
@ -34,6 +37,21 @@ export const migrations = [
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags'
name: '20260827_022334_add_eod_minesweeper_best_flags',
},
{
up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection',
},
{
up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at',
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode'
},
];

File diff suppressed because it is too large Load diff

View file

@ -83,6 +83,7 @@ export interface Config {
assignments: Assignment;
experience: Experience;
'user-notifications': UserNotification;
evaluations: Evaluation;
missions: Mission;
'mission-attendances': MissionAttendance;
campaigns: Campaign;
@ -143,6 +144,7 @@ export interface Config {
assignments: AssignmentsSelect<false> | AssignmentsSelect<true>;
experience: ExperienceSelect<false> | ExperienceSelect<true>;
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
evaluations: EvaluationsSelect<false> | EvaluationsSelect<true>;
missions: MissionsSelect<false> | MissionsSelect<true>;
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
@ -1097,6 +1099,10 @@ export interface Campaign {
name: string;
summary: string;
status: 'concept' | 'inProgress' | 'completed';
/**
* Only developers can designate a campaign as official or custom.
*/
campaignMode: 'official' | 'custom';
description?: {
root: {
type: string;
@ -1157,6 +1163,7 @@ export interface User {
| 'market:closed'
| 'market:sold'
| 'market:expired'
| 'award:granted'
| 'account:discord-request'
| 'finance:deposit'
| 'finance:withdraw'
@ -1187,6 +1194,7 @@ export interface User {
| 'market:closed'
| 'market:sold'
| 'market:expired'
| 'award:granted'
| 'account:discord-request'
| 'finance:deposit'
| 'finance:withdraw'
@ -1280,6 +1288,10 @@ export interface Role {
| 'user-notifications:read'
| 'user-notifications:update'
| 'user-notifications:delete'
| 'evaluations:create'
| 'evaluations:read'
| 'evaluations:update'
| 'evaluations:delete'
| 'missions:create'
| 'missions:read'
| 'missions:update'
@ -1583,6 +1595,7 @@ export interface GameEventLog {
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'evaluations'
| 'tickets'
| 'game-servers'
)
@ -1842,6 +1855,27 @@ export interface UserNotification {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "evaluations".
*/
export interface Evaluation {
id: number;
/**
* Who submitted this evaluation. Private — only visible to users with evaluations:read.
*/
rater: number | User;
ratee: number | User;
mission: number | Mission;
kind: 'leader-direct' | 'leader-indirect' | 'subordinate';
level: 'exceptional' | 'excellent' | 'proficient' | 'adequate' | 'moderate' | 'under-review';
/**
* Private free-text note. Never shown to normal users; managers only.
*/
comment?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "missions".
@ -1890,6 +1924,10 @@ export interface Mission {
zeus?: (number | User)[] | null;
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
visibility: 'unit' | 'leadership' | 'intel' | 'private';
/**
* When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.
*/
completedAt?: string | null;
};
missionRoles: {
maxPlayers: number;
@ -3027,6 +3065,10 @@ export interface PayloadLockedDocument {
relationTo: 'user-notifications';
value: number | UserNotification;
} | null)
| ({
relationTo: 'evaluations';
value: number | Evaluation;
} | null)
| ({
relationTo: 'missions';
value: number | Mission;
@ -3554,6 +3596,20 @@ export interface UserNotificationsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "evaluations_select".
*/
export interface EvaluationsSelect<T extends boolean = true> {
rater?: T;
ratee?: T;
mission?: T;
kind?: T;
level?: T;
comment?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "missions_select".
@ -3584,6 +3640,7 @@ export interface MissionsSelect<T extends boolean = true> {
zeus?: T;
status?: T;
visibility?: T;
completedAt?: T;
};
missionRoles?:
| T
@ -3693,6 +3750,7 @@ export interface CampaignsSelect<T extends boolean = true> {
name?: T;
summary?: T;
status?: T;
campaignMode?: T;
description?: T;
startDate?: T;
endDate?: T;

View file

@ -46,6 +46,7 @@ import { Loadouts } from "@/collections/locker/Loadouts";
import { MarketListings } from "@/collections/market/MarketListings";
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
import { UserNotifications } from "@/collections/users/UserNotifications";
import { Evaluations } from "@/collections/users/Evaluations";
import { Tickets } from "@/collections/tickets/Tickets";
import { Projects } from "@/collections/projects/Projects";
import { Sprints } from "@/collections/projects/Sprints";
@ -203,6 +204,7 @@ export default buildConfig({
Assignments,
Experience,
UserNotifications,
Evaluations,
// Intel
Missions,
@ -313,6 +315,7 @@ export default buildConfig({
[Assignments.slug]: true,
[Experience.slug]: true,
[UserNotifications.slug]: true,
[Evaluations.slug]: true,
// Intel
[Missions.slug]: true,

View file

@ -112,6 +112,15 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "user-notifications:delete", label: "Delete" },
],
},
{
group: "Evaluations",
permissions: [
{ value: "evaluations:create", label: "Create" },
{ value: "evaluations:read", label: "Read" },
{ value: "evaluations:update", label: "Update" },
{ value: "evaluations:delete", label: "Delete" },
],
},
// ---- Intelligence --------------------------------------------------------
{
@ -503,6 +512,10 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"user-notifications:read",
"user-notifications:update",
"user-notifications:delete",
"evaluations:create",
"evaluations:read",
"evaluations:update",
"evaluations:delete",
"missions:create",
"missions:read",
"missions:update",

View file

@ -72,6 +72,8 @@ function main(): void {
commitHash,
commitDate,
commitSubject,
lastUpdated: commitDate ?? buildTime,
commitMessage: commitSubject,
buildTime,
};
@ -89,4 +91,4 @@ function main(): void {
console.log(`[version-info] wrote src/generated/versionInfo.json (${label})`);
}
main();
main();

View file

@ -28,6 +28,7 @@ interface GameEventInput {
| "loadouts"
| "market-listings"
| "market-negotiations"
| "evaluations"
| "tickets"
| "game-servers";
targetId?: number;

View file

@ -89,6 +89,9 @@ export const EventTypes = {
// Attendance
MissionAttendanceChange: "mission:attendance-change",
// Evaluations
EvaluationSubmit: "evaluation:submit",
// Missions
MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",

View file

@ -1,10 +1,14 @@
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
export type ExperienceLevel = {
name: string;
description: any;
requiredPoints: number;
};
export type ResolvedLevel = {
levelName: string;
levelDesc: string;
progress: number;
currentLevelXP: number;
nextLevelXP: number | null;
@ -19,13 +23,11 @@ export type ResolvedLevel = {
* current bracket: (xp - currentLevelXP) / (nextLevelXP - currentLevelXP).
* At the max level the bar sits at 100%.
*/
export function resolveLevel(
xp: number,
levels: ExperienceLevel[],
): ResolvedLevel {
export function resolveLevel(xp: number, levels: ExperienceLevel[]): ResolvedLevel {
if (levels.length === 0) {
return {
levelName: "Unknown",
levelDesc: "Could not retrieve description.",
progress: 0,
currentLevelXP: 0,
nextLevelXP: null,
@ -60,6 +62,9 @@ export function resolveLevel(
return {
levelName: current.name,
levelDesc:
convertLexicalToPlaintext({ data: current.description }) ||
"Failed to retrieve description!",
progress: Math.min(Math.max(progress, 0), 100),
currentLevelXP,
nextLevelXP,

View file

@ -0,0 +1,226 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Award, Profile, User } from "@/payload-types";
import { AwardGrantEntry, diffGrantedAwards } from "@/lib/awards";
let payload: Payload;
const RUN = `aw-${Date.now().toString(36)}`;
function richText(text: string) {
return {
root: {
type: "root",
format: "" as const,
indent: 0,
version: 1,
children: [
{
type: "paragraph",
format: "" as const,
indent: 0,
version: 1,
children: [
{ type: "text", format: 0, style: "", mode: "normal" as const, text, version: 1 },
],
direction: "ltr" as const,
},
],
direction: "ltr" as const,
},
};
}
describe("Award grant notifications", () => {
let user: User;
let profileId: number;
let medalId: number;
let ribbonId: number;
const countGrantNotifications = async (userId: number) => {
const res = await payload.find({
collection: "user-notifications",
where: { user: { equals: userId }, type: { equals: "award:granted" } },
limit: 50,
overrideAccess: true,
depth: 0,
});
return res.docs as Array<{
id: number;
title?: string | null;
message?: string | null;
link?: string | null;
}>;
};
const setAwards = async (awards: AwardGrantEntry[]) => {
await payload.update({
collection: "profiles",
id: profileId,
data: { progression: { awards } },
overrideAccess: true,
depth: 0,
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const makeAward = async (name: string, type: "medal" | "ribbon"): Promise<number> => {
const created = (await payload.create({
collection: "awards",
data: {
name,
description: richText(`${RUN} test award`),
type,
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
return created.id;
};
medalId = await makeAward(`${RUN} Test Medal`, "medal");
ribbonId = await makeAward(`${RUN} Test Ribbon`, "ribbon");
user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-operator`,
discordUsername: `${RUN}-operator-discord`,
displayName: `${RUN} Operator`,
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
// The Users afterChange hook auto-creates a profile for new users.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (profiles.docs.length === 0) throw new Error("Test setup failed: profile was not auto-created.");
profileId = profiles.docs[0].id;
});
afterAll(async () => {
await payload.delete({ collection: "profiles", id: profileId, overrideAccess: true }).catch(() => {});
await payload.delete({ collection: "users", id: user.id, overrideAccess: true }).catch(() => {});
await payload.delete({ collection: "awards", id: medalId, overrideAccess: true }).catch(() => {});
await payload.delete({ collection: "awards", id: ribbonId, overrideAccess: true }).catch(() => {});
});
it("notifies the user when an award is granted", async () => {
await setAwards([{ award: medalId, awardedAt: new Date().toISOString() }]);
const docs = await countGrantNotifications(user.id);
expect(docs).toHaveLength(1);
expect(docs[0].title).toBe(`Award received: ${RUN} Test Medal`);
expect(docs[0].link).toBe(`/profile/${user.username}`);
});
it("does not notify when an existing entry is edited", async () => {
const current = await payload.findByID({
collection: "profiles",
id: profileId,
depth: 0,
overrideAccess: true,
});
const entries = (current as unknown as Profile).progression?.awards ?? [];
expect(entries).toHaveLength(1);
await setAwards([{ ...entries[0], awardReason: "For exceptional service." }]);
expect(await countGrantNotifications(user.id)).toHaveLength(1);
});
it("does not notify when an award is revoked", async () => {
await setAwards([]);
expect(await countGrantNotifications(user.id)).toHaveLength(1);
});
it("notifies again when a revoked award is re-granted", async () => {
await setAwards([
{ award: medalId, awardedAt: new Date().toISOString(), awardReason: "For exceptional service." },
]);
expect(await countGrantNotifications(user.id)).toHaveLength(2);
});
it("notifies once per newly granted award in a single update", async () => {
const current = await payload.findByID({
collection: "profiles",
id: profileId,
depth: 0,
overrideAccess: true,
});
const existing = (current as unknown as Profile).progression?.awards ?? [];
await setAwards([
...existing,
{ award: ribbonId, awardedAt: new Date().toISOString() },
{ award: medalId, awardedAt: new Date(Date.now() + 60_000).toISOString() },
]);
expect(await countGrantNotifications(user.id)).toHaveLength(4);
});
it("includes the reason and unit attribution in the message", async () => {
const docs = await countGrantNotifications(user.id);
expect(docs.some((d) => d.message?.includes("For exceptional service."))).toBe(true);
// No issuer on any of these grants, so they are attributed to the unit.
expect(docs.every((d) => d.message?.includes("On behalf of the unit."))).toBe(true);
});
});
describe("diffGrantedAwards (pure)", () => {
const entry = (over: Partial<{ id?: string; awardId?: number; at?: string }>) => ({
id: over.id,
award: over.awardId ?? 1,
awardedAt: over.at ?? "2026-01-01T00:00:00.000Z",
});
it("returns empty when nothing changed", () => {
const doc = { progression: { awards: [entry({ id: "a" })] } };
expect(diffGrantedAwards(doc, doc)).toHaveLength(0);
});
it("detects new rows by row id", () => {
const before = { progression: { awards: [entry({ id: "a" })] } };
const after = { progression: { awards: [entry({ id: "a" }), entry({ id: "b", awardId: 2 })] } };
expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["b"]);
});
it("falls back to award+date identity when rows have no id", () => {
const before = { progression: { awards: [entry({ awardId: 1 })] } };
// Same award + date, still no ids → not a new grant (reason-only edits keep the date).
const after = { progression: { awards: [entry({ awardId: 1 })] } };
expect(diffGrantedAwards(before, after)).toHaveLength(0);
// New issue date → new grant.
const regranted = { progression: { awards: [entry({ awardId: 1, at: "2026-02-01T00:00:00.000Z" })] } };
expect(diffGrantedAwards(before, regranted)).toHaveLength(1);
});
it("handles the same award granted twice (multiset)", () => {
const before = {
progression: { awards: [entry({ id: "a", awardId: 1 }), entry({ id: "b", awardId: 1 })] },
};
const after = {
progression: {
awards: [
entry({ id: "a", awardId: 1 }),
entry({ id: "b", awardId: 1 }),
entry({ id: "c", awardId: 1 }),
],
},
};
expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["c"]);
});
it("treats a created profile with awards as grants", () => {
const after = { progression: { awards: [entry({ id: "a" })] } };
expect(diffGrantedAwards(null, after)).toHaveLength(1);
});
});

View file

@ -0,0 +1,476 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, Role, User } from "@/payload-types";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
import {
ANONYMITY_THRESHOLD,
evaluateEligibility,
getLatestSubordinateLevel,
getLeaderAggregate,
isMissionEvaluable,
upsertEvaluation,
} from "@/lib/evaluations";
let payload: Payload;
const RUN = `evl-${Date.now().toString(36)}`;
describe("Leadership evaluations", () => {
let mapId: number;
let campaignId: number;
let leaderA: User;
let subordinateB: User;
let participantC: User;
let participantE: User;
let noRsvpF: User;
let outsiderD: User;
let assignmentId: number;
let missionCompletedId: number;
let missionFutureId: number;
const userIds: number[] = [];
const attendanceIds: number[] = [];
const evaluationIds: number[] = [];
const makeUser = async (label: string): 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"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const makeMission = async (
label: string,
status: Mission["ownershipAndStatus"]["status"],
start: Date,
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Leadership evaluation test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: start.toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [leaderA.id],
status,
visibility: "unit",
},
missionRoles: {
maxPlayers: 16,
},
gameDetails: {
serverDetails: {
serverIp: "127.0.0.1",
serverPort: 2302,
},
},
briefing: [],
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
return mission;
};
const rsvp = async (user: User, missionId: number): Promise<void> => {
const attendance = await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: user.id, response: "yes" },
overrideAccess: true,
depth: 0,
});
attendanceIds.push(attendance.id);
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map` },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
leaderA = await makeUser("leader-a");
subordinateB = await makeUser("subordinate-b");
participantC = await makeUser("participant-c");
participantE = await makeUser("participant-e");
noRsvpF = await makeUser("no-rsvp-f");
outsiderD = await makeUser("outsider-d");
const assignment = await payload.create({
collection: "assignments",
data: {
name: `${RUN} Squad`,
type: "squad",
leader: leaderA.id,
members: [subordinateB.id, noRsvpF.id],
},
overrideAccess: true,
depth: 0,
});
assignmentId = assignment.id;
const past = new Date();
past.setDate(past.getDate() - 7);
const future = new Date();
future.setDate(future.getDate() + 14);
const missionCompleted = await makeMission("completed", "Completed", past);
missionCompletedId = missionCompleted.id;
const missionFuture = await makeMission("future-scheduled", "Scheduled", future);
missionFutureId = missionFuture.id;
// Participation proxy: yes-RSVPs for B, C, E. F and D deliberately have none.
await rsvp(subordinateB, missionCompletedId);
await rsvp(participantC, missionCompletedId);
await rsvp(participantE, missionCompletedId);
});
afterAll(async () => {
const evaluations = await payload
.find({
collection: "evaluations",
where: { mission: { in: [missionCompletedId, missionFutureId] } },
limit: 100,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const doc of evaluations?.docs ?? []) {
await payload.delete({ collection: "evaluations", id: doc.id, overrideAccess: true }).catch(
() => {},
);
}
evaluationIds.length = 0;
for (const id of attendanceIds) {
await payload
.delete({ collection: "mission-attendances", id, overrideAccess: true })
.catch(() => {});
}
await payload.delete({ collection: "assignments", id: assignmentId, overrideAccess: true }).catch(
() => {},
);
for (const id of [missionCompletedId, missionFutureId]) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
await payload
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
.catch(() => {});
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
for (const userId of userIds) {
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
}
});
it("classifies mission statuses for evaluability (pure)", () => {
const past = new Date(Date.now() - 86_400_000).toISOString();
const future = new Date(Date.now() + 86_400_000).toISOString();
expect(isMissionEvaluable("Completed")).toBe(true);
expect(isMissionEvaluable("Scheduled", past)).toBe(true);
expect(isMissionEvaluable("Active", past)).toBe(true);
expect(isMissionEvaluable("Scheduled", future)).toBe(false);
expect(isMissionEvaluable("Ready", past)).toBe(false);
expect(isMissionEvaluable("Cancelled", past)).toBe(false);
expect(isMissionEvaluable("Scheduled")).toBe(false);
expect(isMissionEvaluable("Scheduled", "not-a-date")).toBe(false);
});
it("derives the evaluation kind from the rater/ratee/mission relationship", async () => {
const cases = [
{ input: { raterId: subordinateB.id, rateeId: leaderA.id }, expectKind: "leader-direct" },
{ input: { raterId: participantC.id, rateeId: leaderA.id }, expectKind: "leader-indirect" },
{ input: { raterId: leaderA.id, rateeId: subordinateB.id }, expectKind: "subordinate" },
];
for (const { input, expectKind } of cases) {
const result = await evaluateEligibility(payload, { ...input, missionId: missionCompletedId });
expect(result.eligible).toBe(true);
expect(result.kind).toBe(expectKind);
}
// In A's assignment but never RSVPed yes — the participation proxy fails.
const noRsvp = await evaluateEligibility(payload, {
raterId: noRsvpF.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
});
expect(noRsvp.eligible).toBe(false);
expect(noRsvp.reason).toBe("No attendance recorded for this mission.");
// No leadership relationship at all.
const outsider = await evaluateEligibility(payload, {
raterId: outsiderD.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
});
expect(outsider.eligible).toBe(false);
// Self-rating is always rejected.
const selfRating = await evaluateEligibility(payload, {
raterId: leaderA.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
});
expect(selfRating.eligible).toBe(false);
// A not-yet-past mission is not evaluable.
const tooEarly = await evaluateEligibility(payload, {
raterId: subordinateB.id,
rateeId: leaderA.id,
missionId: missionFutureId,
});
expect(tooEarly.eligible).toBe(false);
});
it("keeps one row per rater/ratee/mission/kind", async () => {
const first = await upsertEvaluation(payload, {
raterId: subordinateB.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
kind: "leader-direct",
level: "excellent",
comment: "First pass",
});
evaluationIds.push(first.evaluation.id);
expect(first.created).toBe(true);
const second = await upsertEvaluation(payload, {
raterId: subordinateB.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
kind: "leader-direct",
level: "proficient",
comment: "Revised",
});
expect(second.created).toBe(false);
expect(second.evaluation.id).toBe(first.evaluation.id);
expect(second.evaluation.level).toBe("proficient");
const rows = await payload.find({
collection: "evaluations",
where: {
and: [
{ rater: { equals: subordinateB.id } },
{ ratee: { equals: leaderA.id } },
{ mission: { equals: missionCompletedId } },
{ kind: { equals: "leader-direct" } },
],
},
limit: 10,
depth: 0,
overrideAccess: true,
});
expect(rows.docs).toHaveLength(1);
});
it("withholds the leader aggregate below the anonymity threshold", async () => {
const c = await upsertEvaluation(payload, {
raterId: participantC.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
kind: "leader-indirect",
level: "excellent",
});
evaluationIds.push(c.evaluation.id);
const aggregate = await getLeaderAggregate(payload, leaderA.id);
expect(aggregate.totalRaters).toBe(2);
expect(ANONYMITY_THRESHOLD).toBe(3);
expect(aggregate.visible).toBe(false);
expect(aggregate.levels).toBeNull();
expect(aggregate.averageScore).toBeNull();
});
it("exposes the leader aggregate once the anonymity threshold is met", async () => {
const e = await upsertEvaluation(payload, {
raterId: participantE.id,
rateeId: leaderA.id,
missionId: missionCompletedId,
kind: "leader-indirect",
level: "exceptional",
});
evaluationIds.push(e.evaluation.id);
const aggregate = await getLeaderAggregate(payload, leaderA.id);
expect(aggregate.totalRaters).toBe(3);
expect(aggregate.visible).toBe(true);
expect(aggregate.levels).not.toBeNull();
expect(aggregate.averageScore).toBe(85); // (70 + 85 + 100) / 3
const byId = new Map((aggregate.levels ?? []).map((l) => [l.id, l]));
expect(byId.get("exceptional")?.count).toBe(1);
expect(byId.get("excellent")?.count).toBe(1);
expect(byId.get("proficient")?.count).toBe(1);
expect(byId.get("adequate")).toBeUndefined();
for (const level of aggregate.levels ?? []) {
expect(level.percentage).toBeCloseTo(33.33, 1);
expect(PERFORMANCE_LEVELS.some((p) => p.id === level.id)).toBe(true);
}
});
it("returns only the latest subordinate level, without rater data", async () => {
await upsertEvaluation(payload, {
raterId: leaderA.id,
rateeId: subordinateB.id,
missionId: missionCompletedId,
kind: "subordinate",
level: "adequate",
comment: "Manager note — must never leak",
});
const revised = await upsertEvaluation(payload, {
raterId: leaderA.id,
rateeId: subordinateB.id,
missionId: missionCompletedId,
kind: "subordinate",
level: "excellent",
});
evaluationIds.push(revised.evaluation.id);
const latest = await getLatestSubordinateLevel(payload, subordinateB.id);
expect(latest).not.toBeNull();
expect(latest?.level.id).toBe("excellent");
expect(latest?.missionId).toBe(missionCompletedId);
expect(typeof latest?.at).toBe("string");
expect(latest).not.toHaveProperty("rater");
expect(latest).not.toHaveProperty("comment");
const none = await getLatestSubordinateLevel(payload, participantC.id);
expect(none).toBeNull();
});
it("hides raw evaluation documents from users without the permission", async () => {
// Local ops bypass access control unless overrideAccess: false — this flag
// makes findOperation run the same requirePermission("evaluations:read")
// check that the REST endpoint runs. A user with no access gets a Forbidden
// error rather than an empty list.
await expect(
payload.find({
collection: "evaluations",
limit: 50,
depth: 0,
user: outsiderD,
overrideAccess: false,
}),
).rejects.toThrow(/not allowed/);
});
it("exposes raw evaluation documents to users holding evaluations:read", async () => {
const role = (await payload.create({
collection: "roles",
data: {
name: `${RUN} Evaluation Reader`,
slug: `${RUN}-evaluation-reader`,
permissions: ["evaluations:read"],
},
overrideAccess: true,
depth: 0,
})) as unknown as Role;
const reader = (await payload.create({
collection: "users",
data: {
username: `${RUN}-reader`,
discordUsername: `${RUN}-reader`,
displayName: "READER",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(reader.id);
await payload.update({
collection: "users",
id: reader.id,
data: { roleDocs: [role.id] },
overrideAccess: true,
depth: 0,
});
invalidatePermissionCache();
try {
const result = await payload.find({
collection: "evaluations",
limit: 50,
depth: 0,
user: reader,
overrideAccess: false,
});
expect(result.docs.length).toBeGreaterThan(0);
const doc = result.docs[0] as unknown as Record<string, unknown>;
expect(typeof doc.rater).toBe("number");
expect(typeof doc.ratee).toBe("number");
expect(typeof doc.mission).toBe("number");
expect(["leader-direct", "leader-indirect", "subordinate"]).toContain(doc.kind);
} finally {
await payload.delete({ collection: "roles", id: role.id, overrideAccess: true }).catch(() => {});
}
});
});

View file

@ -196,6 +196,27 @@ describe("Marketplace", () => {
for (const locker of lockers.docs) {
await payload.delete({ collection: "locker-storages", id: locker.id, overrideAccess: true });
}
// profiles.user and user-notifications.user are NOT NULL — delete them before the user.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const profile of profiles.docs) {
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true });
}
const notifications = await payload.find({
collection: "user-notifications",
where: { user: { equals: user.id } },
limit: 100,
depth: 0,
overrideAccess: true,
});
for (const notification of notifications.docs) {
await payload.delete({ collection: "user-notifications", id: notification.id, overrideAccess: true });
}
await payload.delete({ collection: "users", id: user.id, overrideAccess: true });
}

View file

@ -33,7 +33,7 @@ describe("Mission auto-complete", () => {
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" },
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
@ -93,7 +93,9 @@ describe("Mission auto-complete", () => {
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {});
await payload
.delete({ collection: "users", id: authorId, overrideAccess: true })
.catch(() => {});
});
const makeMission = async (
@ -196,6 +198,79 @@ describe("Mission auto-complete", () => {
expect(result.missions).toEqual([]);
});
it("stamps the exact sweep time as completedAt when auto-completing", async () => {
const now = new Date();
now.setHours(15, 0, 0, 0);
const start = new Date(now);
start.setDate(now.getDate() - 1);
start.setHours(20, 0, 0, 0);
const due = await makeMission("timestamp-due", "Scheduled", start);
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
expect(result.completed).toBe(1);
const updated = await fetchMission(due.id);
expect(updated.ownershipAndStatus.status).toBe("Completed");
expect(updated.ownershipAndStatus.completedAt).toBe(now.toISOString());
});
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.
start.setDate(start.getDate() + 1);
start.setHours(20, 0, 0, 0);
const mission = await makeMission("manual-complete", "Scheduled", start);
expect(mission.ownershipAndStatus.completedAt).toBeNull();
// Non-terminal transitions must not stamp a completion time.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Active" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBeNull();
const before = Date.now();
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const after = Date.now();
const completed = await fetchMission(mission.id);
expect(completed.ownershipAndStatus.completedAt).toBeTruthy();
// timestamptz(3) truncates sub-millisecond precision, hence the 1ms tolerance.
const stamped = new Date(completed.ownershipAndStatus.completedAt!).getTime();
expect(stamped).toBeGreaterThanOrEqual(before - 1);
expect(stamped).toBeLessThanOrEqual(after);
// Cancelling and re-completing keeps the original stamp.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Cancelled" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const recompleted = await fetchMission(mission.id);
expect(recompleted.ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
});
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());

View file

@ -0,0 +1,150 @@
import { createElement } from "react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import { GAME_TICK_EVENT } from "@/hooks/useGameTick";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
const routerPush = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: routerPush }),
}));
vi.mock("sonner", () => {
const mockedToast = Object.assign(vi.fn(), { dismiss: vi.fn() });
return { toast: mockedToast };
});
type Leader = {
id: number;
username: string;
displayName: string;
};
type Reminder = {
missionId: number;
missionName: string;
missionCodeName: string;
completedAt: string;
leaders: readonly Leader[];
};
const reminder: Reminder = {
missionId: 42,
missionName: "Operation Nightfall",
missionCodeName: "OP:NIGHTFALL",
completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
leaders: [
{ id: 7, username: "leader-one", displayName: "Leader One" },
{ id: 8, username: "leader-two", displayName: "Leader Two" },
],
};
const fetchMock = vi.fn<typeof fetch>();
const mockedToast = vi.mocked(toast);
function responseFor(reminders: readonly Reminder[]): Response {
return new Response(JSON.stringify({ reminders }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function firstToastOptions() {
const options = mockedToast.mock.calls[0]?.[1];
if (!options) throw new Error("Expected the first reminder toast to have options.");
return options;
}
function invokeButton(button: unknown): void {
if (typeof button !== "object" || button === null || !("onClick" in button)) {
throw new Error("Expected a Sonner button contract.");
}
const onClick = button.onClick;
if (typeof onClick !== "function") throw new Error("Expected a Sonner button handler.");
onClick(new MouseEvent("click"));
}
describe("MissionReminderToasts", () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
vi.stubGlobal("fetch", fetchMock);
mockedToast.mockReturnValue("mission-reminder:42:7");
});
afterEach(() => {
cleanup();
});
it("shows eligible leaders one at a time and links the action to the leader profile", async () => {
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
expect(firstToastOptions().action).toMatchObject({ label: "Rate leader" });
expect(firstToastOptions().cancel).toMatchObject({ label: "Dismiss reminder" });
invokeButton(firstToastOptions().action);
expect(routerPush).toHaveBeenCalledWith("/profile/leader-one");
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(2));
expect(mockedToast.mock.calls[1]?.[1]?.description).toContain("Leader Two");
});
it("does not issue a second request while a realtime refresh overlaps the initial poll", async () => {
let resolveFetch: ((value: Response) => void) | undefined;
fetchMock.mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
render(createElement(MissionReminderToasts));
act(() => window.dispatchEvent(new Event(GAME_TICK_EVENT)));
expect(fetchMock).toHaveBeenCalledTimes(1);
resolveFetch?.(responseFor([reminder]));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
});
it("persists an explicit dismissal for that mission and leader only", async () => {
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
invokeButton(firstToastOptions().cancel);
const stored = localStorage.getItem("ptf:mission-reminder:dismissals");
expect(stored).toContain('"42:7"');
expect(stored).not.toContain('"42:8"');
cleanup();
mockedToast.mockClear();
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
expect(mockedToast.mock.calls[0]?.[1]?.description).toContain("Leader Two");
});
it("does not show a reminder whose completion window has expired", async () => {
const expiredReminder = {
...reminder,
completedAt: new Date(Date.now() - 73 * 60 * 60 * 1000).toISOString(),
};
fetchMock.mockResolvedValue(responseFor([expiredReminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await act(async () => {
await Promise.resolve();
});
expect(mockedToast).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,409 @@
// @vitest-environment node
// jose (via payload's jwtSign) fails under jsdom: its module-level TextEncoder and
// the global Uint8Array come from different realms, so signing throws a TypeError.
import { getFieldsToSign, getPayload, jwtSign, Payload } from "payload";
import config from "@/payload.config";
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Assignment, Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import { GET } from "@/app/api/mission-reminder/route";
import { getMissionReminders } from "@/lib/intelligence/missionReminder";
let payload: Payload;
const RUN = `mrm-${Date.now().toString(36)}`;
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
/** Reference "now" for the boundary fixtures and the service-level window test. */
let fixedNow: Date;
function request(token?: string): NextRequest {
const headers = new Headers();
if (token) headers.set("cookie", `payload-token=${token}`);
return new NextRequest("http://localhost/api/mission-reminder", { headers });
}
/**
* Mint a payload-token JWT for a user, mirroring the impersonation route:
* persist a session on the user doc, then sign with that session's sid.
*/
async function mintToken(userId: number): Promise<string> {
const collection = payload.collections.users.config;
const user = await payload.findByID({
collection: "users",
id: userId,
depth: 0,
overrideAccess: true,
});
const sid = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + collection.auth.tokenExpiration * 1000);
const sessions = (user.sessions ?? []).filter(
(session) => new Date(session.expiresAt).getTime() > now.getTime(),
);
sessions.push({ id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() });
await payload.update({
collection: "users",
id: userId,
data: { sessions },
overrideAccess: true,
});
const { token } = await jwtSign({
fieldsToSign: getFieldsToSign({
collectionConfig: collection,
email: user.email ?? "",
sid,
user,
}),
secret: payload.secret,
tokenExpiration: collection.auth.tokenExpiration,
});
return token;
}
describe("Mission feedback reminder", () => {
let mapId: number;
let campaignId: number;
const userIds: number[] = [];
const missionIds: number[] = [];
const attendanceIds: number[] = [];
const assignmentIds: number[] = [];
let memberId: number;
let leaderAId: number;
let leaderBId: number;
let strangerId: number;
let lonerId: number;
let memberToken: string;
let lonerToken: string;
// Eligible fixtures.
let eligibleMainId: number; // main, 24h ago, member + leaderA yes -> included [leaderA]
let multiLeaderId: number; // main, 12h ago, member + both leaders yes -> included [leaderA, leaderB]
// Ineligible fixtures.
let sideOpId: number; // side operation within the window
let staleMainId: number; // main, 73h ago (past the window)
let noStampId: number; // main, completion stamp cleared -> fail closed
let userNoId: number; // member RSVP'd "no"
let leaderNoId: number; // only a non-leader (stranger) RSVP'd yes
// Boundary fixtures relative to fixedNow.
let boundaryExactId: number; // exactly fixedNow - 72h -> included at fixedNow
let boundaryPastId: number; // fixedNow - 72h - 1ms -> excluded at fixedNow
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
fixedNow = new Date();
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map` },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Reminder test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
const makeUser = async (label: string, displayName: string): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName,
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const member = await makeUser("member", "MEMBER");
const leaderA = await makeUser("leader-a", "LEADER A");
const leaderB = await makeUser("leader-b", "LEADER B");
const stranger = await makeUser("stranger", "STRANGER");
const loner = await makeUser("loner", "LONER");
memberId = member.id;
leaderAId = leaderA.id;
leaderBId = leaderB.id;
strangerId = stranger.id;
lonerId = loner.id;
// The member belongs to two squads, so their direct leaders are both
// leaders (same model as isDirectLeaderOf in @/lib/evaluations).
const makeAssignment = async (label: string, leaderId: number): Promise<Assignment> => {
const assignment = (await payload.create({
collection: "assignments",
data: { name: `${RUN} ${label}`, type: "squad", leader: leaderId, members: [memberId] },
overrideAccess: true,
depth: 0,
})) as unknown as Assignment;
assignmentIds.push(assignment.id);
return assignment;
};
await makeAssignment("Squad A", leaderAId);
await makeAssignment("Squad B", leaderBId);
const makeMission = async (
label: string,
operationType: Mission["operationType"],
completedAt?: string,
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Reminder boundary test mission",
operationType,
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: new Date(fixedNow.getTime() - DAY).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [memberId],
status: "Completed",
visibility: "unit",
...(completedAt ? { completedAt } : {}),
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
const addAttendance = async (
missionId: number,
userId: number,
response: "yes" | "no" | "tentative",
) => {
const attendance = await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response },
overrideAccess: true,
depth: 0,
});
attendanceIds.push((attendance as unknown as { id: number }).id);
};
// Eligible fixtures.
eligibleMainId = (
await makeMission("eligible-main", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(eligibleMainId, memberId, "yes");
await addAttendance(eligibleMainId, leaderAId, "yes");
multiLeaderId = (
await makeMission(
"multi-leader",
"main",
new Date(fixedNow.getTime() - 12 * HOUR).toISOString(),
)
).id;
await addAttendance(multiLeaderId, memberId, "yes");
await addAttendance(multiLeaderId, leaderAId, "yes");
await addAttendance(multiLeaderId, leaderBId, "yes");
// Ineligible fixtures.
sideOpId = (
await makeMission("side-op", "side", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(sideOpId, memberId, "yes");
await addAttendance(sideOpId, leaderAId, "yes");
staleMainId = (
await makeMission(
"stale-main",
"main",
new Date(fixedNow.getTime() - 73 * HOUR).toISOString(),
)
).id;
await addAttendance(staleMainId, memberId, "yes");
await addAttendance(staleMainId, leaderAId, "yes");
// Created as Completed (the hook stamps the completion time), then the
// stamp is cleared: the reminder must fail closed on a missing timestamp.
const noStamp = await makeMission("no-stamp", "main");
noStampId = noStamp.id;
await payload.update({
collection: "missions",
id: noStampId,
data: { ownershipAndStatus: { completedAt: null } },
overrideAccess: true,
});
await addAttendance(noStampId, memberId, "yes");
await addAttendance(noStampId, leaderAId, "yes");
userNoId = (
await makeMission("user-no", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(userNoId, memberId, "no");
await addAttendance(userNoId, leaderAId, "yes");
leaderNoId = (
await makeMission("leader-no", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(leaderNoId, memberId, "yes");
await addAttendance(leaderNoId, strangerId, "yes"); // a non-leader "yes" must not count
// Boundary fixtures relative to fixedNow.
boundaryExactId = (
await makeMission(
"boundary-exact",
"main",
new Date(fixedNow.getTime() - 72 * HOUR).toISOString(),
)
).id;
await addAttendance(boundaryExactId, memberId, "yes");
await addAttendance(boundaryExactId, leaderAId, "yes");
boundaryPastId = (
await makeMission(
"boundary-past",
"main",
new Date(fixedNow.getTime() - 72 * HOUR - 1).toISOString(),
)
).id;
await addAttendance(boundaryPastId, memberId, "yes");
await addAttendance(boundaryPastId, leaderAId, "yes");
memberToken = await mintToken(memberId);
lonerToken = await mintToken(lonerId);
});
afterAll(async () => {
for (const id of attendanceIds) {
await payload
.delete({ collection: "mission-attendances", id, overrideAccess: true })
.catch(() => {});
}
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
for (const id of assignmentIds) {
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
}
// User deletion trips FK constraints unless the hook-provisioned personal
// bank account and profile are removed first.
for (const id of userIds) {
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
await payload
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
.catch(() => {});
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
});
it("rejects unauthenticated requests with 401", async () => {
const res = await GET(request());
expect(res.status).toBe(401);
});
it("returns only eligible missions with the yes-RSVP direct leaders", async () => {
const res = await GET(request(memberToken));
expect(res.status).toBe(200);
const body = (await res.json()) as { reminders: Array<Record<string, unknown>> };
const ids = body.reminders.map((reminder) => reminder.missionId as number);
// Exactly the two eligible missions — nothing else.
expect([...ids].sort()).toEqual([eligibleMainId, multiLeaderId].sort());
const byMissionId = new Map(
body.reminders.map((reminder) => [reminder.missionId as number, reminder]),
);
// Response shape: mission + leader identity only (no rater/comment data).
const eligible = byMissionId.get(eligibleMainId)!;
expect(Object.keys(eligible).sort()).toEqual([
"completedAt",
"leaders",
"missionCodeName",
"missionId",
"missionName",
]);
expect(eligible.missionName).toBe(`${RUN} eligible-main`);
expect(eligible.missionCodeName).toBe(`${RUN}-eligible-main`);
const leaders = eligible.leaders as Array<Record<string, unknown>>;
expect(leaders).toHaveLength(1);
expect(Object.keys(leaders[0]).sort()).toEqual(["displayName", "id", "username"]);
expect(leaders[0].id).toBe(leaderAId);
expect(leaders[0].username).toBe(`${RUN}-leader-a`);
expect(leaders[0].displayName).toBe("LEADER A");
// Both direct leaders who RSVP'd yes are reported.
const multi = byMissionId.get(multiLeaderId)!;
const multiLeaderIds = (multi.leaders as Array<{ id: number }>).map((leader) => leader.id);
expect([...multiLeaderIds].sort()).toEqual([leaderAId, leaderBId].sort());
});
it("returns nothing for a user without direct leaders", async () => {
const res = await GET(request(lonerToken));
expect(res.status).toBe(200);
const body = (await res.json()) as { reminders: unknown[] };
expect(body.reminders).toEqual([]);
});
it("treats the 72h window as inclusive and fails closed just past it", async () => {
const result = await getMissionReminders(payload, { id: memberId }, fixedNow);
const ids = result.map((reminder) => reminder.missionId);
expect(ids).toContain(boundaryExactId); // exactly now - 72h is still eligible
expect(ids).not.toContain(boundaryPastId); // one millisecond past the window is not
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { decodeTokenExp, getSessionExpMs } from "@/lib/session/token";
/** Build a JWT-shaped string with the given payload object. */
function makeToken(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.signature`;
}
describe("decodeTokenExp", () => {
it("returns the numeric exp claim from a well-formed token", () => {
const exp = 1_800_000_000;
expect(decodeTokenExp(makeToken({ sub: "1", exp }))).toBe(exp);
});
it("returns null when the token has no payload segment", () => {
expect(decodeTokenExp("header")).toBeNull();
expect(decodeTokenExp("")).toBeNull();
});
it("returns null when the payload is not valid base64 JSON", () => {
expect(decodeTokenExp("header.not-json.signature")).toBeNull();
});
it("returns null when exp is missing or not a finite number", () => {
expect(decodeTokenExp(makeToken({ sub: "1" }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: "soon" }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: null }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: Number.NaN }))).toBeNull();
});
it("handles URL-safe base64 (JWT padding) in the payload", () => {
// A payload whose base64 would contain - and _ after URL-safe encoding.
const payload = { exp: 1_800_000_000, data: "a+b/c=d" };
const body = btoa(JSON.stringify(payload)).replace(/\+/g, "-").replace(/\//g, "_");
const token = `header.${body}.signature`;
expect(decodeTokenExp(token)).toBe(1_800_000_000);
});
});
describe("getSessionExpMs", () => {
const exp = 1_800_000_000;
const token = makeToken({ sub: "1", exp });
it("returns the exp in milliseconds from the cookie header", () => {
const headers = { get: (name: string) => (name === "cookie" ? `payload-token=${token}` : null) };
expect(getSessionExpMs(headers)).toBe(exp * 1000);
});
it("returns null when there is no cookie header", () => {
const headers = { get: () => null };
expect(getSessionExpMs(headers)).toBeNull();
});
it("returns null when the payload-token cookie is absent", () => {
const headers = { get: () => "other=value" };
expect(getSessionExpMs(headers)).toBeNull();
});
it("parses the token among other cookies", () => {
const headers = { get: () => `foo=1; payload-token=${token}; bar=2` };
expect(getSessionExpMs(headers)).toBe(exp * 1000);
});
it("returns null when the token is malformed", () => {
const headers = { get: () => "payload-token=not-a-jwt" };
expect(getSessionExpMs(headers)).toBeNull();
});
});

View file

@ -12,8 +12,29 @@ let payload: Payload;
const RUN = `tk-${Date.now().toString(36)}`;
const makeUser = async (label: string, roles: NonNullable<User["roles"]> = ["user"]): Promise<User> => {
// getStaffUserIds resolves staff via the RBAC `roleDocs` relationship — the legacy
// `roles` select field is not synced at runtime, so tests must assign role docs.
const getRoleDocId = async (slug: string): Promise<number> => {
const existing = await payload.find({
collection: "roles",
where: { slug: { equals: slug } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs[0]) return existing.docs[0].id;
const created = await payload.create({
collection: "roles",
data: { name: slug, slug },
overrideAccess: true,
depth: 0,
});
return created.id;
};
const makeUser = async (label: string, roleSlugs: string[] = ["user"]): Promise<User> => {
const tag = `${RUN}-${label}`;
const roleDocs = await Promise.all(roleSlugs.map((slug) => getRoleDocId(slug)));
return (await payload.create({
collection: "users",
data: {
@ -22,7 +43,7 @@ const makeUser = async (label: string, roles: NonNullable<User["roles"]> = ["use
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles,
roleDocs,
},
overrideAccess: true,
depth: 0,
@ -93,6 +114,17 @@ describe("Tickets", () => {
for (const user of [reporter, assignee, staff]) {
if (!user) continue;
// profiles.user is NOT NULL — delete the auto-created profile before its user.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const profile of profiles.docs) {
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true });
}
await payload.delete({ collection: "users", id: user.id, overrideAccess: true });
}
});