From e958f38575f84fab22312d8b8fe54cc41fec9c86 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 13 Aug 2026 21:17:42 +0200 Subject: [PATCH] docs: bring README up to date with current tech stack and architecture Tech stack table was describing the original MVP plan (Vercel, Upstash Redis, middleware.ts) rather than what's actually running: self-hosted Docker deployment, self-hosted Redis, next-intl i18n, dark mode, GlitchTip/Sentry monitoring, CSP, and the admin/moderation panel built since. Also refreshes the project structure tree, Prisma schema overview, and setup instructions to match the current codebase. --- README.md | 521 ++++++++++++++++++++---------------------------------- 1 file changed, 190 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index 4091bd4..b7254ef 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own **Core value:** Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's. +**Status:** Live in production at [pawfeed.org](https://pawfeed.org) (public, invite gate disabled since 2026-08-10). + --- ## Tech Stack @@ -16,13 +18,18 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own | API | tRPC | 11.x | | ORM | Prisma | 7.x | | Database | PostgreSQL via Supabase (managed) | 16 | -| Auth | Clerk | 7.x | -| Media Storage | Supabase Storage (S3-compatible) | — | -| Cache / Feed | Upstash Redis (serverless) | — | +| Auth | Clerk (+ `@clerk/themes` for dark-mode-aware Clerk UI) | 7.x | +| Media Storage | Supabase Storage (S3-compatible, presigned uploads) | — | +| Cache / Feed / Rate Limiting | **Self-hosted Redis** (`ioredis`, Docker service — not Upstash) | 5.x | | Client Fetching | TanStack Query | 5.x | | Video | Mux (upload, transcoding, HLS streaming) | — | +| i18n | next-intl (EN/DE message catalogs; legal pages fixed-German) | 4.x | +| Theming | next-themes (account-bound dark mode via `Owner.themePreference`) | — | +| Forms / Validation | react-hook-form + `@hookform/resolvers` + Zod | — | +| Email | Nodemailer (branded HTML transactional mail) | 9.x | +| Error Monitoring / Tracing | `@sentry/nextjs` SDK → **self-hosted GlitchTip** (`glitchtip.pawfeed.org`) | 10.x | | Tests | Vitest | 4.x | -| Hosting | Vercel (target) | — | +| Hosting | **Self-hosted Docker** on an OpenMediaVault NAS (behind Nginx Proxy Manager + Cloudflare) — not Vercel | — | **Design decisions:** - All data access goes through **Prisma → direct Postgres connection**, never through Supabase PostgREST. RLS is enabled on all tables with no policies, which closes the PostgREST surface entirely. @@ -31,6 +38,9 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own - Authentication is handled by **Clerk** (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users. - Short-form video is handled by **Mux**: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via `mux-player-react`. The `getByPostId` query self-heals in dev by polling the Mux API directly, so webhooks are not required during local development. - **Health data is strictly owner-private.** The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters. The emergency vet contact is intentionally excluded from the shared card — it lives only in the private dashboard. +- **Deployment is self-hosted, not Vercel.** `next.config.ts` still sets `output: "standalone"`, but the app ships as a Docker image (`docker/Dockerfile`, `docker/docker-compose.yml`) to a NAS. `./start.sh` rebuilds and restarts the container after pulling changes. +- **Content-Security-Policy runs in Report-Only mode** (`src/proxy.ts`, via Clerk's own `contentSecurityPolicy` middleware option) while directive gaps get closed from real violation reports collected in GlitchTip, before switching to enforced. +- **Admin/moderation panel** lives at a secret path (`/p/[ADMIN_SECRET]/...`) — wrong or missing secret returns a bare 404, not 403, so the panel's existence isn't revealed to unauthenticated probing. Role-gated at the tRPC layer on top of that (`SUPER_ADMIN` / `MODERATOR`). --- @@ -39,102 +49,76 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own ``` src/ app/ - (app)/ # Protected app shell (Clerk middleware) - feed/ # Home feed page + StoryTray - notifications/ # Notification center page - pets/[petId]/ # Pet profile page + ProfileTabs + (app)/ # Protected app shell (Clerk auth via src/proxy.ts) + feed/ # Home feed page + StoryTray + notifications/ # Notification center page + pets/ # Pet list ("my pets" switcher) + pets/[petId]/ # Pet profile page + ProfileTabs pets/[petId]/edit/ pets/[petId]/followers/ pets/[petId]/following/ - pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines, emergency vet) - search/ # Pet + People + Hashtag search - messages/ # DM inbox list - messages/[conversationId]/ # Conversation view - onboarding/ # Pet creation onboarding - (auth)/ # Clerk-rendered sign-in / sign-up - health-card/[token]/ # Public shareable health card (no login required) + pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines, emergency vet) + explore/ # Species/breed-filtered discovery feed + search/ # Pet + People + Hashtag search + hashtag/[tag]/ # Hashtag feed + messages/ # DM inbox list + messages/[conversationId]/ + invite/ # In-app "invite a friend" surface + onboarding/pet/ # First-pet creation onboarding + (auth)/ # Clerk-rendered sign-in / sign-up / password reset / email verify + p/[secret]/ # Admin & moderation panel — path segment IS the secret (ADMIN_SECRET) + users/ moderators/ posts/ reports/ ads/ invites/ verification/ blacklist/ messages/ log/ legal/ + health-card/[token]/ # Public shareable health card (no login required) + join/ # Invite-code landing page, sets the pf_invite cookie + banned/ # Shown to banned owners (proxy.ts redirect target) + impressum/ datenschutz/ nutzungsbedingungen/ # German legal pages, fixed-language, not under (app) api/ - trpc/ # tRPC HTTP handler + trpc/[trpc]/ # tRPC HTTP handler + auth/ + set-invite/ consume-invite/ # Invite-cookie lifecycle around sign-up + account/delete/ # Self-service account deletion + gdpr-export/[token]/ # Tokenized personal-data export (GDPR) + csp-report/ # Receives CSP Report-Only violations, forwards to GlitchTip cron/ - anniversaries/ # Vercel cron: fires birthday + adoption-day notifications daily at 08:00 + anniversaries/ # Birthday + adoption-day notifications, daily 08:00 + trim-feeds/ # Trims each pet's Redis feed sorted set to a 90-day window webhooks/ - mux/ # Mux webhook handler (video.asset.ready etc.) - components/ - feed/ # PostCard, MilestoneCard, RepostCard, FeedList, SkeletonCard - # PawButton (paw reaction), PawBackButton (repost), CommentSheet - # WelcomeCard (new-user onboarding card, localStorage-dismissed) - health/ # HealthDashboard (tabs), WeightChart (SVG bezier), PrintButton, AutoPrint - # EmergencyVetSection (Nominatim search + OSM map, owner-only) - layout/ # Sidebar (with NotificationBell), MobileNav - notifications/ # NotificationBell (badge + count) - pet/ # AvatarUpload, PetForm, PetListItem - post-creation/ # PostTypeSheet, PhotoPostForm, StoryForm, MilestoneForm, - # MultiImageUpload, VideoUploadForm (+ Mux direct upload) - profile/ # ProfileTabs (Posts grid + Milestones list) — post detail as centered Dialog - safety/ # BlockDialog, ReportSheet - social/ # FollowButton - stories/ # PawRing, StoryViewer, StoryTray, StoryForm - ui/ # shadcn component copies (button, dialog, sheet, etc.) - video/ # VideoCard (polls PROCESSING -> plays READY via MuxPlayer) + mux/ # Mux webhook handler (video.asset.ready etc.) + components/ # feed/ health/ layout/ notifications/ pet/ post-creation/ + # profile/ safety/ social/ stories/ video/ content/ (AI-disclosure UI) + # providers/ (ThemeProvider, ClerkThemeProvider) ui/ (shadcn copies) context/ - ActivePetContext.tsx # Global active pet state (pet switcher) + ActivePetContext.tsx # Global active pet state (pet switcher), SSR-initialized + i18n/ + request.ts # next-intl config — check here before assuming a routing scheme lib/ - avatar-url.ts # CDN URL construction for avatars - feed-helpers.ts # fanOutPost, backfillOnFollow (Redis) - hashtag-helpers.ts # extractHashtags, saveHashtags - media-url.ts # CDN URL construction for post/story media - milestone-meta.ts # MilestoneType -> Lucide icon + label map - milestone-thresholds.ts # Follower count thresholds for auto-milestones - mux.ts # Mux Node SDK singleton + MUX_WEBHOOK_SECRET - parse-caption.ts # Tokenises captions into text + #hashtag segments - prisma.ts # Prisma singleton with PgBouncer adapter - redis.ts # Upstash Redis client - supabase-storage.ts # Supabase Storage presigned URL helpers (server-only) - utils.ts # cn() helper + supabase-storage.ts # Supabase Storage presigned URL helpers (server-only) + redis.ts # ioredis client (self-hosted, lazyConnect) + rate-limit.ts # Redis-backed rate limiting middleware + mail.ts / mail-template.ts # Nodemailer + branded HTML email template + mux.ts # Mux Node SDK singleton + assert-pet-ownership.ts # Shared pet-ownership check used across routers + timing-safe-compare.ts # Constant-time string compare for admin/cron secrets + invite-codes.ts # InviteCode generation (word-prefix + 4-char code) trpc/ - init.ts # tRPC context (userId + prisma) - server.ts # Server-side caller for RSC - query-client.ts # TanStack Query client factory - routers/ - _app.ts # Root router - pets.ts # CRUD for pet profiles (birthday + adoptedAt fields included) - posts.ts # Photo posts (create, updateCaption, delete, byPetId) - stories.ts # 24h stories (create, listActive, hasActiveStory, recordView, getViewers) - milestones.ts # Milestone posts (create, listByPet) - feed.ts # Home feed (getFeed with mode: "chrono" | "algo") - follows.ts # Pet-to-pet follow graph - reactions.ts # Paw reactions (toggle, count per post) - comments.ts # Flat comments (create, delete, listByPost) - reposts.ts # Paw-Back repost with attribution - explore.ts # Explore page (species/breed filter) - search.ts # Pet name/breed search + owner username search + hashtag search - notifications.ts # In-app notifications (list, unreadCount, markRead, markAllRead) - videos.ts # Mux video (createUpload, getByPostId with Mux self-heal, updateCaption) - blocks.ts # Block a pet - reports.ts # Report a post or pet - media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload) - messages.ts # DM conversations (create, list, send, markRead) - health.ts # Weight logs, vet visits, vaccines, emergency vet, shareable health card - __tests__/ - helpers/ - prisma-mock.ts # Typed mock PrismaClient factory - auth.test.ts - feed.test.ts - follows.test.ts - blocks.test.ts - reports.test.ts - posts.test.ts - stories.test.ts - milestones.test.ts - middleware.ts # Next.js middleware (Clerk auth guard) + init.ts # tRPC context ({ userId, prisma }); protectedProcedure + server.ts # Server-side caller for RSC prefetching + query-client.ts + routers/ # ~24 domain routers aggregated in _app.ts: + # pets, posts, stories, milestones, follows, blocks, reports, + # feed, reactions, comments, reposts, explore, search, + # notifications, videos, messages, health, admin, invites, + # ads, owner, legal + __tests__/ # One file per domain router; Prisma fully mocked (prisma-mock.ts) + proxy.ts # Next.js middleware (renamed from middleware.ts in Next 16) — + # invite gate + admin-secret gate + Clerk auth.protect() + CSP headers prisma/ - schema.prisma # Full DB schema — see Schema section below - seed.ts # Species + breed seed (Dog/Cat/Bird) + schema.prisma # Full DB schema — see Schema section below + seed.ts # Species + breed seed (Dog/Cat/Bird), with translations supabase/ - migrations/ - 20260613000000_enable_rls_all_tables.sql -vercel.json # Vercel cron config (anniversaries at 08:00 daily) -.planning/ # GSD planning artefacts (phases, plans, summaries) + migrations/ # RLS-enablement SQL (Prisma bypasses RLS via service_role) +docker/ + Dockerfile / docker-compose.yml / start.sh # Self-hosted deployment ``` --- @@ -142,31 +126,29 @@ vercel.json # Vercel cron config (anniversaries at 08:00 daily) ## Prisma Schema — Model Overview ``` -Owner — Clerk userId, owns n Pets -Pet — Social identity. Fields: name, bio, adoptionStory, avatarKey, - dmPolicy, birthday (Date), adoptedAt (Date), species, breed -Post — PHOTO | MILESTONE | REPOST | VIDEO -PostImage — image storage keys (0-based position for carousel) -Milestone — linked 1:1 to a Post (MilestoneType enum) -Story — 24h ephemeral photos; expiresAt indexed -StoryView — viewer tracking; retained 30 days -Follow — Pet → Pet; (followerPetId, followeePetId) PK -Block — Pet → Pet -Report — Post or Pet report with ReportReason enum -Reaction — 1 per (post, pet); unique constraint enforces toggle -Comment — flat, 500-char VarChar -Repost — PostType.REPOST; references originalPost -Hashtag — unique tag string -PostHashtag — Post ↔ Hashtag join -VideoPost — Mux upload/asset/playback IDs + VideoStatus -Conversation — Pet ↔ Pet DM pair; petAId always < petBId -Message — body + readAt + senderPetId -WeightLog — date (Date), weightGrams (Int), notes -VetVisit — date (Date), reason, vetName, notes -Vaccine — name, dateGiven, nextDueDate (Date, optional), notes -EmergencyVet — 1:1 with Pet (unique petId); name*, address, phone, website, lat, lng -HealthCard — 1:1 with Pet; token (uuid), expiresAt (nullable) -Notification — type: FOLLOW|REACTION|COMMENT|MESSAGE|BIRTHDAY|ADOPTION_DAY +Owner — Clerk userId, owns n Pets; themePreference (dark mode) +Pet — Social identity: name, bio, adoptionStory, avatarKey, dmPolicy, + birthday, adoptedAt, species, breed, aiDisclosure +Species / Breed — Taxonomy, each with a *Translation table (i18n, not per-language rows) +Post / PostImage — PHOTO | MILESTONE | REPOST | VIDEO; denormalized reaction/comment/repost counts +Milestone — 1:1 with a Post (MilestoneType enum) +Story / StoryView — 24h ephemeral photos; views retained 30 days +Follow / Block — Pet ↔ Pet social graph (no owner columns) +Report — Post or Pet report (ReportReason enum), unique-constrained against spam +Reaction / Comment / Repost +Hashtag / PostHashtag +VideoPost — Mux upload/asset/playback IDs + VideoStatus +Conversation / Message — Pet ↔ Pet DMs; petAId always < petBId +WeightLog / VetVisit / Vaccine / EmergencyVet / HealthCard — owner-private health data +DataExportToken — GDPR export token lifecycle +Notification — FOLLOW | REACTION | COMMENT | MESSAGE | BIRTHDAY | ADOPTION_DAY +InviteCode — Beta invite-gate codes (word-prefix + 4-char code) +AdminRole — SUPER_ADMIN | MODERATOR, layered on top of ADMIN_OWNER_ID bootstrap +VerificationRequest — "verified pet" application flow with a required checklist +Warning / UserBan / Shadowban / BlacklistEntry — moderation actions +Advertisement / AdReaction / AdComment / AdRepost — sponsored posts with species/breed targeting +ModerationLog — audit trail for every admin/moderator action +LegalDocumentVersion — versioned legal-doc content + update-banner trigger ``` --- @@ -178,8 +160,9 @@ Notification — type: FOLLOW|REACTION|COMMENT|MESSAGE|BIRTHDAY|ADOPTION_DAY - Node.js 20+ - A [Supabase](https://supabase.com) project (PostgreSQL + Storage) - A [Clerk](https://clerk.com) application -- An [Upstash](https://upstash.com) Redis database +- A Redis instance (self-hosted, e.g. `docker run redis` — the app talks to it via `REDIS_URL`; no Upstash-specific code) - A [Mux](https://mux.com) account (for video posts) +- Optional: a [GlitchTip](https://glitchtip.com) instance (self-hosted or hosted) or Sentry project, for error monitoring ### 1. Clone and install @@ -191,32 +174,32 @@ npm install ### 2. Environment variables -Copy `.env.example` to `.env.local` and fill in all values: +Copy `.env.example` to `.env.local` and fill in all values — it documents every variable, including the optional GlitchTip/Sentry ones (`SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`) and the invite gate (`INVITE_REQUIRED`). ```bash cp .env.example .env.local ``` +Key variables (see `.env.example` for the full annotated list): + | Variable | Where to find it | |---|---| | `DATABASE_URL` | Supabase -> Project Settings -> Database -> Connection string (pooled, port 6543) | | `DIRECT_URL` | Supabase -> Project Settings -> Database -> Connection string (direct, port 5432) | -| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard -> API Keys | -| `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase -> Project Settings -> API -> Project URL | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API -> service_role key | -| `UPSTASH_REDIS_REST_URL` | Upstash Console -> Redis database -> REST URL | -| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console -> Redis database -> REST Token | -| `MUX_TOKEN_ID` | Mux Dashboard -> Settings -> API Access Tokens | -| `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` / `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys | +| `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API | +| `REDIS_URL` | Your Redis instance connection string (e.g. `redis://:password@localhost:6379`) | +| `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens | | `MUX_WEBHOOK_SECRET` | Mux Dashboard -> Settings -> Webhooks -> Signing secret | -| `CRON_SECRET` | Generate a random string; paste same value in Vercel env vars | +| `CRON_SECRET` | Generate a random string; used by both cron routes | +| `ADMIN_SECRET` | UUID that gates the `/p/[secret]/` admin panel path | +| `ADMIN_OWNER_ID` | Clerk user ID bootstrapped as SUPER_ADMIN | > `SUPABASE_SERVICE_ROLE_KEY` and `MUX_TOKEN_SECRET` are server-only secrets — never exposed to the client. ### 3. Database setup -Push the Prisma schema to Supabase: +Push the Prisma schema to Supabase (this project uses `db push`, not migration files — local dev and production share one database, so a migration drift/reset would be destructive): ```bash npx prisma db push @@ -240,7 +223,7 @@ Enable Row Level Security on all tables (run once in Supabase SQL Editor): ### 4. Supabase Storage -Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket). +Create a bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket), with `fileSizeLimit`/`allowedMimeTypes` restricted appropriately. ### 5. Mux webhook (production only) @@ -251,9 +234,14 @@ In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://y > In local development, the webhook is not needed. `videos.getByPostId` polls the Mux API directly when the video is still `PROCESSING`, so the status self-heals without a tunnel. -### 6. Vercel cron (production only) +### 6. Cron jobs (production only) -`vercel.json` is already configured. In Vercel project settings, add the `CRON_SECRET` environment variable (same value as in `.env.local`). The cron fires daily at 08:00 UTC at `/api/cron/anniversaries`. +There is no serverless cron here — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the two cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token): + +```bash +curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00 +curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/trim-feeds # daily +``` ### 7. Run the dev server @@ -263,6 +251,17 @@ npm run dev Open [http://localhost:3000](http://localhost:3000). The app redirects unauthenticated users to Clerk sign-in. +### 8. Docker deployment (production) + +```bash +cd docker +cp .env.example .env # fill in production values, not committed to git +docker compose build +./start.sh +``` + +`docker-compose.yml` passes the `NEXT_PUBLIC_*` vars and the Sentry/GlitchTip build-time vars (`SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`) as Docker build args — everything else is injected at runtime via `env_file`. + --- ## Key Commands @@ -270,8 +269,8 @@ Open [http://localhost:3000](http://localhost:3000). The app redirects unauthent | Command | What it does | |---|---| | `npm run dev` | Start Next.js dev server (Turbopack) | -| `npm run build` | Production build | -| `npm run lint` | ESLint | +| `npm run build` | Production build (`output: "standalone"`) | +| `npm run lint` | ESLint (flat config) | | `npx vitest run` | Run full test suite | | `npx vitest run src/__tests__/posts.test.ts` | Run a single test file | | `npx tsc --noEmit` | Type check without emitting | @@ -293,7 +292,11 @@ Clerk User (owner) reactions, comments, reposts, notifications, videos, DMs, health data) ``` -An owner can switch between their pets via `ActivePetContext`. All tRPC mutations assert `pet.ownerId === ctx.userId` before writing. +An owner can switch between their pets via `ActivePetContext`. All tRPC mutations assert `pet.ownerId === ctx.userId` before writing (via the shared `assertPetOwnership()` helper). + +### Middleware — `src/proxy.ts`, not `middleware.ts` + +Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware. It layers four concerns in one Clerk middleware: (1) invite gate — blocks `/sign-up` unless a `pf_invite` cookie is present, unless `INVITE_REQUIRED=false`; (2) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (3) `auth.protect()` for everything not in the public route matcher; (4) Report-Only Content-Security-Policy headers via Clerk's `contentSecurityPolicy` option. ### Media pipeline — Photos @@ -313,7 +316,7 @@ Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (RE VideoCard polls videos.getByPostId every 5s -> transitions from spinner to MuxPlayer ``` -> In dev (no webhook): `getByPostId` checks the Mux API directly when the status is `PROCESSING` and updates the DB on the fly. No ngrok required. +> In dev (no webhook): `getByPostId` checks the Mux API directly when the status is `PROCESSING` and updates the DB on the fly. No tunnel required. ### Feed architecture @@ -328,13 +331,30 @@ feed.getFeed({ petId, cursor, mode: "chrono" | "algo" }) └── chrono mode: preserves Redis sorted-set order (newest first) └── algo mode: client-side re-sort by algoScore() algoScore = reactions×3 + comments×5 + originalReposts×4 + recency bonus - recency bonus: +10 (<2h), +5 (<6h), +2 (<24h) └── Fallback (Redis unavailable): Postgres query on follows + own posts ``` -### Feed toggle (FeedList) +A daily cron (`/api/cron/trim-feeds`) trims each pet's Redis feed sorted set to a 90-day retention window. -`FeedList` shows a pill toggle at the top: **Für dich** (Sparkles, algo) / **Aktuell** (Clock, chrono). The preference is persisted to `localStorage` under `pf_feed_mode`. The mode is passed to `trpc.feed.getFeed.infiniteQueryOptions({ mode })`. +### Admin & moderation panel + +Gated by path secret (`ADMIN_SECRET`, see `proxy.ts`) **and** by role at the tRPC layer: `admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-bootstrapped super-admin) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Every action is written to `ModerationLog`. Covers users, moderators, posts, reports, ads, invites, verification requests, and blacklist entries. + +### Invite system + +`InviteCode` (word-prefix + 4-char code, e.g. `PAWS-7K3M`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up. `INVITE_REQUIRED=false` turns the whole gate off (the current production setting, since 2026-08-10). + +### Error monitoring & CSP + +`@sentry/nextjs` reports both errors and performance traces to a self-hosted GlitchTip instance. CSP currently runs in `reportOnly` mode; violations land at `/api/csp-report`, get forwarded to GlitchTip, and directives get tightened as real traffic surfaces gaps — before switching to enforced. + +### Health data privacy + +All health data (weight logs, vet visits, vaccines, emergency vet) is **owner-private**: +- `/pets/[petId]/health` redirects any non-owner to the public profile +- All tRPC health procedures call `assertOwner()` before any DB access +- The only public surface is the **HealthCard** at `/health-card/[token]` (UUID token, user-selected expiry, PDF export via `?print=1`) +- Does NOT show: emergency vet contact (stays off the shared card) ### Notification triggers (fire-and-forget) @@ -342,209 +362,50 @@ feed.getFeed({ petId, cursor, mode: "chrono" | "algo" }) follows.follow -> FOLLOW notification to followeePet reactions.toggle -> REACTION notification to post owner (skip if own post) comments.create -> COMMENT notification to post owner (skip if own post) -/api/cron/anniversaries (daily 08:00 UTC) - -> BIRTHDAY notification if EXTRACT(MONTH/DAY FROM birthday) = today AND birthday <= NOW()-1year - -> ADOPTION_DAY notification if EXTRACT(MONTH/DAY FROM adoptedAt) = today AND adoptedAt <= NOW()-1year +/api/cron/anniversaries (daily 08:00) + -> BIRTHDAY / ADOPTION_DAY notification, 1-year minimum guard ``` -All in-app triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation. Anniversary notifications require 1+ year minimum before the first trigger. - -### Health data privacy - -All health data (weight logs, vet visits, vaccines, emergency vet) is **owner-private**: -- `/pets/[petId]/health` redirects any non-owner to the public profile -- All tRPC health procedures call `assertOwner()` before any DB access -- The only public surface is the **HealthCard** at `/health-card/[token]` - - Token is a UUID stored in the `HealthCard` table - - Expiry is user-selected: 7 / 14 / 30 / 60 / 90 days; checked server-side before rendering - - Shows: vaccines, weight trend chart, vet visits - - Does NOT show: emergency vet (private contact data stays off the shared card) - - PDF export: `?print=1` auto-triggers `window.print()` after 600ms via `AutoPrint` client component - - `print:hidden` Tailwind variant hides nav chrome during print - -### EmergencyVet (private, owner-only) - -Stored in `EmergencyVet` table (1:1 with Pet). Managed via `EmergencyVetSection.tsx` inside `HealthDashboard`. Features: -- **Nominatim search** (OpenStreetMap geocoding, free, no API key): debounced 500ms, `Accept-Language: de`, dropdown with address suggestions -- **OSM iframe map**: `openstreetmap.org/export/embed.html?bbox=...&marker=lat,lng` -- Manual fields: name (required), address, phone, website -- Clicking a result fills coords + address; manual override always possible -- Attribution: `© OpenStreetMap contributors` shown below map - -### Post detail overlay - -Opening a post from the profile grid or notifications renders the full `PostCard` inside a **centered Dialog** (`Dialog` + `DialogContent`, `showCloseButton={false}`). The PostCard's own close button (X, top-right) and the 3-dots menu handle dismissal. The previous implementation used a bottom-anchored `Sheet`, which caused shadcn's auto-injected X to overlap the 3-dots button. - -### Welcome card (new users) - -`WelcomeCard` appears at the top of `FeedList` when a pet has no posts yet and hasn't dismissed it. It lists 5 features (Posts, Stories, Milestones, DMs, Health) with icons. Dismissed state is stored in `localStorage` as `pf_welcome_seen_{petId}` and is per-pet. Both the X button and "Verstanden" button dismiss it. - -### Story expiry (lazy-delete) - -Stories are never deleted in real time. All queries filter `expiresAt > NOW()`. A weekly cleanup job purges expired rows. Story views are retained 30 days for analytics. +All in-app triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation. ### PawRing (story indicator) -Instead of a standard circular ring (Instagram-style), active stories are indicated by a paw-print SVG outline around the avatar: a circular main pad ring with four toe-bean circles above it. - -- **Orange** — pet has unseen stories -- **Gray** — pet has stories but viewer has seen them all -- **No ring** — no active stories - -The viewer identity comes from `ActivePetContext` inside `PawRing` itself — callers don't need to pass it. +Instead of a standard circular ring, active stories are indicated by a paw-print SVG outline around the avatar: orange (unseen), gray (seen), no ring (none). Identity comes from `ActivePetContext` inside `PawRing` itself. --- -## Development Progress +## What's Shipped -### Phase 1 — Foundation ✅ +Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP: -- Next.js 16 + Tailwind v4 + shadcn/ui (base-nova) scaffold -- Clerk auth with middleware, protected app shell, onboarding flow -- Prisma 7 + Supabase PostgreSQL (pooled via PgBouncer) -- tRPC 11 + TanStack Query 5 wired end-to-end -- Upstash Redis client -- Supabase Storage presigned upload pipeline -- Pet CRUD (create, edit, avatar upload) -- ActivePetContext (pet switcher) -- Responsive layout (Sidebar + MobileNav) +- **i18n** — next-intl EN/DE, species/breed translation tables (legal pages stay fixed-German) +- **Dark mode** — account-bound (`Owner.themePreference`), synced across Clerk's own UI +- **Admin/moderation panel** — users, posts, reports, ads, invites, verification, audit log, 3-tier role matrix +- **Invite system** — beta gate, now disabled for public launch +- **Legal** — Impressum/Datenschutz/AGB, versioned with an update-banner trigger, AI-content disclosure badges on uploads +- **GDPR** — tokenized personal-data export, self-service account deletion +- **Ads** — sponsored posts with species/breed targeting, ad-specific reactions/comments/reposts +- **Security hardening** — Redis-backed rate limiting, CSP (Report-Only), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring +- **Self-hosted deployment** — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare -### Phase 2 — Content Core ✅ +### Open / next up -- **Social graph:** pet-to-pet follow/unfollow, followers/following pages -- **Safety:** block a pet, report a post or profile -- **Feed:** Redis fan-out-on-write, infinite scroll FeedList, PostCard -- **Photo posts:** 1–10 image carousel (Embla), caption edit/delete, presigned upload -- **Stories:** 24h photo stories, paw-print ring indicator (orange/gray), StoryTray, StoryViewer with progress bar + viewer list -- **Milestones:** 5 structured milestone types (Birthday, Adoption Day, First Outing, Vet Visit, New Litter), distinct orange card, PostTypeSheet fully wired for all 3 post types -- **Profile:** Posts 3-col grid (opens full PostCard in Dialog with original-format image) + Milestones tab - -### Phase 3 — Engagement & Discovery ✅ - -- **Paw reactions:** single reaction per post, toggle on/off, live count on PostCard -- **Comments:** flat thread, create/delete own, CommentSheet drawer -- **Paw-Back (Repost):** repost with attribution header in feed (PostType.REPOST) -- **Explore page:** species tabs + breed filter, random fresh content -- **Search:** pet name/breed search, owner username search (via Clerk), hashtag search with 3-col grid -- **Hashtags:** extracted on post create, clickable `#tags` in captions navigate to hashtag feed - -### Phase 4 — Notifications ✅ - -- `Notification` model with `FOLLOW | REACTION | COMMENT | MESSAGE` types -- Fire-and-forget triggers in follows, reactions, and comments routers -- `NotificationBell` in Sidebar + MobileNav (orange badge, 30s polling, "99+" cap) -- `/notifications` page: list with actor avatar, type icon, relative time, post thumbnail -- Clicking a notification navigates to the actor's profile (FOLLOW) or the relevant pet's profile (REACTION/COMMENT) -- Auto-marks all read on page visit - -### Phase 5 — Video ✅ - -- `VideoPost` model + `VideoStatus` enum (`PROCESSING | READY | ERROR`) -- Mux direct upload: client PUTs directly to Mux, bytes never pass through Next.js server -- `VideoCard` component polls `getByPostId` every 5s while PROCESSING; transitions to `MuxPlayer` when READY -- `getByPostId` self-heals in dev by calling Mux API directly (no webhook tunnel required) -- Mux webhook handler at `/api/webhooks/mux` for production (`video.upload.asset_created`, `video.asset.ready`, `video.asset.errored`) -- Video posts appear in feed, profile grid, and notifications alongside photo posts - -### Phase 6 — Messaging ✅ - -- `Conversation` + `Message` models; `DmPolicy` enum (`EVERYONE | FOLLOWERS_ONLY`) on Pet -- Pet-to-pet DMs: inbox sorted by last message, conversation view with read/unread tracking -- `readAt` timestamp per message; unread count badge in Sidebar + MobileNav -- DM policy enforced server-side: FOLLOWERS_ONLY pets reject messages from non-followers -- Conversation deduplication: `petAId` always < `petBId` (sorted on create) — one row per pair - -### Phase 7 — Health & Vet Tracking ✅ - -- `WeightLog`, `VetVisit`, `Vaccine`, `HealthCard`, `EmergencyVet` models — all owner-private -- Health dashboard at `/pets/[petId]/health` with three tabs: Gewicht / Tierarztbesuche / Impfungen -- Weight trend chart: pure SVG bezier curve, no library, PawFeed orange-500 design, hover tooltips; shown when 2+ entries exist -- `WeightChart` is a standalone reusable client component — used in both HealthDashboard and the public health card -- Overdue vaccine badge (AlertTriangle + destructive variant) when `nextDueDate` is in the past -- **Emergency vet** (private, owner dashboard only): Nominatim search → OSM map; tRPC procedures with `assertOwner` -- **Shareable health card** at `/health-card/[token]`: - - Expiry selectable per share: 7 / 14 / 30 / 60 / 90 days (default 14) - - Expired links show a German-language expiry message with the exact date - - Link can be regenerated (old token invalidated) at any time - - Shows: vaccines, weight trend chart, vet visits — **not** emergency vet (private contact data) - - PDF export: "Als PDF öffnen / drucken" opens the card with `?print=1` which auto-triggers `window.print()` — browser-native, no PDF library - - `print:hidden` Tailwind variant hides nav chrome during print - -### v2 Features — Feed & Engagement ✅ - -- **Algorithmic feed toggle** ("Für dich" / "Aktuell") in `FeedList` - - `feed.getFeed` accepts `mode: "chrono" | "algo"` (default `"chrono"`) - - Algo scoring: `reactions×3 + comments×5 + originalReposts×4 + recency bonus` - - Preference persisted in `localStorage` (`pf_feed_mode`) - -- **Anniversary notifications** via Vercel cron (`/api/cron/anniversaries`, daily 08:00 UTC) - - `BIRTHDAY` + `ADOPTION_DAY` notification types added to `NotificationType` enum - - Pet fields `birthday` and `adoptedAt` (`@db.Date`) added to schema + pet edit form - - Raw SQL with `EXTRACT(MONTH/DAY FROM ...)` to match today's day/month across all years - - 1-year minimum guard on both types — no notification in the first year - - Bearer token auth (`Authorization: Bearer CRON_SECRET`) for the route - - Notification page: Cake icon (pink-500) for birthdays, Home icon (emerald-500) for adoption days; link navigates to `/pets/{id}/health` - -- **Welcome card for new users** (`WelcomeCard`) - - Appears at top of feed when pet has 0 posts and hasn't dismissed it - - Lists 5 features with icons: Camera (Posts), Flame (Stories), Heart (Milestones), MessageCircle (DMs), Activity (Health) - - Orange gradient background (`from-orange-50/60 to-background`) - - Dismissed per-pet via `localStorage` (`pf_welcome_seen_{petId}`) - - X button + "Verstanden" button both dismiss - -- **Post detail overlay fix** - - Changed from `Sheet side="bottom"` to centered `Dialog` with `showCloseButton={false}` - - Prevents shadcn's auto-injected close button from overlapping PostCard's 3-dots menu - - `max-w-[560px] max-h-[90dvh] overflow-y-auto rounded-xl p-0` - ---- - -## What's Next (continue here tomorrow) - -These are the natural next features. None are started — all are clean slates. - -### High priority - -| Feature | Notes | +| Item | Notes | |---|---| -| **Explore page improvements** | Currently shows random content. Could show trending posts (by reaction count in last 24h), top hashtags, featured pets. Add "Trending" tab alongside species tabs. | -| **Story video support** | Stories currently only support photos. Add short video stories (Mux or direct upload). StoryViewer already has a progress timer — just needs video playback instead of ``. | -| **Follower milestone auto-notifications** | `MilestoneType` has `FOLLOWERS_500, FOLLOWERS_1K` etc. but no trigger exists yet. Should fire a milestone post automatically when follower count crosses these thresholds. Hook into `follows.follow`. | -| **Google / Apple OAuth** | Clerk supports it natively — just enable in Clerk Dashboard. No code change required. User deliberately deferred this. | - -### Medium priority - -| Feature | Notes | -|---|---| -| **Pet bio / adoption story on profile** | Fields exist in schema (`bio`, `adoptionStory`) but are not displayed on the profile page. Add them to the profile header. | -| **Story reactions** | Stories currently only track views. Add a quick emoji reaction (🐾❤️😂) that appears as a floating animation. | -| **Saved posts** | Bookmarking posts to a private collection. New `SavedPost` table, saved indicator on PostCard, saved-posts tab on profile. | -| **Comment replies** | Flat comments work. Add 1-level reply threading (replyToId FK on Comment). | -| **Admin dashboard** | Basic moderation view: list of reports, pet account overview, manual suspension. Could be a `/admin` route behind a `role` flag on Owner. | - -### Low priority / post-MVP - -| Feature | Notes | -|---|---| -| **i18n (multi-language)** | EN/DE/ES/IT/FR planned. Species and breed names need translation tables — don't duplicate rows per language. Use a locale + key structure. | -| **React Native app** | Listed in CLAUDE.md as a future milestone. Web-first for v1. | -| **Supabase Realtime** | Feed live updates and DM delivery without polling. Replace `setInterval` polling in NotificationBell and conversation view with Supabase channel subscriptions on the relevant tables. | -| **Cloudflare R2 + Images** | Currently using Supabase Storage. R2 has zero egress fees — migration makes sense at scale. | - -### Open bugs / cleanup - -| Issue | Notes | -|---|---| -| Feed Redis cleanup on post delete | When a post is deleted, its ID is NOT removed from Redis sorted sets. `feed.getFeed` silently skips missing IDs (Prisma returns nothing for them) — functionally correct but wastes Redis space. Add cleanup on `posts.delete`. | -| Story views cleanup | `StoryView` records are never deleted. A weekly cron should purge rows where `viewedAt < NOW() - 30 days`. | -| People search with no username | `search.byOwner` via Clerk falls back to first/last name when no Clerk username is set. Works but inconsistent UX. | -| Welcome card logic | Currently checks "pet has 0 posts" but the posts count comes from the feed data, not a dedicated query. Refactor to a `posts.countByPet` call in the health router or a dedicated RSC check. | +| CSP enforced mode | Waiting on a clean Report-Only period in GlitchTip before flipping `reportOnly: false` | +| Story video support | Stories currently photo-only; `StoryViewer` already has a progress timer, needs video playback | +| Follower milestone auto-notifications | `MilestoneType` has `FOLLOWERS_500` etc. but no trigger wired yet | +| Google / Apple OAuth | Supported natively by Clerk — deliberately deferred, no code change required | +| Saved posts | Bookmarking to a private collection — no schema yet | +| Comment replies | Flat comments only today; 1-level threading would need a `replyToId` FK | +| React Native app | Web-first for v1, listed as a future milestone | +| Supabase Realtime | Replace polling (NotificationBell, conversation view) with channel subscriptions | --- ## Testing -Tests are unit tests only (no DB, no network). Prisma and Redis are mocked per file using `createMockPrisma()` from `src/__tests__/helpers/prisma-mock.ts`. +Tests are unit tests only (no DB, no network). Prisma is fully mocked per-model via `createMockPrisma()` in `src/__tests__/helpers/prisma-mock.ts`. ```bash npx vitest run # full suite @@ -557,13 +418,11 @@ npx vitest # watch mode | Issue | Notes | |---|---| -| `DATABASE_URL` must use the **pooled** connection (port 6543, `?pgbouncer=true`) | Prisma uses this at runtime. `DIRECT_URL` (port 5432) is only for `prisma db push` / migrations. | -| Prisma on Windows requires `DIRECT_URL` set in `.env.local` | The Prisma CLI reads `.env.local` via a custom `prisma.config.ts`. If `npx prisma db push` hangs, check `DIRECT_URL` is set. | -| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new models. | -| Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. The client only ever receives signed URLs. | -| `asChild` prop is not available in the base-nova shadcn preset | Do not use `asChild` on any shadcn component. This affects `PopoverTrigger`, `DropdownMenuItem`, etc. Style the trigger element directly. | -| StoryView records are NOT cascade-deleted when a story expires | Intentional: view analytics are retained 30 days. A separate cleanup cron should purge old views. | -| Feed cleanup on post delete is best-effort | When a post is deleted, its ID is not removed from Redis sorted sets. `feed.getFeed` silently skips IDs that Prisma doesn't return. | -| Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. The DB is updated on the next poll cycle. In production, the webhook updates the DB faster (within seconds of transcode completion). | -| People search requires Clerk username to be set | `search.byOwner` queries Clerk's `getUserList`. Users who signed up without a username will match by first/last name instead. | -| `CRON_SECRET` must be set in Vercel env vars | The anniversary cron route checks `Authorization: Bearer CRON_SECRET`. Without it, the route returns 401 and no notifications fire. | +| `DATABASE_URL` must use the **pooled** connection (port 6543, `?pgbouncer=true`) | Prisma uses this at runtime. `DIRECT_URL` (port 5432) is only for `prisma db push`. | +| Prisma schema changes always use `db push`, never `migrate dev`/`reset` | Local dev and production share a single Supabase DB — a migration drift/reset would delete production data. | +| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. | +| Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. | +| `asChild` prop is not available in the base-nova shadcn preset | Style the trigger element directly instead. | +| Local dev cannot exercise authenticated routes | Clerk runs with production keys and rejects non-`pawfeed.org` origins; only public pages are testable against `npm run dev`. | +| Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. | +| `CRON_SECRET` must be set for both cron routes | Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. |