76 lines
5.5 KiB
Markdown
76 lines
5.5 KiB
Markdown
# DATABASE — anwarpy.ai
|
||
|
||
> The schema is **Payload-first**: collections in `web/src/collections/` are the source of truth; SQL in Postgres is a generated artifact. This document maps the logical schema; never edit SQL by hand.
|
||
|
||
## 1. Adapter & migrations
|
||
|
||
- `@payloadcms/db-postgres` (Drizzle). Connection = `DATABASE_URI` (prod: composed by `docker-compose.vps.yml` from `DB_PASSWORD`; local: `web/.env` → LAN Postgres 192.168.88.36).
|
||
- Migrations live in `src/migrations/` (timestamped `YYYYMMDD_HHMMSS.ts` + paired `.json`). Created with `npx payload migrate:create`; applied **on container boot** in prod, or `npx payload migrate` locally.
|
||
- Rules: never edit an applied migration (history: the `20261003_111451` duplicate-`full_name` fix was done in place *before* prod applied it — that's the exception that proves the risk). Data backfills are standalone scripts under `web/scripts/` (`backfill-reading-minutes.ts` pattern), not migrations. `payload_migrations` is bookkeeping — if it says applied, it is.
|
||
|
||
## 2. Collections
|
||
|
||
### `posts` — guides (the core table)
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `title`, `slug` | text | slug **unique**, URL identity, 409 on conflict |
|
||
| `dek` | text | standfirst/summary, feeds metadata + JSON-LD |
|
||
| `category` | select | وكلاء / نماذج / استرجاع / سلامة / أدوات / أعمال |
|
||
| `topics` | select/array | registry in `src/lib/topics.ts` (agents, models, automation, foundations) |
|
||
| `access` | select `free` \| `vip` | **intended tier — data, not state**; never rewritten to simulate launch states |
|
||
| `content` | lexical (richtext) | serialized `{root:{children:[…]}}`; **field-level read ACL** (ARCHITECTURE §3) |
|
||
| `contentType` | select | note / tutorial / case_study / comparison — badge |
|
||
| `authorName` | text | visible byline; also JSON-LD author Person |
|
||
| `sourceId` | text | agent-pipeline idempotency; dedup before slug-409 |
|
||
| `cover` | upload → `media` | og:image source (1200×630) |
|
||
| `readingMinutes` | number | **computed at write** (`countLexicalWords` ÷ `READING_WORDS_PER_MINUTE`=160); stripped from agent payloads; backfilled honestly |
|
||
| `publishedAt` | date | feeds «حُدّث» + JSON-LD |
|
||
| `status` | select `draft` \| `published` | public visibility gate; `publish: true` agent opt-in |
|
||
| derived/rels | — | `views`, `likes` counters (see routes) |
|
||
|
||
### `users`
|
||
Payload auth: `email` (unique), `password` (hash), `fullName`, `interest`/`level` (onboarding), `emailVerified` flag + `verification_token` (written at signup, ADR-compliant), `stripeCustomerId` (Phase 3; hidden/removed when empty — never exposed to frontend). Roles: `admin` vs member (see SECURITY §3). Note: `20261003_111451` removed a duplicate `full_name` column.
|
||
|
||
### `comments`
|
||
`post` (rel → posts), `author` (rel → users, **forced server-side** — the API ignores any client-sent author, ADR-0002), `authorName` (display snapshot), `body` (textarea), `status` (moderation: pending → published / rejected). Anonymous requests are rejected at the route (PRD §3.5). `admin.defaultColumns: post, authorName, status, createdAt`.
|
||
|
||
### `launch_notifications`
|
||
`email`, `topic`, timestamps. Written by `/api/notify` on every submission (even when mail transport is down — the honest-503 still records the row). Duplicate email → no new row, `alreadySubscribed: true`. Zero rows during the 2026-10-10 prod smoke cleanup is the expected state.
|
||
|
||
### `post_likes`
|
||
`user` (rel), `post` (rel) — **one per user per post** (uniqueness enforced in the like route before insert); counter incremented on `posts`. Auth required (401 otherwise).
|
||
|
||
### `media`
|
||
Payload uploads (covers + pipeline images) → volume `media-uploads`; `sharp` for sizes. Public URL pattern `/api/media/file/...`.
|
||
|
||
### `files`
|
||
Guide attachments → volume `files-uploads`; per-file `access` mirrors its post (download gate `GET /api/files/[id]`); ingested via `agent-files.ts` (never accepts `access` from the payload).
|
||
|
||
## 3. Relationships & integrity
|
||
|
||
```
|
||
posts 1─* comments *─1 users
|
||
posts 1─* post_likes *─1 users (unique(user,post))
|
||
posts *─1 media (cover)
|
||
posts 1─* files (each file carries its own access)
|
||
```
|
||
|
||
Enforcement lives in collection hooks/route handlers (Payload has no SQL-level FK constraints across its tables for these patterns) — hence the "check then insert" pattern in `like` (query `where user+post` before `create`). Keep that pattern for any new per-user-once resource.
|
||
|
||
## 4. Indexing
|
||
|
||
- Unique: `posts.slug`, `users.email`, `posts.sourceId` (dedup lookup), `verification_token`.
|
||
- Hot lookups: `comments.post` (+ `status`), `post_likes.post`, `launch_notifications.email` (dup check).
|
||
- Add indexes via migrations when a query pattern shows up in slow logs — not speculatively.
|
||
|
||
## 5. Backups & restore
|
||
|
||
- `web/scripts/backup-db.sh` → `pg_dump` gzip; prod cron/backup policy is an owner action.
|
||
- Rehearsed restore (2026-10-10): restore into a **temp DB** (`anwarpi_restore_test`), verify counts (`posts`, `users`, `payload_migrations`), then drop. Never restore over the live DB without owner approval.
|
||
|
||
## 6. Data rules that are also PRD rules
|
||
|
||
- `access` and `readingMinutes` are stripped from agent payloads (`pickFields`) — the pipeline cannot set them.
|
||
- Reading minutes are computed, never trusted from input; if a script touches them, it must recompute from `content` and report scanned/updated counts.
|
||
- Personal data in `launch_notifications`/`users` emails: never rendered into docs, videos, or evidence (the smoke test uses `@example.com` rows and deletes them).
|