adminandClaude Sonnet 5 1791aca690 docs+chore: add DB restore script and document backup/restore in README
docker/restore-db.sh - emergency-only, not wired into any crontab. Lists
available backups when run without args; restores via pg_restore
(--clean --if-exists --no-owner --no-privileges -j 4) against DIRECT_URL,
same throwaway postgres:17-alpine container pattern as backup-db.sh.
Requires typing RESTORE to confirm (FORCE=1 skips it for scripted use).

README.md gets a new 'Database backups & disaster recovery' section
(setup step 9) documenting all three scripts, the DIRECT_URL vs
DATABASE_URL reasoning, and the storage-metadata restore caveat. No
admin-panel UI for this by design - restore is SSH-only, deliberately
higher-friction than a browser button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 20:17:57 +02:00

PawFeed

A social platform where pets are the stars. Dogs, cats, birds, horses, rabbits, guinea pigs, hamsters, mice, rats, chinchillas, snakes, lizards, turtles, geckos, spiders, and aquariums all get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals. Species/breed is a fully data-driven taxonomy (Species/Breed + translation tables), so adding a new species is a seed-data change, not a code change.

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 (public, invite gate disabled since 2026-08-10).


Tech Stack

Layer Technology Version
Framework Next.js 16.x
UI React + Tailwind CSS v4 + shadcn/ui (base-nova preset) React 19
Language TypeScript 6.x
API tRPC 11.x
ORM Prisma 7.x
Database PostgreSQL via Supabase (managed) 16
Auth Clerk (+ @clerk/themes for dark-mode-aware UI, @clerk/localizations' deDE for German auth UI text) 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)
Payments Stripe (voluntary sponsoring — one-time & monthly, LIVE mode, not Test)
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 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.
  • Media (photos, avatars, stories) is stored in Supabase Storage via presigned PUT URLs — file bytes never pass through the Next.js API server.
  • The social feed uses a Redis fan-out-on-write pattern: on each new post, a sorted set entry is written into every follower's feed inbox. feed.getFeed reads from Redis and hydrates with Prisma. Falls back to Postgres when Redis is unavailable.
  • 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, run as 3 load-balanced replicas (not 1 container, since 2026-08-16 — Next.js standalone is single-process, one container = one CPU core). ./start.sh rebuilds the image and recreates all 3 replicas.
  • Content-Security-Policy is enforced (src/proxy.ts, via Clerk's contentSecurityPolicy middleware option with strict: true) — a per-request nonce + 'strict-dynamic' instead of broad http:/https: sources in script-src. Clerk forwards the generated CSP header (with nonce) on both the response and the downstream request, which is exactly what Next.js's own automatic nonce application reads during SSR, so framework scripts and page bundles pick it up for free; hand-written inline scripts (e.g. next-themes' FOUC-prevention script in src/app/layout.tsx) read the nonce from the x-nonce header explicitly. Mozilla Observatory: A+. 'unsafe-eval' stays in script-src defensively for now (see Known Issues).
  • 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).

Project Structure

src/
  app/
    (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)
      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/ broadcasts/
    unterstuetzen/           # Stripe sponsoring — one-time or monthly, grants a Sponsor badge
    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]/         # 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/     # 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.)
        stripe/             # Stripe webhook handler (checkout.session.completed etc.) — LIVE mode
  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), SSR-initialized
  i18n/
    request.ts              # next-intl config — check here before assuming a routing scheme
  lib/
    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 }); 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), with translations
supabase/
  migrations/                  # RLS-enablement SQL (Prisma bypasses RLS via service_role)
docker/
  Dockerfile / docker-compose.yml / start.sh   # Self-hosted deployment

Prisma Schema — Model Overview

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
Broadcast / BroadcastReceipt — system-wide admin announcements ("Mitteilungen"), owner banner + per-owner read receipts
SponsorContribution     — Stripe sponsoring records (ONE_TIME | SUBSCRIPTION), grants the Sponsor badge

Setup

Prerequisites

  • Node.js 20+
  • A Supabase project (PostgreSQL + Storage)
  • A Clerk application
  • A Redis instance (self-hosted, e.g. docker run redis — the app talks to it via REDIS_URL; no Upstash-specific code)
  • A Mux account (for video posts)
  • Optional: a GlitchTip instance (self-hosted or hosted) or Sentry project, for error monitoring

1. Clone and install

git clone <repo-url>
cd pawfeed
npm install

2. Environment variables

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).

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_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; used by all 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 (this project uses db push, not migration files — local dev and production share one database, so a migration drift/reset would be destructive):

npx prisma db push
npx prisma generate

Seed species and breeds:

npx prisma db seed

Enable Row Level Security on all tables (run once in Supabase SQL Editor):

-- File: supabase/migrations/20260613000000_enable_rls_all_tables.sql
-- Paste the contents of this file into the Supabase SQL Editor and run.

This closes the PostgREST surface. Prisma (direct connection with service_role) is unaffected.

4. Supabase Storage

Create a bucket named pet-avatars in Supabase Storage (Dashboard -> Storage -> New bucket), with fileSizeLimit/allowedMimeTypes restricted appropriately.

5. Mux webhook (production only)

In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to https://your-domain.com/api/webhooks/mux. Subscribe to:

  • video.upload.asset_created
  • video.asset.ready
  • video.asset.errored

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. Cron jobs (production only)

There is no serverless cron here — vercel.json's crons array is inert on a self-hosted deployment. Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):

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
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/pet-suggestions     # weekly, Monday 09:00

7. Run the dev server

npm run dev

Open http://localhost:3000. The app redirects unauthenticated users to Clerk sign-in.

8. Docker deployment (production)

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.

As of 2026-08-16 this runs as 3 replicas (pawfeed-1/2/3), not 1 container — Next.js standalone output is single-process, so a single container can't use more than one CPU core; there's no single-container path anymore. Nginx Proxy Manager load-balances across them via a least_conn upstream (pawfeed_upstream) — see docker/SCALING-ROADMAP.md for the full rationale and the NPM-side config location. For deploys against the live site, run ./docker/rolling-deploy.sh instead of docker compose up -d --build — it rebuilds the image once and restarts the 3 replicas one at a time (gated on GET /api/health), verified zero-downtime. docker compose up -d --build still works but restarts all 3 at once.

9. Database backups & disaster recovery (production only)

There's no admin-panel UI for this — it's deliberately SSH-only, since a one-click restore in a browser is a much bigger blast radius than a command someone has to type deliberately. Three scripts live in docker/, all NAS-side (not run from a local dev machine), all working against DIRECT_URL (the non-pooled port-5432 connection — pg_dump/pg_restore against the pgbouncer-fronted DATABASE_URL are unreliable) via a throwaway postgres:17-alpine container, since the NAS host has no postgresql-client in its PATH (same reason Node/Prisma tooling there also runs in a throwaway container — see CLAUDE.md).

Backup (docker/backup-db.sh) — runs daily at 03:00 via the NAS crontab, no arguments. Dumps in pg_dump -Fc (custom, compressed) format to:

/srv/dev-disk-by-uuid-ad295d7f-a870-4f70-9de2-dfded1fabf7f/BACKUPALL/pawfeed/pawfeed-<timestamp>.dump

30-day retention (older .dump files are deleted after each successful run). Every run is logged to docker/backup.log — there's no local mail delivery configured on this NAS for cron failure notifications, so the log is the only place a failure would show up.

To back up on demand:

ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/backup-db.sh"

Restore (docker/restore-db.sh) — manual/emergency use only, not wired into any crontab.

# List available backups
ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh"

# Restore one (prompts: type RESTORE to confirm — this drops and recreates
# live objects via pg_restore --clean --if-exists)
ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump"

# Non-interactive (scripted use only — skips the confirmation prompt)
ssh daniel@192.168.1.222 "FORCE=1 /Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump"

Runs pg_restore --clean --if-exists --no-owner --no-privileges -j 4 against DIRECT_URL. --no-owner --no-privileges avoids ALTER OWNER/GRANT errors if the connecting role doesn't exactly match the dump's original roles; -j 4 parallelizes the restore (only possible with custom-format dumps). Logged to docker/restore.log.

Caveat: the dump is the entire database, including Supabase-internal schemas (auth, storage, extensions, ...). This app uses Clerk for identity (not Supabase Auth), so auth is normally empty/unused here. storage holds Supabase Storage's metadata rows (bucket/object records) — the actual file bytes live in Supabase's object store separately and aren't part of this dump, so a restore can leave storage.objects rows pointing at files deleted since the backup, or miss rows for files uploaded after it. Treat a restore as "the app is back up," not "storage state is bit-for-bit consistent" — spot-check media after restoring.


Key Commands

Command What it does
npm run dev Start Next.js dev server (Turbopack)
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
npx prisma db push Sync schema to DB (no migration files)
npx prisma generate Regenerate Prisma client after schema change
npx prisma studio Open Prisma Studio (DB browser)
npx prisma db seed Re-seed species + breeds

Architecture Notes

Identity model

Clerk User (owner)
  └── owns 1..n Pet profiles (Postgres)
        └── Pet is the social identity (posts, follows, stories, milestones,
            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 (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

Client -> tRPC getPresignedUrl -> Supabase Storage signed URL
Client -> XHR PUT directly to signed URL (bytes never hit Next.js server)
Client -> tRPC create/confirmUpload (writes storage key to DB)
Feed -> getMediaUrl(storageKey) -> Supabase public CDN URL

Media pipeline — Video (Mux)

Client -> tRPC videos.createUpload -> Mux direct upload URL + PROCESSING Post row
Client -> XHR PUT directly to Mux URL (bytes never hit Next.js server)
Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (READY + playbackId)
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 tunnel required.

Feed architecture

Post created
  └── fanOutPost(redis, postId, petId, createdAt, prisma)
        └── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId

feed.getFeed({ petId, cursor, mode: "chrono" | "algo" })
  └── ZRANGE feed:{petId} -> postIds
        └── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
  └── 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
  └── Fallback (Redis unavailable): Postgres query on follows + own posts

A daily cron (/api/cron/trim-feeds) trims each pet's Redis feed sorted set to a 90-day retention window.

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, blacklist entries, and system-wide broadcast announcements (createBroadcast/deleteBroadcast, both SUPER_ADMIN-gated, deletion requires a reason for the audit trail).

The panel is permanently dark (forced via a literal dark class on the shell, independent of the site-wide theme toggle) with its own active-page nav highlighting (AdminSidebarNav, mobile drawer mirrors it). The dashboard groups its overview stats by category — Activity (emerald), Violations (rose, only lights up when actually > 0), Info (sky) — instead of one flat wall of identical cards; only Phase 1+2 of the visual pass are done (shell tokens + dashboard), Phase 3/4 (shared AdminCard/AdminStat primitives applied to the remaining 11 pages) are deferred.

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 is enforced (nonce + 'strict-dynamic', see Design decisions above); violations still get reported to /api/csp-report → GlitchTip (report-uri + Reporting API, including lineNumber/columnNumber/sample for fast diagnosis) even though the policy is no longer report-only, so regressions surface immediately without blocking users first.

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)

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)
  -> BIRTHDAY / ADOPTION_DAY notification, 1-year minimum guard

All in-app triggers are wrapped in .catch(() => {}) — they never crash the parent mutation.

PawRing (story indicator)

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.


Glossary

The load-bearing concept in this codebase: the pet, not the owner, is the social identity. Every domain term below is defined in relation to that split.

Owner (Clerk account, auth only)
  └── owns 1..n Pet(s)
        └── Pet IS the social identity — profile URL, posts, followers, DMs
              ├── Post (PHOTO | MILESTONE | REPOST | VIDEO)
              │     ├── Milestone   — 1:1 metadata row when Post.type = MILESTONE
              │     ├── PostImage   — 1:n photos on a PHOTO post
              │     ├── VideoPost   — Mux upload/asset state when Post.type = VIDEO
              │     ├── Reaction / Comment / Repost   — engagement on the Post
              │     └── Hashtag (via PostHashtag)
              ├── Story   — 24h ephemeral photo, separate from Post
              ├── Follow  — Pet → Pet edge (the whole social graph is Pet-to-Pet)
              ├── Conversation / Message   — Pet ↔ Pet DMs (petAId always < petBId)
              ├── WeightLog / VetVisit / Vaccine / EmergencyVet   — owner-private health data
              │     └── HealthCard   — the one shareable, tokenized window into that data
              └── VerificationRequest   — "verified pet" application (blue check)
Term What it is Relates to
Owner A Clerk-authenticated human account. Owner.id is the Clerk userId — no separate mapping table. Owns pets, manages settings, is never itself a follower/poster/commenter. 1:n Pet; the human behind one or more pets
Pet The actual social identity — has its own profile URL, avatar, bio, follower count, posts, DMs. Every social action (Post, Follow, Reaction, Comment, Message, ...) is scoped by petId, never ownerId. Belongs to one Owner; everything social hangs off this
Species / Breed Fully data-driven taxonomy (with i18n translation tables) — 16 species categories from dogs to aquariums; Breed is the predominant fish species for an aquarium Pet, not a per-fish profile. Attributes on Pet; drives ad targeting and the spider-exclusion filter
Post The core content unit. type is one of PHOTO, MILESTONE, REPOST, VIDEO. Carries denormalized reactionCount/commentCount/repostCount (maintained transactionally, not computed on read). Authored by a Pet; fans out to followers' Redis feed on create
Milestone A 1:1 sidecar row on a Post when type = MILESTONE (e.g. "500 followers", "adoption anniversary"). Not a separate feed item — it's a Post with extra metadata and different card styling. 1:1 with Post
Story A 24-hour ephemeral photo, structurally separate from Post (own model, own view-tracking with 30-day retention). Not part of the main feed. Authored by a Pet
Follow A directed edge from one Pet to another. The entire social graph (Follow, Block, Reaction, ...) has zero owner columns — resolving "who can see this" never touches Owner. Pet → Pet, no Owner involvement
Reaction / Comment / Repost Engagement primitives on a Post (or Advertisement, which has its own parallel AdReaction/AdComment/AdRepost set). Each create/delete transactionally updates the parent's denormalized counter. Attach to Post (or Advertisement)
Notification Fire-and-forget row created on FOLLOW/REACTION/COMMENT/MESSAGE/BIRTHDAY/ADOPTION_DAY events, always wrapped in .catch(() => {}) so a notification failure can never break the triggering mutation. Targets a Pet (the recipient's active pet)
Conversation / Message DMs. petAId/petBId are canonically ordered (petAId < petBId) so a conversation between two pets has exactly one row regardless of who initiated it. Pet ↔ Pet
HealthCard The only public window into a pet's otherwise strictly owner-private health data (WeightLog, VetVisit, Vaccine, EmergencyVet) — a tokenized, expiring URL meant for vets/sitters, deliberately excluding the emergency vet contact. Derived view over health data owned by a Pet
InviteCode Beta sign-up gate (word-prefix + 4-char code, e.g. PAWS-7K3M), consumed once via a pf_invite cookie. Currently disabled (INVITE_REQUIRED=false) for public launch — the model still exists for a possible future re-gate. Gates Owner account creation, not pet creation
AdminRole / ModerationLog AdminRole is a 3-tier permission row (SUPPORT < MODERATOR < SUPER_ADMIN) checked by every admin.* tRPC procedure via assertAdmin(); ADMIN_OWNER_ID bootstraps one super-admin without needing a row. Every admin action writes a ModerationLog entry (action, target, reason, moderator, IP). Applies to an Owner acting as an admin; targets any entity
Warning / UserBan / Shadowban / BlacklistEntry Moderation actions, all applied to an Owner (the account), not a specific Pet — banning stops the human, not just one of their pets. Target an Owner
Broadcast / BroadcastReceipt Admin-authored system-wide announcements (SUPER_ADMIN-gated create and delete, both audited). BroadcastReceipt is one row per (broadcast, owner), created lazily the first time an owner's banner query surfaces it — no fan-out at send time. Sent by an admin Owner; received by every Owner (not per-pet)
SponsorContribution A voluntary Stripe payment (ONE_TIME or SUBSCRIPTION, LIVE mode) that grants the Sponsor badge, shown on the contributing owner's pet profile(s) and posts. Made by an Owner; the badge displays on their Pet(s)
VerificationRequest An owner-submitted application (with a required checklist) for the "verified pet" blue check, reviewed by an admin. Requested for a Pet, reviewed by an admin Owner
LegalDocumentVersion Versioned Impressum/Datenschutz/AGB content — a version bump triggers an in-app update banner every owner must acknowledge. Acknowledged by an Owner (account-level, not per-pet)
DataExportToken A short-lived token backing the GDPR self-service data export flow. Issued to an Owner

What's Shipped

Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP:

  • i18n — next-intl EN/DE, species/breed translation tables (legal pages stay fixed-German); Clerk's own sign-in/sign-up UI is localized to German via @clerk/localizations' deDE (2026-09-01)
  • 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
  • Sponsoring — voluntary Stripe payments (one-time/monthly, LIVE mode) grant a Sponsor badge shown on the pet profile and every post
  • Broadcasts — admin-authored system-wide announcements with an owner-facing banner, per-owner delivered/read receipts, and audited creation/deletion
  • Story engagement — paw reactions and comments on Stories (StoryReaction/StoryComment, mirroring the Post engagement model), comments render as a translucent overlay on the running story, not a pause-and-read sheet
  • Per-pet Insights dashboard — owner-facing analytics at /pets/[petId]/insights (total reactions/comments, top post/story by each metric), spotlight cards open the real post/story
  • Design systemPRODUCT.md/DESIGN.md ("The Spotlight Porch": one marigold-orange accent, ring-defined surfaces, no drop shadows at rest) captured from the existing brand and used to rebuild the landing page, header, and all 3 legal pages
  • Security hardening — Redis-backed rate limiting, enforced CSP (nonce + strict-dynamic, Mozilla Observatory A+), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring
  • Self-hosted deployment — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare, 3 load-balanced replicas, zero-downtime rolling deploys

Open / next up

Item Notes
Remove 'unsafe-eval' from CSP script-src Kept defensively after 6 isolated eval violations (2026-08-15, never recurred) that live reproduction couldn't pin on app code — likely a browser extension. Revisit once confirmed clean over a longer window.
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 is fully mocked per-model via createMockPrisma() in src/__tests__/helpers/prisma-mock.ts.

npx vitest run          # full suite
npx vitest              # watch mode

Known Issues / Debugging Notes

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.
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 all cron routes Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required.
Server-side requests to the app's own domain must target the DDNS hostname, not pawfeed.org Nginx Proxy Manager's origin TLS certificate is only valid for pawfeed.neodk.ipv64.depawfeed.org works for browsers only because Cloudflare terminates TLS at the edge and doesn't strictly verify the origin cert. A plain curl https://pawfeed.org/... from the NAS itself (e.g. docker/run-cron.sh) fails with an SSL hostname mismatch; use curl --resolve pawfeed.neodk.ipv64.de:443:127.0.0.1 https://pawfeed.neodk.ipv64.de/... instead.
A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug protectedProcedure throws UNAUTHORIZED (401) when Clerk's session is missing/expired; assertAdmin() throws FORBIDDEN (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression.
S
Description
No description provided
Readme
5.4 MiB
Languages
TypeScript 98.4%
Shell 0.6%
CSS 0.5%
JavaScript 0.3%
Dockerfile 0.2%