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
|
||||
|
||||
> **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
|
||||
|
||||
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/"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -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.
|
||||
- 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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`.
|
||||
- `/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 `/`.
|
||||
- `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=<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`.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- `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.
|
||||
|
|
@ -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.
|
||||
- `.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.
|
||||
|
||||
## 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