From 3d935989ee8155ec96a99213769d63184511a637 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Wed, 20 May 2026 11:49:02 +1000 Subject: [PATCH] feat(db): migrate api to d1 --- .env.example | 12 +- .github/workflows/deploy.yml | 13 +- AGENTS.md | 16 +- CONTRIBUTING.md | 2 +- PLAN.md | 18 +- PRD.md | 18 +- README.md | 23 +- apps/api/backend-phase-0-plan.md | 60 +- apps/api/package.json | 1 - apps/api/src/db.ts | 46 +- apps/api/src/index.ts | 4 +- apps/api/src/middleware/auth.ts | 54 +- apps/api/src/routes/billboards.ts | 84 +-- apps/api/src/routes/pois.ts | 82 ++- apps/api/src/routes/quests.ts | 42 +- apps/api/src/routes/reports.ts | 40 +- apps/api/src/routes/signatures.ts | 24 +- apps/api/src/routes/stickers.ts | 27 +- apps/api/src/routes/users.ts | 81 ++- apps/api/src/serialize.ts | 8 + apps/api/src/services/moderation.ts | 11 +- apps/api/src/services/progression.ts | 88 ++- apps/api/src/services/rotations.ts | 38 +- apps/api/src/services/streaks.ts | 7 +- apps/api/src/types.ts | 4 +- apps/api/wrangler.jsonc | 8 + bun.lock | 4 +- .../plans/2026-05-20-leveling-quests.md | 62 +- packages/db/d1/migrations/0001_initial.sql | 509 +++++++++++++ .../db/{supabase/reset.sql => d1/schema.sql} | 461 ++++++------ packages/db/drizzle.config.ts | 7 +- packages/db/package.json | 7 +- packages/db/src/schema/index.ts | 685 +++++++----------- 33 files changed, 1474 insertions(+), 1072 deletions(-) create mode 100644 packages/db/d1/migrations/0001_initial.sql rename packages/db/{supabase/reset.sql => d1/schema.sql} (52%) diff --git a/.env.example b/.env.example index 71812d3..93bac93 100644 --- a/.env.example +++ b/.env.example @@ -10,12 +10,12 @@ # OpenAI Moderation API — used by apps/api/src/services/moderation.ts OPENAI_API_KEY=sk-... -# Postgres connection string for Drizzle (use the pooled Supabase URL) -SUPABASE_POOLER_DATABASE_URL=postgresql://user:pass@host:6543/postgres - -# Supabase project — only needed if using Supabase-specific APIs beyond Postgres -SUPABASE_URL= -SUPABASE_SECRET_KEY= +# Cloudflare D1 / deploy tooling. The Worker receives D1 through the `DB` +# binding in apps/api/wrangler.jsonc; these values are only for Wrangler CLI +# commands such as migrations and deploys. +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_D1_DATABASE_ID=816d5f5d-0be0-4122-88a9-f4bcee86892e # Clerk auth — API verifies session tokens through the Clerk backend SDK CLERK_PUBLISHABLE_KEY=pk_test_... diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f7a366..3c602e7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,13 @@ jobs: # - name: Check # run: bun run check + - name: Apply D1 migrations + working-directory: apps/api + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: bun wrangler d1 migrations apply jematala-db --remote + # Cloudflare only accepts `wrangler secret put` after the Worker exists (initial deploy). - name: Deploy API Worker working-directory: apps/api @@ -53,16 +60,10 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - SUPABASE_POOLER_DATABASE_URL: ${{ secrets.SUPABASE_POOLER_DATABASE_URL }} run: | - printf '%s' "$SUPABASE_POOLER_DATABASE_URL" | bun wrangler secret put SUPABASE_POOLER_DATABASE_URL - printf '%s' "$SUPABASE_URL" | bun wrangler secret put SUPABASE_URL - printf '%s' "$SUPABASE_SECRET_KEY" | bun wrangler secret put SUPABASE_SECRET_KEY printf '%s' "$CLERK_PUBLISHABLE_KEY" | bun wrangler secret put CLERK_PUBLISHABLE_KEY printf '%s' "$CLERK_SECRET_KEY" | bun wrangler secret put CLERK_SECRET_KEY printf '%s' "$OPENAI_API_KEY" | bun wrangler secret put OPENAI_API_KEY diff --git a/AGENTS.md b/AGENTS.md index f6154ef..ac3ac96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ bun run typecheck:app # single-package typecheck bun run build:app # expo export -p web bun run build:api # wrangler deploy --dry-run -# DB reset (local only, needs .env with DATABASE_URL) -bun --cwd packages/db run reset:local +# DB reset (local only, needs .env with CLOUDFLARE_D1_DATABASE_ID) +bun run --cwd packages/db reset:local # Deploy (after CI secrets configured — see README.md for list) bun --cwd apps/api wrangler deploy @@ -28,9 +28,9 @@ bun --cwd apps/app wrangler deploy # static SPA via CF Workers assets ## Architecture - **Monorepo** (Bun workspaces): `apps/app` (Expo Router, entrypoint: `expo-router/entry`), `apps/api` (Hono on CF Workers), `packages/db` (SQL reset), `packages/shared` (Zod schemas + types) -- **No RLS** — DB logic lives in the Hono API worker. **Drizzle ORM planned but not yet installed.** +- **No RLS** — DB logic lives in the Hono API worker. Drizzle ORM is used for D1 schema/query helpers. - **Auth**: Clerk (social login only — Google, Apple). No email/password. -- **Real-time**: Durable Objects manage WebSocket connections (planned). +- **Real-time**: Durable Objects manage WebSocket connections. - **Two CF Workers**: `jematala-api` (`jematala.takuk.me/api/*`), `jematala-app` (`jematala.takuk.me/*`) - **/events routes are scaffold placeholders** — PLAN.md says to replace them with map, billboard, studio, quests, profile routes. @@ -41,11 +41,11 @@ bun --cwd apps/app wrangler deploy # static SPA via CF Workers assets - Shared package subpath export: `@repo/shared/events` - Path aliases in `tsconfig.base.json`: `@repo/db` → `packages/db/src`, `@repo/shared` → `packages/shared/src` - Wrangler configs use `wrangler.jsonc` (not `.toml`), schema at `node_modules/wrangler/config-schema.json` -- DB uses **PostGIS** — `packages/db/supabase/reset.sql` creates the `app` schema and enables the extension +- DB uses **Cloudflare D1** — `packages/db/d1/schema.sql` creates local reset tables and seed data - Shared package exports domain schemas via `@repo/shared` and subpaths such as `@repo/shared/poi`, `@repo/shared/billboard`, `@repo/shared/quest`, and `@repo/shared/user` - Node 25, Bun latest (pinned via mise) - `bun.lock` is checked in; CI uses `--frozen-lockfile` -- **Drizzle ORM** for Postgres queries (not Prisma) +- **Drizzle ORM** for D1 queries (not Prisma) - **Maps**: `react-native-leaflet-view` + OSM - **Icons**: `lucide-react-native`, `@expo/vector-icons`, and `expo-symbols` all available - **HTTP client**: `@tanstack/react-query` for data fetching in the app @@ -59,8 +59,8 @@ bun --cwd apps/app wrangler deploy # static SPA via CF Workers assets - Expo web output is a static SPA; dynamic routes client-side via Expo Router. - Node 25, Bun latest (pinned via mise). `bun.lock` checked in. -- **PostGIS** DB — `packages/db/supabase/reset.sql` creates the `app` schema and enables the extension. -- DB reset: source `.env` first (`set -a; source .env; set +a`) then `bun --cwd packages/db run reset:local`. +- **D1** DB — `packages/db/d1/schema.sql` creates local reset tables and seed data. +- DB reset: source `.env` first (`set -a; source .env; set +a`) then `bun run --cwd packages/db reset:local`. - `wrangler types --env-interface CloudflareBindings` generates binding types for the API worker. - Expo typed routes enabled (`app.json` experiments). - `react-compiler` enabled in `app.json`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532fb77..96465d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ can track what's done. Use `- [x]` for completed items. ``` apps/app Expo Router app (web + mobile) apps/api Cloudflare Worker API (Hono) -packages/db Supabase/PostGIS reset SQL +packages/db D1 reset SQL packages/shared Shared Zod schemas & API contracts ``` diff --git a/PLAN.md b/PLAN.md index 2d87b58..91a5e46 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,7 +14,7 @@ ## Phase 0 — Domain Alignment & Data Model (Everyone, together first) - [x] **All 4** — Whiteboard the domain model: User, POI, Billboard, Placement (Sticker/StickyNote), Quest, DailyQuest, UserProgress, Report -- [x] **BE1** — Write `packages/db/supabase/reset.sql` with all real tables (POIs include picture column, users include avatar + is_admin columns) +- [x] **BE1** — Write `packages/db/d1/schema.sql` with all real tables (POIs include picture column, users include avatar + is_admin columns) - [x] **BE1** — Write Drizzle schema in `packages/db/src/schema/` (POI: picture field; User: avatar field) - [x] **BE1** — Write shared Zod schemas in `packages/shared/src/` (poi includes optional picture, user includes avatar) - [x] **BE1** — Delete old `events`-related code from `packages/shared/src/events.ts` @@ -29,7 +29,7 @@ ## Phase 1a — Foundation: Backend - [x] **BE2** — Set up Clerk JWKS verification middleware in `apps/api/src/middleware/auth.ts` -- [x] **BE2** — Set up Drizzle driver + Supabase connection in `apps/api/src/db.ts` +- [x] **BE2** — Set up Drizzle driver + D1 connection in `apps/api/src/db.ts` - [x] **BE2** — User profile API: avatar upload (base64 PNG) + `PATCH /api/users/me/avatar` - [x] **BE2** — Restructure `apps/api/src/index.ts` — split into route modules (`/pois`, `/billboards`, `/stickers`, `/quests`, `/users`, `/admin`) @@ -62,7 +62,7 @@ - [x] **FE1** — Map: show POI markers with distinct glowing style - [X] **FE1** — Map: show billboard markers with note icon style - [x] **FE1** — Map: show user's current location as their 64×64 avatar (instead of a standard dot) -- [ ] **FE1** — POI discovery UX: toast when entering geofence + quest progress trigger (quest-progress toast plumbing exists for API mutations; geofence trigger still pending) +- [ ] **FE1** — POI discovery UX: toast when entering numeric radius check + quest progress trigger (quest-progress toast plumbing exists for API mutations; numeric radius check trigger still pending) - [X] **FE2** — Billboard expanded view (~60vh overlay): text + username pill + all placements (z-ordered) - [X] **FE2** — Pixel art sticker editor: 64×64 grid, 8-colour palette, tap-to-fill, save to collection - [X] **FE2** — Sticky note composer: text input, preview as sticky note, post to billboard @@ -73,7 +73,7 @@ - [x] **BE1** — Daily quest rotation logic + streak tracking - [x] **BE1** — User profile API: `GET /api/users/:id`, `PATCH /api/users/me` -- [x] **BE2** — Durable Object: WebSocket handler, Postgres connection, broadcast on mutations +- [x] **BE2** — Durable Object: WebSocket handler, D1 connection, broadcast on mutations - [ ] **BE2** — Expo Push Notification integration: register token, send on reply + daily reminder - [x] **FE1** — Quest screen: main quest tiers + daily quest + streak counter + progress bars (currently backed by mock quest data) - [x] **FE1** — Profile screen: level, perks unlocked, stats (notes placed, stickers saved, POIs visited) @@ -121,16 +121,16 @@ Phase 1b BE ──► Phase 4 BE (reporting, analytics) ## Key Architectural Decisions (confirmed) - **Sticker storage:** base64 PNG blob — FE produces B64 string, sends to BE for moderation (B64 moderation via OpenAI) -- **Admin role:** `is_admin` boolean column on `app.users` -- **Primary keys:** internal UUIDv4 values for all primary keys; Clerk user IDs are stored as unique external auth identifiers on `app.users.clerk_user_id` +- **Admin role:** `is_admin` boolean column on `users` +- **Primary keys:** internal UUIDv4 values for all primary keys; Clerk user IDs are stored as unique external auth identifiers on `users.clerk_user_id` - **Map on mobile:** `react-native-leaflet-view` (pavel-corsaghin/react-native-leaflet) -- **Drizzle migrations:** Drizzle Kit with `drizzle-kit push` for hackathon speed +- **D1 migrations:** SQL files under `packages/db/d1/migrations`; local resets use `packages/db/d1/schema.sql` - **Billboard limits:** concurrent active cap starts at 3 and scales with level; posting at the cap soft-deletes the user's oldest active billboard before publishing the new one - **Billboard daily limit:** separate Sydney calendar-day posting cap; seeded as concurrent + 1 and capped at 10/day - **Billboard expiry:** derived from `empty_expires_at`/`expires_at` in active queries (empty billboards disappear after 24 hours; all billboards disappear after 5 days) - **Daily quests:** seeded templates/pool; active quest is deterministically selected from the Sydney calendar day - **POI rotation:** seeded/admin-created POI table; active POI set is deterministically selected from the campus calendar day -- **POI geofence radius:** 30m +- **POI numeric radius check radius:** 30m - **Quest system:** parameterised templates (visit N POIs, leave N notes, place N stickers, receive N replies, save N stickers) with per-level randomised values and tier progression - **Daily quest pool:** 5 curated seeded daily quests; one rotates each Sydney calendar day - **Push notification timing:** 8–9am daily quest reminder when push is enabled @@ -210,4 +210,4 @@ Hardcoded in `constants/coordinates.ts`: ### Maps Backlog (post-hackathon) -- POI discovery toast on geofence enter +- POI discovery toast on numeric radius check enter diff --git a/PRD.md b/PRD.md index 0a8aee7..c8730ac 100644 --- a/PRD.md +++ b/PRD.md @@ -6,7 +6,7 @@ A location-based social exploration game for UNSW students. ## 1. Overview -Mobile app where students discover geofenced Points of Interest (POIs) around UNSW Kensington campus, leave billboard notes, and reply with pixel-art stickers. Progression via quests and levelling unlocks cosmetic perks, billboard capacity, and sticker collection capacity upgrades. +Mobile app where students discover radius-based Points of Interest (POIs) around UNSW Kensington campus, leave billboard notes, and reply with pixel-art stickers. Progression via quests and levelling unlocks cosmetic perks, billboard capacity, and sticker collection capacity upgrades. **Core loop:** Explore campus → discover POIs → leave/read notes → reply with stickers → complete quests → level up → unlock perks → explore more. @@ -16,10 +16,10 @@ Mobile app where students discover geofenced Points of Interest (POIs) around UN | Layer | Choice | | --------------------------- | ------------------------------------------------------------------------- | -| Mobile framework | Expo (React Native) with native geofencing | +| Mobile framework | Expo (React Native) with native numeric radius checks | | API runtime (non-real-time) | Hono on Cloudflare Workers | | Real-time runtime | Cloudflare Durable Objects (WebSockets, broadcasting) | -| Database | PostgreSQL + PostGIS on Supabase (no RLS) | +| Database | Cloudflare D1 (no RLS) | | ORM | Drizzle | | Auth | Clerk (social login only — Google, Apple) | | Maps | react-native-leaflet-view + OSM | @@ -36,10 +36,10 @@ Cloudflare Workers are the entry point for all HTTP requests. Routing depends on | Request type | Handler | Example | | ------------------ | ----------------------------------------- | --------------------------------------- | -| Non-real-time GET | CF Worker (Hono) → Postgres | Fetch user profile, list saved stickers | -| Non-real-time POST | CF Worker (Hono) → Postgres | Update user display name, settings | -| Real-time GET | Durable Object → Postgres (via WebSocket) | Query notes, POIs, and map data | -| Real-time POST | Durable Object → Postgres + broadcast | Post a note, place a sticker | +| Non-real-time GET | CF Worker (Hono) → D1 | Fetch user profile, list saved stickers | +| Non-real-time POST | CF Worker (Hono) → D1 | Update user display name, settings | +| Real-time GET | Durable Object → D1 (via WebSocket) | Query notes, POIs, and map data | +| Real-time POST | Durable Object → D1 + broadcast | Post a note, place a sticker | | WebSocket connect | Durable Object (persistent connection) | Live map updates, push notifications | Durable Objects manage persistent WebSocket connections for real-time features — querying notes and map data, broadcasting changes, and sending notifications. Mutations required for real-time functionality (e.g. posting notes, placing stickers) also run inside the Durable Object so the result can be broadcast immediately. @@ -53,7 +53,7 @@ Non-real-time requests (e.g. updating user settings, fetching saved stickers) go - 2D top-down map centered on UNSW Kensington campus - User sees: their own location dot, POI markers, and billboard notes - **No other users are visible** on the map — anonymity of presence -- POI geofence radius: 30m (tuned during playtesting if needed) +- POI numeric radius check radius: 30m (tuned during playtesting if needed) - Map provider: react-native-leaflet-view with OSM tiles - User represented on map by a 64×64 pixel art avatar drawn at sign-up @@ -223,7 +223,7 @@ Quests are **parameterised templates** rather than fixed one-time objectives. Ea | -------------------------------- | -------------------------------------- | | Primary key format | **UUIDv4** for internal primary keys; Clerk IDs are unique auth-provider identifiers, not primary keys | | Billboard limits | **Concurrent cap + Sydney-day posting cap**; posting at the concurrent cap replaces the user's oldest active billboard | -| POI geofence trigger radius | **30m** | +| POI numeric radius check trigger radius | **30m** | | Map provider | **react-native-leaflet-view** + OSM | | Sticker storage format | **base64 PNG** | | Quest system | **Parameterised templates** (visit N POIs, leave N notes, place N stickers, receive N replies, save N stickers) with per-level randomised values | diff --git a/README.md b/README.md index 856652a..d6dafc4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pixel-art stickers and avatars, and completing daily quests. [Hono](https://hono.dev/), Clerk JWKS auth middleware, and Zod validators - Realtime and scheduled jobs: Cloudflare Durable Objects for WebSocket fan-out and Worker cron triggers for rotations/expiry/streak maintenance -- Database: Supabase Postgres with PostGIS, Drizzle ORM, and Drizzle Kit +- Database: Cloudflare D1 with Drizzle ORM and Drizzle Kit - Shared contracts: workspace Zod schemas and TypeScript types in `packages/shared` - Moderation: OpenAI Moderation API from the API Worker @@ -34,7 +34,7 @@ pixel-art stickers and avatars, and completing daily quests. ```txt apps/app Expo Router app for web and native clients apps/api Cloudflare Worker API using Hono, Durable Objects, and cron -packages/db Drizzle schema plus Supabase/PostGIS reset SQL +packages/db Drizzle schema plus D1 reset SQL packages/shared Shared Zod schemas and TypeScript API contracts ``` @@ -50,7 +50,7 @@ reports, signatures, health checks, and realtime WebSocket connections. - [mise](https://mise.jdx.dev/getting-started.html) - [Bun](https://bun.sh) 1.3.14 or the version installed by `mise` - Cloudflare account access for deployment -- Supabase project with a Postgres pooler URL +- Cloudflare D1 database access - Clerk application configured for social login - OpenAI API key for content moderation @@ -78,18 +78,18 @@ bun run dev:app bun run dev:api ``` -`apps/api` owns server-side API routes. The frontend should not access the Supabase secret key or database directly. +`apps/api` owns server-side API routes. The frontend should only access data through the API Worker. ## Database Changes Drizzle owns the TypeScript schema in `packages/db/src/schema`. For hackathon -speed, schema pushes use Drizzle Kit: +speed, remote schema changes are applied through D1 migrations: ```sh -bun --cwd packages/db run db:push +bun run --cwd packages/db migrate:remote ``` -## Supabase PostGIS Reset +## D1 Reset The temp database schema is intentionally resettable while the product schema is still moving. Do not run this from GitHub Actions. @@ -97,10 +97,10 @@ The temp database schema is intentionally resettable while the product schema is set -a source .env set +a -bun --cwd packages/db run reset:local +bun run --cwd packages/db reset:local ``` -`packages/db/supabase/reset.sql` drops and recreates only the `app` schema, enables PostGIS, and seeds demo data. Use the Supabase pooler URL for future non-destructive CI migrations if needed; keep full resets local/manual. +`packages/db/d1/schema.sql` drops and recreates the local D1 schema and seeds demo data. GitHub Actions applies non-destructive D1 migrations from `packages/db/d1/migrations`. ## Building Web @@ -132,14 +132,11 @@ The deploy workflow runs on pushes to `main` and via `workflow_dispatch`. Config - `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_API_TOKEN` -- `SUPABASE_URL` -- `SUPABASE_SECRET_KEY` -- `SUPABASE_POOLER_DATABASE_URL` - `CLERK_PUBLISHABLE_KEY` - `CLERK_SECRET_KEY` - `OPENAI_API_KEY` -The Cloudflare API token needs permission to deploy Workers, apply Durable Object migrations, write Worker secrets, and configure Worker routes. The API Worker deploy reads `apps/api/wrangler.jsonc`, so the `CAMPUS_REALTIME_ROOM` Durable Object binding, migration, and cron triggers are applied by the deploy run. Secrets are synced immediately after the API Worker exists. +The Cloudflare API token needs permission to deploy Workers, apply Durable Object and D1 migrations, write Worker secrets, and configure Worker routes. The API Worker deploy reads `apps/api/wrangler.jsonc`, so the `DB` D1 binding, `CAMPUS_REALTIME_ROOM` Durable Object binding, migrations, and cron triggers are applied by the deploy run. Secrets are synced immediately after the API Worker exists. The frontend build uses `EXPO_PUBLIC_API_URL` to point static web output at the deployed API route. The temporary deployment uses `https://jematala.takuk.me`, with API traffic routed under `/api/*`. diff --git a/apps/api/backend-phase-0-plan.md b/apps/api/backend-phase-0-plan.md index 21ab9dc..8282053 100644 --- a/apps/api/backend-phase-0-plan.md +++ b/apps/api/backend-phase-0-plan.md @@ -12,12 +12,12 @@ - Sticker storage: base64 PNG blob produced by FE and stored by BE. - POI picture storage: optional compressed 128x128 base64 PNG stored inline in the POI row. -- User avatar storage: 64x64 pixel art base64 PNG stored in `app.users.avatar_base64`. -- Admin role: `is_admin boolean` on `app.users`. -- Primary keys: internal UUIDv4 values for all primary keys; Clerk user IDs are unique external auth identifiers stored on `app.users.clerk_user_id`. -- UUIDv4 generation: `reset.sql` and Drizzle table defaults call `gen_random_uuid()`, which is available on Supabase PostgreSQL 17.6. +- User avatar storage: 64x64 pixel art base64 PNG stored in `users.avatar_base64`. +- Admin role: `is_admin boolean` on `users`. +- Primary keys: internal UUIDv4 text values for all primary keys; Clerk user IDs are unique external auth identifiers stored on `users.clerk_user_id`. +- UUIDv4 generation: API code assigns UUIDv4 text values before inserts; D1 seed rows use stable IDs. - Map provider: `react-native-leaflet-view` with OSM tiles; backend still exposes lat/lng and campus bounds/provider config. -- Drizzle migrations: Drizzle Kit with `drizzle-kit push` for hackathon speed. +- D1 migrations: SQL files under `packages/db/d1/migrations`; local resets use `packages/db/d1/schema.sql`. - POI rotation, empty-billboard expiry, daily quests, and POI/day setup are derived at request time where possible, but Phase 0 needs schema support and seed data. - Quest system: parameterised templates such as `visit_pois`, `leave_billboards`, `place_stickers`, `receive_replies`, and `save_stickers`, with generated per-level values. - Daily quests: fixed curated seeded pool of about 5 templates; one is deterministically selected per Sydney calendar day. @@ -31,7 +31,7 @@ Update or create these files: -- [`packages/db/supabase/reset.sql`](../../packages/db/supabase/reset.sql): full reset SQL for the real tables, seed UNSW campus plus a few demo POIs/quests, and remove event tables. +- [`packages/db/d1/schema.sql`](../../packages/db/d1/schema.sql): full reset SQL for the real tables, seed UNSW campus plus a few demo POIs/quests, and remove event tables. - [`packages/db/src/schema/`](../../packages/db/src/schema/): Drizzle schema matching the SQL tables. - [`packages/shared/src/poi.ts`](../../packages/shared/src/poi.ts): POI schemas and route contracts. - [`packages/shared/src/billboard.ts`](../../packages/shared/src/billboard.ts): billboard and placement schemas. @@ -91,46 +91,46 @@ Reports/admin: ## Phase 0 SQL And Drizzle Model -Use `app` schema and PostGIS. The SQL reset and Drizzle schema should define the same tables. +Use D1 tables without a separate SQL schema. The SQL reset and Drizzle schema should define the same tables. Identity: -- `app.users`: UUIDv4 `id` primary key, unique `clerk_user_id`, username, display name, `avatar_base64`, `is_admin`, level, XP, streak fields, banned/deleted flags, timestamps. Do not reuse Clerk IDs as primary keys; map Clerk JWT `sub` to this row via `clerk_user_id`. -- `app.push_tokens`: optional Phase 0 table if quick; useful for Phase 3 push. +- `users`: UUIDv4 text `id` primary key, unique `clerk_user_id`, username, display name, `avatar_base64`, `is_admin`, level, XP, streak fields, banned/deleted flags, timestamps. Do not reuse Clerk IDs as primary keys; map Clerk JWT `sub` to this row via `clerk_user_id`. +- `push_tokens`: optional Phase 0 table if quick; useful for Phase 3 push. Campus and POIs: -- `app.campuses`: id, name, timezone, map center, radius/bounds. -- `app.pois`: campus id, title, description, optional `picture_base64`, location point, radius meters defaulting to 30m, active/admin fields. -- `app.poi_visits`: user id, POI id, visited date/time, unique first-visit constraint. +- `campuses`: id, name, timezone, map center, radius/bounds. +- `pois`: campus id, title, description, optional `picture_base64`, lat/lng numbers, radius meters defaulting to 30m, active/admin fields. +- `poi_visits`: user id, POI id, visited date/time, unique first-visit constraint. Billboards and placements: -- `app.billboards`: campus id, author id, text body, location point, status, moderation fields, `empty_expires_at`, hard `expires_at`, and deleted timestamps. Track enough timestamps to enforce the concurrent cap, the Sydney calendar-day posting cap, empty-billboard 24-hour expiry, and the 5-day maximum lifetime. -- `app.billboard_placements`: billboard id, author id, kind `sticker | sticky_note`, x/y, z index, sticker asset ref or text body, status, moderation fields. Unique active `(billboard_id, author_id)`. +- `billboards`: campus id, author id, text body, lat/lng numbers, status, moderation fields, `empty_expires_at`, hard `expires_at`, and deleted timestamps. Track enough timestamps to enforce the concurrent cap, the Sydney calendar-day posting cap, empty-billboard 24-hour expiry, and the 5-day maximum lifetime. +- `billboard_placements`: billboard id, author id, kind `sticker | sticky_note`, x/y, z index, sticker asset ref or text body, status, moderation fields. Unique active `(billboard_id, author_id)`. Stickers and collection: -- `app.sticker_assets`: owner id, base64 PNG data, width/height, palette metadata, moderation/status fields for OpenAI image moderation. -- `app.saved_stickers`: user collection rows pointing to sticker assets or saved sticky-note text, with capacity enforced in API. +- `sticker_assets`: owner id, base64 PNG data, width/height, palette metadata, moderation/status fields for OpenAI image moderation. +- `saved_stickers`: user collection rows pointing to sticker assets or saved sticky-note text, with capacity enforced in API. Quests and perks: -- `app.quest_templates`: parameterised level quest template catalog. Columns should include `key`, `trigger_type`, `title_template`, `description_template`, `min_target`, `max_target`, `xp_reward`, and `active`. -- `app.level_quest_sets`: generated or seeded level/tier quest instances with `level`, `template_id`, `target_count`, `xp_reward`, and ordering. -- `app.daily_quest_templates`: parameterised daily quest template catalog, separate from level quests even when it uses the same trigger types. -- `app.daily_quest_pool`: curated daily quest candidates from `daily_quest_templates`, with target counts and XP tuned for daily play. -- `app.user_quest_progress`: user id, quest source/type, quest instance id, optional active date for daily quests, progress count, completed_at, claimable_at, claimed_at, claimed XP. -- `app.perk_definitions`: catalog of perks such as concurrent billboard capacity, daily posting capacity, sticker slot increase, note signature, note border flair, palette expansion. -- `app.level_perks`: maps each level to one or more perk definitions plus any numeric value, e.g. `max_concurrent_billboards = 3` and `daily_billboard_limit = 4`. -- `app.user_perk_unlocks`: records perks unlocked when a user reaches a level, useful for profile display, analytics, and future manual grants. -- `app.streak_reward_definitions`: optional catalog for daily streak bonus rewards such as cosmetics or XP multipliers. Store the reward as JSONB, validate it with the shared streak reward payload schema, and keep a DB check that the stored value is a JSON object. +- `quest_templates`: parameterised level quest template catalog. Columns should include `key`, `trigger_type`, `title_template`, `description_template`, `min_target`, `max_target`, `xp_reward`, and `active`. +- `level_quest_sets`: generated or seeded level/tier quest instances with `level`, `template_id`, `target_count`, `xp_reward`, and ordering. +- `daily_quest_templates`: parameterised daily quest template catalog, separate from level quests even when it uses the same trigger types. +- `daily_quest_pool`: curated daily quest candidates from `daily_quest_templates`, with target counts and XP tuned for daily play. +- `user_quest_progress`: user id, quest source/type, quest instance id, optional active date for daily quests, progress count, completed_at, claimable_at, claimed_at, claimed XP. +- `perk_definitions`: catalog of perks such as concurrent billboard capacity, daily posting capacity, sticker slot increase, note signature, note border flair, palette expansion. +- `level_perks`: maps each level to one or more perk definitions plus any numeric value, e.g. `max_concurrent_billboards = 3` and `daily_billboard_limit = 4`. +- `user_perk_unlocks`: records perks unlocked when a user reaches a level, useful for profile display, analytics, and future manual grants. +- `streak_reward_definitions`: optional catalog for daily streak bonus rewards such as cosmetics or XP multipliers. Store the reward as JSON text, validate it with the shared streak reward payload schema, and keep a DB check that the stored value is a JSON object. Safety/admin: -- `app.reports`: reporter, target type/id, reason, status, admin notes. -- `app.moderation_actions`: admin action log for report outcomes. -- `app.content_moderation_logs`: OpenAI moderation response summary, target type/id, including sticker assets because saved stickers can outlive placements. +- `reports`: reporter, target type/id, reason, status, admin notes. +- `moderation_actions`: admin action log for report outcomes. +- `content_moderation_logs`: OpenAI moderation response summary, target type/id, including sticker assets because saved stickers can outlive placements. Indexes/constraints: @@ -183,14 +183,14 @@ Model expiry and caps from the PRD explicitly: ## Later Phase Notes -- Phase 1a implements Clerk JWKS middleware, Drizzle driver/Supabase connection, and route module structure. +- Phase 1a implements Clerk JWKS middleware, Drizzle driver/D1 connection, and route module structure. - Phase 1b implements the actual POI, billboard, sticker/placement, quest, and user APIs from the contracts above. - Phase 3 adds Durable Object WebSockets. Keep one `CampusRealtimeRoomDO` class with one instance for UNSW in MVP; do not create DOs per message/billboard/POI. - Do not add an app-level WebSocket `ping` message unless real device testing shows a need. ## Implementation Order For Phase 0 -1. Rewrite [`packages/db/supabase/reset.sql`](../../packages/db/supabase/reset.sql) with the real `app` tables and seed data. +1. Rewrite [`packages/db/d1/schema.sql`](../../packages/db/d1/schema.sql) with the real `app` tables and seed data. 2. Add matching Drizzle schema files under [`packages/db/src/schema/`](../../packages/db/src/schema/). 3. Replace shared event schemas with the domain modules and route contracts above. 4. Seed the PRD level perks and the initial quest template/daily pool rows. diff --git a/apps/api/package.json b/apps/api/package.json index 5513376..c48fc44 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,7 +15,6 @@ "@repo/shared": "workspace:*", "drizzle-orm": "^0.45.2", "hono": "^4.12.19", - "postgres": "^3.4.9", "zod": "latest" }, "devDependencies": { diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index 38ca9c1..fd55fce 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -1,18 +1,46 @@ import * as schema from "@repo/db"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; +import { type SQLWrapper, sql } from "drizzle-orm"; +import { type DrizzleD1Database, drizzle } from "drizzle-orm/d1"; import type { Env } from "./types"; +type RawQuery = SQLWrapper | string; +export type Database = DrizzleD1Database & { + execute(query: RawQuery): Promise; +}; + export function getDb(env: Env) { - if (!env.SUPABASE_POOLER_DATABASE_URL) { - throw new Error("SUPABASE_POOLER_DATABASE_URL is not configured."); + if (!env.DB) { + throw new Error("D1 binding DB is not configured."); } - const client = postgres(env.SUPABASE_POOLER_DATABASE_URL, { - connect_timeout: 10, - prepare: false, - }); + const db = drizzle(env.DB, { schema }); + + return Object.assign(db, { + execute(query: RawQuery) { + return db.all(query); + }, + }) satisfies Database; +} + +export function newId() { + return crypto.randomUUID(); +} + +export function newIdSql() { + return sql` + lower(hex(randomblob(4))) || '-' || + lower(hex(randomblob(2))) || '-' || + '4' || substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || + lower(hex(randomblob(6))) + `; +} + +export function nowSql() { + return sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`; +} - return drizzle(client, { schema }); +export function sydneyDateSql() { + return sql`date('now', '+10 hours')`; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ae5d84c..24dc731 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,11 +26,11 @@ app.use("/api/*", cors()); app.get("/api/health", (c) => { return c.json({ - database: Boolean(c.env.SUPABASE_POOLER_DATABASE_URL), + d1: Boolean(c.env.DB), + database: Boolean(c.env.DB), durableObjects: Boolean(c.env.CAMPUS_REALTIME_ROOM), ok: true, service: "jematala-api", - supabase: Boolean(c.env.SUPABASE_URL && c.env.SUPABASE_SECRET_KEY), moderation: Boolean(c.env.OPENAI_API_KEY) && c.env.MODERATION_ENABLED !== "false", }); }); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 098f1ec..c7c27b1 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -2,7 +2,7 @@ import { createClerkClient, type ClerkClient } from "@clerk/backend"; import { sql } from "drizzle-orm"; import type { MiddlewareHandler } from "hono"; -import { getDb } from "../db"; +import { getDb, newId, nowSql } from "../db"; import { unauthorized } from "../http"; import type { AppBindings, AuthUser, Env } from "../types"; @@ -129,27 +129,24 @@ async function upsertAuthUser(env: Env, payload: ClerkClaims): Promise } const db = getDb(env); - const rows = await db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(74291013)`); - - return tx.execute(sql` - insert into app.users (clerk_user_id, username, display_name, is_admin) - values ( - ${payload.sub}, - ${usernameFromClaims(payload)}, - ${displayNameFromClaims(payload)}, - not exists (select 1 from app.users where deleted_at is null) - ) - on conflict (clerk_user_id) do update - set updated_at = now() - returning - id, - clerk_user_id as "clerkUserId", - username, - display_name as "displayName", - is_admin as "isAdmin" - `); - }); + const rows = await db.execute(sql` + insert into users (id, clerk_user_id, username, display_name, is_admin) + values ( + ${newId()}, + ${payload.sub}, + ${usernameFromClaims(payload)}, + ${displayNameFromClaims(payload)}, + not exists (select 1 from users where deleted_at is null) + ) + on conflict (clerk_user_id) do update + set updated_at = ${nowSql()} + returning + id, + clerk_user_id as "clerkUserId", + username, + display_name as "displayName", + is_admin as "isAdmin" + `); const user = rows[0]; if (!user) { @@ -157,14 +154,19 @@ async function upsertAuthUser(env: Env, payload: ClerkClaims): Promise } await db.execute(sql` - insert into app.user_perk_unlocks (user_id, level_perk_id, source_level) + insert into user_perk_unlocks (user_id, level_perk_id, source_level) select ${user.id}, level_perks.id, level_perks.level - from app.level_perks - where level_perks.level <= (select level from app.users where id = ${user.id}) + from level_perks + where + level_perks.level >= 1 + and level_perks.level <= (select level from users where id = ${user.id}) on conflict (user_id, level_perk_id) do nothing `); - return user; + return { + ...user, + isAdmin: Boolean(user.isAdmin), + }; } function authMiddleware(options: AuthOptions = {}): MiddlewareHandler { diff --git a/apps/api/src/routes/billboards.ts b/apps/api/src/routes/billboards.ts index 625328f..1f84b00 100644 --- a/apps/api/src/routes/billboards.ts +++ b/apps/api/src/routes/billboards.ts @@ -12,7 +12,7 @@ import { zValidator } from "@hono/zod-validator"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { getDb } from "../db"; +import { getDb, newId, nowSql } from "../db"; import { conflict, forbidden, notFound, tooManyRequests } from "../http"; import { getAuthUser, optionalAuth, requireAuth } from "../middleware/auth"; import { jsonObject, isoDateTime } from "../serialize"; @@ -82,8 +82,8 @@ billboardsRoute.get("/billboards", optionalAuth, async (c) => { billboards.lng, billboards.status, ( - select count(*)::int - from app.billboard_placements + select count(*) + from billboard_placements where billboard_placements.billboard_id = billboards.id and billboard_placements.deleted_at is null @@ -93,18 +93,18 @@ billboardsRoute.get("/billboards", optionalAuth, async (c) => { billboards.empty_expires_at as "emptyExpiresAt", billboards.expires_at as "expiresAt", billboards.created_at as "createdAt" - from app.billboards - join app.users on users.id = billboards.author_id + from billboards + join users on users.id = billboards.author_id where billboards.deleted_at is null and billboards.hidden_at is null and billboards.status = 'active' - and billboards.expires_at > now() - and (${campusId ?? null}::uuid is null or billboards.campus_id = ${campusId ?? null}) - and (${c.req.query("north") ?? null}::double precision is null or billboards.lat <= ${c.req.query("north") ?? null}) - and (${c.req.query("south") ?? null}::double precision is null or billboards.lat >= ${c.req.query("south") ?? null}) - and (${c.req.query("east") ?? null}::double precision is null or billboards.lng <= ${c.req.query("east") ?? null}) - and (${c.req.query("west") ?? null}::double precision is null or billboards.lng >= ${c.req.query("west") ?? null}) + and billboards.expires_at > ${nowSql()} + and (${campusId ?? null} is null or billboards.campus_id = ${campusId ?? null}) + and (${c.req.query("north") ?? null} is null or billboards.lat <= ${c.req.query("north") ?? null}) + and (${c.req.query("south") ?? null} is null or billboards.lat >= ${c.req.query("south") ?? null}) + and (${c.req.query("east") ?? null} is null or billboards.lng <= ${c.req.query("east") ?? null}) + and (${c.req.query("west") ?? null} is null or billboards.lng >= ${c.req.query("west") ?? null}) order by billboards.created_at desc limit 100 `); @@ -155,22 +155,22 @@ billboardsRoute.post( const countRows = await db.execute<{ activeCount: number; todayCount: number }>(sql` select ( - select count(*)::int - from app.billboards + select count(*) + from billboards where author_id = ${authUser.id} and deleted_at is null and hidden_at is null and status = 'active' - and expires_at > now() + and expires_at > ${nowSql()} ) as "activeCount", ( - select count(*)::int - from app.billboards + select count(*) + from billboards where author_id = ${authUser.id} - and (timezone('Australia/Sydney', created_at))::date = - (timezone('Australia/Sydney', now()))::date + and date(created_at, '+10 hours') = + date('now', '+10 hours') ) as "todayCount" `); const counts = countRows[0]!; @@ -183,17 +183,17 @@ billboardsRoute.post( if (counts.activeCount >= capacities.maxConcurrentBillboards) { const replacedRows = await db.execute<{ id: string }>(sql` - update app.billboards - set deleted_at = now(), updated_at = now() + update billboards + set deleted_at = ${nowSql()}, updated_at = ${nowSql()} where id = ( select id - from app.billboards + from billboards where author_id = ${authUser.id} and deleted_at is null and hidden_at is null and status = 'active' - and expires_at > now() + and expires_at > ${nowSql()} order by created_at asc limit 1 ) @@ -204,25 +204,25 @@ billboardsRoute.post( } const insertRows = await db.execute<{ id: string }>(sql` - insert into app.billboards ( + insert into billboards ( + id, campus_id, author_id, body, lat, lng, - location_point, status, moderation_summary ) values ( + ${newId()}, ${input.campusId}, ${authUser.id}, ${input.body}, ${input.lat}, ${input.lng}, - st_setsrid(st_makepoint(${input.lng}, ${input.lat}), 4326)::geography, 'active', - ${moderation.rawResponse ? JSON.stringify(moderation.rawResponse) : null}::jsonb + ${moderation.rawResponse ? JSON.stringify(moderation.rawResponse) : null} ) returning id `); @@ -267,8 +267,8 @@ billboardsRoute.delete("/billboards/:id", requireAuth, async (c) => { } await db.execute(sql` - update app.billboards - set deleted_at = now(), updated_at = now() + update billboards + set deleted_at = ${nowSql()}, updated_at = ${nowSql()} where id = ${id.data} `); queueRealtimeBroadcast(c.executionCtx, c.env, { @@ -303,8 +303,8 @@ billboardsRoute.post( } const existingRows = await db.execute<{ count: number }>(sql` - select count(*)::int as count - from app.billboard_placements + select count(*) as count + from billboard_placements where billboard_id = ${id.data} and author_id = ${authUser.id} and deleted_at is null `); @@ -315,7 +315,8 @@ billboardsRoute.post( const stickerAssetId = input.kind === "sticker" ? input.stickerAssetId : null; const insertRows = await db.execute<{ id: string }>(sql` - insert into app.billboard_placements ( + insert into billboard_placements ( + id, billboard_id, author_id, kind, @@ -328,6 +329,7 @@ billboardsRoute.post( moderation_summary ) values ( + ${newId()}, ${id.data}, ${authUser.id}, ${input.kind}, @@ -336,7 +338,7 @@ billboardsRoute.post( coalesce( ( select max(z_index) + 1 - from app.billboard_placements + from billboard_placements where billboard_id = ${id.data} ), 0 @@ -344,7 +346,7 @@ billboardsRoute.post( ${stickerAssetId}, ${body}, 'active', - ${moderation?.rawResponse ? JSON.stringify(moderation.rawResponse) : null}::jsonb + ${moderation?.rawResponse ? JSON.stringify(moderation.rawResponse) : null} ) returning id `); @@ -396,8 +398,8 @@ async function loadBillboard(db: ReturnType, id: string) { billboards.lng, billboards.status, ( - select count(*)::int - from app.billboard_placements + select count(*) + from billboard_placements where billboard_placements.billboard_id = billboards.id and billboard_placements.deleted_at is null @@ -407,14 +409,14 @@ async function loadBillboard(db: ReturnType, id: string) { billboards.empty_expires_at as "emptyExpiresAt", billboards.expires_at as "expiresAt", billboards.created_at as "createdAt" - from app.billboards - join app.users on users.id = billboards.author_id + from billboards + join users on users.id = billboards.author_id where billboards.id = ${id} and billboards.deleted_at is null and billboards.hidden_at is null and billboards.status = 'active' - and billboards.expires_at > now() + and billboards.expires_at > ${nowSql()} `); const billboard = rows[0]; @@ -447,9 +449,9 @@ async function loadPlacements(db: ReturnType, billboardId: string) sticker_assets.palette as "stickerPalette", sticker_assets.status as "stickerStatus", sticker_assets.created_at as "stickerAssetCreatedAt" - from app.billboard_placements - join app.users on users.id = billboard_placements.author_id - left join app.sticker_assets on sticker_assets.id = billboard_placements.sticker_asset_id + from billboard_placements + join users on users.id = billboard_placements.author_id + left join sticker_assets on sticker_assets.id = billboard_placements.sticker_asset_id where billboard_placements.billboard_id = ${billboardId} and billboard_placements.deleted_at is null diff --git a/apps/api/src/routes/pois.ts b/apps/api/src/routes/pois.ts index e0a7284..1e5779b 100644 --- a/apps/api/src/routes/pois.ts +++ b/apps/api/src/routes/pois.ts @@ -13,12 +13,12 @@ import { sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; -import { getDb } from "../db"; +import { getDb, newId, nowSql, sydneyDateSql } from "../db"; import { badRequest, notFound } from "../http"; import { getAuthUser, optionalAuth, requireAuth } from "../middleware/auth"; import { incrementQuestProgress, questProgressUpdate } from "../services/progression"; import { ensureDailyRotations } from "../services/rotations"; -import { isoDate, isoDateTime } from "../serialize"; +import { isoDate, isoDateTime, jsonObject } from "../serialize"; import type { AppBindings, AuthUser } from "../types"; import { requireAdmin } from "./users"; @@ -75,17 +75,17 @@ poisRoute.get("/pois", optionalAuth, async (c) => { pois.is_active as "isActive", poi_daily_activations.active_on as "activeOn", exists ( - select 1 from app.poi_visits + select 1 from poi_visits where poi_visits.poi_id = pois.id and poi_visits.user_id = ${authUser?.id ?? null} ) as visited, pois.created_at as "createdAt", pois.updated_at as "updatedAt", - 0::int as "visitCount" - from app.pois - join app.poi_daily_activations + 0 as "visitCount" + from pois + join poi_daily_activations on poi_daily_activations.poi_id = pois.id and poi_daily_activations.campus_id = pois.campus_id - and poi_daily_activations.active_on = (timezone(${campus.timezone}, now()))::date + and poi_daily_activations.active_on = ${sydneyDateSql()} where pois.campus_id = ${campus.id} and pois.deleted_at is null @@ -124,7 +124,7 @@ poisRoute.post("/pois", requireAuth, zValidator("json", upsertPoiInputSchema), a const rows = "id" in input ? await db.execute<{ id: string }>(sql` - update app.pois + update pois set campus_id = coalesce(${input.campusId ?? null}, campus_id), title = coalesce(${input.title ?? null}, title), @@ -132,39 +132,33 @@ poisRoute.post("/pois", requireAuth, zValidator("json", upsertPoiInputSchema), a picture_base64 = coalesce(${input.pictureBase64 ?? null}, picture_base64), lat = coalesce(${input.lat ?? null}, lat), lng = coalesce(${input.lng ?? null}, lng), - location_point = case - when ${input.lat ?? null}::double precision is not null - and ${input.lng ?? null}::double precision is not null - then st_setsrid(st_makepoint(${input.lng}, ${input.lat}), 4326)::geography - else location_point - end, radius_meters = coalesce(${input.radiusMeters ?? null}, radius_meters), is_active = coalesce(${input.isActive ?? null}, is_active), - updated_at = now() + updated_at = ${nowSql()} where id = ${input.id} returning id `) : await db.execute<{ id: string }>(sql` - insert into app.pois ( + insert into pois ( + id, campus_id, title, description, picture_base64, lat, lng, - location_point, radius_meters, is_active, created_by ) values ( + ${newId()}, ${input.campusId}, ${input.title}, ${input.description ?? null}, ${input.pictureBase64 ?? null}, ${input.lat}, ${input.lng}, - st_setsrid(st_makepoint(${input.lng}, ${input.lat}), 4326)::geography, ${input.radiusMeters}, ${input.isActive}, ${authUser.id} @@ -197,21 +191,17 @@ poisRoute.post( } const rows = await db.execute<{ - campusId: string; + lat: number; + lng: number; radiusMeters: number; visitedAt: Date | string; - withinRadius: boolean; }>(sql` select - campus_id as "campusId", + lat, + lng, radius_meters as "radiusMeters", - now() as "visitedAt", - st_dwithin( - location_point, - st_setsrid(st_makepoint(${input.lng}, ${input.lat}), 4326)::geography, - radius_meters - ) as "withinRadius" - from app.pois + ${nowSql()} as "visitedAt" + from pois where id = ${id.data} and deleted_at is null and is_active `); const poi = rows[0]; @@ -220,7 +210,9 @@ poisRoute.post( notFound("POI not found."); } - if (!poi.withinRadius) { + const withinRadius = distanceMeters(input.lat, input.lng, poi.lat, poi.lng) <= poi.radiusMeters; + + if (!withinRadius) { return c.json( visitPoiResponseSchema.parse({ firstVisit: false, @@ -233,7 +225,7 @@ poisRoute.post( } const insertRows = await db.execute<{ inserted: boolean; visitedAt: Date | string }>(sql` - insert into app.poi_visits (user_id, poi_id) + insert into poi_visits (user_id, poi_id) values (${authUser.id}, ${id.data}) on conflict (user_id, poi_id) do nothing returning true as inserted, visited_at as "visitedAt" @@ -272,7 +264,7 @@ async function loadCampus(db: ReturnType, campusId: string | undef radius_meters as "radiusMeters", bounds, map_provider as "mapProvider" - from app.campuses + from campuses where ${campusId ? sql`id = ${campusId}` : sql`true`} order by created_at limit 1 @@ -300,20 +292,20 @@ async function loadPoi(db: ReturnType, id: string, userId: string pois.is_active as "isActive", poi_daily_activations.active_on as "activeOn", exists ( - select 1 from app.poi_visits + select 1 from poi_visits where poi_visits.poi_id = pois.id and poi_visits.user_id = ${userId ?? null} ) as visited, pois.created_at as "createdAt", pois.updated_at as "updatedAt", ( - select count(*)::int from app.poi_visits where poi_visits.poi_id = pois.id + select count(*) from poi_visits where poi_visits.poi_id = pois.id ) as "visitCount" - from app.pois - left join app.campuses on campuses.id = pois.campus_id - left join app.poi_daily_activations + from pois + left join campuses on campuses.id = pois.campus_id + left join poi_daily_activations on poi_daily_activations.poi_id = pois.id and poi_daily_activations.campus_id = pois.campus_id - and poi_daily_activations.active_on = (timezone(campuses.timezone, now()))::date + and poi_daily_activations.active_on = ${sydneyDateSql()} where pois.id = ${id} and pois.deleted_at is null `); const poi = rows[0]; @@ -327,7 +319,7 @@ async function loadPoi(db: ReturnType, id: string, userId: string function campusResponse(row: CampusRow) { return { - bounds: row.bounds, + bounds: jsonObject(row.bounds) ?? row.bounds, center: { lat: row.centerLat, lng: row.centerLng, @@ -346,13 +338,13 @@ function poiSummary(row: PoiRow) { campusId: row.campusId, description: row.description, id: row.id, - isActive: row.isActive, + isActive: Boolean(row.isActive), lat: row.lat, lng: row.lng, pictureBase64: row.pictureBase64, radiusMeters: row.radiusMeters, title: row.title, - visited: row.visited, + visited: Boolean(row.visited), }; } @@ -368,3 +360,13 @@ function poiDetail(row: PoiRow) { function safeAuthUser(c: { var: { authUser?: AuthUser } }) { return c.var.authUser; } + +function distanceMeters(fromLat: number, fromLng: number, toLat: number, toLng: number) { + const metersPerLatDegree = 111_320; + const averageLatRadians = (((fromLat + toLat) / 2) * Math.PI) / 180; + const metersPerLngDegree = metersPerLatDegree * Math.cos(averageLatRadians); + const deltaLat = (fromLat - toLat) * metersPerLatDegree; + const deltaLng = (fromLng - toLng) * metersPerLngDegree; + + return Math.hypot(deltaLat, deltaLng); +} diff --git a/apps/api/src/routes/quests.ts b/apps/api/src/routes/quests.ts index 6c25cfd..520ea19 100644 --- a/apps/api/src/routes/quests.ts +++ b/apps/api/src/routes/quests.ts @@ -8,7 +8,7 @@ import { zValidator } from "@hono/zod-validator"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { getDb } from "../db"; +import { getDb, nowSql } from "../db"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { applyStreakMilestoneUnlocks } from "../services/progression"; import { ensureDailyRotations } from "../services/rotations"; @@ -60,8 +60,8 @@ questsRoute.post( when user_quest_progress.source = 'level_quest' then level_quest_sets.xp_reward else 0 end as "xpReward" - from app.user_quest_progress - left join app.level_quest_sets + from user_quest_progress + left join level_quest_sets on user_quest_progress.source = 'level_quest' and user_quest_progress.source_id = level_quest_sets.id where user_quest_progress.id = ${id} and user_quest_progress.user_id = ${authUser.id} @@ -101,38 +101,38 @@ questsRoute.post( const userBefore = await loadUser(c.env, authUser.id); await db.execute(sql` - update app.user_quest_progress - set claimed_at = now(), claimed_xp = ${quest.xpReward}, updated_at = now() + update user_quest_progress + set claimed_at = ${nowSql()}, claimed_xp = ${quest.xpReward}, updated_at = ${nowSql()} where id = ${id} and claimed_at is null `); await db.execute(sql` - update app.users + update users set xp = case when ${quest.source} = 'level_quest' then xp + ${quest.xpReward} else xp end, last_daily_claimed_on = case - when ${quest.source} = 'daily_quest' then (timezone('Australia/Sydney', now()))::date + when ${quest.source} = 'daily_quest' then date('now', '+10 hours') else last_daily_claimed_on end, daily_streak = case when ${quest.source} = 'daily_quest' and ( last_daily_claimed_on is null - or last_daily_claimed_on < (timezone('Australia/Sydney', now()))::date + or last_daily_claimed_on < date('now', '+10 hours') ) then daily_streak + 1 else daily_streak end, streak_updated_on = case - when ${quest.source} = 'daily_quest' then (timezone('Australia/Sydney', now()))::date + when ${quest.source} = 'daily_quest' then date('now', '+10 hours') else streak_updated_on end, - updated_at = now() + updated_at = ${nowSql()} where id = ${authUser.id} `); if (quest.source === "daily_quest") { const streakRows = await db.execute<{ dailyStreak: number }>(sql` - select daily_streak as "dailyStreak" from app.users where id = ${authUser.id} + select daily_streak as "dailyStreak" from users where id = ${authUser.id} `); const streak = streakRows[0]?.dailyStreak ?? 0; await applyStreakMilestoneUnlocks(db, authUser.id, streak); @@ -145,7 +145,7 @@ questsRoute.post( const userAfter = await loadUser(c.env, authUser.id); const unlockedRows = await db.execute<{ levelPerkId: string }>(sql` select level_perk_id as "levelPerkId" - from app.user_perk_unlocks + from user_perk_unlocks where user_id = ${authUser.id} and source_level > ${userBefore.level} order by source_level, level_perk_id `); @@ -165,10 +165,10 @@ questsRoute.post( async function maybeLevelUp(db: ReturnType, userId: string) { const pendingRows = await db.execute<{ pending: number }>(sql` - select count(*)::int as pending - from app.user_quest_progress - join app.level_quest_sets on level_quest_sets.id = user_quest_progress.source_id - join app.users on users.id = user_quest_progress.user_id + select count(*) as pending + from user_quest_progress + join level_quest_sets on level_quest_sets.id = user_quest_progress.source_id + join users on users.id = user_quest_progress.user_id where user_quest_progress.user_id = ${userId} and user_quest_progress.source = 'level_quest' @@ -181,8 +181,8 @@ async function maybeLevelUp(db: ReturnType, userId: string) { } const leveledRows = await db.execute<{ level: number }>(sql` - update app.users - set level = level + 1, updated_at = now() + update users + set level = level + 1, updated_at = ${nowSql()} where id = ${userId} returning level `); @@ -193,10 +193,10 @@ async function maybeLevelUp(db: ReturnType, userId: string) { } await db.execute(sql` - insert into app.user_perk_unlocks (user_id, level_perk_id, source_level) + insert into user_perk_unlocks (user_id, level_perk_id, source_level) select ${userId}, id, level - from app.level_perks - where level <= ${level} + from level_perks + where level >= 1 and level <= ${level} on conflict (user_id, level_perk_id) do nothing `); } diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 505c576..f7181e2 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -9,7 +9,7 @@ import { zValidator } from "@hono/zod-validator"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { getDb } from "../db"; +import { getDb, newId, nowSql } from "../db"; import { notFound } from "../http"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { isoDateTime } from "../serialize"; @@ -56,10 +56,10 @@ reportsRoute.post( const authUser = getAuthUser(c); const input = c.req.valid("json"); const rows = await db.execute<{ id: string }>(sql` - insert into app.reports (reporter_id, target_type, target_id, reason, details) - values (${authUser.id}, ${input.targetType}, ${input.targetId}, ${input.reason}, ${input.details ?? null}) + insert into reports (id, reporter_id, target_type, target_id, reason, details) + values (${newId()}, ${authUser.id}, ${input.targetType}, ${input.targetId}, ${input.reason}, ${input.details ?? null}) on conflict (reporter_id, target_type, target_id) do update - set reason = excluded.reason, details = excluded.details, updated_at = now() + set reason = excluded.reason, details = excluded.details, updated_at = ${nowSql()} returning id `); const report = await loadReport(db, rows[0]!.id); @@ -105,7 +105,8 @@ reportsRoute.post( await applyModerationAction(db, report, input.action); const actionRows = await db.execute(sql` - insert into app.moderation_actions ( + insert into moderation_actions ( + id, report_id, admin_id, action, @@ -114,6 +115,7 @@ reportsRoute.post( notes ) values ( + ${newId()}, ${report.id}, ${authUser.id}, ${input.action}, @@ -132,11 +134,11 @@ reportsRoute.post( created_at as "createdAt" `); await db.execute(sql` - update app.reports + update reports set status = case when ${input.action} = 'dismiss' then 'dismissed' else 'resolved' end, admin_notes = ${input.notes ?? null}, - updated_at = now() + updated_at = ${nowSql()} where id = ${report.id} `); @@ -183,8 +185,8 @@ function reportSelectSql() { users.avatar_base64 as "reporterAvatarBase64", users.level as "reporterLevel", users.created_at as "reporterCreatedAt" - from app.reports - join app.users on users.id = reports.reporter_id + from reports + join users on users.id = reports.reporter_id `; } @@ -195,32 +197,32 @@ async function applyModerationAction( ) { if (report.targetType === "billboard" && (action === "hide" || action === "remove")) { await db.execute(sql` - update app.billboards + update billboards set - hidden_at = case when ${action} = 'hide' then now() else hidden_at end, - deleted_at = case when ${action} = 'remove' then now() else deleted_at end, + hidden_at = case when ${action} = 'hide' then ${nowSql()} else hidden_at end, + deleted_at = case when ${action} = 'remove' then ${nowSql()} else deleted_at end, status = case when ${action} = 'hide' then 'hidden' else 'removed' end, - updated_at = now() + updated_at = ${nowSql()} where id = ${report.targetId} `); } if (report.targetType === "placement" && (action === "hide" || action === "remove")) { await db.execute(sql` - update app.billboard_placements + update billboard_placements set - hidden_at = case when ${action} = 'hide' then now() else hidden_at end, - deleted_at = case when ${action} = 'remove' then now() else deleted_at end, + hidden_at = case when ${action} = 'hide' then ${nowSql()} else hidden_at end, + deleted_at = case when ${action} = 'remove' then ${nowSql()} else deleted_at end, status = case when ${action} = 'hide' then 'hidden' else 'removed' end, - updated_at = now() + updated_at = ${nowSql()} where id = ${report.targetId} `); } if (report.targetType === "user" && action === "ban") { await db.execute(sql` - update app.users - set banned_at = coalesce(banned_at, now()), updated_at = now() + update users + set banned_at = coalesce(banned_at, ${nowSql()}), updated_at = ${nowSql()} where id = ${report.targetId} `); } diff --git a/apps/api/src/routes/signatures.ts b/apps/api/src/routes/signatures.ts index 63a3a28..2f21cbd 100644 --- a/apps/api/src/routes/signatures.ts +++ b/apps/api/src/routes/signatures.ts @@ -41,14 +41,14 @@ signaturesRoute.get("/signatures", async (c) => { asset_base64 as "assetBase64", streak_day_required as "streakDayRequired", active - from app.signatures + from signatures where active order by streak_day_required `); return c.json( listSignaturesResponseSchema.parse({ - catalog: rows.map((row) => ({ ...row })), + catalog: rows.map((row) => ({ ...row, active: Boolean(row.active) })), }), ); }); @@ -67,17 +67,17 @@ signaturesRoute.get("/users/me/signatures", requireAuth, async (c) => { signatures.active, user_signatures.unlocked_at as "unlockedAt", user_signatures.is_equipped as "isEquipped" - from app.user_signatures - join app.signatures on signatures.id = user_signatures.signature_id + from user_signatures + join signatures on signatures.id = user_signatures.signature_id where user_signatures.user_id = ${authUser.id} order by signatures.streak_day_required `), db.execute<{ unlocked: boolean }>(sql` select exists ( select 1 - from app.user_perk_unlocks - join app.level_perks on level_perks.id = user_perk_unlocks.level_perk_id - join app.perk_definitions on perk_definitions.id = level_perks.perk_id + from user_perk_unlocks + join level_perks on level_perks.id = user_perk_unlocks.level_perk_id + join perk_definitions on perk_definitions.id = level_perks.perk_id where user_perk_unlocks.user_id = ${authUser.id} and perk_definitions.key = ${SIGNATURE_FEATURE_PERK_KEY} ) as unlocked @@ -86,11 +86,11 @@ signaturesRoute.get("/users/me/signatures", requireAuth, async (c) => { return c.json( listMySignaturesResponseSchema.parse({ - signatureFeatureUnlocked: featureRows[0]?.unlocked ?? false, + signatureFeatureUnlocked: Boolean(featureRows[0]?.unlocked), unlocked: rows.map((row) => ({ - isEquipped: row.isEquipped, + isEquipped: Boolean(row.isEquipped), signature: { - active: row.active, + active: Boolean(row.active), assetBase64: row.assetBase64, id: row.id, key: row.key, @@ -113,14 +113,14 @@ signaturesRoute.patch( const input = c.req.valid("json"); await db.execute(sql` - update app.user_signatures + update user_signatures set is_equipped = false where user_id = ${authUser.id} and is_equipped `); if (input.signatureId !== null) { await db.execute(sql` - update app.user_signatures + update user_signatures set is_equipped = true where user_id = ${authUser.id} and signature_id = ${input.signatureId} `); diff --git a/apps/api/src/routes/stickers.ts b/apps/api/src/routes/stickers.ts index 6a5636b..8ed70eb 100644 --- a/apps/api/src/routes/stickers.ts +++ b/apps/api/src/routes/stickers.ts @@ -11,7 +11,7 @@ import { zValidator } from "@hono/zod-validator"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { getDb } from "../db"; +import { getDb, newId, nowSql } from "../db"; import { conflict, forbidden, notFound } from "../http"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { jsonObject, isoDateTime } from "../serialize"; @@ -64,7 +64,8 @@ stickersRoute.post( } const rows = await db.execute<{ id: string }>(sql` - insert into app.sticker_assets ( + insert into sticker_assets ( + id, owner_id, png_base64, width, @@ -74,13 +75,14 @@ stickersRoute.post( moderation_summary ) values ( + ${newId()}, ${authUser.id}, ${input.pngBase64}, ${input.width}, ${input.height}, - ${input.palette ? JSON.stringify(input.palette) : null}::jsonb, + ${input.palette ? JSON.stringify(input.palette) : null}, 'active', - ${moderation.rawResponse ? JSON.stringify(moderation.rawResponse) : null}::jsonb + ${moderation.rawResponse ? JSON.stringify(moderation.rawResponse) : null} ) returning id `); @@ -118,8 +120,8 @@ stickersRoute.post( const input = c.req.valid("json"); const capacity = await getUserCapacities(db, authUser.id); const countRows = await db.execute<{ count: number }>(sql` - select count(*)::int as count - from app.saved_stickers + select count(*) as count + from saved_stickers where user_id = ${authUser.id} and deleted_at is null `); @@ -128,8 +130,9 @@ stickersRoute.post( } const rows = await db.execute<{ id: string }>(sql` - insert into app.saved_stickers (user_id, kind, sticker_asset_id, body, label) + insert into saved_stickers (id, user_id, kind, sticker_asset_id, body, label) values ( + ${newId()}, ${authUser.id}, ${input.kind}, ${input.kind === "sticker" ? input.stickerAssetId : null}, @@ -162,8 +165,8 @@ stickersRoute.delete("/users/me/saved-stickers/:id", requireAuth, async (c) => { } const rows = await db.execute<{ id: string }>(sql` - update app.saved_stickers - set deleted_at = now() + update saved_stickers + set deleted_at = ${nowSql()} where id = ${id.data} and user_id = ${authUser.id} and deleted_at is null returning id `); @@ -186,7 +189,7 @@ async function loadStickerAsset(db: ReturnType, id: string) { palette, status, created_at as "createdAt" - from app.sticker_assets + from sticker_assets where id = ${id} and deleted_at is null `); const sticker = rows[0]; @@ -215,8 +218,8 @@ async function loadSavedStickers(db: ReturnType, userId: string) { sticker_assets.palette as "stickerPalette", sticker_assets.status as "stickerStatus", sticker_assets.created_at as "stickerCreatedAt" - from app.saved_stickers - left join app.sticker_assets on sticker_assets.id = saved_stickers.sticker_asset_id + from saved_stickers + left join sticker_assets on sticker_assets.id = saved_stickers.sticker_asset_id where saved_stickers.user_id = ${userId} and saved_stickers.deleted_at is null order by saved_stickers.created_at desc `); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 40f358d..2af8521 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -11,7 +11,7 @@ import { zValidator } from "@hono/zod-validator"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { getDb } from "../db"; +import { getDb, nowSql } from "../db"; import { forbidden, notFound } from "../http"; import { getAuthUser, requireAuth } from "../middleware/auth"; import { @@ -19,7 +19,7 @@ import { getUserCapacities, questRowToProgress, } from "../services/progression"; -import { isoDateTime, nullableIsoDate, nullableIsoDateTime } from "../serialize"; +import { isoDateTime, jsonObject, nullableIsoDate, nullableIsoDateTime } from "../serialize"; import type { AppBindings } from "../types"; type UserRow = { @@ -69,11 +69,11 @@ usersRoute.patch( const authUser = getAuthUser(c); const db = getDb(c.env); const rows = await db.execute(sql` - update app.users + update users set username = coalesce(${input.username ?? null}, username), display_name = coalesce(${input.displayName ?? null}, display_name), - updated_at = now() + updated_at = ${nowSql()} where id = ${authUser.id} returning id, @@ -105,8 +105,8 @@ usersRoute.patch( const authUser = getAuthUser(c); const db = getDb(c.env); const rows = await db.execute(sql` - update app.users - set avatar_base64 = ${input.avatarBase64}, updated_at = now() + update users + set avatar_base64 = ${input.avatarBase64}, updated_at = ${nowSql()} where id = ${authUser.id} returning id, @@ -147,39 +147,39 @@ usersRoute.get("/users/me/progress", requireAuth, async (c) => { stickersSaved: number; }>(sql` select - (select count(*)::int from app.poi_visits where user_id = ${authUser.id}) as "poisVisited", + (select count(*) from poi_visits where user_id = ${authUser.id}) as "poisVisited", ( - select count(*)::int - from app.billboards + select count(*) + from billboards where author_id = ${authUser.id} - and (timezone('Australia/Sydney', created_at))::date = - (timezone('Australia/Sydney', now()))::date + and date(created_at, '+10 hours') = + date('now', '+10 hours') ) as "billboardsCreatedToday", ( - select count(*)::int - from app.billboards + select count(*) + from billboards where author_id = ${authUser.id} and deleted_at is null and hidden_at is null and status = 'active' - and expires_at > now() + and expires_at > ${nowSql()} ) as "activeBillboards", ( - select count(*)::int - from app.saved_stickers + select count(*) + from saved_stickers where user_id = ${authUser.id} and deleted_at is null ) as "stickersSaved", ( - select count(*)::int - from app.billboard_placements + select count(*) + from billboard_placements where author_id = ${authUser.id} and deleted_at is null ) as "placementsCreated", ( - select count(*)::int - from app.billboard_placements - join app.billboards on billboards.id = billboard_placements.billboard_id + select count(*) + from billboard_placements + join billboards on billboards.id = billboard_placements.billboard_id where billboards.author_id = ${authUser.id} and billboard_placements.author_id <> ${authUser.id} @@ -250,7 +250,7 @@ export async function loadUser(env: AppBindings["Bindings"], id: string) { deleted_at as "deletedAt", created_at as "createdAt", updated_at as "updatedAt" - from app.users + from users where id = ${id} and deleted_at is null `); @@ -285,7 +285,7 @@ function currentUser(user: UserRow) { bannedAt: nullableIsoDateTime(user.bannedAt), dailyStreak: user.dailyStreak, deletedAt: nullableIsoDateTime(user.deletedAt), - isAdmin: user.isAdmin, + isAdmin: Boolean(user.isAdmin), lastDailyClaimedOn: nullableIsoDate(user.lastDailyClaimedOn), streakUpdatedOn: nullableIsoDate(user.streakUpdatedOn), updatedAt: isoDateTime(user.updatedAt), @@ -306,7 +306,7 @@ function levelPerk(row: PerkRow) { return { id: row.levelPerkId, level: row.level, - metadata: row.metadata, + metadata: jsonObject(row.metadata), numericValue: row.numericValue, perk: perk(row), }; @@ -334,9 +334,9 @@ async function loadUnlockedPerks(db: ReturnType, userId: string) { perk_definitions.description, user_perk_unlocks.source_level as "sourceLevel", user_perk_unlocks.unlocked_at as "unlockedAt" - from app.user_perk_unlocks - join app.level_perks on level_perks.id = user_perk_unlocks.level_perk_id - join app.perk_definitions on perk_definitions.id = level_perks.perk_id + from user_perk_unlocks + join level_perks on level_perks.id = user_perk_unlocks.level_perk_id + join perk_definitions on perk_definitions.id = level_perks.perk_id where user_perk_unlocks.user_id = ${userId} order by level_perks.level, perk_definitions.key `); @@ -353,10 +353,10 @@ async function loadNextPerks(db: ReturnType, level: number) { perk_definitions.key as "perkKey", perk_definitions.name, perk_definitions.description - from app.level_perks - join app.perk_definitions on perk_definitions.id = level_perks.perk_id + from level_perks + join perk_definitions on perk_definitions.id = level_perks.perk_id where level_perks.level = ( - select min(level) from app.level_perks where level > ${level} + select min(level) from level_perks where level > ${level} ) order by level_perks.level, perk_definitions.key `); @@ -392,7 +392,7 @@ export async function loadQuestRows(db: ReturnType, userId: string replace( coalesce(quest_templates.title_template, daily_quest_templates.title_template), '{target}', - user_quest_progress.target_count::text + user_quest_progress.target_count ) as title, replace( coalesce( @@ -400,23 +400,26 @@ export async function loadQuestRows(db: ReturnType, userId: string daily_quest_templates.description_template ), '{target}', - user_quest_progress.target_count::text + user_quest_progress.target_count ) as description, level_quest_sets.level, level_quest_sets.sort_order as "sortOrder", user_quest_progress.active_on as "activeOn" - from app.user_quest_progress - left join app.level_quest_sets + from user_quest_progress + left join level_quest_sets on user_quest_progress.source = 'level_quest' and user_quest_progress.source_id = level_quest_sets.id - left join app.quest_templates on quest_templates.id = level_quest_sets.template_id - left join app.daily_quest_pool + left join quest_templates on quest_templates.id = level_quest_sets.template_id + left join daily_quest_pool on user_quest_progress.source = 'daily_quest' - and user_quest_progress.source_id = daily_quest_pool.id - left join app.daily_quest_templates + and daily_quest_pool.id = user_quest_progress.source_id + left join daily_quest_templates on daily_quest_templates.id = daily_quest_pool.template_id where user_quest_progress.user_id = ${userId} - order by user_quest_progress.source, level_quest_sets.sort_order nulls last + order by + user_quest_progress.source, + level_quest_sets.sort_order is null, + level_quest_sets.sort_order `); return rows.map(questRowToProgress); diff --git a/apps/api/src/serialize.ts b/apps/api/src/serialize.ts index af3ae08..bf0d040 100644 --- a/apps/api/src/serialize.ts +++ b/apps/api/src/serialize.ts @@ -21,6 +21,14 @@ export function nullableIsoDate(value: Date | string | null) { } export function jsonObject(value: unknown): JsonObject | null { + if (typeof value === "string") { + try { + return jsonObject(JSON.parse(value)); + } catch { + return null; + } + } + if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts index 422b0a8..ff3f46a 100644 --- a/apps/api/src/services/moderation.ts +++ b/apps/api/src/services/moderation.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; +import { newId } from "../db"; import type { getDb } from "../db"; import type { Env } from "../types"; @@ -220,7 +221,8 @@ export async function recordModerationLog( result: ModerationResult, ) { await db.execute(sql` - insert into app.content_moderation_logs ( + insert into content_moderation_logs ( + id, target_type, target_id, flagged, @@ -229,12 +231,13 @@ export async function recordModerationLog( raw_response ) values ( + ${newId()}, ${targetType}, ${targetId}, ${result.flagged}, - ${result.categories ? JSON.stringify(result.categories) : null}::jsonb, - ${result.scores ? JSON.stringify(result.scores) : null}::jsonb, - ${result.rawResponse ? JSON.stringify(result.rawResponse) : null}::jsonb + ${result.categories ? JSON.stringify(result.categories) : null}, + ${result.scores ? JSON.stringify(result.scores) : null}, + ${result.rawResponse ? JSON.stringify(result.rawResponse) : null} ) `); } diff --git a/apps/api/src/services/progression.ts b/apps/api/src/services/progression.ts index 6f0f056..2c7e6b8 100644 --- a/apps/api/src/services/progression.ts +++ b/apps/api/src/services/progression.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; +import { newId, newIdSql, nowSql, sydneyDateSql } from "../db"; import type { getDb } from "../db"; import { isoDate, isoDateTime, nullableIsoDateTime } from "../serialize"; @@ -29,23 +30,24 @@ type CapacityRow = { export async function ensureQuestProgress(db: Database, userId: string) { await db.execute(sql` - insert into app.user_quest_progress (user_id, source, source_id, target_count) - select ${userId}, 'level_quest', level_quest_sets.id, level_quest_sets.target_count - from app.users - join app.level_quest_sets on level_quest_sets.level = users.level + insert into user_quest_progress (id, user_id, source, source_id, target_count) + select ${newIdSql()}, ${userId}, 'level_quest', level_quest_sets.id, level_quest_sets.target_count + from users + join level_quest_sets on level_quest_sets.level = users.level where users.id = ${userId} on conflict (user_id, source, source_id) where active_on is null do nothing `); await db.execute(sql` - insert into app.user_quest_progress (user_id, source, source_id, target_count, active_on) + insert into user_quest_progress (id, user_id, source, source_id, target_count, active_on) select + ${newIdSql()}, ${userId}, 'daily_quest', daily_quest_pool.id, daily_quest_pool.target_count, - (timezone('Australia/Sydney', now()))::date - from app.daily_quest_pool + ${sydneyDateSql()} + from daily_quest_pool where daily_quest_pool.active on conflict (user_id, source, source_id, active_on) where active_on is not null do nothing `); @@ -65,41 +67,40 @@ export async function incrementQuestProgress( user_quest_progress.id, user_quest_progress.progress_count, user_quest_progress.target_count - from app.user_quest_progress - left join app.level_quest_sets + from user_quest_progress + left join level_quest_sets on user_quest_progress.source = 'level_quest' and user_quest_progress.source_id = level_quest_sets.id - left join app.quest_templates + left join quest_templates on level_quest_sets.template_id = quest_templates.id - left join app.daily_quest_pool + left join daily_quest_pool on user_quest_progress.source = 'daily_quest' and user_quest_progress.source_id = daily_quest_pool.id - left join app.daily_quest_templates + left join daily_quest_templates on daily_quest_pool.template_id = daily_quest_templates.id where user_quest_progress.user_id = ${userId} and user_quest_progress.claimed_at is null and coalesce(quest_templates.trigger_type, daily_quest_templates.trigger_type) = ${triggerType} ) - update app.user_quest_progress + update user_quest_progress set - progress_count = least( + progress_count = min( user_quest_progress.target_count, user_quest_progress.progress_count + ${delta} ), completed_at = case when user_quest_progress.completed_at is not null then user_quest_progress.completed_at - when user_quest_progress.progress_count + ${delta} >= user_quest_progress.target_count then now() + when user_quest_progress.progress_count + ${delta} >= user_quest_progress.target_count then ${nowSql()} else null end, claimable_at = case when user_quest_progress.claimable_at is not null then user_quest_progress.claimable_at - when user_quest_progress.progress_count + ${delta} >= user_quest_progress.target_count then now() + when user_quest_progress.progress_count + ${delta} >= user_quest_progress.target_count then ${nowSql()} else null end, - updated_at = now() - from matching_progress - where user_quest_progress.id = matching_progress.id + updated_at = ${nowSql()} + where user_quest_progress.id in (select id from matching_progress) returning user_quest_progress.id as "questId", user_quest_progress.source, @@ -117,15 +118,15 @@ export async function getUserCapacities(db: Database, userId: string) { const rows = await db.execute(sql` with user_level as ( select level - from app.users + from users where id = ${userId} ), level_caps as ( select perk_definitions.key, max(level_perks.numeric_value) as cap - from app.level_perks - join app.perk_definitions on perk_definitions.id = level_perks.perk_id + from level_perks + join perk_definitions on perk_definitions.id = level_perks.perk_id cross join user_level where level_perks.level >= 1 @@ -136,9 +137,9 @@ export async function getUserCapacities(db: Database, userId: string) { select perk_definitions.key, coalesce(sum(level_perks.numeric_value), 0) as delta - from app.level_perks - join app.perk_definitions on perk_definitions.id = level_perks.perk_id - join app.user_perk_unlocks + from level_perks + join perk_definitions on perk_definitions.id = level_perks.perk_id + join user_perk_unlocks on user_perk_unlocks.level_perk_id = level_perks.id and user_perk_unlocks.user_id = ${userId} where level_perks.level = 0 @@ -185,8 +186,8 @@ export async function getUserCapacities(db: Database, userId: string) { export function questProgressUpdate(row: ProgressUpdateRow) { return { - claimable: row.claimable, - completed: row.completed, + claimable: Boolean(row.claimable), + completed: Boolean(row.completed), progressCount: row.progressCount, questId: row.questId, source: row.source, @@ -255,45 +256,52 @@ type StreakRewardJson = { >; }; +function parseStreakReward(value: StreakRewardJson | string): StreakRewardJson { + return typeof value === "string" ? (JSON.parse(value) as StreakRewardJson) : value; +} + export async function applyStreakMilestoneUnlocks( db: Database, userId: string, currentStreak: number, ) { - const rewardRows = await db.execute<{ streakDays: number; reward: StreakRewardJson }>(sql` + const rewardRows = await db.execute<{ + streakDays: number; + reward: StreakRewardJson | string; + }>(sql` select streak_days as "streakDays", reward - from app.streak_reward_definitions + from streak_reward_definitions where active and streak_days = ${currentStreak} `); for (const row of rewardRows) { - for (const reward of row.reward.rewards) { + for (const reward of parseStreakReward(row.reward).rewards) { if (reward.type === "signature") { await db.execute(sql` - insert into app.user_signatures (user_id, signature_id, is_equipped) + insert into user_signatures (user_id, signature_id, is_equipped) select ${userId}, signatures.id, false - from app.signatures + from signatures where signatures.key = ${reward.signatureKey} on conflict (user_id, signature_id) do nothing `); } else if (reward.type === "capacity_billboard") { - // Streak-derived capacity perks are stored at level=0 in app.level_perks + // Streak-derived capacity perks are stored at level=0 in level_perks // with metadata.source='streak'. The unique (level, perk_id) means there's // exactly one canonical streak-billboard row in the table; each user who // crosses day-14 links to it via user_perk_unlocks. await db.execute(sql` - insert into app.level_perks (level, perk_id, numeric_value, metadata) - select 0, id, ${reward.amount}, - jsonb_build_object('source', 'streak', 'streakDay', ${row.streakDays}) - from app.perk_definitions + insert into level_perks (id, level, perk_id, numeric_value, metadata) + select ${newId()}, 0, id, ${reward.amount}, + ${JSON.stringify({ source: "streak", streakDay: row.streakDays })} + from perk_definitions where key = 'max_concurrent_billboards' on conflict (level, perk_id) do nothing `); await db.execute(sql` - insert into app.user_perk_unlocks (user_id, level_perk_id, source_level) + insert into user_perk_unlocks (user_id, level_perk_id, source_level) select ${userId}, level_perks.id, 0 - from app.level_perks - join app.perk_definitions on perk_definitions.id = level_perks.perk_id + from level_perks + join perk_definitions on perk_definitions.id = level_perks.perk_id where level_perks.level = 0 and perk_definitions.key = 'max_concurrent_billboards' on conflict (user_id, level_perk_id) do nothing diff --git a/apps/api/src/services/rotations.ts b/apps/api/src/services/rotations.ts index e06843e..a17d0a3 100644 --- a/apps/api/src/services/rotations.ts +++ b/apps/api/src/services/rotations.ts @@ -1,44 +1,44 @@ import { sql } from "drizzle-orm"; +import { nowSql, sydneyDateSql } from "../db"; import type { getDb } from "../db"; type Database = ReturnType; export async function ensureDailyRotations(db: Database) { await db.execute(sql` - insert into app.poi_daily_activations (campus_id, poi_id, active_on) - select - campuses.id, - selected_pois.id, - (timezone(campuses.timezone, now()))::date - from app.campuses - cross join lateral ( - select pois.id - from app.pois + insert into poi_daily_activations (campus_id, poi_id, active_on) + with ranked_pois as ( + select + campuses.id as campus_id, + pois.id as poi_id, + row_number() over (partition by campuses.id order by random()) as rotation_rank + from campuses + join pois on pois.campus_id = campuses.id where - pois.campus_id = campuses.id - and pois.is_active + pois.is_active and pois.deleted_at is null - order by random() - limit 5 - ) selected_pois + ) + select campus_id, poi_id, ${sydneyDateSql()} + from ranked_pois + where rotation_rank <= 5 on conflict (campus_id, poi_id, active_on) do nothing `); } export async function expireBillboards(db: Database) { await db.execute(sql` - update app.billboards - set deleted_at = coalesce(deleted_at, now()), updated_at = now() + update billboards + set deleted_at = coalesce(deleted_at, ${nowSql()}), updated_at = ${nowSql()} where deleted_at is null and ( - expires_at <= now() + expires_at <= ${nowSql()} or ( - empty_expires_at <= now() + empty_expires_at <= ${nowSql()} and not exists ( select 1 - from app.billboard_placements + from billboard_placements where billboard_placements.billboard_id = billboards.id and billboard_placements.deleted_at is null diff --git a/apps/api/src/services/streaks.ts b/apps/api/src/services/streaks.ts index f66de83..602a2e1 100644 --- a/apps/api/src/services/streaks.ts +++ b/apps/api/src/services/streaks.ts @@ -1,18 +1,19 @@ import { sql } from "drizzle-orm"; +import { nowSql } from "../db"; import type { getDb } from "../db"; type Database = ReturnType; export async function resetBrokenStreaks(db: Database) { await db.execute(sql` - update app.users - set daily_streak = 0, updated_at = now() + update users + set daily_streak = 0, updated_at = ${nowSql()} where daily_streak > 0 and ( last_daily_claimed_on is null - or last_daily_claimed_on < (timezone('Australia/Sydney', now()))::date - interval '1 day' + or last_daily_claimed_on < date('now', '+10 hours', '-1 day') ) `); } diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index a628bde..f0fc0f6 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -2,13 +2,11 @@ import type { Context } from "hono"; export type Env = { CAMPUS_REALTIME_ROOM: DurableObjectNamespace; + DB: D1Database; CLERK_PUBLISHABLE_KEY?: string; CLERK_SECRET_KEY?: string; OPENAI_API_KEY?: string; MODERATION_ENABLED?: string; - SUPABASE_POOLER_DATABASE_URL?: string; - SUPABASE_SECRET_KEY?: string; - SUPABASE_URL?: string; }; export type AuthUser = { diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index d341c70..2c1bb7d 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -25,6 +25,14 @@ "zone_name": "takuk.me", }, ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "jematala-db", + "database_id": "816d5f5d-0be0-4122-88a9-f4bcee86892e", + "migrations_dir": "../../packages/db/d1/migrations", + }, + ], "durable_objects": { "bindings": [ { diff --git a/bun.lock b/bun.lock index b582913..118f4dc 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,6 @@ "@repo/shared": "workspace:*", "drizzle-orm": "^0.45.2", "hono": "^4.12.19", - "postgres": "^3.4.9", "zod": "latest", }, "devDependencies": { @@ -87,7 +86,6 @@ "version": "0.0.0", "dependencies": { "drizzle-orm": "^0.45.2", - "postgres": "^3.4.9", }, "devDependencies": { "drizzle-kit": "^0.31.10", @@ -326,7 +324,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260518.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tnqofUq+ZvKliQHhboygbH7iy/Zm/MaCCotIlrqVj5a988+tPtndxyLM0r4vaAIC10iy/2LWCkwnE67VFTFiUA=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260519.1", "", {}, "sha512-BMWAwg4RyyZn3zcdoXbqpfogm2DGfNb83DXNCM1oFUMhYtEX8I+B+oxf67YPKvSiAEbzd7nHzW2mLv3eBH8Etw=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260520.1", "", {}, "sha512-wdmf9Fwabp06OgK9ZyCl8Q77GZ94k9J7OA9qA65FbgxZ/hd3hYRkVNiY7Bvsx4tKim+sWmKqGOblecZInAvoRg=="], "@coinbase/wallet-sdk": ["@coinbase/wallet-sdk@4.3.7", "", { "dependencies": { "@noble/hashes": "^1.4.0", "clsx": "^1.2.1", "eventemitter3": "^5.0.1", "preact": "^10.24.2", "viem": "^2.27.2" } }, "sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w=="], diff --git a/docs/superpowers/plans/2026-05-20-leveling-quests.md b/docs/superpowers/plans/2026-05-20-leveling-quests.md index fcc0c3d..72bab86 100644 --- a/docs/superpowers/plans/2026-05-20-leveling-quests.md +++ b/docs/superpowers/plans/2026-05-20-leveling-quests.md @@ -6,7 +6,7 @@ **Architecture:** The existing schema and services already match ~80% of the spec — the work is **deltas**, not a rewrite. Seed data changes drive most of the gameplay tuning. The biggest code changes are: (a) the claim flow stops awarding XP for daily quests; (b) a new streak-break path runs on the existing CF `scheduled()` cron; (c) two small new tables (`signatures`, `user_signatures`) and a new shared schema for streak rewards that support a `capacity_billboard` variant; (d) capacity computation in `getUserCapacities` extends to streak perks. Frontend deltas are confined to `QuestCard` (daily reward label), a new `NextMilestonePreview`, and a signature picker on the profile screen (gated by L4). -**Tech Stack:** Bun workspaces · Postgres + PostGIS (Supabase) · Drizzle ORM · Drizzle Kit (push) · Hono on Cloudflare Workers · Zod (shared schemas) · Expo / React Native · React Native Leaflet (not touched by this plan) +**Tech Stack:** Bun workspaces · Cloudflare D1 · Drizzle ORM · Drizzle Kit (push) · Hono on Cloudflare Workers · Zod (shared schemas) · Expo / React Native · React Native Leaflet (not touched by this plan) **Spec reference:** `docs/superpowers/specs/2026-05-20-leveling-quests-design.md` @@ -23,7 +23,7 @@ These are the only verification commands this project supports. Most tasks use o | `bun --cwd apps/api dev` | Start Worker locally (port 8787 default) | Smoke check API responses | | `bun --cwd apps/app start` | Start Expo on app | Smoke check UI | | `bun --cwd packages/db drizzle-kit push` | Apply Drizzle schema changes to DB | After Drizzle schema edits | -| `psql "$DATABASE_URL" -f packages/db/supabase/reset.sql` | Reset + reseed DB | After seed edits | +| `D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -f packages/db/d1/schema.sql` | Reset + reseed DB | After seed edits | > **Note:** `reset.sql` resets the entire `app` schema and reseeds. If running it during execution, all other working state is lost; this is fine for hackathon iteration but be aware. @@ -33,7 +33,7 @@ These are the only verification commands this project supports. Most tasks use o | File | Action | Responsibility | |---|---|---| -| `packages/db/supabase/reset.sql` | modify | Add `signatures` and `user_signatures` tables, revise `level_perks` seed (cap-5 numbers), expand `level_quest_sets` seed to all 10 levels with new ramp, replace `streak_reward_definitions` seed (days 3/7/14/30) | +| `packages/db/d1/schema.sql` | modify | Add `signatures` and `user_signatures` tables, revise `level_perks` seed (cap-5 numbers), expand `level_quest_sets` seed to all 10 levels with new ramp, replace `streak_reward_definitions` seed (days 3/7/14/30) | | `packages/db/src/schema/index.ts` | modify | Add Drizzle definitions for `signatures` and `user_signatures` to mirror SQL | | `packages/shared/src/quest.ts` | modify | Add `capacity_billboard` variant to `streakRewardSchema`; remove `xp_multiplier` variant | | `packages/shared/src/signature.ts` | create | Zod schemas + types for `Signature`, `UserSignature`, `ListSignaturesResponse`, `EquipSignatureInput` | @@ -59,11 +59,11 @@ These are the only verification commands this project supports. Most tasks use o **Why:** The current seed has L2/L5/L8 each adding +1 concurrent (reaching 6) and L10 jumping to 10. Per spec §5, level path caps at 5 (L2 +1, L8 +1) and the L10 jump is removed. **Files:** -- Modify: `packages/db/supabase/reset.sql:420-438` +- Modify: `packages/db/d1/schema.sql:420-438` - [ ] **Step 1: Replace the `level_perks` insert block** -Open `packages/db/supabase/reset.sql` and locate the `insert into app.level_perks` block (around lines 420-438). Replace the entire block with: +Open `packages/db/d1/schema.sql` and locate the `insert into app.level_perks` block (around lines 420-438). Replace the entire block with: ```sql insert into app.level_perks (id, level, perk_id, numeric_value, metadata) @@ -102,7 +102,7 @@ Note: row id `…907` (was L5 concurrent +1) and `…90e` (was L10 concurrent 10 Grep the file for the new value cap: ```bash -grep "level_perks" packages/db/supabase/reset.sql | head -20 +grep "level_perks" packages/db/d1/schema.sql | head -20 ``` Confirm L8 concurrent shows `5` and L10 has no concurrent row. @@ -110,7 +110,7 @@ Confirm L8 concurrent shows `5` and L10 has no concurrent row. - [ ] **Step 3: Commit** ```bash -git add packages/db/supabase/reset.sql +git add packages/db/d1/schema.sql git commit -m "db: cap level-perk concurrent billboards at 5" ``` @@ -121,7 +121,7 @@ git commit -m "db: cap level-perk concurrent billboards at 5" **Why:** Current seed covers L1–L3 only. Per spec §3.1, tier sizes are 1-2-2-3-3-3-4-4-5-5 (total 32 quests). Per spec §3.2, no template repeats within a tier, and target values scale with tier. **Files:** -- Modify: `packages/db/supabase/reset.sql:388-394` +- Modify: `packages/db/d1/schema.sql:388-394` Template UUID reference (from existing seed, lines 372-378): - `visit_pois` → `00000000-0000-4000-8000-000000000301` @@ -209,7 +209,7 @@ values Open the new block and count rows by `level`. Expected counts: L1=1, L2=2, L3=2, L4=3, L5=3, L6=3, L7=4, L8=4, L9=5, L10=5. Total = 32. ```bash -grep -E "', [0-9]+, '00000000-0000-4000-8000-000000000(301|302|303|304|305)'," packages/db/supabase/reset.sql | wc -l +grep -E "', [0-9]+, '00000000-0000-4000-8000-000000000(301|302|303|304|305)'," packages/db/d1/schema.sql | wc -l ``` Expected: at least 32 (some rows may match if `select random` blocks include similar substring — eyeball the actual block to confirm). @@ -217,7 +217,7 @@ Expected: at least 32 (some rows may match if `select random` blocks include sim - [ ] **Step 3: Commit** ```bash -git add packages/db/supabase/reset.sql +git add packages/db/d1/schema.sql git commit -m "db: seed level quest sets for all 10 levels (32 quests, 1-2-2-3-3-3-4-4-5-5)" ``` @@ -228,7 +228,7 @@ git commit -m "db: seed level quest sets for all 10 levels (32 quests, 1-2-2-3-3 **Why:** Spec §4.5: streak milestones at days 3 / 7 / 14 / 30. Days 3, 7, 30 grant a signature; day 14 grants `+1 concurrent billboard`. The current seed has different days and uses `xp_multiplier` (which the spec drops). **Files:** -- Modify: `packages/db/supabase/reset.sql:440-443` +- Modify: `packages/db/d1/schema.sql:440-443` - [ ] **Step 1: Replace the `streak_reward_definitions` insert** @@ -250,7 +250,7 @@ values - [ ] **Step 2: Commit** ```bash -git add packages/db/supabase/reset.sql +git add packages/db/d1/schema.sql git commit -m "db: seed streak milestones at days 3/7/14/30" ``` @@ -261,11 +261,11 @@ git commit -m "db: seed streak milestones at days 3/7/14/30" **Why:** Spec §7.2 — earned signatures need to be stored and equipped. `signatures` is the seed catalog; `user_signatures` is the junction with `is_equipped` (at most one true per user). **Files:** -- Modify: `packages/db/supabase/reset.sql` (append new tables before the `create index` block around line 326) +- Modify: `packages/db/d1/schema.sql` (append new tables before the `create index` block around line 326) - [ ] **Step 1: Append the new tables** -In `packages/db/supabase/reset.sql`, just BEFORE the `create index pois_location_point_idx` line (around line 326), insert: +In `packages/db/d1/schema.sql`, just BEFORE the `create index pois_active_campus_idx` line (around line 326), insert: ```sql create table app.signatures ( @@ -292,7 +292,7 @@ create unique index user_signatures_one_equipped_idx - [ ] **Step 2: Append seed signatures at end of file** -At the very END of `packages/db/supabase/reset.sql`, append: +At the very END of `packages/db/d1/schema.sql`, append: ```sql insert into app.signatures (id, key, name, asset_base64, streak_day_required) @@ -313,7 +313,7 @@ The base64 above is a 1×1 transparent PNG placeholder. The team will replace `a - [ ] **Step 3: Commit** ```bash -git add packages/db/supabase/reset.sql +git add packages/db/d1/schema.sql git commit -m "db: add signatures and user_signatures tables" ``` @@ -389,30 +389,30 @@ git commit -m "db: drizzle schema for signatures and user_signatures" - [ ] **Step 1: Reset and reseed** -Run (assumes `DATABASE_URL` env var is set): +Run (assumes `CLOUDFLARE_D1_DATABASE_ID` env var is set): ```bash -psql "$DATABASE_URL" -f packages/db/supabase/reset.sql +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -f packages/db/d1/schema.sql ``` Expected output: a series of `DROP`, `CREATE`, `INSERT` lines. No `ERROR` lines. -- [ ] **Step 2: Sanity check via psql** +- [ ] **Step 2: Sanity check via D1 CLI** ```bash -psql "$DATABASE_URL" -c "select level, count(*) from app.level_quest_sets group by level order by level;" +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -c "select level, count(*) from app.level_quest_sets group by level order by level;" ``` Expected: 10 rows with counts 1, 2, 2, 3, 3, 3, 4, 4, 5, 5. ```bash -psql "$DATABASE_URL" -c "select streak_days, name from app.streak_reward_definitions order by streak_days;" +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -c "select streak_days, name from app.streak_reward_definitions order by streak_days;" ``` Expected: 4 rows for days 3, 7, 14, 30. ```bash -psql "$DATABASE_URL" -c "select count(*) from app.signatures;" +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -c "select count(*) from app.signatures;" ``` Expected: 3. @@ -638,7 +638,7 @@ Start the API: bun --cwd apps/api dev ``` -In a separate shell, claim a daily quest using the demo bearer token (set `USER_ID` and `QUEST_ID` from `psql` queries): +In a separate shell, claim a daily quest using the demo bearer token (set `USER_ID` and `QUEST_ID` from `D1 CLI` queries): ```bash curl -s -X POST -H "Authorization: Bearer dev" -H "Content-Type: application/json" \ @@ -730,12 +730,12 @@ export async function applyStreakMilestoneUnlocks( - [ ] **Step 2: Allow level=0 rows in the existing CHECK constraint** -The current `level_perks` table has `check (level >= 1)` which would block streak-source rows (level=0). Open `packages/db/supabase/reset.sql` and find the `create table app.level_perks` block (around lines 262-270). Change `check (level >= 1)` to `check (level >= 0)`. Also update the Drizzle definition: in `packages/db/src/schema/index.ts:518`, change `sql\`${table.level} >= 1\`` to `sql\`${table.level} >= 0\``. +The current `level_perks` table has `check (level >= 1)` which would block streak-source rows (level=0). Open `packages/db/d1/schema.sql` and find the `create table app.level_perks` block (around lines 262-270). Change `check (level >= 1)` to `check (level >= 0)`. Also update the Drizzle definition: in `packages/db/src/schema/index.ts:518`, change `sql\`${table.level} >= 1\`` to `sql\`${table.level} >= 0\``. - [ ] **Step 3: Re-apply schema** ```bash -psql "$DATABASE_URL" -f packages/db/supabase/reset.sql +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -f packages/db/d1/schema.sql ``` (This re-runs all the seed data too — fine.) @@ -751,7 +751,7 @@ Expected: PASS. - [ ] **Step 5: Commit** ```bash -git add apps/api/src/services/progression.ts packages/db/supabase/reset.sql packages/db/src/schema/index.ts +git add apps/api/src/services/progression.ts packages/db/d1/schema.sql packages/db/src/schema/index.ts git commit -m "api: applyStreakMilestoneUnlocks helper + allow level=0 perks" ``` @@ -877,7 +877,7 @@ Expected: PASS. - [ ] **Step 4: Smoke check** -With the dev DB at a fresh state (run `psql … -f reset.sql` if needed), hit `/api/users/me/progress` for the demo user and confirm `capacities` reflects the base L1 values (concurrent=3, daily=4, sticker=10). Then `UPDATE app.users SET daily_streak = 14, level = 8 WHERE id = '00000000-0000-4000-8000-000000000002';`, call `applyStreakMilestoneUnlocks` (e.g. via curl after claiming a daily — or in psql: `INSERT INTO app.level_perks (level, perk_id, numeric_value, metadata) SELECT 0, id, 1, '{"source":"streak"}'::jsonb FROM app.perk_definitions WHERE key='max_concurrent_billboards';` then `INSERT INTO app.user_perk_unlocks(user_id, level_perk_id, source_level) SELECT '...', id, 0 FROM app.level_perks WHERE level=0;`). Hit `/api/users/me/progress` again: `maxConcurrentBillboards` should be 6 (L8=5 + streak=1). +With the dev DB at a fresh state (run `D1 CLI … -f reset.sql` if needed), hit `/api/users/me/progress` for the demo user and confirm `capacities` reflects the base L1 values (concurrent=3, daily=4, sticker=10). Then `UPDATE app.users SET daily_streak = 14, level = 8 WHERE id = '00000000-0000-4000-8000-000000000002';`, call `applyStreakMilestoneUnlocks` (e.g. via curl after claiming a daily — or in D1 CLI: `INSERT INTO app.level_perks (level, perk_id, numeric_value, metadata) SELECT 0, id, 1, '{"source":"streak"}'::jsonb FROM app.perk_definitions WHERE key='max_concurrent_billboards';` then `INSERT INTO app.user_perk_unlocks(user_id, level_perk_id, source_level) SELECT '...', id, 0 FROM app.level_perks WHERE level=0;`). Hit `/api/users/me/progress` again: `maxConcurrentBillboards` should be 6 (L8=5 + streak=1). - [ ] **Step 5: Commit** @@ -958,13 +958,13 @@ Expected: PASS. - [ ] **Step 4: Smoke check (manual)** ```bash -psql "$DATABASE_URL" -c "update app.users set daily_streak = 7, last_daily_claimed_on = (current_date - 3) where id = '00000000-0000-4000-8000-000000000002';" +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -c "update app.users set daily_streak = 7, last_daily_claimed_on = (current_date - 3) where id = '00000000-0000-4000-8000-000000000002';" ``` Then invoke the function via REPL or temporarily expose a debug route to call `resetBrokenStreaks`. The simplest smoke test: ```bash -psql "$DATABASE_URL" -c " +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -c " update app.users set daily_streak = 0 where @@ -1707,7 +1707,7 @@ Expected: 0 errors across lint, format, and typecheck. Reset DB: ```bash -psql "$DATABASE_URL" -f packages/db/supabase/reset.sql +D1 CLI "$CLOUDFLARE_D1_DATABASE_ID" -f packages/db/d1/schema.sql ``` Start API and app: @@ -1719,7 +1719,7 @@ bun --cwd apps/app start Walk through: 1. `GET /api/quests` — should return one daily quest (`xpReward: 0` in the daily-template shape if the SELECT is reading from `daily_quest_pool` — re-verify that the read path normalises daily XP to 0) and one or more level quests for the demo user's current level. -2. Manually advance `daily_streak` to 3 for the demo user via psql, then claim today's daily. Verify `user_signatures` now has the day-3 signature. +2. Manually advance `daily_streak` to 3 for the demo user via D1 CLI, then claim today's daily. Verify `user_signatures` now has the day-3 signature. 3. Set `daily_streak = 14`, claim daily. Verify `level_perks` has a level=0 row for `max_concurrent_billboards`, `user_perk_unlocks` has the matching row, and `GET /api/users/me/progress` returns `maxConcurrentBillboards` = base+streak. 4. App: Quests tab shows "+1 streak" reward on the daily card and a next-milestone hint underneath. 5. App: Profile tab shows the banking message for L1; if you bump the user to L4 in DB, the picker appears. diff --git a/packages/db/d1/migrations/0001_initial.sql b/packages/db/d1/migrations/0001_initial.sql new file mode 100644 index 0000000..d461fd3 --- /dev/null +++ b/packages/db/d1/migrations/0001_initial.sql @@ -0,0 +1,509 @@ + +create table users ( + id text primary key, + clerk_user_id text not null unique, + username text not null unique, + display_name text not null, + avatar_base64 text, + is_admin boolean not null default false, + level integer not null default 1 check (level >= 1), + xp integer not null default 0 check (xp >= 0), + daily_streak integer not null default 0 check (daily_streak >= 0), + streak_updated_on date, + last_daily_claimed_on date, + banned_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table push_tokens ( + id text primary key, + user_id text not null references users(id) on delete cascade, + token text not null unique, + platform push_platform not null default 'expo', + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + revoked_at text +); + +create table campuses ( + id text primary key, + name text not null, + timezone text not null, + center_lat real not null, + center_lng real not null, + radius_meters integer not null check (radius_meters > 0), + bounds text not null, + map_provider text not null default 'openstreetmap', + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table pois ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, + title text not null, + description text, + picture_base64 text, + lat real not null, + lng real not null, + radius_meters integer not null default 30 check (radius_meters > 0), + is_active boolean not null default true, + created_by text references users(id) on delete set null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text +); + +create table poi_visits ( + user_id text not null references users(id) on delete cascade, + poi_id text not null references pois(id) on delete cascade, + visited_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + visited_on date not null default (date('now', '+10 hours')), + primary key (user_id, poi_id) +); + +create table poi_daily_activations ( + campus_id text not null references campuses(id) on delete cascade, + poi_id text not null references pois(id) on delete cascade, + active_on date not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + primary key (campus_id, poi_id, active_on) +); + +create table billboards ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, + author_id text not null references users(id) on delete cascade, + body text not null, + lat real not null, + lng real not null, + status content_status not null default 'pending', + moderation_summary text, + empty_expires_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+24 hours')), + expires_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+5 days')), + hidden_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table sticker_assets ( + id text primary key, + owner_id text not null references users(id) on delete cascade, + png_base64 text not null, + width integer not null default 64 check (width > 0), + height integer not null default 64 check (height > 0), + palette text, + status content_status not null default 'pending', + moderation_summary text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text +); + +create table billboard_placements ( + id text primary key, + billboard_id text not null references billboards(id) on delete cascade, + author_id text not null references users(id) on delete cascade, + kind placement_kind not null, + x real not null check (x >= 0 and x <= 1), + y real not null check (y >= 0 and y <= 1), + z_index integer not null default 0, + sticker_asset_id text references sticker_assets(id) on delete restrict, + body text, + status content_status not null default 'pending', + moderation_summary text, + hidden_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + check ( + ( + kind = 'sticker' + and sticker_asset_id is not null + and body is null + ) + or ( + kind = 'sticky_note' + and sticker_asset_id is null + and body is not null + ) + ) +); + +create table saved_stickers ( + id text primary key, + user_id text not null references users(id) on delete cascade, + kind saved_sticker_kind not null, + sticker_asset_id text references sticker_assets(id) on delete cascade, + body text, + label text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text, + check ( + ( + kind = 'sticker' + and sticker_asset_id is not null + and body is null + ) + or ( + kind = 'sticky_note' + and sticker_asset_id is null + and body is not null + ) + ) +); + +create table quest_templates ( + id text primary key, + key text not null unique, + trigger_type quest_trigger_type not null, + title_template text not null, + description_template text not null, + min_target integer not null check (min_target > 0), + max_target integer not null check (max_target >= min_target), + xp_reward integer not null check (xp_reward >= 0), + active boolean not null default true, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table daily_quest_templates ( + id text primary key, + key text not null unique, + trigger_type quest_trigger_type not null, + title_template text not null, + description_template text not null, + min_target integer not null check (min_target > 0), + max_target integer not null check (max_target >= min_target), + xp_reward integer not null check (xp_reward >= 0), + active boolean not null default true, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table level_quest_sets ( + id text primary key, + level integer not null check (level >= 1), + template_id text not null references quest_templates(id) on delete restrict, + target_count integer not null check (target_count > 0), + xp_reward integer not null check (xp_reward >= 0), + sort_order integer not null default 0, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique (level, sort_order) +); + +create table daily_quest_pool ( + id text primary key, + template_id text not null references daily_quest_templates(id) on delete restrict, + target_count integer not null check (target_count > 0), + xp_reward integer not null check (xp_reward >= 0), + active boolean not null default true, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table daily_quest_assignments ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, + active_on date not null, + daily_quest_pool_id text not null references daily_quest_pool(id) on delete restrict, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique (campus_id, active_on) +); + +create table user_quest_progress ( + id text primary key, + user_id text not null references users(id) on delete cascade, + source quest_source not null, + source_id text not null, + active_on date, + progress_count integer not null default 0 check (progress_count >= 0), + target_count integer not null check (target_count > 0), + completed_at text, + claimable_at text, + claimed_at text, + claimed_xp integer check (claimed_xp is null or claimed_xp >= 0), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + check ( + ( + source = 'daily_quest' + and active_on is not null + ) + or ( + source = 'level_quest' + and active_on is null + ) + ) +); + +create table perk_definitions ( + id text primary key, + key text not null unique, + name text not null, + description text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table level_perks ( + id text primary key, + level integer not null check (level >= 0), + perk_id text not null references perk_definitions(id) on delete restrict, + numeric_value integer, + metadata text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique (level, perk_id) +); + +create table user_perk_unlocks ( + user_id text not null references users(id) on delete cascade, + level_perk_id text not null references level_perks(id) on delete restrict, + source_level integer not null check (source_level >= 0), + unlocked_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + primary key (user_id, level_perk_id) +); + +create table streak_reward_definitions ( + id text primary key, + streak_days integer not null unique check (streak_days > 0), + name text not null, + reward text not null check (json_type(reward) = 'object'), + active boolean not null default true, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table reports ( + id text primary key, + reporter_id text not null references users(id) on delete cascade, + target_type report_target_type not null, + target_id text not null, + reason report_reason not null, + details text, + status report_status not null default 'open', + admin_notes text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique (reporter_id, target_type, target_id) +); + +create table moderation_actions ( + id text primary key, + report_id text references reports(id) on delete set null, + admin_id text not null references users(id) on delete restrict, + action moderation_action_type not null, + target_type report_target_type not null, + target_id text not null, + notes text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table content_moderation_logs ( + id text primary key, + target_type content_moderation_target_type not null, + target_id text not null, + provider text not null default 'openai', + flagged boolean not null, + categories text, + scores text, + raw_response text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table signatures ( + id text primary key, + key text not null unique, + name text not null, + asset_base64 text not null, + streak_day_required integer not null check (streak_day_required > 0), + active boolean not null default true, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table user_signatures ( + user_id text not null references users(id) on delete cascade, + signature_id text not null references signatures(id) on delete cascade, + unlocked_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + is_equipped boolean not null default false, + primary key (user_id, signature_id) +); + +create unique index user_signatures_one_equipped_idx + on user_signatures (user_id) where is_equipped; + +create index pois_active_campus_idx on pois (campus_id, is_active) where deleted_at is null; +create index poi_visits_poi_idx on poi_visits (poi_id); +create index billboards_active_idx on billboards (campus_id, status, expires_at) where deleted_at is null; +create index billboards_author_day_idx on billboards ( + author_id, + date(created_at, '+10 hours') +); +create unique index billboard_placements_one_per_user_idx on billboard_placements ( + billboard_id, + author_id +) +where deleted_at is null; +create index billboard_placements_billboard_idx on billboard_placements (billboard_id, z_index); +create index sticker_assets_owner_idx on sticker_assets (owner_id); +create index saved_stickers_user_idx on saved_stickers (user_id) where deleted_at is null; +create unique index user_quest_progress_level_unique_idx on user_quest_progress ( + user_id, + source, + source_id +) +where active_on is null; +create unique index user_quest_progress_daily_unique_idx on user_quest_progress ( + user_id, + source, + source_id, + active_on +) +where active_on is not null; +create index user_quest_progress_user_idx on user_quest_progress (user_id); +create index reports_status_idx on reports (status, created_at); + +insert into campuses (id, name, timezone, center_lat, center_lng, radius_meters, bounds) +values ( + '00000000-0000-4000-8000-000000000100', + 'UNSW Kensington', + 'Australia/Sydney', + -33.9173, + 151.2313, + 1200, + '{"north":-33.9095,"south":-33.9249,"east":151.2398,"west":151.2252}' +); + +insert into pois (id, campus_id, title, description, lat, lng) +values + ('00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000100', 'Main Library', 'A busy study landmark near the centre of campus.', -33.9173, 151.2313), + ('00000000-0000-4000-8000-000000000202', '00000000-0000-4000-8000-000000000100', 'Basser Steps', 'A classic meeting spot between upper and lower campus.', -33.9179, 151.2298), + ('00000000-0000-4000-8000-000000000203', '00000000-0000-4000-8000-000000000100', 'Quadrangle Lawn', 'Open green space for quick quest stops.', -33.9170, 151.2334), + ('00000000-0000-4000-8000-000000000204', '00000000-0000-4000-8000-000000000100', 'Red Centre', 'A bright landmark for art, design, and engineering students.', -33.9161, 151.2306), + ('00000000-0000-4000-8000-000000000205', '00000000-0000-4000-8000-000000000100', 'Village Green', 'A broad outdoor hub for lunch breaks and quick meetups.', -33.9152, 151.2345), + ('00000000-0000-4000-8000-000000000206', '00000000-0000-4000-8000-000000000100', 'Science Theatre', 'A lower-campus lecture landmark with steady student traffic.', -33.9192, 151.2291); + +insert into quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) +values + ('00000000-0000-4000-8000-000000000301', 'visit_pois', 'visit_pois', 'Visit {target} new POIs', 'Discover {target} campus landmarks you have not visited before.', 1, 15, 50), + ('00000000-0000-4000-8000-000000000302', 'leave_billboards', 'leave_billboards', 'Leave {target} billboards', 'Post {target} notes around campus.', 1, 12, 50), + ('00000000-0000-4000-8000-000000000303', 'place_stickers', 'place_stickers', 'Place {target} stickers', 'Reply to billboards with {target} sticker placements.', 1, 12, 50), + ('00000000-0000-4000-8000-000000000304', 'receive_replies', 'receive_replies', 'Receive {target} replies', 'Have other students reply to your billboards {target} times.', 1, 10, 75), + ('00000000-0000-4000-8000-000000000305', 'save_stickers', 'save_stickers', 'Save {target} stickers', 'Save {target} stickers or sticky notes to your collection.', 1, 12, 40); + +insert into daily_quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) +values + ('00000000-0000-4000-8000-000000000401', 'daily_explorer', 'visit_pois', 'Daily wander', 'Visit {target} active POIs today.', 1, 3, 0), + ('00000000-0000-4000-8000-000000000402', 'daily_note', 'leave_billboards', 'Campus bulletin', 'Leave {target} billboard today.', 1, 2, 0), + ('00000000-0000-4000-8000-000000000403', 'daily_sticker', 'place_stickers', 'Sticker hello', 'Place {target} stickers on billboards today.', 1, 3, 0), + ('00000000-0000-4000-8000-000000000404', 'daily_save', 'save_stickers', 'Pocket a favourite', 'Save {target} sticker or sticky note today.', 1, 2, 0), + ('00000000-0000-4000-8000-000000000405', 'daily_replies', 'receive_replies', 'Start a conversation', 'Receive {target} replies on your billboards today.', 1, 2, 0); + +insert into level_quest_sets (id, level, template_id, target_count, xp_reward, sort_order) +values + -- L1 (1 quest) + ('00000000-0000-4000-8000-000000000501', 1, '00000000-0000-4000-8000-000000000301', 3, 60, 1), + + -- L2 (2 quests) + ('00000000-0000-4000-8000-000000000502', 2, '00000000-0000-4000-8000-000000000302', 2, 40, 1), + ('00000000-0000-4000-8000-000000000503', 2, '00000000-0000-4000-8000-000000000303', 2, 40, 2), + + -- L3 (2 quests) + ('00000000-0000-4000-8000-000000000504', 3, '00000000-0000-4000-8000-000000000301', 4, 80, 1), + ('00000000-0000-4000-8000-000000000505', 3, '00000000-0000-4000-8000-000000000305', 3, 60, 2), + + -- L4 (3 quests) + ('00000000-0000-4000-8000-000000000506', 4, '00000000-0000-4000-8000-000000000302', 3, 60, 1), + ('00000000-0000-4000-8000-000000000507', 4, '00000000-0000-4000-8000-000000000303', 3, 60, 2), + ('00000000-0000-4000-8000-000000000508', 4, '00000000-0000-4000-8000-000000000304', 2, 90, 3), + + -- L5 (3 quests) + ('00000000-0000-4000-8000-000000000509', 5, '00000000-0000-4000-8000-000000000301', 6, 120, 1), + ('00000000-0000-4000-8000-00000000050a', 5, '00000000-0000-4000-8000-000000000302', 4, 80, 2), + ('00000000-0000-4000-8000-00000000050b', 5, '00000000-0000-4000-8000-000000000305', 4, 80, 3), + + -- L6 (3 quests) + ('00000000-0000-4000-8000-00000000050c', 6, '00000000-0000-4000-8000-000000000303', 5, 100, 1), + ('00000000-0000-4000-8000-00000000050d', 6, '00000000-0000-4000-8000-000000000304', 3, 120, 2), + ('00000000-0000-4000-8000-00000000050e', 6, '00000000-0000-4000-8000-000000000305', 5, 100, 3), + + -- L7 (4 quests) + ('00000000-0000-4000-8000-00000000050f', 7, '00000000-0000-4000-8000-000000000301', 7, 140, 1), + ('00000000-0000-4000-8000-000000000510', 7, '00000000-0000-4000-8000-000000000302', 5, 100, 2), + ('00000000-0000-4000-8000-000000000511', 7, '00000000-0000-4000-8000-000000000303', 6, 120, 3), + ('00000000-0000-4000-8000-000000000512', 7, '00000000-0000-4000-8000-000000000304', 4, 160, 4), + + -- L8 (4 quests) + ('00000000-0000-4000-8000-000000000513', 8, '00000000-0000-4000-8000-000000000302', 6, 120, 1), + ('00000000-0000-4000-8000-000000000514', 8, '00000000-0000-4000-8000-000000000303', 7, 140, 2), + ('00000000-0000-4000-8000-000000000515', 8, '00000000-0000-4000-8000-000000000304', 5, 200, 3), + ('00000000-0000-4000-8000-000000000516', 8, '00000000-0000-4000-8000-000000000305', 6, 120, 4), + + -- L9 (5 quests — all 5 templates) + ('00000000-0000-4000-8000-000000000517', 9, '00000000-0000-4000-8000-000000000301', 9, 180, 1), + ('00000000-0000-4000-8000-000000000518', 9, '00000000-0000-4000-8000-000000000302', 7, 140, 2), + ('00000000-0000-4000-8000-000000000519', 9, '00000000-0000-4000-8000-000000000303', 8, 160, 3), + ('00000000-0000-4000-8000-00000000051a', 9, '00000000-0000-4000-8000-000000000304', 6, 240, 4), + ('00000000-0000-4000-8000-00000000051b', 9, '00000000-0000-4000-8000-000000000305', 8, 160, 5), + + -- L10 (5 quests — all 5 templates, highest targets) + ('00000000-0000-4000-8000-00000000051c', 10, '00000000-0000-4000-8000-000000000301', 11, 220, 1), + ('00000000-0000-4000-8000-00000000051d', 10, '00000000-0000-4000-8000-000000000302', 8, 160, 2), + ('00000000-0000-4000-8000-00000000051e', 10, '00000000-0000-4000-8000-000000000303', 10, 200, 3), + ('00000000-0000-4000-8000-00000000051f', 10, '00000000-0000-4000-8000-000000000304', 7, 280, 4), + ('00000000-0000-4000-8000-000000000520', 10, '00000000-0000-4000-8000-000000000305', 9, 180, 5); + +insert into daily_quest_pool (id, template_id, target_count, xp_reward) +values + ('00000000-0000-4000-8000-000000000601', '00000000-0000-4000-8000-000000000401', 1, 0), + ('00000000-0000-4000-8000-000000000602', '00000000-0000-4000-8000-000000000402', 1, 0), + ('00000000-0000-4000-8000-000000000603', '00000000-0000-4000-8000-000000000403', 2, 0), + ('00000000-0000-4000-8000-000000000604', '00000000-0000-4000-8000-000000000404', 1, 0), + ('00000000-0000-4000-8000-000000000605', '00000000-0000-4000-8000-000000000405', 1, 0); + +insert into perk_definitions (id, key, name, description) +values + ('00000000-0000-4000-8000-000000000800', 'max_concurrent_billboards', 'Concurrent billboards', 'Maximum active billboards a user can maintain.'), + ('00000000-0000-4000-8000-000000000801', 'daily_billboard_limit', 'Daily billboard limit', 'Billboards a user can post per calendar day.'), + ('00000000-0000-4000-8000-000000000802', 'sticker_slots', 'Sticker slots', 'Saved sticker and sticky note collection capacity.'), + ('00000000-0000-4000-8000-000000000803', 'note_signature', 'Note signature', 'Cosmetic signature on notes and stickers.'), + ('00000000-0000-4000-8000-000000000804', 'note_border_flair', 'Note border flair', 'Cosmetic border treatment for notes.'), + ('00000000-0000-4000-8000-000000000805', 'palette_expansion', 'Palette expansion', 'Additional sticker colour palette.'); + +insert into level_perks (id, level, perk_id, numeric_value, metadata) +values + -- L1 base + ('00000000-0000-4000-8000-000000000900', 1, '00000000-0000-4000-8000-000000000800', 3, null), + ('00000000-0000-4000-8000-000000000901', 1, '00000000-0000-4000-8000-000000000801', 4, null), + ('00000000-0000-4000-8000-000000000902', 1, '00000000-0000-4000-8000-000000000802', 10, null), + -- L2: +1 concurrent, +1 per day + ('00000000-0000-4000-8000-000000000903', 2, '00000000-0000-4000-8000-000000000800', 4, null), + ('00000000-0000-4000-8000-000000000904', 2, '00000000-0000-4000-8000-000000000801', 5, null), + -- L3: +2 sticker slots + ('00000000-0000-4000-8000-000000000905', 3, '00000000-0000-4000-8000-000000000802', 12, null), + -- L4: signature display feature unlock + ('00000000-0000-4000-8000-000000000906', 4, '00000000-0000-4000-8000-000000000803', null, '{"enabled":true}'), + -- L5: +1 per day (no concurrent bump per revised spec) + ('00000000-0000-4000-8000-000000000908', 5, '00000000-0000-4000-8000-000000000801', 6, null), + -- L6: note border flair + ('00000000-0000-4000-8000-000000000909', 6, '00000000-0000-4000-8000-000000000804', null, '{"enabled":true}'), + -- L7: +2 sticker slots + ('00000000-0000-4000-8000-00000000090a', 7, '00000000-0000-4000-8000-000000000802', 14, null), + -- L8: +1 concurrent, +1 per day (final concurrent bump from leveling) + ('00000000-0000-4000-8000-00000000090b', 8, '00000000-0000-4000-8000-000000000800', 5, null), + ('00000000-0000-4000-8000-00000000090c', 8, '00000000-0000-4000-8000-000000000801', 7, null), + -- L9: palette expansion + ('00000000-0000-4000-8000-00000000090d', 9, '00000000-0000-4000-8000-000000000805', null, '{"palette":"extended"}'), + -- L10: +1 per day, +2 sticker slots (no concurrent bump — capped at 5 from L8) + ('00000000-0000-4000-8000-00000000090f', 10, '00000000-0000-4000-8000-000000000801', 8, null), + ('00000000-0000-4000-8000-000000000910', 10, '00000000-0000-4000-8000-000000000802', 16, null); + +insert into streak_reward_definitions (id, streak_days, name, reward) +values + ('00000000-0000-4000-8000-000000000a01', 3, 'Three day spark', + '{"rewards":[{"type":"signature","signatureKey":"signature_a"}]}'), + ('00000000-0000-4000-8000-000000000a02', 7, 'Week one trail', + '{"rewards":[{"type":"signature","signatureKey":"signature_b"}]}'), + ('00000000-0000-4000-8000-000000000a03', 14, 'Fortnight forager', + '{"rewards":[{"type":"capacity_billboard","amount":1}]}'), + ('00000000-0000-4000-8000-000000000a04', 30, 'Month-long marker', + '{"rewards":[{"type":"signature","signatureKey":"signature_c"}]}'); + +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b01', 'signature_a', 'Sunrise Mark', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 3); +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b02', 'signature_b', 'Pixel Bloom', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 7); +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b03', 'signature_c', 'Prestige Cipher', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 30); diff --git a/packages/db/supabase/reset.sql b/packages/db/d1/schema.sql similarity index 52% rename from packages/db/supabase/reset.sql rename to packages/db/d1/schema.sql index 69108ff..4232d48 100644 --- a/packages/db/supabase/reset.sql +++ b/packages/db/d1/schema.sql @@ -1,28 +1,35 @@ -drop schema if exists app cascade; -create schema app; - -create extension if not exists postgis with schema extensions; - -create type app.content_status as enum ('pending', 'active', 'hidden', 'removed', 'rejected'); -create type app.placement_kind as enum ('sticker', 'sticky_note'); -create type app.saved_sticker_kind as enum ('sticker', 'sticky_note'); -create type app.quest_trigger_type as enum ( - 'visit_pois', - 'leave_billboards', - 'place_stickers', - 'receive_replies', - 'save_stickers' -); -create type app.quest_source as enum ('level_quest', 'daily_quest'); -create type app.report_target_type as enum ('billboard', 'placement', 'user'); -create type app.content_moderation_target_type as enum ('billboard', 'placement', 'sticker_asset'); -create type app.report_reason as enum ('spam', 'harassment', 'hate', 'sexual', 'violence', 'self_harm', 'other'); -create type app.report_status as enum ('open', 'reviewing', 'resolved', 'dismissed'); -create type app.moderation_action_type as enum ('hide', 'remove', 'warn', 'ban', 'dismiss'); -create type app.push_platform as enum ('expo', 'ios', 'android', 'web'); - -create table app.users ( - id uuid primary key default gen_random_uuid(), +pragma foreign_keys = off; + +drop table if exists content_moderation_logs; +drop table if exists moderation_actions; +drop table if exists reports; +drop table if exists user_signatures; +drop table if exists signatures; +drop table if exists streak_reward_definitions; +drop table if exists user_perk_unlocks; +drop table if exists level_perks; +drop table if exists perk_definitions; +drop table if exists user_quest_progress; +drop table if exists daily_quest_assignments; +drop table if exists daily_quest_pool; +drop table if exists level_quest_sets; +drop table if exists daily_quest_templates; +drop table if exists quest_templates; +drop table if exists saved_stickers; +drop table if exists billboard_placements; +drop table if exists sticker_assets; +drop table if exists billboards; +drop table if exists poi_daily_activations; +drop table if exists poi_visits; +drop table if exists pois; +drop table if exists campuses; +drop table if exists push_tokens; +drop table if exists users; + +pragma foreign_keys = on; + +create table users ( + id text primary key, clerk_user_id text not null unique, username text not null unique, display_name text not null, @@ -33,114 +40,111 @@ create table app.users ( daily_streak integer not null default 0 check (daily_streak >= 0), streak_updated_on date, last_daily_claimed_on date, - banned_at timestamptz, - deleted_at timestamptz, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + banned_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.push_tokens ( - id uuid primary key default gen_random_uuid(), - user_id uuid not null references app.users(id) on delete cascade, +create table push_tokens ( + id text primary key, + user_id text not null references users(id) on delete cascade, token text not null unique, - platform app.push_platform not null default 'expo', - created_at timestamptz not null default now(), - revoked_at timestamptz + platform push_platform not null default 'expo', + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + revoked_at text ); -create table app.campuses ( - id uuid primary key default gen_random_uuid(), +create table campuses ( + id text primary key, name text not null, timezone text not null, - center_lat double precision not null, - center_lng double precision not null, + center_lat real not null, + center_lng real not null, radius_meters integer not null check (radius_meters > 0), - bounds jsonb not null, + bounds text not null, map_provider text not null default 'openstreetmap', - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.pois ( - id uuid primary key default gen_random_uuid(), - campus_id uuid not null references app.campuses(id) on delete cascade, +create table pois ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, title text not null, description text, picture_base64 text, - location_point geography(point, 4326) not null, - lat double precision not null, - lng double precision not null, + lat real not null, + lng real not null, radius_meters integer not null default 30 check (radius_meters > 0), is_active boolean not null default true, - created_by uuid references app.users(id) on delete set null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - deleted_at timestamptz + created_by text references users(id) on delete set null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text ); -create table app.poi_visits ( - user_id uuid not null references app.users(id) on delete cascade, - poi_id uuid not null references app.pois(id) on delete cascade, - visited_at timestamptz not null default now(), - visited_on date not null default ((timezone('Australia/Sydney', now()))::date), +create table poi_visits ( + user_id text not null references users(id) on delete cascade, + poi_id text not null references pois(id) on delete cascade, + visited_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + visited_on date not null default (date('now', '+10 hours')), primary key (user_id, poi_id) ); -create table app.poi_daily_activations ( - campus_id uuid not null references app.campuses(id) on delete cascade, - poi_id uuid not null references app.pois(id) on delete cascade, +create table poi_daily_activations ( + campus_id text not null references campuses(id) on delete cascade, + poi_id text not null references pois(id) on delete cascade, active_on date not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), primary key (campus_id, poi_id, active_on) ); -create table app.billboards ( - id uuid primary key default gen_random_uuid(), - campus_id uuid not null references app.campuses(id) on delete cascade, - author_id uuid not null references app.users(id) on delete cascade, +create table billboards ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, + author_id text not null references users(id) on delete cascade, body text not null, - location_point geography(point, 4326) not null, - lat double precision not null, - lng double precision not null, - status app.content_status not null default 'pending', - moderation_summary jsonb, - empty_expires_at timestamptz not null default (now() + interval '24 hours'), - expires_at timestamptz not null default (now() + interval '5 days'), - hidden_at timestamptz, - deleted_at timestamptz, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - check (expires_at > created_at and expires_at <= created_at + interval '5 days'), - check (empty_expires_at > created_at and empty_expires_at <= created_at + interval '24 hours') + lat real not null, + lng real not null, + status content_status not null default 'pending', + moderation_summary text, + empty_expires_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+24 hours')), + expires_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+5 days')), + hidden_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.sticker_assets ( - id uuid primary key default gen_random_uuid(), - owner_id uuid not null references app.users(id) on delete cascade, +create table sticker_assets ( + id text primary key, + owner_id text not null references users(id) on delete cascade, png_base64 text not null, width integer not null default 64 check (width > 0), height integer not null default 64 check (height > 0), - palette jsonb, - status app.content_status not null default 'pending', - moderation_summary jsonb, - created_at timestamptz not null default now(), - deleted_at timestamptz + palette text, + status content_status not null default 'pending', + moderation_summary text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text ); -create table app.billboard_placements ( - id uuid primary key default gen_random_uuid(), - billboard_id uuid not null references app.billboards(id) on delete cascade, - author_id uuid not null references app.users(id) on delete cascade, - kind app.placement_kind not null, - x double precision not null check (x >= 0 and x <= 1), - y double precision not null check (y >= 0 and y <= 1), +create table billboard_placements ( + id text primary key, + billboard_id text not null references billboards(id) on delete cascade, + author_id text not null references users(id) on delete cascade, + kind placement_kind not null, + x real not null check (x >= 0 and x <= 1), + y real not null check (y >= 0 and y <= 1), z_index integer not null default 0, - sticker_asset_id uuid references app.sticker_assets(id) on delete restrict, + sticker_asset_id text references sticker_assets(id) on delete restrict, body text, - status app.content_status not null default 'pending', - moderation_summary jsonb, - hidden_at timestamptz, - deleted_at timestamptz, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + status content_status not null default 'pending', + moderation_summary text, + hidden_at text, + deleted_at text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), check ( ( kind = 'sticker' @@ -155,15 +159,15 @@ create table app.billboard_placements ( ) ); -create table app.saved_stickers ( - id uuid primary key default gen_random_uuid(), - user_id uuid not null references app.users(id) on delete cascade, - kind app.saved_sticker_kind not null, - sticker_asset_id uuid references app.sticker_assets(id) on delete cascade, +create table saved_stickers ( + id text primary key, + user_id text not null references users(id) on delete cascade, + kind saved_sticker_kind not null, + sticker_asset_id text references sticker_assets(id) on delete cascade, body text, label text, - created_at timestamptz not null default now(), - deleted_at timestamptz, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + deleted_at text, check ( ( kind = 'sticker' @@ -178,66 +182,75 @@ create table app.saved_stickers ( ) ); -create table app.quest_templates ( - id uuid primary key default gen_random_uuid(), +create table quest_templates ( + id text primary key, key text not null unique, - trigger_type app.quest_trigger_type not null, + trigger_type quest_trigger_type not null, title_template text not null, description_template text not null, min_target integer not null check (min_target > 0), max_target integer not null check (max_target >= min_target), xp_reward integer not null check (xp_reward >= 0), active boolean not null default true, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.daily_quest_templates ( - id uuid primary key default gen_random_uuid(), +create table daily_quest_templates ( + id text primary key, key text not null unique, - trigger_type app.quest_trigger_type not null, + trigger_type quest_trigger_type not null, title_template text not null, description_template text not null, min_target integer not null check (min_target > 0), max_target integer not null check (max_target >= min_target), xp_reward integer not null check (xp_reward >= 0), active boolean not null default true, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.level_quest_sets ( - id uuid primary key default gen_random_uuid(), +create table level_quest_sets ( + id text primary key, level integer not null check (level >= 1), - template_id uuid not null references app.quest_templates(id) on delete restrict, + template_id text not null references quest_templates(id) on delete restrict, target_count integer not null check (target_count > 0), xp_reward integer not null check (xp_reward >= 0), sort_order integer not null default 0, - created_at timestamptz not null default now(), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), unique (level, sort_order) ); -create table app.daily_quest_pool ( - id uuid primary key default gen_random_uuid(), - template_id uuid not null references app.daily_quest_templates(id) on delete restrict, +create table daily_quest_pool ( + id text primary key, + template_id text not null references daily_quest_templates(id) on delete restrict, target_count integer not null check (target_count > 0), xp_reward integer not null check (xp_reward >= 0), active boolean not null default true, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +create table daily_quest_assignments ( + id text primary key, + campus_id text not null references campuses(id) on delete cascade, + active_on date not null, + daily_quest_pool_id text not null references daily_quest_pool(id) on delete restrict, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + unique (campus_id, active_on) ); -create table app.user_quest_progress ( - id uuid primary key default gen_random_uuid(), - user_id uuid not null references app.users(id) on delete cascade, - source app.quest_source not null, - source_id uuid not null, +create table user_quest_progress ( + id text primary key, + user_id text not null references users(id) on delete cascade, + source quest_source not null, + source_id text not null, active_on date, progress_count integer not null default 0 check (progress_count >= 0), target_count integer not null check (target_count > 0), - completed_at timestamptz, - claimable_at timestamptz, - claimed_at timestamptz, + completed_at text, + claimable_at text, + claimed_at text, claimed_xp integer check (claimed_xp is null or claimed_xp >= 0), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), check ( ( source = 'daily_quest' @@ -250,133 +263,131 @@ create table app.user_quest_progress ( ) ); -create table app.perk_definitions ( - id uuid primary key default gen_random_uuid(), +create table perk_definitions ( + id text primary key, key text not null unique, name text not null, description text not null, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.level_perks ( - id uuid primary key default gen_random_uuid(), - level integer not null check (level >= 1), - perk_id uuid not null references app.perk_definitions(id) on delete restrict, +create table level_perks ( + id text primary key, + level integer not null check (level >= 0), + perk_id text not null references perk_definitions(id) on delete restrict, numeric_value integer, - metadata jsonb, - created_at timestamptz not null default now(), + metadata text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), unique (level, perk_id) ); -create table app.user_perk_unlocks ( - user_id uuid not null references app.users(id) on delete cascade, - level_perk_id uuid not null references app.level_perks(id) on delete restrict, - source_level integer not null check (source_level >= 1), - unlocked_at timestamptz not null default now(), +create table user_perk_unlocks ( + user_id text not null references users(id) on delete cascade, + level_perk_id text not null references level_perks(id) on delete restrict, + source_level integer not null check (source_level >= 0), + unlocked_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), primary key (user_id, level_perk_id) ); -create table app.streak_reward_definitions ( - id uuid primary key default gen_random_uuid(), +create table streak_reward_definitions ( + id text primary key, streak_days integer not null unique check (streak_days > 0), name text not null, - reward jsonb not null check (jsonb_typeof(reward) = 'object'), + reward text not null check (json_type(reward) = 'object'), active boolean not null default true, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.reports ( - id uuid primary key default gen_random_uuid(), - reporter_id uuid not null references app.users(id) on delete cascade, - target_type app.report_target_type not null, - target_id uuid not null, - reason app.report_reason not null, +create table reports ( + id text primary key, + reporter_id text not null references users(id) on delete cascade, + target_type report_target_type not null, + target_id text not null, + reason report_reason not null, details text, - status app.report_status not null default 'open', + status report_status not null default 'open', admin_notes text, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), unique (reporter_id, target_type, target_id) ); -create table app.moderation_actions ( - id uuid primary key default gen_random_uuid(), - report_id uuid references app.reports(id) on delete set null, - admin_id uuid not null references app.users(id) on delete restrict, - action app.moderation_action_type not null, - target_type app.report_target_type not null, - target_id uuid not null, +create table moderation_actions ( + id text primary key, + report_id text references reports(id) on delete set null, + admin_id text not null references users(id) on delete restrict, + action moderation_action_type not null, + target_type report_target_type not null, + target_id text not null, notes text, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.content_moderation_logs ( - id uuid primary key default gen_random_uuid(), - target_type app.content_moderation_target_type not null, - target_id uuid not null, +create table content_moderation_logs ( + id text primary key, + target_type content_moderation_target_type not null, + target_id text not null, provider text not null default 'openai', flagged boolean not null, - categories jsonb, - scores jsonb, - raw_response jsonb, - created_at timestamptz not null default now() + categories text, + scores text, + raw_response text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.signatures ( - id uuid primary key default gen_random_uuid(), +create table signatures ( + id text primary key, key text not null unique, name text not null, asset_base64 text not null, streak_day_required integer not null check (streak_day_required > 0), active boolean not null default true, - created_at timestamptz not null default now() + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -create table app.user_signatures ( - user_id uuid not null references app.users(id) on delete cascade, - signature_id uuid not null references app.signatures(id) on delete cascade, - unlocked_at timestamptz not null default now(), +create table user_signatures ( + user_id text not null references users(id) on delete cascade, + signature_id text not null references signatures(id) on delete cascade, + unlocked_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), is_equipped boolean not null default false, primary key (user_id, signature_id) ); create unique index user_signatures_one_equipped_idx - on app.user_signatures (user_id) where is_equipped; - -create index pois_location_point_idx on app.pois using gist (location_point); -create index pois_active_campus_idx on app.pois (campus_id, is_active) where deleted_at is null; -create index poi_visits_poi_idx on app.poi_visits (poi_id); -create index billboards_location_point_idx on app.billboards using gist (location_point); -create index billboards_active_idx on app.billboards (campus_id, status, expires_at) where deleted_at is null; -create index billboards_author_day_idx on app.billboards ( + on user_signatures (user_id) where is_equipped; + +create index pois_active_campus_idx on pois (campus_id, is_active) where deleted_at is null; +create index poi_visits_poi_idx on poi_visits (poi_id); +create index billboards_active_idx on billboards (campus_id, status, expires_at) where deleted_at is null; +create index billboards_author_day_idx on billboards ( author_id, - ((timezone('Australia/Sydney', created_at))::date) + date(created_at, '+10 hours') ); -create unique index billboard_placements_one_per_user_idx on app.billboard_placements ( +create unique index billboard_placements_one_per_user_idx on billboard_placements ( billboard_id, author_id ) where deleted_at is null; -create index billboard_placements_billboard_idx on app.billboard_placements (billboard_id, z_index); -create index sticker_assets_owner_idx on app.sticker_assets (owner_id); -create index saved_stickers_user_idx on app.saved_stickers (user_id) where deleted_at is null; -create unique index user_quest_progress_level_unique_idx on app.user_quest_progress ( +create index billboard_placements_billboard_idx on billboard_placements (billboard_id, z_index); +create index sticker_assets_owner_idx on sticker_assets (owner_id); +create index saved_stickers_user_idx on saved_stickers (user_id) where deleted_at is null; +create unique index user_quest_progress_level_unique_idx on user_quest_progress ( user_id, source, source_id ) where active_on is null; -create unique index user_quest_progress_daily_unique_idx on app.user_quest_progress ( +create unique index user_quest_progress_daily_unique_idx on user_quest_progress ( user_id, source, source_id, active_on ) where active_on is not null; -create index user_quest_progress_user_idx on app.user_quest_progress (user_id); -create index reports_status_idx on app.reports (status, created_at); +create index user_quest_progress_user_idx on user_quest_progress (user_id); +create index reports_status_idx on reports (status, created_at); -insert into app.campuses (id, name, timezone, center_lat, center_lng, radius_meters, bounds) +insert into campuses (id, name, timezone, center_lat, center_lng, radius_meters, bounds) values ( '00000000-0000-4000-8000-000000000100', 'UNSW Kensington', @@ -384,19 +395,19 @@ values ( -33.9173, 151.2313, 1200, - '{"north":-33.9095,"south":-33.9249,"east":151.2398,"west":151.2252}'::jsonb + '{"north":-33.9095,"south":-33.9249,"east":151.2398,"west":151.2252}' ); -insert into app.pois (id, campus_id, title, description, location_point, lat, lng) +insert into pois (id, campus_id, title, description, lat, lng) values - ('00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000100', 'Main Library', 'A busy study landmark near the centre of campus.', st_setsrid(st_makepoint(151.2313, -33.9173), 4326)::geography, -33.9173, 151.2313), - ('00000000-0000-4000-8000-000000000202', '00000000-0000-4000-8000-000000000100', 'Basser Steps', 'A classic meeting spot between upper and lower campus.', st_setsrid(st_makepoint(151.2298, -33.9179), 4326)::geography, -33.9179, 151.2298), - ('00000000-0000-4000-8000-000000000203', '00000000-0000-4000-8000-000000000100', 'Quadrangle Lawn', 'Open green space for quick quest stops.', st_setsrid(st_makepoint(151.2334, -33.9170), 4326)::geography, -33.9170, 151.2334), - ('00000000-0000-4000-8000-000000000204', '00000000-0000-4000-8000-000000000100', 'Red Centre', 'A bright landmark for art, design, and engineering students.', st_setsrid(st_makepoint(151.2306, -33.9161), 4326)::geography, -33.9161, 151.2306), - ('00000000-0000-4000-8000-000000000205', '00000000-0000-4000-8000-000000000100', 'Village Green', 'A broad outdoor hub for lunch breaks and quick meetups.', st_setsrid(st_makepoint(151.2345, -33.9152), 4326)::geography, -33.9152, 151.2345), - ('00000000-0000-4000-8000-000000000206', '00000000-0000-4000-8000-000000000100', 'Science Theatre', 'A lower-campus lecture landmark with steady student traffic.', st_setsrid(st_makepoint(151.2291, -33.9192), 4326)::geography, -33.9192, 151.2291); - -insert into app.quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) + ('00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000100', 'Main Library', 'A busy study landmark near the centre of campus.', -33.9173, 151.2313), + ('00000000-0000-4000-8000-000000000202', '00000000-0000-4000-8000-000000000100', 'Basser Steps', 'A classic meeting spot between upper and lower campus.', -33.9179, 151.2298), + ('00000000-0000-4000-8000-000000000203', '00000000-0000-4000-8000-000000000100', 'Quadrangle Lawn', 'Open green space for quick quest stops.', -33.9170, 151.2334), + ('00000000-0000-4000-8000-000000000204', '00000000-0000-4000-8000-000000000100', 'Red Centre', 'A bright landmark for art, design, and engineering students.', -33.9161, 151.2306), + ('00000000-0000-4000-8000-000000000205', '00000000-0000-4000-8000-000000000100', 'Village Green', 'A broad outdoor hub for lunch breaks and quick meetups.', -33.9152, 151.2345), + ('00000000-0000-4000-8000-000000000206', '00000000-0000-4000-8000-000000000100', 'Science Theatre', 'A lower-campus lecture landmark with steady student traffic.', -33.9192, 151.2291); + +insert into quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) values ('00000000-0000-4000-8000-000000000301', 'visit_pois', 'visit_pois', 'Visit {target} new POIs', 'Discover {target} campus landmarks you have not visited before.', 1, 15, 50), ('00000000-0000-4000-8000-000000000302', 'leave_billboards', 'leave_billboards', 'Leave {target} billboards', 'Post {target} notes around campus.', 1, 12, 50), @@ -404,7 +415,7 @@ values ('00000000-0000-4000-8000-000000000304', 'receive_replies', 'receive_replies', 'Receive {target} replies', 'Have other students reply to your billboards {target} times.', 1, 10, 75), ('00000000-0000-4000-8000-000000000305', 'save_stickers', 'save_stickers', 'Save {target} stickers', 'Save {target} stickers or sticky notes to your collection.', 1, 12, 40); -insert into app.daily_quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) +insert into daily_quest_templates (id, key, trigger_type, title_template, description_template, min_target, max_target, xp_reward) values ('00000000-0000-4000-8000-000000000401', 'daily_explorer', 'visit_pois', 'Daily wander', 'Visit {target} active POIs today.', 1, 3, 0), ('00000000-0000-4000-8000-000000000402', 'daily_note', 'leave_billboards', 'Campus bulletin', 'Leave {target} billboard today.', 1, 2, 0), @@ -412,7 +423,7 @@ values ('00000000-0000-4000-8000-000000000404', 'daily_save', 'save_stickers', 'Pocket a favourite', 'Save {target} sticker or sticky note today.', 1, 2, 0), ('00000000-0000-4000-8000-000000000405', 'daily_replies', 'receive_replies', 'Start a conversation', 'Receive {target} replies on your billboards today.', 1, 2, 0); -insert into app.level_quest_sets (id, level, template_id, target_count, xp_reward, sort_order) +insert into level_quest_sets (id, level, template_id, target_count, xp_reward, sort_order) values -- L1 (1 quest) ('00000000-0000-4000-8000-000000000501', 1, '00000000-0000-4000-8000-000000000301', 3, 60, 1), @@ -466,7 +477,7 @@ values ('00000000-0000-4000-8000-00000000051f', 10, '00000000-0000-4000-8000-000000000304', 7, 280, 4), ('00000000-0000-4000-8000-000000000520', 10, '00000000-0000-4000-8000-000000000305', 9, 180, 5); -insert into app.daily_quest_pool (id, template_id, target_count, xp_reward) +insert into daily_quest_pool (id, template_id, target_count, xp_reward) values ('00000000-0000-4000-8000-000000000601', '00000000-0000-4000-8000-000000000401', 1, 0), ('00000000-0000-4000-8000-000000000602', '00000000-0000-4000-8000-000000000402', 1, 0), @@ -474,7 +485,7 @@ values ('00000000-0000-4000-8000-000000000604', '00000000-0000-4000-8000-000000000404', 1, 0), ('00000000-0000-4000-8000-000000000605', '00000000-0000-4000-8000-000000000405', 1, 0); -insert into app.perk_definitions (id, key, name, description) +insert into perk_definitions (id, key, name, description) values ('00000000-0000-4000-8000-000000000800', 'max_concurrent_billboards', 'Concurrent billboards', 'Maximum active billboards a user can maintain.'), ('00000000-0000-4000-8000-000000000801', 'daily_billboard_limit', 'Daily billboard limit', 'Billboards a user can post per calendar day.'), @@ -483,7 +494,7 @@ values ('00000000-0000-4000-8000-000000000804', 'note_border_flair', 'Note border flair', 'Cosmetic border treatment for notes.'), ('00000000-0000-4000-8000-000000000805', 'palette_expansion', 'Palette expansion', 'Additional sticker colour palette.'); -insert into app.level_perks (id, level, perk_id, numeric_value, metadata) +insert into level_perks (id, level, perk_id, numeric_value, metadata) values -- L1 base ('00000000-0000-4000-8000-000000000900', 1, '00000000-0000-4000-8000-000000000800', 3, null), @@ -495,33 +506,33 @@ values -- L3: +2 sticker slots ('00000000-0000-4000-8000-000000000905', 3, '00000000-0000-4000-8000-000000000802', 12, null), -- L4: signature display feature unlock - ('00000000-0000-4000-8000-000000000906', 4, '00000000-0000-4000-8000-000000000803', null, '{"enabled":true}'::jsonb), + ('00000000-0000-4000-8000-000000000906', 4, '00000000-0000-4000-8000-000000000803', null, '{"enabled":true}'), -- L5: +1 per day (no concurrent bump per revised spec) ('00000000-0000-4000-8000-000000000908', 5, '00000000-0000-4000-8000-000000000801', 6, null), -- L6: note border flair - ('00000000-0000-4000-8000-000000000909', 6, '00000000-0000-4000-8000-000000000804', null, '{"enabled":true}'::jsonb), + ('00000000-0000-4000-8000-000000000909', 6, '00000000-0000-4000-8000-000000000804', null, '{"enabled":true}'), -- L7: +2 sticker slots ('00000000-0000-4000-8000-00000000090a', 7, '00000000-0000-4000-8000-000000000802', 14, null), -- L8: +1 concurrent, +1 per day (final concurrent bump from leveling) ('00000000-0000-4000-8000-00000000090b', 8, '00000000-0000-4000-8000-000000000800', 5, null), ('00000000-0000-4000-8000-00000000090c', 8, '00000000-0000-4000-8000-000000000801', 7, null), -- L9: palette expansion - ('00000000-0000-4000-8000-00000000090d', 9, '00000000-0000-4000-8000-000000000805', null, '{"palette":"extended"}'::jsonb), + ('00000000-0000-4000-8000-00000000090d', 9, '00000000-0000-4000-8000-000000000805', null, '{"palette":"extended"}'), -- L10: +1 per day, +2 sticker slots (no concurrent bump — capped at 5 from L8) ('00000000-0000-4000-8000-00000000090f', 10, '00000000-0000-4000-8000-000000000801', 8, null), ('00000000-0000-4000-8000-000000000910', 10, '00000000-0000-4000-8000-000000000802', 16, null); -insert into app.streak_reward_definitions (id, streak_days, name, reward) +insert into streak_reward_definitions (id, streak_days, name, reward) values ('00000000-0000-4000-8000-000000000a01', 3, 'Three day spark', - '{"rewards":[{"type":"signature","signatureKey":"signature_a"}]}'::jsonb), + '{"rewards":[{"type":"signature","signatureKey":"signature_a"}]}'), ('00000000-0000-4000-8000-000000000a02', 7, 'Week one trail', - '{"rewards":[{"type":"signature","signatureKey":"signature_b"}]}'::jsonb), + '{"rewards":[{"type":"signature","signatureKey":"signature_b"}]}'), ('00000000-0000-4000-8000-000000000a03', 14, 'Fortnight forager', - '{"rewards":[{"type":"capacity_billboard","amount":1}]}'::jsonb), + '{"rewards":[{"type":"capacity_billboard","amount":1}]}'), ('00000000-0000-4000-8000-000000000a04', 30, 'Month-long marker', - '{"rewards":[{"type":"signature","signatureKey":"signature_c"}]}'::jsonb); + '{"rewards":[{"type":"signature","signatureKey":"signature_c"}]}'); -insert into app.signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b01', 'signature_a', 'Sunrise Mark', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 3); -insert into app.signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b02', 'signature_b', 'Pixel Bloom', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 7); -insert into app.signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b03', 'signature_c', 'Prestige Cipher', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 30); +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b01', 'signature_a', 'Sunrise Mark', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 3); +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b02', 'signature_b', 'Pixel Bloom', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 7); +insert into signatures (id, key, name, asset_base64, streak_day_required) values ('00000000-0000-4000-8000-000000000b03', 'signature_c', 'Prestige Cipher', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 30); diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index ac23a61..95bb61e 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -3,8 +3,11 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/schema/index.ts", out: "./drizzle", - dialect: "postgresql", + dialect: "sqlite", + driver: "d1-http", dbCredentials: { - url: process.env.DATABASE_URL ?? "", + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", + databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID ?? "816d5f5d-0be0-4122-88a9-f4bcee86892e", + token: process.env.CLOUDFLARE_API_TOKEN ?? "", }, }); diff --git a/packages/db/package.json b/packages/db/package.json index 11651cd..ed42683 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,12 +9,13 @@ }, "scripts": { "db:push": "drizzle-kit push", - "reset:local": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f supabase/reset.sql", + "migrate:local": "bun --cwd ../../apps/api wrangler d1 migrations apply jematala-db --local", + "migrate:remote": "bun --cwd ../../apps/api wrangler d1 migrations apply jematala-db --remote", + "reset:local": "bun --cwd ../../apps/api wrangler d1 execute jematala-db --local --file ../../packages/db/d1/schema.sql", "typecheck": "tsc --noEmit" }, "dependencies": { - "drizzle-orm": "^0.45.2", - "postgres": "^3.4.9" + "drizzle-orm": "^0.45.2" }, "devDependencies": { "drizzle-kit": "^0.31.10", diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index a751942..a5ad475 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,112 +1,46 @@ import { sql } from "drizzle-orm"; import { - boolean, check, - customType, - date, - doublePrecision, index, integer, - jsonb, - pgSchema, primaryKey, + real, + sqliteTable, text, - timestamp, uniqueIndex, - uuid, -} from "drizzle-orm/pg-core"; +} from "drizzle-orm/sqlite-core"; export type JsonObject = Record; -export const appSchema = pgSchema("app"); - -export const contentStatusEnum = appSchema.enum("content_status", [ - "pending", - "active", - "hidden", - "removed", - "rejected", -]); -export const placementKindEnum = appSchema.enum("placement_kind", ["sticker", "sticky_note"]); -export const savedStickerKindEnum = appSchema.enum("saved_sticker_kind", [ - "sticker", - "sticky_note", -]); -export const questTriggerTypeEnum = appSchema.enum("quest_trigger_type", [ - "visit_pois", - "leave_billboards", - "place_stickers", - "receive_replies", - "save_stickers", -]); -export const questSourceEnum = appSchema.enum("quest_source", ["level_quest", "daily_quest"]); -export const reportTargetTypeEnum = appSchema.enum("report_target_type", [ - "billboard", - "placement", - "user", -]); -export const contentModerationTargetTypeEnum = appSchema.enum("content_moderation_target_type", [ - "billboard", - "placement", - "sticker_asset", -]); -export const reportReasonEnum = appSchema.enum("report_reason", [ - "spam", - "harassment", - "hate", - "sexual", - "violence", - "self_harm", - "other", -]); -export const reportStatusEnum = appSchema.enum("report_status", [ - "open", - "reviewing", - "resolved", - "dismissed", -]); -export const moderationActionTypeEnum = appSchema.enum("moderation_action_type", [ - "hide", - "remove", - "warn", - "ban", - "dismiss", -]); -export const pushPlatformEnum = appSchema.enum("push_platform", ["expo", "ios", "android", "web"]); - -const geographyPoint = customType<{ data: string; driverData: string }>({ - dataType() { - return "geography(point, 4326)"; - }, -}); - -const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow(); -const updatedAt = timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(); -const deletedAt = timestamp("deleted_at", { withTimezone: true }); -const currentSydneyDate = sql`(timezone('Australia/Sydney', now()))::date`; -const uuidPrimaryKey = (name = "id") => - uuid(name) - .primaryKey() - .default(sql`gen_random_uuid()`); +const timestamp = (name: string) => + text(name) + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`); +const nullableTimestamp = (name: string) => text(name); +const dateText = (name: string) => text(name); +const id = (name = "id") => text(name).primaryKey(); +const requiredId = (name: string) => text(name).notNull(); +const bool = (name: string) => integer(name, { mode: "boolean" }); +const json = (name: string) => text(name, { mode: "json" }).$type(); -export const users = appSchema.table( +export const users = sqliteTable( "users", { - id: uuidPrimaryKey(), + id: id(), clerkUserId: text("clerk_user_id").notNull().unique(), username: text("username").notNull().unique(), displayName: text("display_name").notNull(), avatarBase64: text("avatar_base64"), - isAdmin: boolean("is_admin").notNull().default(false), + isAdmin: bool("is_admin").notNull().default(false), level: integer("level").notNull().default(1), xp: integer("xp").notNull().default(0), dailyStreak: integer("daily_streak").notNull().default(0), - streakUpdatedOn: date("streak_updated_on"), - lastDailyClaimedOn: date("last_daily_claimed_on"), - bannedAt: timestamp("banned_at", { withTimezone: true }), - deletedAt, - createdAt, - updatedAt, + streakUpdatedOn: dateText("streak_updated_on"), + lastDailyClaimedOn: dateText("last_daily_claimed_on"), + bannedAt: nullableTimestamp("banned_at"), + deletedAt: nullableTimestamp("deleted_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }, (table) => [ check("users_level_check", sql`${table.level} >= 1`), @@ -115,77 +49,69 @@ export const users = appSchema.table( ], ); -export const pushTokens = appSchema.table( +export const pushTokens = sqliteTable( "push_tokens", { - id: uuidPrimaryKey(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + id: id(), + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), token: text("token").notNull().unique(), - platform: pushPlatformEnum("platform").notNull().default("expo"), - createdAt, - revokedAt: timestamp("revoked_at", { withTimezone: true }), + platform: text("platform", { enum: ["expo", "ios", "android", "web"] }) + .notNull() + .default("expo"), + createdAt: timestamp("created_at"), + revokedAt: nullableTimestamp("revoked_at"), }, (table) => [index("push_tokens_user_idx").on(table.userId)], ); -export const campuses = appSchema.table( +export const campuses = sqliteTable( "campuses", { - id: uuidPrimaryKey(), + id: id(), name: text("name").notNull(), timezone: text("timezone").notNull(), - centerLat: doublePrecision("center_lat").notNull(), - centerLng: doublePrecision("center_lng").notNull(), + centerLat: real("center_lat").notNull(), + centerLng: real("center_lng").notNull(), radiusMeters: integer("radius_meters").notNull(), - bounds: jsonb("bounds").$type().notNull(), + bounds: json("bounds").notNull(), mapProvider: text("map_provider").notNull().default("openstreetmap"), - createdAt, + createdAt: timestamp("created_at"), }, (table) => [check("campuses_radius_meters_check", sql`${table.radiusMeters} > 0`)], ); -export const pois = appSchema.table( +export const pois = sqliteTable( "pois", { - id: uuidPrimaryKey(), - campusId: uuid("campus_id") - .notNull() - .references(() => campuses.id, { onDelete: "cascade" }), + id: id(), + campusId: requiredId("campus_id").references(() => campuses.id, { onDelete: "cascade" }), title: text("title").notNull(), description: text("description"), pictureBase64: text("picture_base64"), - locationPoint: geographyPoint("location_point").notNull(), - lat: doublePrecision("lat").notNull(), - lng: doublePrecision("lng").notNull(), + lat: real("lat").notNull(), + lng: real("lng").notNull(), radiusMeters: integer("radius_meters").notNull().default(30), - isActive: boolean("is_active").notNull().default(true), - createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), - createdAt, - updatedAt, - deletedAt, + isActive: bool("is_active").notNull().default(true), + createdBy: text("created_by").references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + deletedAt: nullableTimestamp("deleted_at"), }, (table) => [ - index("pois_location_point_idx").using("gist", table.locationPoint), - index("pois_active_campus_idx") - .on(table.campusId, table.isActive) - .where(sql`${table.deletedAt} is null`), + index("pois_active_campus_idx").on(table.campusId, table.isActive), check("pois_radius_meters_check", sql`${table.radiusMeters} > 0`), ], ); -export const poiVisits = appSchema.table( +export const poiVisits = sqliteTable( "poi_visits", { - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - poiId: uuid("poi_id") + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), + poiId: requiredId("poi_id").references(() => pois.id, { onDelete: "cascade" }), + visitedAt: timestamp("visited_at"), + visitedOn: text("visited_on") .notNull() - .references(() => pois.id, { onDelete: "cascade" }), - visitedAt: timestamp("visited_at", { withTimezone: true }).notNull().defaultNow(), - visitedOn: date("visited_on").notNull().default(currentSydneyDate), + .default(sql`(date('now', '+10 hours'))`), }, (table) => [ primaryKey({ columns: [table.userId, table.poiId] }), @@ -193,82 +119,62 @@ export const poiVisits = appSchema.table( ], ); -export const poiDailyActivations = appSchema.table( +export const poiDailyActivations = sqliteTable( "poi_daily_activations", { - campusId: uuid("campus_id") - .notNull() - .references(() => campuses.id, { onDelete: "cascade" }), - poiId: uuid("poi_id") - .notNull() - .references(() => pois.id, { onDelete: "cascade" }), - activeOn: date("active_on").notNull(), + campusId: requiredId("campus_id").references(() => campuses.id, { onDelete: "cascade" }), + poiId: requiredId("poi_id").references(() => pois.id, { onDelete: "cascade" }), + activeOn: text("active_on").notNull(), + createdAt: timestamp("created_at"), }, (table) => [primaryKey({ columns: [table.campusId, table.poiId, table.activeOn] })], ); -export const billboards = appSchema.table( +export const billboards = sqliteTable( "billboards", { - id: uuidPrimaryKey(), - campusId: uuid("campus_id") - .notNull() - .references(() => campuses.id, { onDelete: "cascade" }), - authorId: uuid("author_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + id: id(), + campusId: requiredId("campus_id").references(() => campuses.id, { onDelete: "cascade" }), + authorId: requiredId("author_id").references(() => users.id, { onDelete: "cascade" }), body: text("body").notNull(), - locationPoint: geographyPoint("location_point").notNull(), - lat: doublePrecision("lat").notNull(), - lng: doublePrecision("lng").notNull(), - status: contentStatusEnum("status").notNull().default("pending"), - moderationSummary: jsonb("moderation_summary").$type(), - emptyExpiresAt: timestamp("empty_expires_at", { withTimezone: true }) + lat: real("lat").notNull(), + lng: real("lng").notNull(), + status: text("status", { enum: ["pending", "active", "hidden", "removed", "rejected"] }) .notNull() - .default(sql`now() + interval '24 hours'`), - expiresAt: timestamp("expires_at", { withTimezone: true }) + .default("pending"), + moderationSummary: json("moderation_summary"), + emptyExpiresAt: text("empty_expires_at") .notNull() - .default(sql`now() + interval '5 days'`), - hiddenAt: timestamp("hidden_at", { withTimezone: true }), - deletedAt, - createdAt, - updatedAt, + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+24 hours'))`), + expiresAt: text("expires_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+5 days'))`), + hiddenAt: nullableTimestamp("hidden_at"), + deletedAt: nullableTimestamp("deleted_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }, (table) => [ - index("billboards_location_point_idx").using("gist", table.locationPoint), - index("billboards_active_idx") - .on(table.campusId, table.status, table.expiresAt) - .where(sql`${table.deletedAt} is null`), - index("billboards_author_day_idx").on( - table.authorId, - sql`((timezone('Australia/Sydney', ${table.createdAt}))::date)`, - ), - check( - "billboards_expires_at_check", - sql`${table.expiresAt} > ${table.createdAt} and ${table.expiresAt} <= ${table.createdAt} + interval '5 days'`, - ), - check( - "billboards_empty_expires_at_check", - sql`${table.emptyExpiresAt} > ${table.createdAt} and ${table.emptyExpiresAt} <= ${table.createdAt} + interval '24 hours'`, - ), + index("billboards_active_idx").on(table.campusId, table.status, table.expiresAt), + index("billboards_author_day_idx").on(table.authorId, table.createdAt), ], ); -export const stickerAssets = appSchema.table( +export const stickerAssets = sqliteTable( "sticker_assets", { - id: uuidPrimaryKey(), - ownerId: uuid("owner_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + id: id(), + ownerId: requiredId("owner_id").references(() => users.id, { onDelete: "cascade" }), pngBase64: text("png_base64").notNull(), width: integer("width").notNull().default(64), height: integer("height").notNull().default(64), - palette: jsonb("palette").$type(), - status: contentStatusEnum("status").notNull().default("pending"), - moderationSummary: jsonb("moderation_summary").$type(), - createdAt, - deletedAt, + palette: json("palette"), + status: text("status", { enum: ["pending", "active", "hidden", "removed", "rejected"] }) + .notNull() + .default("pending"), + moderationSummary: json("moderation_summary"), + createdAt: timestamp("created_at"), + deletedAt: nullableTimestamp("deleted_at"), }, (table) => [ index("sticker_assets_owner_idx").on(table.ownerId), @@ -277,30 +183,30 @@ export const stickerAssets = appSchema.table( ], ); -export const billboardPlacements = appSchema.table( +export const billboardPlacements = sqliteTable( "billboard_placements", { - id: uuidPrimaryKey(), - billboardId: uuid("billboard_id") - .notNull() - .references(() => billboards.id, { onDelete: "cascade" }), - authorId: uuid("author_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - kind: placementKindEnum("kind").notNull(), - x: doublePrecision("x").notNull(), - y: doublePrecision("y").notNull(), + id: id(), + billboardId: requiredId("billboard_id").references(() => billboards.id, { + onDelete: "cascade", + }), + authorId: requiredId("author_id").references(() => users.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["sticker", "sticky_note"] }).notNull(), + x: real("x").notNull(), + y: real("y").notNull(), zIndex: integer("z_index").notNull().default(0), - stickerAssetId: uuid("sticker_asset_id").references(() => stickerAssets.id, { + stickerAssetId: text("sticker_asset_id").references(() => stickerAssets.id, { onDelete: "restrict", }), body: text("body"), - status: contentStatusEnum("status").notNull().default("pending"), - moderationSummary: jsonb("moderation_summary").$type(), - hiddenAt: timestamp("hidden_at", { withTimezone: true }), - deletedAt, - createdAt, - updatedAt, + status: text("status", { enum: ["pending", "active", "hidden", "removed", "rejected"] }) + .notNull() + .default("pending"), + moderationSummary: json("moderation_summary"), + hiddenAt: nullableTimestamp("hidden_at"), + deletedAt: nullableTimestamp("deleted_at"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }, (table) => [ uniqueIndex("billboard_placements_one_per_user_idx") @@ -309,161 +215,115 @@ export const billboardPlacements = appSchema.table( index("billboard_placements_billboard_idx").on(table.billboardId, table.zIndex), check("billboard_placements_x_check", sql`${table.x} >= 0 and ${table.x} <= 1`), check("billboard_placements_y_check", sql`${table.y} >= 0 and ${table.y} <= 1`), - check( - "billboard_placements_kind_payload_check", - sql` - ( - ${table.kind} = 'sticker' - and ${table.stickerAssetId} is not null - and ${table.body} is null - ) - or ( - ${table.kind} = 'sticky_note' - and ${table.stickerAssetId} is null - and ${table.body} is not null - ) - `, - ), ], ); -export const savedStickers = appSchema.table( +export const savedStickers = sqliteTable( "saved_stickers", { - id: uuidPrimaryKey(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - kind: savedStickerKindEnum("kind").notNull(), - stickerAssetId: uuid("sticker_asset_id").references(() => stickerAssets.id, { + id: id(), + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["sticker", "sticky_note"] }).notNull(), + stickerAssetId: text("sticker_asset_id").references(() => stickerAssets.id, { onDelete: "cascade", }), body: text("body"), label: text("label"), - createdAt, - deletedAt, + createdAt: timestamp("created_at"), + deletedAt: nullableTimestamp("deleted_at"), }, - (table) => [ - index("saved_stickers_user_idx") - .on(table.userId) - .where(sql`${table.deletedAt} is null`), - check( - "saved_stickers_kind_payload_check", - sql` - ( - ${table.kind} = 'sticker' - and ${table.stickerAssetId} is not null - and ${table.body} is null - ) - or ( - ${table.kind} = 'sticky_note' - and ${table.stickerAssetId} is null - and ${table.body} is not null - ) - `, - ), - ], + (table) => [index("saved_stickers_user_idx").on(table.userId)], ); -export const questTemplates = appSchema.table( - "quest_templates", - { - id: uuidPrimaryKey(), - key: text("key").notNull().unique(), - triggerType: questTriggerTypeEnum("trigger_type").notNull(), - titleTemplate: text("title_template").notNull(), - descriptionTemplate: text("description_template").notNull(), - minTarget: integer("min_target").notNull(), - maxTarget: integer("max_target").notNull(), - xpReward: integer("xp_reward").notNull(), - active: boolean("active").notNull().default(true), - createdAt, - }, - (table) => [ - check("quest_templates_min_target_check", sql`${table.minTarget} > 0`), - check("quest_templates_max_target_check", sql`${table.maxTarget} >= ${table.minTarget}`), - check("quest_templates_xp_reward_check", sql`${table.xpReward} >= 0`), - ], -); +export const questTemplates = sqliteTable("quest_templates", { + id: id(), + key: text("key").notNull().unique(), + triggerType: text("trigger_type", { + enum: ["visit_pois", "leave_billboards", "place_stickers", "receive_replies", "save_stickers"], + }).notNull(), + titleTemplate: text("title_template").notNull(), + descriptionTemplate: text("description_template").notNull(), + minTarget: integer("min_target").notNull(), + maxTarget: integer("max_target").notNull(), + xpReward: integer("xp_reward").notNull(), + active: bool("active").notNull().default(true), + createdAt: timestamp("created_at"), +}); -export const dailyQuestTemplates = appSchema.table( - "daily_quest_templates", - { - id: uuidPrimaryKey(), - key: text("key").notNull().unique(), - triggerType: questTriggerTypeEnum("trigger_type").notNull(), - titleTemplate: text("title_template").notNull(), - descriptionTemplate: text("description_template").notNull(), - minTarget: integer("min_target").notNull(), - maxTarget: integer("max_target").notNull(), - xpReward: integer("xp_reward").notNull(), - active: boolean("active").notNull().default(true), - createdAt, - }, - (table) => [ - check("daily_quest_templates_min_target_check", sql`${table.minTarget} > 0`), - check("daily_quest_templates_max_target_check", sql`${table.maxTarget} >= ${table.minTarget}`), - check("daily_quest_templates_xp_reward_check", sql`${table.xpReward} >= 0`), - ], -); +export const dailyQuestTemplates = sqliteTable("daily_quest_templates", { + id: id(), + key: text("key").notNull().unique(), + triggerType: text("trigger_type", { + enum: ["visit_pois", "leave_billboards", "place_stickers", "receive_replies", "save_stickers"], + }).notNull(), + titleTemplate: text("title_template").notNull(), + descriptionTemplate: text("description_template").notNull(), + minTarget: integer("min_target").notNull(), + maxTarget: integer("max_target").notNull(), + xpReward: integer("xp_reward").notNull(), + active: bool("active").notNull().default(true), + createdAt: timestamp("created_at"), +}); -export const levelQuestSets = appSchema.table( +export const levelQuestSets = sqliteTable( "level_quest_sets", { - id: uuidPrimaryKey(), + id: id(), level: integer("level").notNull(), - templateId: uuid("template_id") - .notNull() - .references(() => questTemplates.id, { onDelete: "restrict" }), + templateId: requiredId("template_id").references(() => questTemplates.id, { + onDelete: "restrict", + }), targetCount: integer("target_count").notNull(), xpReward: integer("xp_reward").notNull(), sortOrder: integer("sort_order").notNull().default(0), - createdAt, + createdAt: timestamp("created_at"), }, - (table) => [ - uniqueIndex("level_quest_sets_level_sort_idx").on(table.level, table.sortOrder), - check("level_quest_sets_level_check", sql`${table.level} >= 1`), - check("level_quest_sets_target_count_check", sql`${table.targetCount} > 0`), - check("level_quest_sets_xp_reward_check", sql`${table.xpReward} >= 0`), - ], + (table) => [uniqueIndex("level_quest_sets_level_sort_idx").on(table.level, table.sortOrder)], ); -export const dailyQuestPool = appSchema.table( - "daily_quest_pool", +export const dailyQuestPool = sqliteTable("daily_quest_pool", { + id: id(), + templateId: requiredId("template_id").references(() => dailyQuestTemplates.id, { + onDelete: "restrict", + }), + targetCount: integer("target_count").notNull(), + xpReward: integer("xp_reward").notNull(), + active: bool("active").notNull().default(true), + createdAt: timestamp("created_at"), +}); + +export const dailyQuestAssignments = sqliteTable( + "daily_quest_assignments", { - id: uuidPrimaryKey(), - templateId: uuid("template_id") - .notNull() - .references(() => dailyQuestTemplates.id, { onDelete: "restrict" }), - targetCount: integer("target_count").notNull(), - xpReward: integer("xp_reward").notNull(), - active: boolean("active").notNull().default(true), - createdAt, + id: id(), + campusId: requiredId("campus_id").references(() => campuses.id, { onDelete: "cascade" }), + activeOn: text("active_on").notNull(), + dailyQuestPoolId: requiredId("daily_quest_pool_id").references(() => dailyQuestPool.id, { + onDelete: "restrict", + }), + createdAt: timestamp("created_at"), }, (table) => [ - check("daily_quest_pool_target_count_check", sql`${table.targetCount} > 0`), - check("daily_quest_pool_xp_reward_check", sql`${table.xpReward} >= 0`), + uniqueIndex("daily_quest_assignments_campus_day_idx").on(table.campusId, table.activeOn), ], ); -export const userQuestProgress = appSchema.table( +export const userQuestProgress = sqliteTable( "user_quest_progress", { - id: uuidPrimaryKey(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - source: questSourceEnum("source").notNull(), - sourceId: uuid("source_id").notNull(), - activeOn: date("active_on"), + id: id(), + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), + source: text("source", { enum: ["level_quest", "daily_quest"] }).notNull(), + sourceId: requiredId("source_id"), + activeOn: text("active_on"), progressCount: integer("progress_count").notNull().default(0), targetCount: integer("target_count").notNull(), - completedAt: timestamp("completed_at", { withTimezone: true }), - claimableAt: timestamp("claimable_at", { withTimezone: true }), - claimedAt: timestamp("claimed_at", { withTimezone: true }), + completedAt: nullableTimestamp("completed_at"), + claimableAt: nullableTimestamp("claimable_at"), + claimedAt: nullableTimestamp("claimed_at"), claimedXp: integer("claimed_xp"), - createdAt, - updatedAt, + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }, (table) => [ uniqueIndex("user_quest_progress_level_unique_idx") @@ -473,116 +333,71 @@ export const userQuestProgress = appSchema.table( .on(table.userId, table.source, table.sourceId, table.activeOn) .where(sql`${table.activeOn} is not null`), index("user_quest_progress_user_idx").on(table.userId), - check( - "user_quest_progress_active_on_check", - sql` - ( - ${table.source} = 'daily_quest' - and ${table.activeOn} is not null - ) - or ( - ${table.source} = 'level_quest' - and ${table.activeOn} is null - ) - `, - ), - check("user_quest_progress_progress_count_check", sql`${table.progressCount} >= 0`), - check("user_quest_progress_target_count_check", sql`${table.targetCount} > 0`), - check( - "user_quest_progress_claimed_xp_check", - sql`${table.claimedXp} is null or ${table.claimedXp} >= 0`, - ), ], ); -export const perkDefinitions = appSchema.table("perk_definitions", { - id: uuidPrimaryKey(), +export const perkDefinitions = sqliteTable("perk_definitions", { + id: id(), key: text("key").notNull().unique(), name: text("name").notNull(), description: text("description").notNull(), - createdAt, + createdAt: timestamp("created_at"), }); -export const levelPerks = appSchema.table( +export const levelPerks = sqliteTable( "level_perks", { - id: uuidPrimaryKey(), + id: id(), level: integer("level").notNull(), - perkId: uuid("perk_id") - .notNull() - .references(() => perkDefinitions.id, { onDelete: "restrict" }), + perkId: requiredId("perk_id").references(() => perkDefinitions.id, { onDelete: "restrict" }), numericValue: integer("numeric_value"), - metadata: jsonb("metadata").$type(), - createdAt, + metadata: json("metadata"), + createdAt: timestamp("created_at"), }, - (table) => [ - uniqueIndex("level_perks_level_perk_idx").on(table.level, table.perkId), - check("level_perks_level_check", sql`${table.level} >= 1`), - ], + (table) => [uniqueIndex("level_perks_level_perk_idx").on(table.level, table.perkId)], ); -export const userPerkUnlocks = appSchema.table( +export const userPerkUnlocks = sqliteTable( "user_perk_unlocks", { - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - levelPerkId: uuid("level_perk_id") - .notNull() - .references(() => levelPerks.id, { onDelete: "restrict" }), + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), + levelPerkId: requiredId("level_perk_id").references(() => levelPerks.id, { + onDelete: "restrict", + }), sourceLevel: integer("source_level").notNull(), - unlockedAt: timestamp("unlocked_at", { withTimezone: true }).notNull().defaultNow(), + unlockedAt: timestamp("unlocked_at"), }, - (table) => [ - primaryKey({ columns: [table.userId, table.levelPerkId] }), - check("user_perk_unlocks_source_level_check", sql`${table.sourceLevel} >= 1`), - ], + (table) => [primaryKey({ columns: [table.userId, table.levelPerkId] })], ); -export const streakRewardDefinitions = appSchema.table( - "streak_reward_definitions", - { - id: uuidPrimaryKey(), - streakDays: integer("streak_days").notNull().unique(), - name: text("name").notNull(), - reward: jsonb("reward").$type().notNull(), - active: boolean("active").notNull().default(true), - createdAt, - }, - (table) => [ - check("streak_reward_definitions_streak_days_check", sql`${table.streakDays} > 0`), - check( - "streak_reward_definitions_reward_object_check", - sql`jsonb_typeof(${table.reward}) = 'object'`, - ), - ], -); +export const streakRewardDefinitions = sqliteTable("streak_reward_definitions", { + id: id(), + streakDays: integer("streak_days").notNull().unique(), + name: text("name").notNull(), + reward: json("reward").notNull(), + active: bool("active").notNull().default(true), + createdAt: timestamp("created_at"), +}); -export const signatures = appSchema.table( - "signatures", - { - id: uuidPrimaryKey(), - key: text("key").notNull().unique(), - name: text("name").notNull(), - assetBase64: text("asset_base64").notNull(), - streakDayRequired: integer("streak_day_required").notNull(), - active: boolean("active").notNull().default(true), - createdAt, - }, - (table) => [check("signatures_streak_day_check", sql`${table.streakDayRequired} > 0`)], -); +export const signatures = sqliteTable("signatures", { + id: id(), + key: text("key").notNull().unique(), + name: text("name").notNull(), + assetBase64: text("asset_base64").notNull(), + streakDayRequired: integer("streak_day_required").notNull(), + active: bool("active").notNull().default(true), + createdAt: timestamp("created_at"), +}); -export const userSignatures = appSchema.table( +export const userSignatures = sqliteTable( "user_signatures", { - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - signatureId: uuid("signature_id") - .notNull() - .references(() => signatures.id, { onDelete: "cascade" }), - unlockedAt: timestamp("unlocked_at", { withTimezone: true }).notNull().defaultNow(), - isEquipped: boolean("is_equipped").notNull().default(false), + userId: requiredId("user_id").references(() => users.id, { onDelete: "cascade" }), + signatureId: requiredId("signature_id").references(() => signatures.id, { + onDelete: "cascade", + }), + unlockedAt: timestamp("unlocked_at"), + isEquipped: bool("is_equipped").notNull().default(false), }, (table) => [ primaryKey({ columns: [table.userId, table.signatureId] }), @@ -592,21 +407,23 @@ export const userSignatures = appSchema.table( ], ); -export const reports = appSchema.table( +export const reports = sqliteTable( "reports", { - id: uuidPrimaryKey(), - reporterId: uuid("reporter_id") + id: id(), + reporterId: requiredId("reporter_id").references(() => users.id, { onDelete: "cascade" }), + targetType: text("target_type", { enum: ["billboard", "placement", "user"] }).notNull(), + targetId: requiredId("target_id"), + reason: text("reason", { + enum: ["spam", "harassment", "hate", "sexual", "violence", "self_harm", "other"], + }).notNull(), + status: text("status", { enum: ["open", "reviewing", "resolved", "dismissed"] }) .notNull() - .references(() => users.id, { onDelete: "cascade" }), - targetType: reportTargetTypeEnum("target_type").notNull(), - targetId: uuid("target_id").notNull(), - reason: reportReasonEnum("reason").notNull(), + .default("open"), details: text("details"), - status: reportStatusEnum("status").notNull().default("open"), adminNotes: text("admin_notes"), - createdAt, - updatedAt, + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }, (table) => [ uniqueIndex("reports_reporter_target_idx").on( @@ -618,27 +435,25 @@ export const reports = appSchema.table( ], ); -export const moderationActions = appSchema.table("moderation_actions", { - id: uuidPrimaryKey(), - reportId: uuid("report_id").references(() => reports.id, { onDelete: "set null" }), - adminId: uuid("admin_id") - .notNull() - .references(() => users.id, { onDelete: "restrict" }), - action: moderationActionTypeEnum("action").notNull(), - targetType: reportTargetTypeEnum("target_type").notNull(), - targetId: uuid("target_id").notNull(), +export const moderationActions = sqliteTable("moderation_actions", { + id: id(), + reportId: text("report_id").references(() => reports.id, { onDelete: "set null" }), + adminId: requiredId("admin_id").references(() => users.id, { onDelete: "restrict" }), + action: text("action", { enum: ["hide", "remove", "warn", "ban", "dismiss"] }).notNull(), + targetType: text("target_type", { enum: ["billboard", "placement", "user"] }).notNull(), + targetId: requiredId("target_id"), notes: text("notes"), - createdAt, + createdAt: timestamp("created_at"), }); -export const contentModerationLogs = appSchema.table("content_moderation_logs", { - id: uuidPrimaryKey(), - targetType: contentModerationTargetTypeEnum("target_type").notNull(), - targetId: uuid("target_id").notNull(), +export const contentModerationLogs = sqliteTable("content_moderation_logs", { + id: id(), + targetType: text("target_type", { enum: ["billboard", "placement", "sticker_asset"] }).notNull(), + targetId: requiredId("target_id"), provider: text("provider").notNull().default("openai"), - flagged: boolean("flagged").notNull(), - categories: jsonb("categories").$type(), - scores: jsonb("scores").$type(), - rawResponse: jsonb("raw_response").$type(), - createdAt, + flagged: bool("flagged").notNull(), + categories: json("categories"), + scores: json("scores"), + rawResponse: json("raw_response"), + createdAt: timestamp("created_at"), });