From 97269788eea7eb8c2e41f49be4c296099d94f9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Thu, 5 Mar 2026 11:01:11 +0100 Subject: [PATCH] feat: add shared @sexy.pivoine.art/types package and fix type safety across frontend/backend - Create packages/types with shared TypeScript domain model interfaces (User, Video, Model, Article, Comment, Recording, etc.) - Wire both frontend and backend packages to use @sexy.pivoine.art/types via workspace:* - Update backend Pothos objectRef types to use shared interfaces instead of inline types - Update frontend $lib/types.ts to re-export from shared package - Fix all type errors introduced by more accurate nullable types (avatar/banner as string|null UUIDs, author nullable, events/device_info as object[]) - Add artist_name to comment user select in backend resolver - Widen utility function signatures (getAssetUrl, getUserInitials, calcReadingTime) to accept null/undefined Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CLAUDE.md | 94 ++ packages/backend/package.json | 1 + .../backend/src/graphql/resolvers/comments.ts | 2 + packages/backend/src/graphql/types/index.ts | 865 ++++++------------ packages/frontend/package.json | 1 + packages/frontend/src/lib/api.ts | 2 +- .../src/lib/components/header/header.svelte | 4 +- .../src/lib/components/meta/meta.svelte | 2 +- .../recording-card/recording-card.svelte | 12 +- packages/frontend/src/lib/services.ts | 5 +- packages/frontend/src/lib/types.ts | 208 +---- packages/frontend/src/lib/utils.ts | 6 +- .../src/routes/leaderboard/+page.svelte | 6 +- .../frontend/src/routes/magazine/+page.svelte | 18 +- .../src/routes/magazine/[slug]/+page.svelte | 5 +- .../frontend/src/routes/me/+page.server.ts | 2 +- packages/frontend/src/routes/me/+page.svelte | 14 +- .../frontend/src/routes/models/+page.svelte | 10 +- .../src/routes/models/[slug]/+page.svelte | 6 +- .../frontend/src/routes/play/+page.server.ts | 3 +- .../frontend/src/routes/play/+page.svelte | 23 +- .../src/routes/sitemap.xml/+server.ts | 2 +- .../src/routes/tags/[tag]/+page.server.ts | 2 +- .../src/routes/users/[id]/+page.server.ts | 19 +- .../src/routes/users/[id]/+page.svelte | 15 +- .../src/routes/videos/[slug]/+page.svelte | 14 +- packages/types/package.json | 16 + packages/types/src/index.ts | 281 ++++++ packages/types/tsconfig.json | 10 + pnpm-lock.yaml | 12 + 31 files changed, 839 insertions(+), 822 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/types/package.json create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/tsconfig.json diff --git a/.gitignore b/.gitignore index 76489a8..0c6e65d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target/ pkg/ .claude/ +.data/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..27f5fd0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +`sexy.pivoine.art` is a self-hosted adult content platform (18+) built as a pnpm monorepo with three packages: `frontend` (SvelteKit 5), `backend` (Fastify + GraphQL), and `buttplug` (hardware integration via WebBluetooth/WASM). + +## Common Commands + +Run from the repo root unless otherwise noted. + +```bash +# Development +pnpm dev:data # Start postgres & redis via Docker +pnpm dev:backend # Start backend on http://localhost:4000 +pnpm dev # Start backend + frontend (frontend on :3000) + +# Linting & Formatting +pnpm lint # ESLint across all packages +pnpm lint:fix # Auto-fix ESLint issues +pnpm format # Prettier format all files +pnpm format:check # Check formatting without changes + +# Build +pnpm build:frontend # SvelteKit production build +pnpm build:backend # Compile backend TypeScript to dist/ + +# Database migrations (from packages/backend/) +pnpm migrate # Run pending Drizzle migrations +``` + +## Architecture + +### Monorepo Layout + +``` +packages/ + frontend/ # SvelteKit 2 + Svelte 5 + Tailwind CSS 4 + backend/ # Fastify v5 + GraphQL Yoga v5 + Drizzle ORM + buttplug/ # TypeScript/Rust hybrid, compiles to WASM +``` + +### Backend (`packages/backend/src/`) + +- **`index.ts`** — Fastify server entry: registers plugins (CORS, multipart, static), mounts GraphQL at `/graphql`, serves transformed assets at `/assets/:id` +- **`graphql/builder.ts`** — Pothos schema builder (code-first GraphQL) +- **`graphql/context.ts`** — Injects `currentUser` from Redis session into every request +- **`lib/auth.ts`** — Session management: `nanoid(32)` token stored in Redis with 24h TTL, set as httpOnly cookie +- **`db/schema/`** — Drizzle ORM table definitions (users, videos, files, comments, gamification, etc.) +- **`migrations/`** — SQL migration files managed by Drizzle Kit + +### Frontend (`packages/frontend/src/`) + +- **`lib/api.ts`** — GraphQL client (graphql-request) +- **`lib/services.ts`** — All API calls (login, videos, comments, models, etc.) +- **`lib/types.ts`** — Shared TypeScript types +- **`hooks.server.ts`** — Auth guard: reads session cookie, fetches `me` query, redirects if needed +- **`routes/`** — SvelteKit file-based routing: `/`, `/login`, `/signup`, `/me`, `/models`, `/models/[slug]`, `/videos`, `/play/[slug]`, `/magazine`, `/leaderboard` + +### Asset Pipeline + +Backend serves images with server-side Sharp transforms, cached to disk as WebP. Presets: `mini` (80×80), `thumbnail` (300×300), `preview` (800px wide), `medium` (1400px wide), `banner` (1600×480 cropped). + +### Gamification + +Points + achievements system tracked in `user_points` and `user_stats` tables. Logic in `packages/backend/src/lib/gamification.ts` and the `gamification` resolver. + +## Code Style + +- **TypeScript strict mode** in all packages +- **ESLint flat config** (`eslint.config.js` at root) — `any` is allowed but discouraged; enforces consistent type imports +- **Prettier**: 2-space indent, trailing commas, 100-char line width, Svelte plugin +- Migrations folder (`packages/backend/src/migrations/`) is excluded from lint + +## Environment Variables (Backend) + +| Variable | Purpose | +|----------|---------| +| `DATABASE_URL` | PostgreSQL connection string | +| `REDIS_URL` | Redis connection string | +| `COOKIE_SECRET` | Session cookie signing | +| `CORS_ORIGIN` | Frontend origin URL | +| `UPLOAD_DIR` | File storage path | +| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) | + +## Docker + +```bash +docker compose up -d # Start all services (postgres, redis, backend, frontend) +arty up -d # Preferred way to manage containers in this project +``` + +Production images are built and pushed to `dev.pivoine.art` via Gitea Actions on push to `main`. diff --git a/packages/backend/package.json b/packages/backend/package.json index 0a77e2b..3f09f44 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -14,6 +14,7 @@ "check": "tsc --noEmit" }, "dependencies": { + "@sexy.pivoine.art/types": "workspace:*", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.0.2", "@fastify/multipart": "^9.0.3", diff --git a/packages/backend/src/graphql/resolvers/comments.ts b/packages/backend/src/graphql/resolvers/comments.ts index c918665..805de0c 100644 --- a/packages/backend/src/graphql/resolvers/comments.ts +++ b/packages/backend/src/graphql/resolvers/comments.ts @@ -25,6 +25,7 @@ builder.queryField("commentsForVideo", (t) => id: users.id, first_name: users.first_name, last_name: users.last_name, + artist_name: users.artist_name, avatar: users.avatar, }) .from(users) @@ -66,6 +67,7 @@ builder.mutationField("createCommentForVideo", (t) => id: users.id, first_name: users.first_name, last_name: users.last_name, + artist_name: users.artist_name, avatar: users.avatar, }) .from(users) diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index 1b6ab61..6ad7902 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -1,383 +1,269 @@ +import type { + MediaFile, + User, + VideoModel, + VideoFile, + Video, + ModelPhoto, + Model, + ArticleAuthor, + Article, + CommentUser, + Comment, + Stats, + Recording, + VideoLikeStatus, + VideoLikeResponse, + VideoPlayResponse, + VideoAnalytics, + Analytics, + LeaderboardEntry, + UserStats, + UserAchievement, + RecentPoint, + UserGamification, + Achievement, +} from "@sexy.pivoine.art/types"; import { builder } from "../builder"; -// File type -export const FileType = builder - .objectRef<{ - id: string; - title: string | null; - description: string | null; - filename: string; - mime_type: string | null; - filesize: number | null; - duration: number | null; - uploaded_by: string | null; - date_created: Date; - }>("File") +export const FileType = builder.objectRef("File").implement({ + fields: (t) => ({ + id: t.exposeString("id"), + title: t.exposeString("title", { nullable: true }), + description: t.exposeString("description", { nullable: true }), + filename: t.exposeString("filename"), + mime_type: t.exposeString("mime_type", { nullable: true }), + filesize: t.exposeFloat("filesize", { nullable: true }), + duration: t.exposeInt("duration", { nullable: true }), + uploaded_by: t.exposeString("uploaded_by", { nullable: true }), + date_created: t.expose("date_created", { type: "DateTime" }), + }), +}); + +export const UserType = builder.objectRef("User").implement({ + fields: (t) => ({ + id: t.exposeString("id"), + email: t.exposeString("email"), + first_name: t.exposeString("first_name", { nullable: true }), + last_name: t.exposeString("last_name", { nullable: true }), + artist_name: t.exposeString("artist_name", { nullable: true }), + slug: t.exposeString("slug", { nullable: true }), + description: t.exposeString("description", { nullable: true }), + tags: t.exposeStringList("tags", { nullable: true }), + role: t.exposeString("role"), + avatar: t.exposeString("avatar", { nullable: true }), + banner: t.exposeString("banner", { nullable: true }), + email_verified: t.exposeBoolean("email_verified"), + date_created: t.expose("date_created", { type: "DateTime" }), + }), +}); + +// CurrentUser is the same shape as User +export const CurrentUserType = builder.objectRef("CurrentUser").implement({ + fields: (t) => ({ + id: t.exposeString("id"), + email: t.exposeString("email"), + first_name: t.exposeString("first_name", { nullable: true }), + last_name: t.exposeString("last_name", { nullable: true }), + artist_name: t.exposeString("artist_name", { nullable: true }), + slug: t.exposeString("slug", { nullable: true }), + description: t.exposeString("description", { nullable: true }), + tags: t.exposeStringList("tags", { nullable: true }), + role: t.exposeString("role"), + avatar: t.exposeString("avatar", { nullable: true }), + banner: t.exposeString("banner", { nullable: true }), + email_verified: t.exposeBoolean("email_verified"), + date_created: t.expose("date_created", { type: "DateTime" }), + }), +}); + +export const VideoModelType = builder.objectRef("VideoModel").implement({ + fields: (t) => ({ + id: t.exposeString("id"), + artist_name: t.exposeString("artist_name", { nullable: true }), + slug: t.exposeString("slug", { nullable: true }), + avatar: t.exposeString("avatar", { nullable: true }), + }), +}); + +export const VideoFileType = builder.objectRef("VideoFile").implement({ + fields: (t) => ({ + id: t.exposeString("id"), + filename: t.exposeString("filename"), + mime_type: t.exposeString("mime_type", { nullable: true }), + duration: t.exposeInt("duration", { nullable: true }), + }), +}); + +export const VideoType = builder.objectRef