`, `emitGameEvent` after mutation). Moderator = `hasIntelligenceQualification(payload, user)` (intel division members + admin/developer pass). Emits `wiki:page-create` / `wiki:page-edit` / `wiki:page-restore` / `wiki:page-lock` / `wiki:page-unlock` / `wiki:page-delete`.
- **Markdown rendering**: `react-markdown@10.1.0` + `remark-gfm@4.0.1` (GFM provides footnotes natively: `[^1]` marker + `[^1]:` definition) + `remark-directive@4.0.0` (layout/callout directives). The shared pipeline lives in `src/lib/wiki/markdown.tsx` (client-safe, no Payload imports): exports `wikiRemarkPlugins` (= `[remarkGfm, remarkDirective]`), `wikiRemarkRehypeOptions` (handlers mapping containerDirective/leafDirective → div and textDirective → span), `wikiMarkdownComponents`, and re-exports `ReactMarkdown`. Wired into both the server component `WikiContent` and the editor's live preview. No raw HTML (no rehype-raw). Images are URL-only `
` (no upload support yet). Captioned images `` render the caption as a hover `title` only, no figure/figcaption (deliberate: a figure inside a `` is invalid HTML and would cause a hydration mismatch). Two/three-column layout via `::::columns` / `:::column` / `::::` fences, the closing fence must use the same or more colons. `:::note` and `:::warning` callouts render as styled divs. Wikilinks `[[Page]]`/`[[Page|label]]` and `{{Template}}` expansion still run through `prepareMarkdown` before rendering.
- **Editor**: `WikiEditor` (`src/components/frontend/wiki/WikiEditor.tsx`): title/category/tags/edit-summary fields plus a markdown body with live preview (`prepareMarkdown` → react-markdown via the shared pipeline) and an "Insert wikilink" dialog (`WikiLinkDialog`, picks from existing pages at the cursor). The body (`WikiEditorBody.tsx`) sits under a formatting toolbar (`WikiEditorToolbar.tsx`): bold / italic / strikethrough / heading / inline code / code block / quote / bulleted list / numbered list / table, plus insert actions for wikilink, footnote, captioned image, 2-column, 3-column, and citation. Selection helpers live in `src/lib/wiki/editorFormatting.ts`. Keyboard shortcuts: Ctrl/Cmd+B bold, Ctrl/Cmd+I italic, Ctrl/Cmd+Shift+X strikethrough, Ctrl/Cmd+Shift+H heading, Ctrl/Cmd+K insert wikilink, Ctrl/Cmd+E inline code. An in-editor Markdown reference guide (`MarkdownGuide.tsx`) sits next to the live preview.
- **UI**: `src/app/(frontend)/wiki/`: `/wiki` index (`WikiIndex`: search, tag filter, category tabs, "New page"), `/wiki/new`, `/wiki/[slug]` (detail: `WikiContent` render + `WikiToolbar` with Edit/History/Lock/Delete; `LockdownBanner` when locked; missing pages show a "Create this page" CTA that prefills `?title=`), `/wiki/[slug]/edit` (redirects to `/wiki/new?title=` for missing pages, shows `LockdownBanner` when locked), `/wiki/[slug]/history` (`RevisionList` with type badges and moderator-only Restore). Tests: `tests/int/wiki.int.spec.ts`.
- **Event targets**: `wiki-pages`, `wiki-revisions`, `wiki-templates` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Wiki event types in `eventTypes.ts`.
- **Database**: migration batch 28 `src/migrations/20260904_230838_add_wiki_collections.ts`: tables `wiki_pages` (+ `_texts`), `wiki_revisions`, `wiki_templates`, plus the three wiki values added to the `game_event_logs` `target_collection` enum. Dev uses `bun run payload migrate` for schema changes (`push: false`).
- **Gotchas**: image uploads go through the `uploadWikiImage` server action in `src/app/(frontend)/wiki/actions.ts` (any logged-in user, image MIME only, 8 MB cap, stored in `media` with `read: () => true` so images are public; the editor toolbar's "Upload image" button inserts `` — requires `experimental.serverActions.bodySizeLimit` (10mb) in `next.config.mjs`); lockdown blocks edit/restore server-side (actions throw `"This page is locked and cannot be edited."`); restore/delete/lock are intelligence-qualification gated; `WikiRevisions` denies create/update/delete outright, so all revision writes go through the service layer with `overrideAccess`.
## Discord Bot
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), plus existing `APP_URL`. Logs through `payload.logger`.
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop) and `notificationBridge` (poll → Discord DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce` is guild-only.
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Ready`/`Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.
- **Notifications / announcements (feature 3)**: `notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick) and DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`). `/announce` (staff only) posts an announcement embed. New notify sites partially done: banking emits `finance:deposit`; shipments has no notify site yet.
- **Remaining polish**: the web preferences UI does not yet expose the `preferences.discord` toggles — they exist on the Users collection and are read by the bridge, but are currently only settable in the Payload admin panel.
## Auth
Username-based login (no email login). Users log in via Payload admin with `username` only.
- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`. Because the gate replaces `{children}` for guests, page-level `if (!user) redirect(...)` blocks under `(frontend)` are **unreachable dead code for guests** — they only serve as type-guards for the authed render path.
- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push(returnTo ?? "/")` + `router.refresh()`.
- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
## Database
PostgreSQL via `@payloadcms/db-postgres`. Schema defined in `src/payload-generated-schema.ts`. Migrations in `src/migrations/` (timestamp-named .ts + .json pairs, registered in `migrations/index.ts`). Drizzle config reads `DATABASE_URI` from env.
In development, `postgresAdapter` uses `push: false` — run `bun run payload migrate` after schema changes to apply them locally.
## Code style
- **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons.
- **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`).
- **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging.
## UI components
shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add ` to add new ones. Frontend components in `src/components/frontend/`. Payload admin custom components referenced in `payload.config.ts` under `admin.components`.
**Rule**: always prefer shadcn/ui components (Dialog, Button, Input, Textarea, Select, etc.) over raw HTML elements or custom-built alternatives. Only build new components when no existing shadcn component fits the need. Do not override shadcn's default dark theme classes with custom `bg-*` `border-*` overrides — the admin panel's theme handles styling.
## Environment
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
- `.env.example` — template. `DATABASE_URI` line still shows MongoDB (outdated — trust `.env.stg` for the real Postgres format), but `APP_URL` + `GAME_TICK_NOTIFY_SECRET` are current and required by the game tick.
- `.env.stg` — test/staging database
- `test.env` — NODE_OPTIONS for playwright (loaded by playwright config)
## Deploy
`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
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.stg` as the real reference.
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
- Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.
- Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`).
## Server Actions convention
Every `actions.ts` file follows the same pattern (10+ files use it):
1. `"use server"` directive at top
2. `import config from "@payload-config"` + `const payload = await getPayload({ config })`
3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()`
4. Return type `ActionResult`: `{ success: boolean; error?: string; data?: T }`
5. Permission check: `const { user } = await authenticate()` then `await hasPermission(payload, user, "domain:action")`
6. Mutation via `payload.create` / `payload.update` / `payload.delete`
7. Event emission: `await emitGameEvent(payload, { type: EventTypes.xxx, message, ... })`
8. Error handling: `catch (e) { return { success: false, error: e instanceof Error ? e.message : "Unknown error" } }`
The `authenticate()` function is **duplicated in every file** — not extracted to a shared helper. If you add a new server action, copy the pattern from an existing one (e.g., `src/app/(frontend)/logistics/banking/actions.ts`). Do NOT attempt to extract it to a shared helper unless the entire codebase migrates at once.