docs: add AGENTS.md subdirectories and login case study
Add domain-specific AGENTS.md files for collections, components, lib, utils, and bot. Add login-return-url case study documenting the return-URL flow design decisions and debugging lessons learned.
This commit is contained in:
parent
fb74e6b0c2
commit
a8e147e52a
7 changed files with 642 additions and 3 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -1,5 +1,12 @@
|
||||||
# AGENTS.md — Polaris Task Force
|
# AGENTS.md — Polaris Task Force
|
||||||
|
|
||||||
|
> **Sub-AGENTS.md files** (read these for domain-specific context):
|
||||||
|
> - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive
|
||||||
|
> - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph
|
||||||
|
> - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
|
||||||
|
> - `src/utils/AGENTS.md` — Access control layers, event log, utilities
|
||||||
|
> - `src/bot/AGENTS.md` — Discord bot architecture, commands, services
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend.
|
Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend.
|
||||||
|
|
@ -32,7 +39,7 @@ bun run generate:importmap # regenerates payload admin importMap
|
||||||
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
|
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
|
||||||
```
|
```
|
||||||
|
|
||||||
The typecheck should now pass clean (the two pre-existing errors in `hasLogisticsQualification.ts` and `payload-generated-schema.ts` were resolved by the Payload 3.88.0 upgrade). Any error is yours.
|
Known pre-existing errors (not yours, don't widen scope to fix them): `src/collections/users/Users.ts:57`, `src/tools/seed/backfillProfiles.ts:68`, `src/tools/seed/seedProfiles.ts:19` — all the same Payload profiles-create overload mismatch. Any **other** error introduced by your changes is yours.
|
||||||
|
|
||||||
## Test details
|
## Test details
|
||||||
|
|
||||||
|
|
@ -47,6 +54,27 @@ The typecheck should now pass clean (the two pre-existing errors in `hasLogistic
|
||||||
- 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.
|
- 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.
|
- Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting.
|
||||||
|
|
||||||
|
## Agent debugging SOP (read before fixing any bug)
|
||||||
|
|
||||||
|
These rules were extracted from a real multi-failure debugging session — the full story, including every wrong turn, is in `docs/case-studies/login-return-url.md`. Follow them literally; each one exists because skipping it produced a wrong fix.
|
||||||
|
|
||||||
|
1. **Trace the real render path before writing any fix.** State in your reply: which layout wraps the failing route, what each conditional renders, and which component actually owns the navigation or mutation you're changing. If a layout conditionally replaces `{children}` (the `(frontend)` guest gate renders `<LandingPage />` instead of the page), then code inside those children — including `redirect()` calls — is **dead code for that branch** and never runs.
|
||||||
|
2. **Treat every framework API as a hypothesis.** Before calling a header, hook, or helper, confirm it exists and returns what you expect — against the running app or current docs. An observed default value (e.g. `?next=%2F` when you expected `%2Fflappy`) means the data source was **empty** and your fallback leaked through — not that the data got mangled.
|
||||||
|
3. **Two failed fixes = your mental model is wrong.** Do not add a third fallback layer on top of a failing approach. Stop editing, re-read the flow, find the wrong assumption.
|
||||||
|
4. **Ask "which component actually knows this fact?"** The current URL is known client-side (`usePathname()`), not in server layouts. Attach data where it is known, at the point the navigation happens — not where it is merely convenient to compute.
|
||||||
|
5. **Match producer and consumer.** If you emit a query param (`next`), confirm the consumer reads that exact name (`returnTo`). Mismatches fail silently.
|
||||||
|
6. **Verify end-to-end before reporting done.** curl the failing route as a guest, follow the redirect, hit the API, check the authed route (a copy-pasteable matrix is in the case study). "Should work" is not verification.
|
||||||
|
7. **Restate before acting.** For non-trivial changes, output: assumptions → plan → verification command. Then implement.
|
||||||
|
|
||||||
|
## Next.js 16 hard rules
|
||||||
|
|
||||||
|
Break these and you get runtime errors or silently dead code:
|
||||||
|
|
||||||
|
- **`searchParams` and `params` props are Promises** in pages/layouts. `await` them before any property access. Error if violated: ``Route used `searchParams.x`. `searchParams` is a Promise and must be unwrapped with `await` or `React.use()```.
|
||||||
|
- **Server components (layouts/pages) cannot mutate cookies.** `cookies()` from `next/headers` is read-only there; calling `.set()` throws `Cookies can only be modified in a Server Action or Route Handler`. Writing cookies is only legal in Server Actions and Route Handlers.
|
||||||
|
- **`headers.get("x-invoke-path")` does not reliably contain the current pathname** in layouts. Never build redirect logic on it. Reliable sources of the current path: `usePathname()` (client components) and the request object (middleware / Route Handlers).
|
||||||
|
- **`redirect()` narrows poorly across control flow.** After an `if (!user) redirect(...)` early exit, TypeScript may still see `user` as nullable when the narrowing crosses a closure boundary — keep an explicit truthy branch around later `user` usage.
|
||||||
|
- **Sanitize user-controllable redirect targets** (open-redirect guard): accept only values starting with `/` and reject `//` (protocol-relative URLs). Working example: `safeReturnTo` in `src/app/login/page.tsx`.
|
||||||
|
|
||||||
## Generated files — never edit manually
|
## Generated files — never edit manually
|
||||||
|
|
||||||
|
|
@ -228,8 +256,9 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
|
||||||
|
|
||||||
Username-based login (no email login). Users log in via Payload admin with `username` only.
|
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`.
|
- `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("/")` + `router.refresh()`. The page redirects already-authed users to `/`.
|
- `/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=<current path>`. 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`.
|
- 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`.
|
- 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`.
|
||||||
|
|
||||||
|
|
@ -264,6 +293,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
|
|
||||||
## Gotchas
|
## 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.test` as the real reference.
|
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference.
|
||||||
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
|
- `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.
|
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
|
||||||
|
|
@ -271,3 +301,17 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
- Payload admin layout and importMap are auto-generated — do not edit by hand.
|
- Payload admin layout and importMap are auto-generated — do not edit by hand.
|
||||||
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
|
- `.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 start the dev server, whose Payload `push: true` path may skip the offending table.
|
- `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 start the dev server, whose Payload `push: true` path may skip the offending table.
|
||||||
|
|
||||||
|
## 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<T>`: `{ 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.
|
||||||
|
|
|
||||||
238
docs/case-studies/login-return-url.md
Normal file
238
docs/case-studies/login-return-url.md
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
# Case study: preserving the return URL through login
|
||||||
|
|
||||||
|
How a "remember where I was going" feature was implemented (and mis-implemented three times
|
||||||
|
before that) in this repo. Written for agents — especially smaller local models — as a pattern
|
||||||
|
to copy and a set of anti-patterns to recognize early. Every wrong turn below was really taken;
|
||||||
|
the point of writing it down is that each failure had a *signal* that said "stop, your model is
|
||||||
|
wrong" long before anyone listened to it.
|
||||||
|
|
||||||
|
The distilled rules live in `AGENTS.md` → "Agent debugging SOP" and "Next.js 16 hard rules".
|
||||||
|
This document is the evidence behind them.
|
||||||
|
|
||||||
|
## The task
|
||||||
|
|
||||||
|
Unauthenticated users who navigate to a protected page (e.g. `/flappy`) should, after logging
|
||||||
|
in, land back on that page instead of the dashboard.
|
||||||
|
|
||||||
|
## The symptom (user report #1)
|
||||||
|
|
||||||
|
> I just logged out, went to `/flappy`, found the landing page and was redirected to `/login`
|
||||||
|
> (no url params!), logged in, and was brought to the dashboard instead of back to Flappy.
|
||||||
|
|
||||||
|
## The failure arc
|
||||||
|
|
||||||
|
### Attempt 1 — redirect from the page: never runs
|
||||||
|
|
||||||
|
**What was done:** the login page and `LoginForm` were taught to read a `returnTo` query
|
||||||
|
param, and the protected pages (`flappy`, `helpdesk`) got guest blocks like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!user) {
|
||||||
|
const pathname = headers.get("x-invoke-path") || "/flappy";
|
||||||
|
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it seemed reasonable:** pages own their auth checks; `redirect()` is the canonical
|
||||||
|
Next.js way to bounce unauthenticated users.
|
||||||
|
|
||||||
|
**What actually happened:** no query param appeared at all. The user landed on `/login` bare
|
||||||
|
and went to `/` after login.
|
||||||
|
|
||||||
|
**Why it failed (two independent reasons):**
|
||||||
|
|
||||||
|
1. **The page never rendered.** `src/app/(frontend)/layout.tsx` is the real gate, and for
|
||||||
|
guests it *replaces* `{children}`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{user ? (
|
||||||
|
<SidebarProvider>… {children} …</SidebarProvider>
|
||||||
|
) : (
|
||||||
|
<LandingPage /> // ← guests never reach {children}
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `if (!user) redirect(...)` inside a page under `(frontend)` is **dead code for
|
||||||
|
guests**. The `redirect()` never executes because the page component never renders.
|
||||||
|
2. **Producer/consumer mismatch.** The pages emitted `?next=…`; the login page read
|
||||||
|
`searchParams.returnTo`. Even if the redirect had fired, the param would have been ignored.
|
||||||
|
Query-param mismatches fail silently — there is no error, just nothing.
|
||||||
|
|
||||||
|
**The signal that was missed:** the user said "I found the landing page" — the landing page
|
||||||
|
rendering *at all* proves the page body (and its redirect) never ran. That was the clue.
|
||||||
|
|
||||||
|
### Attempt 2 — move the redirect into the layout: `?next=%2F`
|
||||||
|
|
||||||
|
**What was done:** the same `x-invoke-path` redirect logic was moved into the layout, firing
|
||||||
|
for every guest.
|
||||||
|
|
||||||
|
**What actually happened (user report #2):**
|
||||||
|
|
||||||
|
> I did see `?next=` appear, but it was actually `?next=%2F` despite expecting `?next=/flappy`.
|
||||||
|
|
||||||
|
**Why it failed:** `headers.get("x-invoke-path")` does **not** reliably contain the current
|
||||||
|
pathname in Next.js 16 layouts — here it returned `/` (or nothing, hitting the `|| "/"`
|
||||||
|
fallback). Read the symptom the right way: `%2F` is not a mangled `/flappy`; it is the
|
||||||
|
**default value leaking through**. When you observe a default instead of your data, the data
|
||||||
|
source was empty. Mangling was never on the table.
|
||||||
|
|
||||||
|
**Bonus failure:** this also silently changed product behavior — guests lost the landing page
|
||||||
|
entirely and got an instant redirect instead. The task never asked for that.
|
||||||
|
|
||||||
|
### Attempt 3 — store the path in a cookie: runtime error
|
||||||
|
|
||||||
|
**What was done:** since the layout couldn't put the path in the URL, it tried to stash it in
|
||||||
|
a cookie: `cookies().set("loginRedirect", pathname, …)` in the layout body, then
|
||||||
|
`redirect("/login")`.
|
||||||
|
|
||||||
|
**What actually happened (user report #3):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Cookies can only be modified in a Server Action or Route Handler.
|
||||||
|
at RootLayout (src/app/(frontend)/layout.tsx:68:16)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it failed:** in Next.js App Router, server components (layouts/pages) **cannot mutate
|
||||||
|
cookies**. `cookies()` from `next/headers` is read-only there. Writes are only legal in Server
|
||||||
|
Actions and Route Handlers. The framework error message is precise and correct — read it
|
||||||
|
literally instead of routing around it.
|
||||||
|
|
||||||
|
### Attempt 4 — referer fallback: complexity accretion
|
||||||
|
|
||||||
|
**What was done:** with `x-invoke-path` proven useless, a `referer`-header fallback was added
|
||||||
|
on top of the cookie approach (and then a second, duplicated copy of the same fallback block).
|
||||||
|
|
||||||
|
**Why it failed:** `referer` is empty on direct navigation (typing a URL), and when present it
|
||||||
|
points at the *previous* page, not the requested one. It cannot answer this question by
|
||||||
|
construction. This attempt also demonstrates the worst failure pattern of the whole session:
|
||||||
|
**when a fix fails, adding a fallback on top of it preserves the wrong assumption and adds
|
||||||
|
code.** By now there were three mechanisms layered (header → cookie → referer), none of which
|
||||||
|
could work, and duplicated code on top.
|
||||||
|
|
||||||
|
**This is the point where the correct move was:** stop editing. Two-plus failed fixes means
|
||||||
|
the mental model is wrong, not the implementation.
|
||||||
|
|
||||||
|
## The turn: trace the render path
|
||||||
|
|
||||||
|
Re-reading the flow instead of patching it produced the key facts:
|
||||||
|
|
||||||
|
1. Guests never reach page code — the layout's `LandingPage` branch is the whole guest
|
||||||
|
experience. So the path can only be captured **where the guest actually is**: inside the
|
||||||
|
landing page.
|
||||||
|
2. The landing page's CTA was a plain `<Link href="/login">` — a **static** link with no
|
||||||
|
knowledge of where the user is standing. *That* is the navigation point the whole feature
|
||||||
|
hangs on, and it is the thing that should carry the path.
|
||||||
|
3. Server components cannot reliably know the current path (`x-invoke-path` unreliable, no
|
||||||
|
request URL in layouts). The current path **is** reliably known by `usePathname()` in
|
||||||
|
client components — exactly at the point where the user clicks "log in".
|
||||||
|
|
||||||
|
Rule of thumb that falls out: **attach data where the fact is known, at the point the
|
||||||
|
navigation happens — not where it is convenient to compute.**
|
||||||
|
|
||||||
|
## The fix (4 small changes)
|
||||||
|
|
||||||
|
1. **`src/components/frontend/auth/LoginLink.tsx`** (new, ~8 lines) — a client component that
|
||||||
|
knows the current path and builds the link:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function LoginLink(props: Omit<ComponentProps<typeof Link>, "href">) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return <Link {...props} href={`/login?returnTo=${encodeURIComponent(pathname)}`} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `<Link href="/login">` for
|
||||||
|
`<LoginLink>`. A guest at `/flappy` now gets `href="/login?returnTo=%2Fflappy"`.
|
||||||
|
3. **`src/app/login/page.tsx`** — awaits `searchParams` (Next 16: it's a Promise), sanitizes
|
||||||
|
the target against open redirects, sends already-authed users straight to their target:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function safeReturnTo(value: string | undefined): string | undefined {
|
||||||
|
if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: {
|
||||||
|
searchParams: Promise<{ returnTo?: string }>;
|
||||||
|
}) {
|
||||||
|
const { returnTo } = await searchParams;
|
||||||
|
const target = safeReturnTo(returnTo);
|
||||||
|
// …
|
||||||
|
if (user) redirect(target ?? "/");
|
||||||
|
return <LoginForm returnTo={target} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **`src/components/frontend/auth/LoginForm.tsx`** — after a successful login POST,
|
||||||
|
`router.push(returnTo ?? "/")`. (Also: dropped `returnTo` from the POST body — the API
|
||||||
|
never read it; the redirect is purely client-side.)
|
||||||
|
|
||||||
|
And **reverted** the layout to its original guest flow, plus removed the dead page-level
|
||||||
|
redirect blocks' reliance on `x-invoke-path`. Net result: less code than the failing versions.
|
||||||
|
|
||||||
|
## The verification matrix
|
||||||
|
|
||||||
|
Run against a live dev server with the dev user (`dev` / `Test123`). Copy-pasteable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Guest hits the protected route — landing page renders, CTA carries the path
|
||||||
|
curl -s http://localhost:3000/flappy | grep -o 'href="/login?returnTo=[^"]*"'
|
||||||
|
# expect: href="/login?returnTo=%2Fflappy"
|
||||||
|
|
||||||
|
# 2. Login page passes returnTo through to the form
|
||||||
|
curl -s "http://localhost:3000/login?returnTo=%2Fflappy" | grep -o '%2Fflappy'
|
||||||
|
# expect: %2Fflappy (present in the RSC payload)
|
||||||
|
|
||||||
|
# 3. Login API sets the auth cookie
|
||||||
|
curl -s -X POST http://localhost:3000/api/users/login \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"username":"dev","password":"Test123"}' -c /tmp/cookies.txt -o /dev/null -w "%{http_code}\n"
|
||||||
|
# expect: 200
|
||||||
|
|
||||||
|
# 4. Authed user reaches the protected page
|
||||||
|
curl -s -b /tmp/cookies.txt http://localhost:3000/flappy -o /dev/null -w "%{http_code}\n"
|
||||||
|
# expect: 200
|
||||||
|
|
||||||
|
# 5. Authed user hitting /login?returnTo=... bounces straight to the target
|
||||||
|
curl -s -b /tmp/cookies.txt -o /dev/null \
|
||||||
|
-w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/login?returnTo=%2Fflappy"
|
||||||
|
# expect: 307 -> http://localhost:3000/flappy
|
||||||
|
```
|
||||||
|
|
||||||
|
All five passed against the running dev server before the work was reported done. Note the
|
||||||
|
dev-server subtlety encountered along the way: a spawned `bun run dev` detected an
|
||||||
|
already-running server, exited, and the curls actually hit the *existing* hot-reloaded server —
|
||||||
|
which is fine (that's the surface the user sees), but know which process you're testing
|
||||||
|
against. Check the dev log for "Another next dev server is already running" and its PID.
|
||||||
|
|
||||||
|
## Signals you are on the wrong path (recognize these early)
|
||||||
|
|
||||||
|
- **A default value shows up instead of your data** (`?next=%2F`). The data source is empty.
|
||||||
|
Find out why it's empty — don't post-process the value.
|
||||||
|
- **The component you're editing never renders** for the scenario you're fixing (guests and
|
||||||
|
`{children}` replacement). Verify by asking what the user *saw* — if they saw the landing
|
||||||
|
page, page code didn't run.
|
||||||
|
- **A framework error message names a restriction** ("Cookies can only be modified in a Server
|
||||||
|
Action or Route Handler"). It is stating a rule, not a bug. Restructure to comply; don't
|
||||||
|
fight it.
|
||||||
|
- **You're adding a second or third fallback.** Each fallback is an admission the previous
|
||||||
|
model was wrong — while keeping it. Stop and re-derive instead.
|
||||||
|
- **The fix changes behavior the task never asked about** (landing page disappears). Scope
|
||||||
|
creep during a bug fix is a sign the approach is wrong, not a bonus.
|
||||||
|
|
||||||
|
## The rules (mirror of the AGENTS.md SOP)
|
||||||
|
|
||||||
|
1. Trace the real render path before writing any fix.
|
||||||
|
2. Treat every framework API as a hypothesis; verify it.
|
||||||
|
3. Two failed fixes = wrong mental model. Stop, re-read, find the wrong assumption.
|
||||||
|
4. Attach data where the fact is known (`usePathname()` client-side), at the point of
|
||||||
|
navigation.
|
||||||
|
5. Match producer and consumer (`next` vs `returnTo` fails silently).
|
||||||
|
6. Verify end-to-end (curl matrix above) before reporting done.
|
||||||
|
7. Restate before acting: assumptions → plan → verification command.
|
||||||
69
src/bot/AGENTS.md
Normal file
69
src/bot/AGENTS.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# AGENTS.md — Discord Bot
|
||||||
|
|
||||||
|
> **Parent**: `../../AGENTS.md` — env vars (`DISCORD_TOKEN`, `DISCORD_GUILD_ID`), deployment, Payload config.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Standalone long-running process (`bun run bot`). Imports `@payload-config` directly, shares PostgreSQL with web app. Under active development.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
index.ts # Entry point
|
||||||
|
config.ts # Env validation (fail-fast on missing required vars)
|
||||||
|
|
||||||
|
commands/
|
||||||
|
index.ts # Command registry (global vs guild scope)
|
||||||
|
ping.ts # /ping (global, DM-usable)
|
||||||
|
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
||||||
|
link.ts # /link (global, links discordId to existing user)
|
||||||
|
announce.ts # /announce (guild-only, staff only)
|
||||||
|
|
||||||
|
events/
|
||||||
|
interactionCreate.ts # Routes ptf-att: RSVP button interactions
|
||||||
|
|
||||||
|
services/
|
||||||
|
index.ts # Service registry
|
||||||
|
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
|
||||||
|
notificationBridge.ts # Poll user-notifications → Discord DMs
|
||||||
|
signup.ts # Signup service logic
|
||||||
|
|
||||||
|
lib/
|
||||||
|
roles.ts # isStaff check
|
||||||
|
resolve.ts # discordId ↔ Payload user lookups
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command registration scope
|
||||||
|
|
||||||
|
- **Global** (DM-usable): `signup`, `link`, `ping`
|
||||||
|
- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
|
||||||
|
|
||||||
|
## Feature flow: signup/link
|
||||||
|
|
||||||
|
`/signup` is DM-only. Creates Payload user with `username` = `discordUsername` = caller's Discord username. Ephemeral reply carries temp password (guaranteed delivery path); `interaction.user.send()` is best-effort persistent copy. `/link` works in servers and DMs — matches `discordUsername` → sets `discordId`.
|
||||||
|
|
||||||
|
## Feature flow: attendance
|
||||||
|
|
||||||
|
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). Web UI: `MissionAttendance` component.
|
||||||
|
|
||||||
|
## Feature flow: notifications
|
||||||
|
|
||||||
|
`notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick). DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`).
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Task | Path |
|
||||||
|
|------|------|
|
||||||
|
| Add new slash command | `bot/commands/<name>.ts` + register in `bot/commands/index.ts` |
|
||||||
|
| Add button interaction | `bot/events/interactionCreate.ts` |
|
||||||
|
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
|
||||||
|
| Change DM bridging | `bot/services/notificationBridge.ts` |
|
||||||
|
| User lookup patterns | `bot/lib/resolve.ts` |
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **NEVER** run bot and web app with separate database connections without connection pooling — they share PostgreSQL
|
||||||
|
- **NEVER** register guild-only commands if the command needs to work in DMs (signup, link, ping must be global)
|
||||||
|
- **NEVER** assume DM delivery succeeded — `/signup` uses ephemeral reply as primary delivery path
|
||||||
|
- **NEVER** modify `discordId` directly in Payload admin — use the `/link` command or the resolve helper
|
||||||
81
src/collections/AGENTS.md
Normal file
81
src/collections/AGENTS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# AGENTS.md — Payload Collections
|
||||||
|
|
||||||
|
> **Parent**: `../../AGENTS.md` — commands, RBAC basics, Payload CMS config.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
36 collections across 12 domain groups. Access control defined in `src/permissions/index.ts` (616 lines, 100+ permissions across 25 groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
collections/
|
||||||
|
Media.ts # Generic media upload
|
||||||
|
Shims.ts # Global: CSS/JS shims (admin-only)
|
||||||
|
|
||||||
|
users/ 9 files # Users (auth), Ranks, Profiles, Awards,
|
||||||
|
# Qualifications, Assignments, Experience,
|
||||||
|
# Roles (dynamic RBAC), UserNotifications
|
||||||
|
intelligence/ 5 files # Missions, MissionAttendances, Campaigns,
|
||||||
|
# Factions, Technologies
|
||||||
|
logistics/ 5 files # Structures, Resources, Assets, Vehicles, Shipments
|
||||||
|
banking/ 3 files # BankAccounts, BankTransactions, LedgerEntries
|
||||||
|
market/ 2 files # MarketListings, MarketNegotiations
|
||||||
|
locker/ 2 files # LockerStorages, Loadouts
|
||||||
|
game/ 6 files # GameRules (global), GameStructures, GameVehicles,
|
||||||
|
# GameNpcs, GameHardResources, GameEventLogs
|
||||||
|
server/ 2 files # MissionFiles, ModLists
|
||||||
|
world/ 2 files # Maps, NarrativeEvents (React Flow editor)
|
||||||
|
tickets/ 1 file # Tickets (Lexical rich text)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
Users ──┬── Profiles ──┬── Qualifications
|
||||||
|
│ ├── Awards
|
||||||
|
│ ├── Assignments ── Ranks
|
||||||
|
│ └── Experience
|
||||||
|
├── UserNotifications
|
||||||
|
└── BankAccounts ── BankTransactions ── LedgerEntries
|
||||||
|
|
||||||
|
GameStructures ── Structures (template) ── Resources/Assets/Vehicles
|
||||||
|
GameVehicles ── Vehicles (template)
|
||||||
|
GameNpcs ── MarketListings ── MarketNegotiations
|
||||||
|
MissionAttendances ── Missions ── Campaigns
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access control pattern
|
||||||
|
|
||||||
|
Collections define `access` at field + document level using helpers from `src/utils/access-control/`:
|
||||||
|
- `isRole(role)` — single role check
|
||||||
|
- `hasRoles(roles[])` — any-of role check
|
||||||
|
- `hasPermission(payload, user, "collection:action")` — full RBAC (cached 30s)
|
||||||
|
- `hasLogisticsQualification()` / `hasIntelligenceQualification()` — queries Profiles
|
||||||
|
|
||||||
|
Admin group access: `developer` only for destructive operations, `admin` for read/write.
|
||||||
|
|
||||||
|
## Hooks with side effects
|
||||||
|
|
||||||
|
- `Structures` `beforeChange`: emits `structure:resize`
|
||||||
|
- `GameStructures` `afterChange`: emits storage edit events
|
||||||
|
- `Users` `afterChange`: maintains profile sync
|
||||||
|
- `BankTransactions` `beforeValidate`: auto-generates `transactionNumber`
|
||||||
|
- `MarketNegotiations`: patience meter enforcement
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Task | Path |
|
||||||
|
|------|------|
|
||||||
|
| Add a new collection | Create `<Name>.ts` here, register in `src/payload.config.ts` |
|
||||||
|
| Add RBAC permission | `src/permissions/index.ts` (add to group + add check) |
|
||||||
|
| Modify collection access | `<collection>/access.ts` or inline in collection file |
|
||||||
|
| Add field hook | Inline in collection definition (beforeChange/afterChange) |
|
||||||
|
| Relationship graph | See key relationships above; Payload manages FK constraints |
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **NEVER** edit `src/payload-types.ts` or `src/payload-generated-schema.ts` — run `bun run generate:types` instead
|
||||||
|
- **NEVER** add `access` functions that call `payload.auth()` without handling the null user case
|
||||||
|
- **NEVER** use `payload.create` in `afterChange` hooks without checking for infinite loops
|
||||||
|
- **NEVER** mix `overrideAccess: true` without a permission check — always gate behind RBAC first
|
||||||
65
src/components/frontend/AGENTS.md
Normal file
65
src/components/frontend/AGENTS.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# AGENTS.md — Frontend Components
|
||||||
|
|
||||||
|
> **Parent**: `../../AGENTS.md` — commands, auth flow, Next.js 16 rules, deployment.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
100+ client components organized by domain. The `(frontend)` layout renders guests `LandingPage` (no app shell); authed users get `AppSidebar` + `SiteHeader` + `CommandPalette`.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
components/frontend/
|
||||||
|
auth/ 2 files LoginForm, LoginLink (returnTo via usePathname)
|
||||||
|
account/ 4 files Profile, password, preferences, Discord link
|
||||||
|
banking/ 9 files Account cards, ledger, deposit/withdraw/transfer dialogs
|
||||||
|
blocks/ 6 files AppSidebar, NavCore, NavUser, XPDisplay
|
||||||
|
dashboard/ 6 files ProfileSummary, QuickStats, MissionBriefing, RecentEvents
|
||||||
|
flappy/ 4 files Canvas game, leaderboard, sounds
|
||||||
|
helpdesk/ 6 files Ticket list/detail, create dialog, timeline
|
||||||
|
intelligence/ 13 files Mission cards/attendance/comms, campaign/faction cards
|
||||||
|
locker/ 12 files Grid, equipment editor, loadouts, wardrobe
|
||||||
|
logistics/ 8 files Shipment cards/actions, toast notifications
|
||||||
|
market/ 10 files Listing cards, negotiation flow, NPC chat, patience meter
|
||||||
|
notifications/ 2 files Bell (polling), inbox page
|
||||||
|
realtime/ 1 file GameTickRealtime (SSE -> router.refresh)
|
||||||
|
roster/ 1 file Org chart view
|
||||||
|
storage/ 14 files Structure grid, storage dialogs, event ledger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Server → Client data flow
|
||||||
|
Pages are server components that fetch via `getPayload()`, then pass data as props to client components. Client components receive typed props and never call Payload directly.
|
||||||
|
|
||||||
|
### Item compound component
|
||||||
|
`storage/` uses a compound `<Item>` component for grid cells with slots: `<Item.Slot name="icon">`, `<Item.Slot name="stats">`, `<Item.Menu>`. Attachments render as stacked badges on the grid cell; popover shows full detail on hover.
|
||||||
|
|
||||||
|
### SSE consumers
|
||||||
|
- `GameTickRealtime` mounts in `(frontend)/layout.tsx` for all authed users.
|
||||||
|
- `ShipmentToasts` mounts only for logistics-qualified users.
|
||||||
|
- Both use `useGameTick()` hook (custom `ptf:game-tick` event dispatch).
|
||||||
|
|
||||||
|
### Nested dialogs
|
||||||
|
Market negotiation uses `MakeOfferDialog` nested inside `ListingCard` dialog. Loadout editor uses `EquipmentEditorDialog` nested inside locker grid. When a parent dialog unmounts (e.g., sold listing), the child stays visible for 3.5s minimum via `useNow()` hook for readability before refresh.
|
||||||
|
|
||||||
|
### Route anomalies
|
||||||
|
`logistics/vehicles/VehiclesList.tsx` is a client component placed directly in the route directory (not under `components/frontend/`). This is the only route with an inline component file.
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Task | Path |
|
||||||
|
|------|------|
|
||||||
|
| Add a new page | `src/app/(frontend)/<domain>/page.tsx` + create client component here |
|
||||||
|
| Add nav entry | `src/components/frontend/blocks/NavCore.tsx` |
|
||||||
|
| Modify auth gate | `src/app/(frontend)/layout.tsx` (conditional renders `LandingPage` or shell) |
|
||||||
|
| Add SSE consumer | `src/hooks/useGameTick.ts` + mount in layout |
|
||||||
|
| NPC dialogue/chatter | `src/lib/market/npcDialogue.ts` (pure, client-safe import) |
|
||||||
|
| Keyboard shortcuts | `src/components/command-palette/` |
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **NEVER** call `getPayload()` in client components — fetch in server page, pass as props
|
||||||
|
- **NEVER** add `useRouter().refresh()` in SSE consumers without a guard — use the `useGameTick()` hook
|
||||||
|
- **NEVER** use shadcn defaults for dark theme — the admin panel's theme handles styling
|
||||||
|
- **NEVER** place server-side logic (hooks, Payload calls) in `"use client"` files
|
||||||
85
src/lib/AGENTS.md
Normal file
85
src/lib/AGENTS.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# AGENTS.md — Shared Business Logic
|
||||||
|
|
||||||
|
> **Parent**: `../../AGENTS.md` — Payload config, server actions pattern, env vars.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
23 files across 8 domain subdirectories. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
utils.ts # cn() class merger (Tailwind)
|
||||||
|
storageRules.ts # Storage validation (prohibited → whitelist → per-item cap)
|
||||||
|
shipping.ts # Fuel cost, transit time, vehicle effective speed
|
||||||
|
distance.ts # Haversine distance calculation
|
||||||
|
logistics.ts # Shared logistics helpers
|
||||||
|
versionInfo.ts # Build version display
|
||||||
|
|
||||||
|
attendance/ # Mission attendance service (single write path)
|
||||||
|
banking/ # Transaction engine, account creation, formatting
|
||||||
|
locker/ # Grid logic, placement validation, loadouts
|
||||||
|
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
|
||||||
|
notifications/ # User notification helper + muteable types
|
||||||
|
realtime/ # In-process SSE bus (single-instance only)
|
||||||
|
tickets/ # Ticket vocabulary, Lexical helpers, staff resolution
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency graph
|
||||||
|
|
||||||
|
```
|
||||||
|
Server actions ──┬── storageRules.ts
|
||||||
|
├── lib/banking/index.ts ──┬── format.ts
|
||||||
|
│ └── ui.ts
|
||||||
|
├── lib/market/index.ts ──┬── negotiations.ts
|
||||||
|
│ ├── npcs.ts
|
||||||
|
│ ├── npcDialogue.ts (pure, client-safe)
|
||||||
|
│ └── chatBubbles.ts (pure, client-safe)
|
||||||
|
├── lib/locker/index.ts ── search.ts
|
||||||
|
├── lib/attendance/index.ts
|
||||||
|
├── lib/notifications/index.ts
|
||||||
|
└── lib/tickets/
|
||||||
|
|
||||||
|
game-tick script ── shipping.ts, distance.ts, storageRules.ts
|
||||||
|
market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vendor logic)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pure vs Payload-dependent
|
||||||
|
|
||||||
|
- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `storageRules.ts` (logic only)
|
||||||
|
- **Payload-dependent** (import `@payload-config`, server-only): everything else
|
||||||
|
|
||||||
|
## Key modules
|
||||||
|
|
||||||
|
### `storageRules.ts`
|
||||||
|
Enforcement order: **prohibited → whitelist → per-item cap**. Functions: `checkStorageDeposit`, `isResourceProhibited`, `isResourceWhitelisted`, `getResourceStorageCap`, `storageViolationMessage`. Used in structure actions, shipment creation, and arrival processing.
|
||||||
|
|
||||||
|
### `banking/index.ts`
|
||||||
|
`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency.
|
||||||
|
|
||||||
|
### `market/index.ts`
|
||||||
|
`buyListing()` — validates, debits buyer, credits locker, marks sold. Supports partial buys via `quantity` parameter. `creditLockerQuantity()` — merges stackables or fills empty grid spots; throws if no space.
|
||||||
|
|
||||||
|
### `market/negotiations.ts`
|
||||||
|
NPC vendor pricing engine: stance starts at asking price, only moves down. Offers ≥ stance accepted; within 2% with 85% chance; well-below draw concession (30% of gap). Patience meter increments per round, closes at cap.
|
||||||
|
|
||||||
|
### `realtime/bus.ts`
|
||||||
|
Module-level subscriber Set. SSE endpoint `GET /api/realtime` subscribes; game tick POST `/api/game-tick/notify` broadcasts. **Single-instance only** — will not work across multiple server processes.
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Task | Path |
|
||||||
|
|------|------|
|
||||||
|
| Add storage rule logic | `storageRules.ts` (pure functions) |
|
||||||
|
| Modify transaction flow | `lib/banking/index.ts` — `applyTransaction()` |
|
||||||
|
| Change NPC pricing | `lib/market/negotiations.ts` — NPC_ACCEPT constants |
|
||||||
|
| Add notification type | `lib/notifications/notificationTypes.ts` |
|
||||||
|
| Add pure client+server logic | Verify no Payload import; place in appropriate domain dir |
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **NEVER** import `@payload-config` in files under `market/npcDialogue.ts` or `market/chatBubbles.ts` — they must stay client-safe
|
||||||
|
- **NEVER** duplicate business logic in server actions — always delegate to `lib/{domain}/`
|
||||||
|
- **NEVER** use module-level state in `realtime/bus.ts` for cross-instance scenarios — use external pub/sub (Redis) instead
|
||||||
|
- **NEVER** mutate `storageRules.ts` enforcement order without updating all 3 call sites (structure actions, shipment creation, arrival processing)
|
||||||
57
src/utils/AGENTS.md
Normal file
57
src/utils/AGENTS.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# AGENTS.md — Utilities & Access Control
|
||||||
|
|
||||||
|
> **Parent**: `../../AGENTS.md` — RBAC overview, permission groups, event system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
10 files across 3 subdirectories. Core cross-cutting concerns: three-layer RBAC, fire-and-forget event logging, XP resolution.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
utils/
|
||||||
|
access-control/ 6 files Permission checking, role gates, qualification queries
|
||||||
|
event-log/ 3 files Event emitter, type constants, formatting
|
||||||
|
xp/ 1 file Level resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access control layers
|
||||||
|
|
||||||
|
Three independent access mechanisms, each used in different contexts:
|
||||||
|
|
||||||
|
### 1. `hasPermission(payload, user, "domain:action")`
|
||||||
|
Full RBAC check against `src/permissions/index.ts` (100+ permissions). Superuser bypass. Cached 30s via `loadUserPermissions`. Used in server actions and collection access functions.
|
||||||
|
|
||||||
|
### 2. `isRole(role)` / `hasRoles(roles[])`
|
||||||
|
Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("admin")` for admin-only UI). No Payload call — reads from the user object directly.
|
||||||
|
|
||||||
|
### 3. `hasLogisticsQualification()` / `hasIntelligenceQualification()`
|
||||||
|
Queries `Profiles.progression.qualifications` for specific qualification strings (case-insensitive). Admin/developer always pass. Used to gate logistics-only and intelligence-only UI sections.
|
||||||
|
|
||||||
|
## Event log system
|
||||||
|
|
||||||
|
### Emitter: `emitGameEvent(payload, { type, message, ... })`
|
||||||
|
Fire-and-forget create on `game-event-logs`. Always sets `system: true`. Silent error catch (no throw). Always called AFTER successful mutation in server actions.
|
||||||
|
|
||||||
|
### Event types: `EventTypes` constants (95 types across 12 categories)
|
||||||
|
Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories: `mission:*`, `finance:*`, `market:*`, `structure:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `attendance:*`, `system:*`.
|
||||||
|
|
||||||
|
### Display: `formatType(type)` — human-readable label for event types.
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
| Task | Path |
|
||||||
|
|------|------|
|
||||||
|
| Add new RBAC permission | `src/permissions/index.ts` + use in `hasPermission` calls |
|
||||||
|
| Add qualification gate | `access-control/hasLogisticsQualification.ts` (or create similar) |
|
||||||
|
| Add event type constant | `event-log/eventTypes.ts` (add to `EventTypes` object) |
|
||||||
|
| Emit event from action | `import { emitGameEvent } from "@/utils/event-log/emit"` |
|
||||||
|
| Check qualification in layout | Use `hasLogisticsQualification(payload, user)` |
|
||||||
|
| Modify XP calculation | `xp/resolveLevel.ts` |
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **NEVER** throw in `emitGameEvent` — it's fire-and-forget by design
|
||||||
|
- **NEVER** use `isRole` for permission-sensitive operations — use `hasPermission` instead
|
||||||
|
- **NEVER** hardcode qualification strings — use the constants in the qualification collection
|
||||||
|
- **NEVER** add event types without adding to `EventTypes` constants (breaks TypeScript narrowing)
|
||||||
Loading…
Reference in a new issue