From add5b5b35e0e9496a82de007d8bf07576197ce5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:49:11 +0000 Subject: [PATCH 01/20] Phase C: scaffold opt-in LLM re-ranker for recommendation picks Adds the LLM reasoning layer that re-ranks ML candidates, gated behind a feature flag (off by default). The existing pick flow continues to work without the LLM; Phase B's recommender plugs in via maybeRerank(). - llm.service: Anthropic Sonnet 4.6 call with prompt caching on the system prompt and per-household taste blurbs, structured tool-use output (submit_pick), 8s timeout, typed LLMUnavailable on failure. - llmCache.service: lru-cache keyed on mood + 15-min runtime bucket + sorted candidate tmdb_ids + taste profile versions; 24h TTL, 1000 max. - featureFlags.service: isLlmRerankEnabled() reads recommendation.llm_rerank from AppSettings with LLM_RERANK_ENABLED env override and 60s in-process cache. - tasteProfile.summary: deterministic blurb renderer used as cached prompt context. - AppSettings seed adds recommendation.llm_rerank (default false). - .env.example documents ANTHROPIC_API_KEY and LLM_RERANK_ENABLED. - Happy-path tests for the summariser and cache key bucketing. --- backend/.env.example | 4 + backend/package.json | 2 + backend/src/services/featureFlags.service.ts | 61 +++++ backend/src/services/llm.service.ts | 228 ++++++++++++++++++ backend/src/services/llm.types.ts | 70 ++++++ backend/src/services/llmCache.service.test.ts | 41 ++++ backend/src/services/llmCache.service.ts | 58 +++++ backend/src/services/settings.service.ts | 6 + .../src/services/tasteProfile.summary.test.ts | 27 +++ backend/src/services/tasteProfile.summary.ts | 64 +++++ package-lock.json | 193 ++++++++++++++- 11 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 backend/src/services/featureFlags.service.ts create mode 100644 backend/src/services/llm.service.ts create mode 100644 backend/src/services/llm.types.ts create mode 100644 backend/src/services/llmCache.service.test.ts create mode 100644 backend/src/services/llmCache.service.ts create mode 100644 backend/src/services/tasteProfile.summary.test.ts create mode 100644 backend/src/services/tasteProfile.summary.ts diff --git a/backend/.env.example b/backend/.env.example index be06832..f914695 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,6 +20,10 @@ EMAIL_SENDER_NAME=PairFlix Notifications # Feature Flags MAINTENANCE_MODE=false ENABLE_MATCHING=true +LLM_RERANK_ENABLED=false + +# Anthropic (LLM re-ranker, opt-in) +ANTHROPIC_API_KEY= APP_CLIENT_URL=http://url:1234 APP_ADMIN_URL=http://url:1234 diff --git a/backend/package.json b/backend/package.json index c70a50c..68c9eff 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "format:check": "prettier --check \"src/**/*.{ts,js,json}\"" }, "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@types/nodemailer": "^6.4.17", "@types/sequelize": "^4.28.20", "bcryptjs": "^3.0.2", @@ -24,6 +25,7 @@ "express": "^4.18.2", "express-rate-limit": "^7.5.0", "jsonwebtoken": "^9.0.0", + "lru-cache": "^11.0.2", "node-cron": "^4.1.0", "nodemailer": "^7.0.3", "pg": "^8.13.1", diff --git a/backend/src/services/featureFlags.service.ts b/backend/src/services/featureFlags.service.ts new file mode 100644 index 0000000..4d4b195 --- /dev/null +++ b/backend/src/services/featureFlags.service.ts @@ -0,0 +1,61 @@ +import { settingsService } from './settings.service'; + +const CACHE_TTL_MS = 60 * 1000; + +interface CachedFlag { + value: boolean; + fetchedAt: number; +} + +const flagCache: Map = new Map(); + +function envOverride(name: string): boolean | undefined { + const raw = process.env[name]; + if (raw === undefined) return undefined; + return raw.toLowerCase() === 'true'; +} + +async function readBoolSetting( + key: string, + envName: string, + defaultValue: boolean +): Promise { + const now = Date.now(); + const cached = flagCache.get(key); + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { + return cached.value; + } + + const env = envOverride(envName); + let value: boolean; + if (env !== undefined) { + value = env; + } else { + try { + const raw = await settingsService.getSetting(key, defaultValue); + value = typeof raw === 'boolean' ? raw : defaultValue; + } catch { + value = defaultValue; + } + } + + flagCache.set(key, { value, fetchedAt: now }); + return value; +} + +export async function isLlmRerankEnabled(): Promise { + return readBoolSetting( + 'recommendation.llm_rerank', + 'LLM_RERANK_ENABLED', + false + ); +} + +export function clearFeatureFlagCache(): void { + flagCache.clear(); +} + +export const featureFlagsService = { + isLlmRerankEnabled, + clearFeatureFlagCache, +}; diff --git a/backend/src/services/llm.service.ts b/backend/src/services/llm.service.ts new file mode 100644 index 0000000..b9de9d0 --- /dev/null +++ b/backend/src/services/llm.service.ts @@ -0,0 +1,228 @@ +import Anthropic from '@anthropic-ai/sdk'; + +import { auditLogService } from './audit.service'; +import { isLlmRerankEnabled } from './featureFlags.service'; +import { llmCacheService } from './llmCache.service'; +import { + LLMUnavailable, + type LlmCandidate, + type LlmRerankContext, + type LlmRerankInput, + type LlmRerankResult, + type LlmTasteProfile, + type LlmUsage, +} from './llm.types'; + +const MODEL = 'claude-sonnet-4-6'; +const MAX_TOKENS = 512; +const TIMEOUT_MS = 8_000; + +const SYSTEM_PROMPT = [ + 'You are a film-pick reasoner for a couples movie app.', + 'You receive 10 candidate films pre-filtered by an ML scorer and short taste', + 'blurbs for each household member. Pick ONE film both people are likely to', + 'enjoy together for the given mood and available minutes, plus up to 2', + 'alternates. Prefer shared genre overlap and respect dislikes. Keep the', + 'rationale to one short sentence shown on the card. Always call the', + 'submit_pick tool. Never reply in prose.', +].join(' '); + +const SUBMIT_PICK_TOOL = { + name: 'submit_pick', + description: + 'Submit the final pick, up to 2 alternates, and a one-sentence rationale.', + input_schema: { + type: 'object', + properties: { + pick_tmdb_id: { + type: 'integer', + description: 'tmdb_id of the chosen film from the candidate list', + }, + alternates_tmdb_ids: { + type: 'array', + items: { type: 'integer' }, + maxItems: 2, + description: 'Up to 2 alternate tmdb_ids from the candidate list', + }, + rationale: { + type: 'string', + description: 'One short sentence explaining the pick', + }, + }, + required: ['pick_tmdb_id', 'alternates_tmdb_ids', 'rationale'], + additionalProperties: false, + }, +} as const; + +interface SubmitPickInput { + pick_tmdb_id: number; + alternates_tmdb_ids: number[]; + rationale: string; +} + +let client: Anthropic | null = null; + +function getClient(): Anthropic { + if (client) return client; + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new LLMUnavailable('ANTHROPIC_API_KEY is not set'); + } + client = new Anthropic({ apiKey }); + return client; +} + +function renderTasteBlock(profiles: LlmTasteProfile[]): string { + const lines = profiles.map( + (p, i) => `Member ${i + 1} (${p.user_id}): ${p.summary}` + ); + return ['Household taste profiles:', ...lines].join('\n'); +} + +function renderCandidatesBlock( + candidates: LlmCandidate[], + mood: string, + minutes: number +): string { + const header = `Mood: ${mood}\nMax runtime: ${minutes} min\nCandidates:`; + const rows = candidates.map(c => { + const genres = c.genres.join(', '); + return `- ${c.tmdb_id} | ${c.title} (${c.year}) | ${c.runtime} min | [${genres}] | ${c.overview}`; + }); + return [header, ...rows, 'Call submit_pick with your choice.'].join('\n'); +} + +function extractToolUse( + content: Anthropic.Messages.ContentBlock[] +): SubmitPickInput { + for (const block of content) { + if (block.type === 'tool_use' && block.name === 'submit_pick') { + return block.input as SubmitPickInput; + } + } + throw new LLMUnavailable('Model did not call submit_pick tool'); +} + +function extractUsage(raw: Anthropic.Messages.Message['usage']): LlmUsage { + const usage: LlmUsage = { + input_tokens: raw.input_tokens, + output_tokens: raw.output_tokens, + }; + if (typeof raw.cache_read_input_tokens === 'number') { + usage.cache_read_input_tokens = raw.cache_read_input_tokens; + } + if (typeof raw.cache_creation_input_tokens === 'number') { + usage.cache_creation_input_tokens = raw.cache_creation_input_tokens; + } + return usage; +} + +export async function rerankCandidates( + input: LlmRerankInput +): Promise { + const anthropic = getClient(); + + const tasteBlock = renderTasteBlock(input.tasteProfiles); + const candidatesBlock = renderCandidatesBlock( + input.candidates, + input.mood, + input.minutes + ); + + try { + const response = await anthropic.messages.create( + { + model: MODEL, + max_tokens: MAX_TOKENS, + system: [ + { + type: 'text', + text: SYSTEM_PROMPT, + cache_control: { type: 'ephemeral' }, + }, + ], + tools: [SUBMIT_PICK_TOOL], + tool_choice: { type: 'tool', name: 'submit_pick' }, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: tasteBlock, + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: candidatesBlock, + }, + ], + }, + ], + }, + { timeout: TIMEOUT_MS } + ); + + const toolInput = extractToolUse(response.content); + return { + pick_tmdb_id: toolInput.pick_tmdb_id, + alternates_tmdb_ids: (toolInput.alternates_tmdb_ids ?? []).slice(0, 2), + rationale: toolInput.rationale, + raw_usage: extractUsage(response.usage), + }; + } catch (err) { + if (err instanceof LLMUnavailable) throw err; + throw new LLMUnavailable('Anthropic call failed', err); + } +} + +/** + * Integration point for Phase B's recommender. + * + * Contract: + * - Returns `null` if the LLM rerank flag is off, the cache returns nothing + * and the call fails, or any unexpected error occurs. + * - When `null` is returned, callers MUST fall back to their pure-ML top-1. + * - When a result is returned, `pick_tmdb_id` and `alternates_tmdb_ids` are + * guaranteed to be drawn from the input candidate list (the model is + * instructed to do so; callers should still validate before surfacing). + */ +export async function maybeRerank( + candidates: LlmCandidate[], + ctx: LlmRerankContext +): Promise { + try { + const enabled = await isLlmRerankEnabled(); + if (!enabled) return null; + + const cacheKey = llmCacheService.buildCacheKey({ + mood: ctx.mood, + minutes: ctx.minutes, + candidates, + tasteProfileVersions: ctx.tasteProfileVersions, + }); + + const cached = llmCacheService.get(cacheKey); + if (cached) return cached; + + const result = await rerankCandidates({ + candidates, + tasteProfiles: ctx.tasteProfiles, + mood: ctx.mood, + minutes: ctx.minutes, + }); + + llmCacheService.set(cacheKey, result); + return result; + } catch (err) { + void auditLogService.warn('LLM rerank unavailable', 'llm-service', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + return null; + } +} + +export const llmService = { + rerankCandidates, + maybeRerank, +}; diff --git a/backend/src/services/llm.types.ts b/backend/src/services/llm.types.ts new file mode 100644 index 0000000..6cf59e8 --- /dev/null +++ b/backend/src/services/llm.types.ts @@ -0,0 +1,70 @@ +/** + * Phase C LLM re-ranker types. + * + * Defined locally rather than imported from the Phase B recommender, since + * Phase B is being built in parallel in another worktree. The shapes here + * describe only what the re-ranker needs as input/output; the merge will + * reconcile against the recommender's canonical types. + */ + +export interface LlmCandidate { + tmdb_id: number; + title: string; + year: number; + runtime: number; + overview: string; + genres: string[]; +} + +export interface LlmTasteProfile { + user_id: string; + summary: string; +} + +export interface LlmRerankInput { + candidates: LlmCandidate[]; + tasteProfiles: LlmTasteProfile[]; + mood: string; + minutes: number; +} + +export interface LlmUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +} + +export interface LlmRerankResult { + pick_tmdb_id: number; + alternates_tmdb_ids: number[]; + rationale: string; + raw_usage: LlmUsage; +} + +/** + * Context passed to maybeRerank by the caller. Used both for cache key + * construction and for the LLM prompt. + */ +export interface LlmRerankContext { + mood: string; + minutes: number; + tasteProfiles: LlmTasteProfile[]; + /** + * Opaque version markers per taste profile (e.g. updated_at). Included in + * the cache key so a profile change invalidates cached LLM picks. + */ + tasteProfileVersions: string[]; +} + +export class LLMUnavailable extends Error { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'LLMUnavailable'; + if (cause !== undefined) { + this.cause = cause; + } + } +} diff --git a/backend/src/services/llmCache.service.test.ts b/backend/src/services/llmCache.service.test.ts new file mode 100644 index 0000000..5e7862d --- /dev/null +++ b/backend/src/services/llmCache.service.test.ts @@ -0,0 +1,41 @@ +import { buildCacheKey } from './llmCache.service'; + +describe('llmCache.buildCacheKey', () => { + const base = { + mood: 'chill', + tasteProfileVersions: ['v1', 'v2'], + candidates: [{ tmdb_id: 10 }, { tmdb_id: 20 }, { tmdb_id: 30 }], + }; + + it('buckets minutes to 15-minute steps (88 and 92 share a key)', () => { + const k88 = buildCacheKey({ ...base, minutes: 88 }); + const k92 = buildCacheKey({ ...base, minutes: 92 }); + expect(k88).toBe(k92); + }); + + it('treats candidate order as irrelevant', () => { + const sorted = buildCacheKey({ ...base, minutes: 90 }); + const shuffled = buildCacheKey({ + ...base, + minutes: 90, + candidates: [{ tmdb_id: 30 }, { tmdb_id: 10 }, { tmdb_id: 20 }], + }); + expect(sorted).toBe(shuffled); + }); + + it('differs when mood changes', () => { + const a = buildCacheKey({ ...base, minutes: 90 }); + const b = buildCacheKey({ ...base, mood: 'tense', minutes: 90 }); + expect(a).not.toBe(b); + }); + + it('differs when taste profile versions change', () => { + const a = buildCacheKey({ ...base, minutes: 90 }); + const b = buildCacheKey({ + ...base, + minutes: 90, + tasteProfileVersions: ['v1', 'v3'], + }); + expect(a).not.toBe(b); + }); +}); diff --git a/backend/src/services/llmCache.service.ts b/backend/src/services/llmCache.service.ts new file mode 100644 index 0000000..35db00a --- /dev/null +++ b/backend/src/services/llmCache.service.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto'; +import { LRUCache } from 'lru-cache'; + +import type { LlmCandidate, LlmRerankResult } from './llm.types'; + +const CACHE_MAX = 1000; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +const cache = new LRUCache({ + max: CACHE_MAX, + ttl: CACHE_TTL_MS, +}); + +export interface CacheKeyInput { + mood: string; + minutes: number; + candidates: Pick[]; + tasteProfileVersions: string[]; +} + +export function buildCacheKey(input: CacheKeyInput): string { + // Bucket minutes to 15-min buckets and sort candidate IDs so cosmetic + // differences (88 vs 92 min; candidate order) hit the same cache entry. + const minutes_bucket = Math.round(input.minutes / 15) * 15; + const candidate_tmdb_ids_sorted = input.candidates + .map(c => c.tmdb_id) + .slice() + .sort((a, b) => a - b); + const taste_profile_versions = input.tasteProfileVersions.slice().sort(); + + const payload = JSON.stringify({ + mood: input.mood, + minutes_bucket, + candidate_tmdb_ids_sorted, + taste_profile_versions, + }); + + return crypto.createHash('sha256').update(payload).digest('hex'); +} + +export function get(key: string): LlmRerankResult | null { + return cache.get(key) ?? null; +} + +export function set(key: string, value: LlmRerankResult): void { + cache.set(key, value); +} + +export function clear(): void { + cache.clear(); +} + +export const llmCacheService = { + buildCacheKey, + get, + set, + clear, +}; diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts index fc31dbd..bb18e53 100644 --- a/backend/src/services/settings.service.ts +++ b/backend/src/services/settings.service.ts @@ -690,6 +690,12 @@ export class SettingsService { category: 'features', description: 'Enable activity feed', }, + 'recommendation.llm_rerank': { + value: false, + category: 'features', + description: + 'Enable LLM re-ranking of ML candidate picks (opt-in, cost control)', + }, }; return defaultSettings; diff --git a/backend/src/services/tasteProfile.summary.test.ts b/backend/src/services/tasteProfile.summary.test.ts new file mode 100644 index 0000000..93c5325 --- /dev/null +++ b/backend/src/services/tasteProfile.summary.test.ts @@ -0,0 +1,27 @@ +import { summariseTasteProfile } from './tasteProfile.summary'; + +describe('summariseTasteProfile', () => { + it('renders the canonical happy-path blurb deterministically', () => { + const blurb = summariseTasteProfile({ + user_id: 'u1', + genre_weights: { comedy: 0.6, drama: 0.25, horror: 0.05 }, + preferred_runtime: 95, + likes: ['feel-good'], + dislikes: ['horror'], + }); + + expect(blurb).toBe( + 'Leans comedy (60%) and drama (25%). Prefers ~95 min films. Likes feel-good, avoids horror.' + ); + + // Deterministic: same input -> same string + const again = summariseTasteProfile({ + user_id: 'u1', + genre_weights: { drama: 0.25, comedy: 0.6, horror: 0.05 }, + preferred_runtime: 95, + likes: ['feel-good'], + dislikes: ['horror'], + }); + expect(again).toBe(blurb); + }); +}); diff --git a/backend/src/services/tasteProfile.summary.ts b/backend/src/services/tasteProfile.summary.ts new file mode 100644 index 0000000..298d0cb --- /dev/null +++ b/backend/src/services/tasteProfile.summary.ts @@ -0,0 +1,64 @@ +/** + * Deterministic, no-LLM summariser for a user's taste profile. + * + * Produces a short blurb consumed by the LLM re-ranker as cached prompt + * context. Determinism matters: identical input must produce identical + * output so the Anthropic prompt cache hits. + */ + +export interface TasteProfileInput { + user_id: string; + /** genre name -> weight in [0, 1] (does not need to sum to 1) */ + genre_weights?: Record; + /** preferred runtime in minutes (optional) */ + preferred_runtime?: number; + /** free-form positive tags, e.g. "feel-good", "twisty" */ + likes?: string[]; + /** free-form negative tags, e.g. "horror", "gore" */ + dislikes?: string[]; +} + +const PCT = (n: number): number => Math.round(n * 100); + +function topGenres( + weights: Record | undefined, + limit = 2 +): Array<[string, number]> { + if (!weights) return []; + const entries = Object.entries(weights).filter(([, v]) => v > 0); + entries.sort((a, b) => { + if (b[1] !== a[1]) return b[1] - a[1]; + return a[0].localeCompare(b[0]); + }); + return entries.slice(0, limit); +} + +export function summariseTasteProfile(profile: TasteProfileInput): string { + const parts: string[] = []; + + const top = topGenres(profile.genre_weights, 2); + if (top.length === 1) { + const [g, w] = top[0]!; + parts.push(`Leans ${g} (${PCT(w)}%).`); + } else if (top.length >= 2) { + const [g1, w1] = top[0]!; + const [g2, w2] = top[1]!; + parts.push(`Leans ${g1} (${PCT(w1)}%) and ${g2} (${PCT(w2)}%).`); + } + + if (typeof profile.preferred_runtime === 'number') { + parts.push(`Prefers ~${Math.round(profile.preferred_runtime)} min films.`); + } + + const likes = (profile.likes ?? []).slice().sort(); + const dislikes = (profile.dislikes ?? []).slice().sort(); + if (likes.length > 0 && dislikes.length > 0) { + parts.push(`Likes ${likes.join(', ')}, avoids ${dislikes.join(', ')}.`); + } else if (likes.length > 0) { + parts.push(`Likes ${likes.join(', ')}.`); + } else if (dislikes.length > 0) { + parts.push(`Avoids ${dislikes.join(', ')}.`); + } + + return parts.join(' '); +} diff --git a/package-lock.json b/package-lock.json index 62fa8bd..b05ad7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -441,6 +441,7 @@ "name": "pairflix-backend", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@types/nodemailer": "^6.4.17", "@types/sequelize": "^4.28.20", "bcryptjs": "^3.0.2", @@ -449,6 +450,7 @@ "express": "^4.18.2", "express-rate-limit": "^7.5.0", "jsonwebtoken": "^9.0.0", + "lru-cache": "^11.0.2", "node-cron": "^4.1.0", "nodemailer": "^7.0.3", "pg": "^8.13.1", @@ -830,6 +832,15 @@ "version": "4.1.1", "license": "MIT" }, + "backend/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "backend/node_modules/mkdirp": { "version": "1.0.4", "dev": true, @@ -1413,6 +1424,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "dev": true, @@ -4322,6 +4363,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.17", "license": "MIT", @@ -4832,6 +4883,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -4881,6 +4944,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "dev": true, @@ -5217,7 +5292,6 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -5889,7 +5963,6 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6322,7 +6395,6 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6709,7 +6781,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7407,6 +7478,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7819,8 +7899,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "dev": true, + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7833,6 +7914,25 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/formidable": { "version": "3.5.4", "dev": true, @@ -8181,7 +8281,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8376,6 +8475,15 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "dev": true, @@ -10964,6 +11072,68 @@ "node": ">=6.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, @@ -14069,6 +14239,15 @@ "node": ">=10.13.0" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "dev": true, From 895916ed45fdae07907e811c217bdc973638d370 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 19:48:31 +0000 Subject: [PATCH 02/20] docs: add CLAUDE.md agent guide Captures product intent (Notion business plan), the active pivot from user-to-user matching to household title matching, the five in-flight phase branches, repo layout, commands, and per-workspace conventions so agent sessions have load-bearing context from session start. --- CLAUDE.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca71904 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# Pairflix — Agent guide + +This file is read by Claude Code at session start. Keep it concise and load-bearing. If you find yourself repeating an instruction in chat, codify it here instead. + +## What Pairflix is + +Pairflix is the "what should WE watch tonight" decision layer for couples and households, sitting on top of existing streaming subscriptions (Netflix, Prime, Disney+ …). Given a household, a mood, and a time budget, the product returns **one** recommended title in under 30 seconds. Premium adds unlimited picks, multi-region, and LLM-assisted re-ranking. + +The canonical product spec lives in Notion: **Pairflix — Business Plan** (`https://www.notion.so/ba9c9af72f9a4f448b554378d8dc43b4`). Treat it as the source of truth for product intent. + +## Active pivot — context for every change + +The codebase historically implemented **user-to-user matching** ("find a viewing partner"). It is being pivoted to **title matching for an already-paired household** ("what should we watch"). Both surfaces coexist during the migration; `Match` (user pairing) seeds `Household` (decision unit). + +Five in-flight phases, each on a sub-branch of `claude/inspiring-tesla-lAShW`: + +- **Phase A — `*-phase-a-models`** — `Household`, `HouseholdMember`, `TasteProfile`, `WatchedTogether`; `Content.providers` JSONB; first real Sequelize migration. +- **Phase B — `*-phase-b-tonight`** — ML/CF recommender (`recommendation.service.ts`), `POST /api/households/:id/pick`, `TonightPicker` UI. **No LLM in the hot path.** +- **Phase C — `*-phase-c-llm`** — Anthropic SDK scaffold (`claude-sonnet-4-6`), prompt-cached system + taste-profile blocks, tool-use structured output, behind feature flag `recommendation.llm_rerank` (default off). +- **Phase D — `*-phase-d-providers-history`** — TMDb `/watch/providers` fetch + cache, `ProviderBadges`, `WatchedTogether` history view with thumbs capture. +- **Phase E — `*-phase-e-pricing`** — `Subscription`, `PickUsage`, entitlements service + quota middleware, **mock checkout only** (no Stripe SDK yet). + +Default to extending these along their existing seams rather than introducing a parallel structure. + +## Repository layout + +Monorepo, npm workspaces: + +- `backend/` — Express + Sequelize + Postgres, TypeScript strict, API mounted at `/api/v1`. Port `8000` in dev. +- `app.client/` — React 18 + Vite + styled-components + React Query. Port `5173`. +- `app.admin/` — Admin panel, same stack as client. Port `5174`. +- `lib.components/` — Shared component library. Import from here before adding a new primitive. +- `docs/` — Architecture, schema, decision log, phase roadmaps. **Update `docs/db-schema.md` whenever a model changes.** +- `scripts/` — Dev + migration helpers. + +## Commands + +Run from repo root unless stated. + +| Task | Command | +|---|---| +| Install | `npm install` | +| Dev (all) | `npm run dev` | +| Dev (one) | `npm run dev:backend` \| `dev:client` \| `dev:admin` | +| Typecheck (per workspace) | `cd && npx tsc --noEmit` | +| Build (all) | `npm run build:all` | +| Test (all) | `npm run test:all` | +| Test (one) | `npm run test:backend` \| `test:client` \| `test:admin` \| `test:components` | +| Lint | `npm run lint:all` (or `lint:`) | +| Format | `npm run format:all` | + +`npm run dev` expects Postgres reachable. Easiest: `docker-compose up -d postgres`. + +## Conventions + +### General +- TypeScript strict everywhere. No `any` outside of test fixtures or third-party shims. +- Tabs for indentation in `backend/`, 2-space in the React workspaces — match the file you're editing. +- No emojis in code, identifiers, or commit messages. Emojis in `docs/*.md` are pre-existing; do not add more. +- No comments unless WHY is non-obvious. No "added for X" or "used by Y" comments — those belong in the PR description. +- No defensive checks beyond schema/middleware boundaries. Trust internal callers. +- Don't add backwards-compat shims for code you're replacing in the same change. + +### Backend +- Layering: `routes/` → `controllers/` → `services/` → `models/`. Controllers stay thin; business logic in services. +- File naming: models `PascalCase.ts`, everything else `dot.notation.ts` (e.g. `match.service.ts`, `auth.routes.ts`). +- Register every new Sequelize model in `backend/src/models/index.ts` and define associations there, matching the existing pattern. +- Sequelize migrations live under `backend/src/db/migrations/` (created in Phase A — before then, schema was sync-driven). New tables go via migration, not `sync({ alter: true })`. +- Auth: JWT bearer; `req.user` populated by `authMiddleware`. New protected routes mount it explicitly. +- Rate limiting: extend the existing `express-rate-limit` configs rather than rolling new ones. +- Logging: use the existing logger in `backend/src/utils/`. Do not add `console.log` to checked-in code. + +### Frontend (client + admin) +- Feature folders under `app.client/src/features//`. Each feature owns its pages, components, hooks, and types. +- Data fetching: React Query. No bare `useEffect(fetch…)`. +- Styling: styled-components, theme-aware. Use primitives from `lib.components` before reaching for a div. +- Routing: React Router. Add new routes alongside the existing tree in `App.tsx`. +- Forms: keep them controlled; reuse existing form primitives from `lib.components`. + +### Database +- One schema change = one migration. Wrap multi-step changes in a transaction. +- JSONB for flexible/schemaless fields (`providers`, `weights`, settings). Index keys you'll filter on. +- Document new tables/columns in `docs/db-schema.md` in the same PR. + +### Tests +- Jest across all workspaces; `ts-jest` in backend, RTL in frontend. +- Co-locate `*.test.ts` / `*.test.tsx` next to the file under test. +- New service ⇒ at least one happy-path + one failure-mode unit test. Don't test framework code. +- API endpoints get a `supertest` integration test if they introduce non-trivial logic. + +### Git +- Always commit on a feature branch off `claude/inspiring-tesla-lAShW` (or the user's stated branch). Never commit on `main`. +- Conventional-ish commit messages (`feat:`, `fix:`, `chore:`, `docs:`). No model-identifier strings, no marketing names in commit messages, PR titles, or code. +- Do not push unless asked. After the user asks to push, open a draft PR by default. +- Never `--no-verify`, never force-push to `main`. + +## External dependencies + +- **TMDb** — title metadata, search, watch providers. Single API key in `TMDB_API_KEY`. Treat as best-effort; provider data is region-locked and patchy — degrade gracefully. +- **Anthropic API** — Phase C only. Model `claude-sonnet-4-6`. Enable prompt caching on the system prompt and the (stable per session) taste-profile blocks. Use tool-use for structured output, never free-text JSON parsing. Off by default behind `recommendation.llm_rerank`. +- **Stripe** — Phase E reserves the surface but the SDK is **not yet integrated**. Do not import `stripe` until the user signs off on go-live. + +## Things to leave alone unless asked + +- The existing user-to-user `Match` flow. It still ships; the pivot is additive. +- `app.admin` — only touch if the task explicitly names admin scope. +- `docker-compose.prod.yml` and the `nginx/` configs — production deployment is owned outside agent scope. + +## When in doubt + +- Notion business plan = product truth. +- `docs/architecture.md`, `docs/db-schema.md`, `docs/decision-log.md` = engineering truth. +- Existing patterns in neighbouring files = style truth. + +If those three disagree, ask. From 759d6829189674b5b80080e23194e1e0837e14d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:51:47 +0000 Subject: [PATCH 03/20] feat: add household, taste profile, and watched-together models (phase A) Scaffolds the Household decision unit, per-user TasteProfile, and WatchedTogether signal table for the pivot to title recommendation. Adds providers JSONB to content and a single Sequelize migration that backfills households + members from accepted matches and seeds empty taste profiles for existing users. --- .../20260601000000-phase-a-households.ts | 228 ++++++++++++++++++ backend/src/models/Content.ts | 32 ++- backend/src/models/Household.ts | 56 +++++ backend/src/models/HouseholdMember.ts | 75 ++++++ backend/src/models/TasteProfile.ts | 74 ++++++ backend/src/models/WatchedTogether.ts | 112 +++++++++ backend/src/models/index.ts | 47 ++++ backend/src/services/tasteProfile.service.ts | 127 ++++++++++ backend/src/services/tmdb.service.ts | 11 + backend/src/types/index.ts | 61 +++++ docs/db-schema.md | 94 ++++++++ 11 files changed, 916 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20260601000000-phase-a-households.ts create mode 100644 backend/src/models/Household.ts create mode 100644 backend/src/models/HouseholdMember.ts create mode 100644 backend/src/models/TasteProfile.ts create mode 100644 backend/src/models/WatchedTogether.ts create mode 100644 backend/src/services/tasteProfile.service.ts diff --git a/backend/src/db/migrations/20260601000000-phase-a-households.ts b/backend/src/db/migrations/20260601000000-phase-a-households.ts new file mode 100644 index 0000000..b59141d --- /dev/null +++ b/backend/src/db/migrations/20260601000000-phase-a-households.ts @@ -0,0 +1,228 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + const sequelize = queryInterface.sequelize; + + await sequelize.transaction(async transaction => { + await queryInterface.createTable( + 'households', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { transaction } + ); + + await queryInterface.createTable( + 'household_members', + { + household_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { model: 'households', key: 'id' }, + onDelete: 'CASCADE', + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { model: 'users', key: 'user_id' }, + onDelete: 'CASCADE', + }, + role: { + type: DataTypes.ENUM('owner', 'member'), + allowNull: false, + defaultValue: 'member', + }, + joined_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { transaction } + ); + + await queryInterface.createTable( + 'taste_profiles', + { + user_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { model: 'users', key: 'user_id' }, + onDelete: 'CASCADE', + }, + weights: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + embedding: { + type: DataTypes.JSONB, + allowNull: true, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { transaction } + ); + + await queryInterface.createTable( + 'watched_together', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'households', key: 'id' }, + onDelete: 'CASCADE', + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + watched_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + enjoyed: { + type: DataTypes.BOOLEAN, + allowNull: true, + }, + mood_at_pick: { + type: DataTypes.STRING, + allowNull: true, + }, + minutes_budget_at_pick: { + type: DataTypes.INTEGER, + allowNull: true, + }, + }, + { transaction } + ); + + await queryInterface.addIndex('watched_together', { + name: 'idx_watched_together_household_watched_at', + fields: ['household_id', { name: 'watched_at', order: 'DESC' }], + transaction, + }); + + await queryInterface.addIndex('watched_together', { + name: 'idx_watched_together_household_tmdb', + fields: ['household_id', 'tmdb_id'], + transaction, + }); + + await queryInterface.addColumn( + 'content', + 'providers', + { + type: DataTypes.JSONB, + allowNull: true, + defaultValue: {}, + }, + { transaction } + ); + + await sequelize.query( + ` + INSERT INTO households (id, name, created_at, updated_at) + SELECT gen_random_uuid(), NULL, NOW(), NOW() + FROM matches + WHERE status = 'accepted' + `, + { transaction } + ); + + await sequelize.query( + ` + WITH accepted AS ( + SELECT + m.match_id, + m.user1_id, + m.user2_id, + ROW_NUMBER() OVER (ORDER BY m.created_at, m.match_id) AS rn + FROM matches m + WHERE m.status = 'accepted' + ), + new_households AS ( + SELECT + h.id, + ROW_NUMBER() OVER (ORDER BY h.created_at, h.id) AS rn + FROM households h + ) + INSERT INTO household_members (household_id, user_id, role, joined_at) + SELECT nh.id, a.user1_id, 'member', NOW() + FROM accepted a + JOIN new_households nh ON nh.rn = a.rn + UNION ALL + SELECT nh.id, a.user2_id, 'member', NOW() + FROM accepted a + JOIN new_households nh ON nh.rn = a.rn + ON CONFLICT (household_id, user_id) DO NOTHING + `, + { transaction } + ); + + await sequelize.query( + ` + INSERT INTO taste_profiles (user_id, weights, embedding, updated_at) + SELECT u.user_id, '{}'::jsonb, NULL, NOW() + FROM users u + ON CONFLICT (user_id) DO NOTHING + `, + { transaction } + ); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + const sequelize = queryInterface.sequelize; + + await sequelize.transaction(async transaction => { + await queryInterface.removeColumn('content', 'providers', { transaction }); + await queryInterface.dropTable('watched_together', { transaction }); + await queryInterface.dropTable('taste_profiles', { transaction }); + await queryInterface.dropTable('household_members', { transaction }); + await queryInterface.dropTable('households', { transaction }); + await sequelize.query( + `DROP TYPE IF EXISTS "enum_watched_together_media_type"`, + { transaction } + ); + await sequelize.query( + `DROP TYPE IF EXISTS "enum_household_members_role"`, + { transaction } + ); + }); +} diff --git a/backend/src/models/Content.ts b/backend/src/models/Content.ts index 04dac73..6273249 100644 --- a/backend/src/models/Content.ts +++ b/backend/src/models/Content.ts @@ -1,5 +1,22 @@ import { DataTypes, Model, type Optional, type Sequelize } from 'sequelize'; +export interface ProviderEntry { + provider_id: number; + provider_name: string; + logo_path: string | null; +} + +export interface ProviderRegion { + flatrate?: ProviderEntry[]; + rent?: ProviderEntry[]; + buy?: ProviderEntry[]; +} + +export interface ContentProviders { + [region: string]: ProviderRegion | string | undefined; + last_updated_at?: string; +} + interface ContentAttributes { id: string; title: string; @@ -8,13 +25,19 @@ interface ContentAttributes { tmdb_id: number; reported_count: number; removal_reason?: string; + providers: ContentProviders | null; created_at: Date; updated_at: Date; } type ContentCreationAttributes = Optional< ContentAttributes, - 'id' | 'reported_count' | 'created_at' | 'updated_at' | 'removal_reason' + | 'id' + | 'reported_count' + | 'created_at' + | 'updated_at' + | 'removal_reason' + | 'providers' >; class Content extends Model { @@ -32,6 +55,8 @@ class Content extends Model { declare removal_reason?: string; + declare providers: ContentProviders | null; + declare created_at: Date; declare updated_at: Date; @@ -77,6 +102,11 @@ class Content extends Model { type: DataTypes.TEXT, allowNull: true, }, + providers: { + type: DataTypes.JSONB, + allowNull: true, + defaultValue: {}, + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, diff --git a/backend/src/models/Household.ts b/backend/src/models/Household.ts new file mode 100644 index 0000000..b0156f2 --- /dev/null +++ b/backend/src/models/Household.ts @@ -0,0 +1,56 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; + +interface HouseholdAttributes { + id: string; + name: string | null; + created_at: Date; + updated_at: Date; +} + +interface HouseholdCreationAttributes { + name?: string | null; +} + +class Household extends Model { + declare id: string; + + declare name: string | null; + + declare created_at: Date; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return Household.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'Household', + tableName: 'households', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +export default Household; diff --git a/backend/src/models/HouseholdMember.ts b/backend/src/models/HouseholdMember.ts new file mode 100644 index 0000000..29f86d9 --- /dev/null +++ b/backend/src/models/HouseholdMember.ts @@ -0,0 +1,75 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; +import User from './User'; + +type HouseholdRole = 'owner' | 'member'; + +interface HouseholdMemberAttributes { + household_id: string; + user_id: string; + role: HouseholdRole; + joined_at: Date; +} + +interface HouseholdMemberCreationAttributes { + household_id: string; + user_id: string; + role?: HouseholdRole; + joined_at?: Date; +} + +class HouseholdMember extends Model< + HouseholdMemberAttributes, + HouseholdMemberCreationAttributes +> { + declare household_id: string; + + declare user_id: string; + + declare role: HouseholdRole; + + declare joined_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return HouseholdMember.init( + { + household_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: Household, + key: 'id', + }, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: User, + key: 'user_id', + }, + }, + role: { + type: DataTypes.ENUM('owner', 'member'), + allowNull: false, + defaultValue: 'member', + }, + joined_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'HouseholdMember', + tableName: 'household_members', + timestamps: false, + } + ); + } +} + +export default HouseholdMember; diff --git a/backend/src/models/TasteProfile.ts b/backend/src/models/TasteProfile.ts new file mode 100644 index 0000000..05baf85 --- /dev/null +++ b/backend/src/models/TasteProfile.ts @@ -0,0 +1,74 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import User from './User'; + +export interface TasteWeights { + genres?: Record; + runtime_pref?: number | null; + era?: Record; + tone?: Record; +} + +interface TasteProfileAttributes { + user_id: string; + weights: TasteWeights; + embedding: number[] | null; + updated_at: Date; +} + +interface TasteProfileCreationAttributes { + user_id: string; + weights?: TasteWeights; + embedding?: number[] | null; +} + +class TasteProfile extends Model< + TasteProfileAttributes, + TasteProfileCreationAttributes +> { + declare user_id: string; + + declare weights: TasteWeights; + + declare embedding: number[] | null; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return TasteProfile.init( + { + user_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: User, + key: 'user_id', + }, + }, + weights: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: {}, + }, + embedding: { + type: DataTypes.JSONB, + allowNull: true, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'TasteProfile', + tableName: 'taste_profiles', + timestamps: true, + createdAt: false, + updatedAt: 'updated_at', + } + ); + } +} + +export default TasteProfile; diff --git a/backend/src/models/WatchedTogether.ts b/backend/src/models/WatchedTogether.ts new file mode 100644 index 0000000..c4e374b --- /dev/null +++ b/backend/src/models/WatchedTogether.ts @@ -0,0 +1,112 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; + +type WatchedMediaType = 'movie' | 'tv'; + +interface WatchedTogetherAttributes { + id: string; + household_id: string; + tmdb_id: number; + media_type: WatchedMediaType; + watched_at: Date; + enjoyed: boolean | null; + mood_at_pick: string | null; + minutes_budget_at_pick: number | null; +} + +interface WatchedTogetherCreationAttributes { + household_id: string; + tmdb_id: number; + media_type: WatchedMediaType; + watched_at?: Date; + enjoyed?: boolean | null; + mood_at_pick?: string | null; + minutes_budget_at_pick?: number | null; +} + +class WatchedTogether extends Model< + WatchedTogetherAttributes, + WatchedTogetherCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare tmdb_id: number; + + declare media_type: WatchedMediaType; + + declare watched_at: Date; + + declare enjoyed: boolean | null; + + declare mood_at_pick: string | null; + + declare minutes_budget_at_pick: number | null; + + static initialize(sequelize: Sequelize): ModelStatic { + return WatchedTogether.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: Household, + key: 'id', + }, + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + watched_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + enjoyed: { + type: DataTypes.BOOLEAN, + allowNull: true, + }, + mood_at_pick: { + type: DataTypes.STRING, + allowNull: true, + }, + minutes_budget_at_pick: { + type: DataTypes.INTEGER, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'WatchedTogether', + tableName: 'watched_together', + timestamps: false, + indexes: [ + { + name: 'idx_watched_together_household_watched_at', + fields: [ + 'household_id', + { name: 'watched_at', order: 'DESC' }, + ], + }, + { + name: 'idx_watched_together_household_tmdb', + fields: ['household_id', 'tmdb_id'], + }, + ], + } + ); + } +} + +export default WatchedTogether; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3ec3f8c..c8ce606 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -5,10 +5,14 @@ import AuditLog from './AuditLog'; import Content from './Content'; import ContentReport from './ContentReport'; import EmailVerification from './EmailVerification'; +import Household from './Household'; +import HouseholdMember from './HouseholdMember'; import Match from './Match'; import PasswordReset from './PasswordReset'; +import TasteProfile from './TasteProfile'; import User from './User'; import UserSession from './UserSession'; +import WatchedTogether from './WatchedTogether'; import WatchlistEntry from './WatchlistEntry'; export function initializeModels(sequelize: Sequelize) { @@ -35,6 +39,10 @@ export function initializeModels(sequelize: Sequelize) { EmailVerification.initialize(sequelize); PasswordReset.initialize(sequelize); UserSession.initialize(sequelize); + Household.initialize(sequelize); + HouseholdMember.initialize(sequelize); + TasteProfile.initialize(sequelize); + WatchedTogether.initialize(sequelize); // Set up associations after all models are initialized Match.belongsTo(User, { as: 'user1', foreignKey: 'user1_id' }); @@ -105,6 +113,41 @@ export function initializeModels(sequelize: Sequelize) { as: 'watchlistEntry', }); WatchlistEntry.hasMany(Match, { foreignKey: 'entry_id', as: 'matches' }); + + Household.belongsToMany(User, { + through: HouseholdMember, + foreignKey: 'household_id', + otherKey: 'user_id', + as: 'members', + }); + User.belongsToMany(Household, { + through: HouseholdMember, + foreignKey: 'user_id', + otherKey: 'household_id', + as: 'households', + }); + HouseholdMember.belongsTo(Household, { foreignKey: 'household_id' }); + HouseholdMember.belongsTo(User, { foreignKey: 'user_id' }); + Household.hasMany(HouseholdMember, { + foreignKey: 'household_id', + as: 'memberships', + }); + User.hasMany(HouseholdMember, { + foreignKey: 'user_id', + as: 'householdMemberships', + }); + + TasteProfile.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); + User.hasOne(TasteProfile, { foreignKey: 'user_id', as: 'tasteProfile' }); + + WatchedTogether.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + Household.hasMany(WatchedTogether, { + foreignKey: 'household_id', + as: 'watchedTogether', + }); } catch (error) { console.error('Error initializing models:', error); throw new Error( @@ -125,4 +168,8 @@ export default { EmailVerification, PasswordReset, UserSession, + Household, + HouseholdMember, + TasteProfile, + WatchedTogether, }; diff --git a/backend/src/services/tasteProfile.service.ts b/backend/src/services/tasteProfile.service.ts new file mode 100644 index 0000000..e7bb1b4 --- /dev/null +++ b/backend/src/services/tasteProfile.service.ts @@ -0,0 +1,127 @@ +import Household from '../models/Household'; +import HouseholdMember from '../models/HouseholdMember'; +import TasteProfile, { type TasteWeights } from '../models/TasteProfile'; +import WatchlistEntry from '../models/WatchlistEntry'; +import { getMovieDetails, getTVDetails } from './tmdb.service'; + +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +interface TitleDetails { + genre_ids: number[]; + runtime: number | null; +} + +async function fetchTitleDetails( + tmdbId: number, + mediaType: 'movie' | 'tv' +): Promise { + try { + if (mediaType === 'movie') { + const movie = await getMovieDetails(tmdbId); + return { + genre_ids: (movie.genres ?? []).map(g => g.id), + runtime: movie.runtime ?? null, + }; + } + const tv = await getTVDetails(tmdbId); + const runtimes = tv.episode_run_time ?? []; + return { + genre_ids: (tv.genres ?? []).map(g => g.id), + runtime: runtimes.length > 0 ? (runtimes[0] ?? null) : null, + }; + } catch { + return null; + } +} + +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2; + } + return sorted[mid] ?? null; +} + +export async function recomputeForUser(userId: string): Promise { + const entries = await WatchlistEntry.findAll({ where: { user_id: userId } }); + + const genreCounts: Record = {}; + const ratedRuntimes: number[] = []; + + for (const entry of entries) { + const details = await fetchTitleDetails(entry.tmdb_id, entry.media_type); + if (!details) continue; + + for (const genreId of details.genre_ids) { + const key = String(genreId); + genreCounts[key] = (genreCounts[key] ?? 0) + 1; + } + + if ( + entry.rating !== undefined && + entry.rating !== null && + entry.rating >= 7 && + details.runtime !== null + ) { + ratedRuntimes.push(details.runtime); + } + } + + const total = Object.values(genreCounts).reduce((s, v) => s + v, 0); + const genres: Record = {}; + if (total > 0) { + for (const [key, count] of Object.entries(genreCounts)) { + genres[key] = count / total; + } + } + + const weights: TasteWeights = { + genres, + runtime_pref: median(ratedRuntimes), + era: {}, + tone: {}, + }; + + const [profile] = await TasteProfile.upsert({ + user_id: userId, + weights, + }); + + return profile; +} + +export interface HouseholdMemberProfile { + user_id: string; + weights: TasteWeights; +} + +export async function recomputeForHousehold( + householdId: string +): Promise { + const household = await Household.findByPk(householdId, { + include: [{ model: HouseholdMember, as: 'memberships' }], + }); + if (!household) return []; + + const memberships = (household.get('memberships') as + | HouseholdMember[] + | undefined) ?? []; + + const results: HouseholdMemberProfile[] = []; + const now = Date.now(); + + for (const membership of memberships) { + const existing = await TasteProfile.findByPk(membership.user_id); + const isStale = + !existing || + now - new Date(existing.updated_at).getTime() > STALE_AFTER_MS; + const profile = isStale + ? await recomputeForUser(membership.user_id) + : existing; + results.push({ user_id: profile.user_id, weights: profile.weights }); + } + + return results; +} diff --git a/backend/src/services/tmdb.service.ts b/backend/src/services/tmdb.service.ts index 305d906..47a8ef8 100644 --- a/backend/src/services/tmdb.service.ts +++ b/backend/src/services/tmdb.service.ts @@ -10,12 +10,20 @@ export interface TMDbResponse { status_message?: string; } +export interface TMDbGenre { + id: number; + name: string; +} + export interface TMDbMovie { id: number; title: string; poster_path: string | null; overview: string; status: string; + genres?: TMDbGenre[]; + runtime?: number | null; + release_date?: string; } export interface TMDbTV { @@ -24,6 +32,9 @@ export interface TMDbTV { poster_path: string | null; overview: string; status: string; + genres?: TMDbGenre[]; + episode_run_time?: number[]; + first_air_date?: string; } export type TMDbDetails = TMDbMovie | TMDbTV; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 7022229..0623fc1 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -161,3 +161,64 @@ export type ActivityContext = | 'search' | 'media' | 'system'; + +export type HouseholdRole = 'owner' | 'member'; + +export interface HouseholdMemberDTO { + user_id: string; + role: HouseholdRole; + joined_at: Date; +} + +export interface HouseholdDTO { + id: string; + name: string | null; + created_at: Date; + updated_at: Date; + members?: HouseholdMemberDTO[]; +} + +export interface CreateHouseholdRequest { + name?: string | null; + member_user_ids?: string[]; +} + +export interface TasteWeightsDTO { + genres?: Record; + runtime_pref?: number | null; + era?: Record; + tone?: Record; +} + +export interface TasteProfileDTO { + user_id: string; + weights: TasteWeightsDTO; + embedding: number[] | null; + updated_at: Date; +} + +export interface WatchedTogetherDTO { + id: string; + household_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at: Date; + enjoyed: boolean | null; + mood_at_pick: string | null; + minutes_budget_at_pick: number | null; +} + +export interface CreateWatchedTogetherRequest { + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at?: Date; + enjoyed?: boolean | null; + mood_at_pick?: string | null; + minutes_budget_at_pick?: number | null; +} + +export interface UpdateWatchedTogetherRequest { + enjoyed?: boolean | null; + mood_at_pick?: string | null; + minutes_budget_at_pick?: number | null; +} diff --git a/docs/db-schema.md b/docs/db-schema.md index cd2a965..3e5dbe5 100644 --- a/docs/db-schema.md +++ b/docs/db-schema.md @@ -267,6 +267,100 @@ Settings are organized into these categories: } ``` +### Households + +Represents a viewing unit (usually a couple) that makes "what should WE watch" decisions together. Backfilled from accepted `matches`. + +```sql +CREATE TABLE households ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +``` + +### Household Members + +Join table mapping users to households with a role. + +```sql +CREATE TABLE household_members ( + household_id UUID REFERENCES households(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(user_id) ON DELETE CASCADE, + role TEXT CHECK (role IN ('owner', 'member')) NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (household_id, user_id) +); +``` + +### Taste Profiles + +Per-user genre / era / tone / runtime preferences derived from watchlist entries and ratings. Used by the recommender. `embedding` is reserved for a future LLM-derived vector and is currently nullable. + +```sql +CREATE TABLE taste_profiles ( + user_id UUID PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + weights JSONB NOT NULL DEFAULT '{}'::jsonb, + embedding JSONB, + updated_at TIMESTAMPTZ DEFAULT now() +); +``` + +`weights` shape: + +```json +{ + "genres": { "28": 0.7, "35": 0.3 }, + "runtime_pref": 95, + "era": { "2010s": 0.6 }, + "tone": { "funny": 0.5, "dark": 0.2 } +} +``` + +### Watched Together + +Gold-standard training signal: titles that a household actually watched together, with optional post-watch sentiment. + +```sql +CREATE TABLE watched_together ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE, + tmdb_id INTEGER NOT NULL, + media_type TEXT CHECK (media_type IN ('movie', 'tv')) NOT NULL, + watched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + enjoyed BOOLEAN, + mood_at_pick TEXT, + minutes_budget_at_pick INTEGER +); + +CREATE INDEX idx_watched_together_household_watched_at + ON watched_together(household_id, watched_at DESC); +CREATE INDEX idx_watched_together_household_tmdb + ON watched_together(household_id, tmdb_id); +``` + +### Content (providers) + +The `content` table gains a `providers` JSONB column for region-keyed availability fetched from TMDb `/watch/providers`. Default `{}`, nullable. + +```sql +ALTER TABLE content ADD COLUMN providers JSONB DEFAULT '{}'::jsonb; +``` + +Shape: + +```json +{ + "US": { + "flatrate": [{ "provider_id": 8, "provider_name": "Netflix", "logo_path": "/..." }], + "rent": [], + "buy": [] + }, + "last_updated_at": "2026-06-01T12:00:00Z" +} +``` + ## Indexes ```sql From de39ad04908f6311fff16d0f27724562bf5588b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:54:37 +0000 Subject: [PATCH 04/20] feat: scaffold Phase B Tonight pick flow (household recommender + UI) Adds the headline "what should we watch tonight" decision flow: a heuristic recommender service (no LLM), POST /api/households/:id/pick and /picks/:tmdbId/commit endpoints, and a React TonightPicker screen with mood chips, time slider, provider filter, and result card. Backend: - recommendation.service.ts with merged taste profile (intersection-favoured), TMDb /discover candidate set cached per (mood, minutes_bucket, region) for 30 min, scoring (genre 0.5, runtime fit 0.2, mood 0.15, recency 0.15), watch-providers filter when requested, templated rationale. - recommendation.types.ts stubs HouseholdRepository / TasteProfile / WatchedTogether so Phase A models can drop in without changing the service. - tmdb.service.ts gains discoverMedia + getWatchProviders (24h cache) + full-detail helpers. Frontend: - features/tonight/{TonightPicker,useTonightPick,useActiveHousehold, useTonightHomepage} wired into Routes and navigation. /tonight is the default landing when the (Phase A) pref tonightAsHomepage is true. --- app.client/src/components/layout/Routes.tsx | 10 +- app.client/src/config/navigation.ts | 7 + .../src/features/tonight/TonightPicker.tsx | 336 ++++++++++++++ .../features/tonight/useActiveHousehold.ts | 21 + .../features/tonight/useTonightHomepage.ts | 24 + .../src/features/tonight/useTonightPick.ts | 38 ++ app.client/src/services/api/households.ts | 76 ++++ app.client/src/services/api/index.ts | 3 + backend/src/app.ts | 2 + .../src/controllers/household.controller.ts | 83 ++++ backend/src/routes/household.routes.ts | 15 + .../src/services/recommendation.service.ts | 429 ++++++++++++++++++ backend/src/services/recommendation.types.ts | 87 ++++ backend/src/services/tmdb.service.ts | 137 ++++++ 14 files changed, 1267 insertions(+), 1 deletion(-) create mode 100644 app.client/src/features/tonight/TonightPicker.tsx create mode 100644 app.client/src/features/tonight/useActiveHousehold.ts create mode 100644 app.client/src/features/tonight/useTonightHomepage.ts create mode 100644 app.client/src/features/tonight/useTonightPick.ts create mode 100644 app.client/src/services/api/households.ts create mode 100644 backend/src/controllers/household.controller.ts create mode 100644 backend/src/routes/household.routes.ts create mode 100644 backend/src/services/recommendation.service.ts create mode 100644 backend/src/services/recommendation.types.ts diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index d5d3bf3..7202819 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -13,6 +13,8 @@ import ProfilePage from '../../features/auth/ProfilePage'; import RegisterPage from '../../features/auth/RegisterPage'; import ResetPasswordPage from '../../features/auth/ResetPasswordPage'; import MatchPage from '../../features/match/MatchPage'; +import TonightPicker from '../../features/tonight/TonightPicker'; +import { useTonightHomepagePreference } from '../../features/tonight/useTonightHomepage'; import WatchlistPage from '../../features/watchlist/WatchlistPage'; import { useAuth } from '../../hooks/useAuth'; @@ -40,6 +42,8 @@ const LogoutRoute: React.FC = () => { const AppRoutes: React.FC = () => { const { user, isAuthenticated, logout } = useAuth(); + const { tonightAsHomepage } = useTonightHomepagePreference(); + const defaultPath = tonightAsHomepage ? '/tonight' : '/watchlist'; const handleLogout = () => { logout(); @@ -77,6 +81,10 @@ const AppRoutes: React.FC = () => { element={ + } />} + /> } />} @@ -95,7 +103,7 @@ const AppRoutes: React.FC = () => { /> {/* Default route */} - } /> + } /> } /> diff --git a/app.client/src/config/navigation.ts b/app.client/src/config/navigation.ts index 1fd518d..acd645d 100644 --- a/app.client/src/config/navigation.ts +++ b/app.client/src/config/navigation.ts @@ -6,6 +6,7 @@ import { HiChartBarSquare, HiHeart, HiListBullet, + HiSparkles, HiUser, } from 'react-icons/hi2'; @@ -49,6 +50,12 @@ export const createClientNavigation = ( sections: [ { items: [ + { + key: 'tonight', + label: 'Tonight', + path: '/tonight', + icon: React.createElement(HiSparkles), + }, { key: 'watchlist', label: 'My Watchlist', diff --git a/app.client/src/features/tonight/TonightPicker.tsx b/app.client/src/features/tonight/TonightPicker.tsx new file mode 100644 index 0000000..c6fc514 --- /dev/null +++ b/app.client/src/features/tonight/TonightPicker.tsx @@ -0,0 +1,336 @@ +import { + Badge, + Button, + Card, + CardContent, + Container, + Flex, + H1, + H2, + PageContainer, + Typography, +} from '@pairflix/components'; +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import type { Theme } from '../../styles/theme'; +import { useActiveHousehold } from './useActiveHousehold'; +import { useTonightHomepagePreference } from './useTonightHomepage'; +import { useCommitPick, useTonightPick } from './useTonightPick'; +import type { Mood, RecommendationCard } from '../../services/api/households'; + +const MOODS: { id: Mood; label: string }[] = [ + { id: 'funny', label: 'Funny' }, + { id: 'feelgood', label: 'Feel-good' }, + { id: 'romantic', label: 'Romantic' }, + { id: 'thoughtful', label: 'Thoughtful' }, + { id: 'tense', label: 'Tense' }, + { id: 'dark', label: 'Dark' }, + { id: 'action', label: 'Action' }, +]; + +const PROVIDER_OPTIONS: { id: string; label: string }[] = [ + { id: 'netflix', label: 'Netflix' }, + { id: 'prime', label: 'Prime Video' }, + { id: 'disney_plus', label: 'Disney+' }, +]; + +const ChipRow = styled(Flex)<{ theme: Theme }>` + flex-wrap: wrap; + gap: ${({ theme }) => theme.spacing.sm}; +`; + +const Chip = styled.button<{ selected: boolean; theme: Theme }>` + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border-radius: 999px; + border: 1px solid + ${({ selected, theme }) => + selected ? theme.colors.primary : theme.colors.border}; + background: ${({ selected, theme }) => + selected ? theme.colors.primary : 'transparent'}; + color: ${({ selected, theme }) => + selected ? '#ffffff' : theme.colors.text.primary}; + cursor: pointer; + font-size: ${({ theme }) => theme.typography.fontSize.sm}; + transition: background 0.15s ease; + + &:hover { + background: ${({ selected, theme }) => + selected ? theme.colors.primary : theme.colors.background.secondary}; + } +`; + +const SliderRow = styled.div<{ theme: Theme }>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.md}; + margin: ${({ theme }) => theme.spacing.md} 0; +`; + +const Slider = styled.input.attrs({ type: 'range' })<{ theme: Theme }>` + flex: 1; +`; + +const ProviderRow = styled(Flex)<{ theme: Theme }>` + gap: ${({ theme }) => theme.spacing.md}; + flex-wrap: wrap; +`; + +const PickButton = styled(Button)<{ theme: Theme }>` + margin-top: ${({ theme }) => theme.spacing.lg}; + font-size: ${({ theme }) => theme.typography.fontSize.lg}; + padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.xl}; +`; + +const ResultCard = styled(Card).attrs({ variant: 'primary' as const })<{ + theme: Theme; +}>` + margin-top: ${({ theme }) => theme.spacing.xl}; +`; + +const Poster = styled.img<{ theme: Theme }>` + width: 200px; + aspect-ratio: 2/3; + object-fit: cover; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + flex-shrink: 0; +`; + +const Rationale = styled(Typography)<{ theme: Theme }>` + font-style: italic; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const ActionRow = styled(Flex)<{ theme: Theme }>` + margin-top: ${({ theme }) => theme.spacing.lg}; + gap: ${({ theme }) => theme.spacing.md}; + flex-wrap: wrap; +`; + +const FormSection = styled.div<{ theme: Theme }>` + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + +const Label = styled(Typography).attrs({ variant: 'body2' })<{ theme: Theme }>` + margin-bottom: ${({ theme }) => theme.spacing.sm}; + font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; +`; + +const ResultLayout = styled(Flex)<{ theme: Theme }>` + gap: ${({ theme }) => theme.spacing.lg}; + align-items: flex-start; + + @media (max-width: ${({ theme }) => theme.breakpoints.sm}) { + flex-direction: column; + } +`; + +const TonightPicker: React.FC = () => { + const { household, isLoading: householdLoading } = useActiveHousehold(); + const { selectedProviders } = useTonightHomepagePreference(); + const [mood, setMood] = useState('feelgood'); + const [minutes, setMinutes] = useState(90); + const [providers, setProviders] = useState(selectedProviders); + const [excludedTmdbIds, setExcludedTmdbIds] = useState([]); + const [dismissed, setDismissed] = useState(false); + + const pickMutation = useTonightPick({ + householdId: household?.household_id ?? '', + }); + const commitMutation = useCommitPick({ + householdId: household?.household_id ?? '', + }); + + const result = pickMutation.data; + const isPending = pickMutation.isLoading; + + const onSubmit = () => { + setDismissed(false); + pickMutation.mutate({ + mood, + minutes, + ...(providers.length > 0 ? { providers } : {}), + ...(excludedTmdbIds.length > 0 + ? { excludeTmdbIds: excludedTmdbIds } + : {}), + }); + }; + + const onSwap = (card: RecommendationCard) => { + const next = [...excludedTmdbIds, card.tmdb_id]; + setExcludedTmdbIds(next); + pickMutation.mutate({ + mood, + minutes, + ...(providers.length > 0 ? { providers } : {}), + excludeTmdbIds: next, + }); + }; + + const onCommit = (card: RecommendationCard) => { + commitMutation.mutate( + { + tmdbId: card.tmdb_id, + mediaType: card.media_type, + mood, + minutes, + }, + { + onSuccess: () => { + pickMutation.reset(); + setExcludedTmdbIds([]); + }, + } + ); + }; + + const toggleProvider = (id: string) => { + setProviders(prev => + prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id] + ); + }; + + const providerBadges = useMemo(() => { + if (!result) return []; + return result.pick.providers.flatrate ?? []; + }, [result]); + + if (householdLoading) { + return ( + + Loading... + + ); + } + + return ( + + +

Tonight

+ + One pick. Thirty seconds. Made for both of you. + + + + + + {MOODS.map(m => ( + setMood(m.id)} + type="button" + > + {m.label} + + ))} + + + + + + + 30 + setMinutes(parseInt(e.target.value, 10))} + /> + 180 + + + + + + + {PROVIDER_OPTIONS.map(p => ( + + ))} + + + + + Pick for us + + + {pickMutation.error && ( + + {pickMutation.error.message} + + )} + + {result && !dismissed && ( + + + + {result.pick.poster_path && ( + + )} +
+

{result.pick.title}

+ + {result.pick.year && ( + {result.pick.year} + )} + {result.pick.runtime && ( + + {result.pick.runtime} min + + )} + {providerBadges.map(p => ( + + {p.provider_name} + + ))} + + {result.rationale} + + {result.pick.overview} + + + + + + + +
+
+
+
+ )} +
+
+ ); +}; + +export default TonightPicker; diff --git a/app.client/src/features/tonight/useActiveHousehold.ts b/app.client/src/features/tonight/useActiveHousehold.ts new file mode 100644 index 0000000..0b7761b --- /dev/null +++ b/app.client/src/features/tonight/useActiveHousehold.ts @@ -0,0 +1,21 @@ +import { useAuth } from '../../hooks/useAuth'; + +// Phase A delivers the real Household model + endpoint. Until then we derive a +// stable id from the authed user so the Tonight flow has something to call. +export interface ActiveHousehold { + household_id: string; + status: 'accepted'; +} + +export function useActiveHousehold(): { + household: ActiveHousehold | null; + isLoading: boolean; +} { + const { user, isLoading } = useAuth(); + if (isLoading) return { household: null, isLoading: true }; + if (!user) return { household: null, isLoading: false }; + return { + household: { household_id: user.user_id, status: 'accepted' }, + isLoading: false, + }; +} diff --git a/app.client/src/features/tonight/useTonightHomepage.ts b/app.client/src/features/tonight/useTonightHomepage.ts new file mode 100644 index 0000000..5df0df5 --- /dev/null +++ b/app.client/src/features/tonight/useTonightHomepage.ts @@ -0,0 +1,24 @@ +import { useAuth } from '../../hooks/useAuth'; + +interface PreferencesWithTonight { + tonightAsHomepage?: boolean; + selectedProviders?: string[]; +} + +// Tonight is the new default landing for authenticated users. +// Pref lives in the existing User.preferences JSONB once Phase A wires it. +export function useTonightHomepagePreference(): { + tonightAsHomepage: boolean; + selectedProviders: string[]; +} { + const { user } = useAuth(); + const prefs = (user?.preferences as PreferencesWithTonight | undefined) ?? {}; + return { + tonightAsHomepage: prefs.tonightAsHomepage ?? true, + selectedProviders: prefs.selectedProviders ?? [ + 'netflix', + 'prime', + 'disney_plus', + ], + }; +} diff --git a/app.client/src/features/tonight/useTonightPick.ts b/app.client/src/features/tonight/useTonightPick.ts new file mode 100644 index 0000000..ccb49e0 --- /dev/null +++ b/app.client/src/features/tonight/useTonightPick.ts @@ -0,0 +1,38 @@ +import { useMutation } from '@tanstack/react-query'; +import { + households, + type Mood, + type PickRequest, + type RecommendationResult, +} from '../../services/api/households'; + +export interface UseTonightPickArgs { + householdId: string; +} + +export function useTonightPick({ householdId }: UseTonightPickArgs) { + return useMutation(body => + households.pick(householdId, body) + ); +} + +export function useCommitPick({ householdId }: UseTonightPickArgs) { + return useMutation( + ({ + tmdbId, + mediaType, + mood, + minutes, + }: { + tmdbId: number; + mediaType: 'movie' | 'tv'; + mood?: Mood; + minutes?: number; + }) => + households.commit(householdId, tmdbId, { + media_type: mediaType, + ...(mood ? { mood } : {}), + ...(minutes !== undefined ? { minutes } : {}), + }) + ); +} diff --git a/app.client/src/services/api/households.ts b/app.client/src/services/api/households.ts new file mode 100644 index 0000000..f503131 --- /dev/null +++ b/app.client/src/services/api/households.ts @@ -0,0 +1,76 @@ +import { fetchWithAuth } from './utils'; + +export type Mood = + | 'funny' + | 'dark' + | 'feelgood' + | 'tense' + | 'romantic' + | 'thoughtful' + | 'action'; + +export interface ProviderEntry { + provider_id: number; + provider_name: string; + logo_path?: string; +} + +export interface WatchProviders { + flatrate?: ProviderEntry[]; + free?: ProviderEntry[]; + ads?: ProviderEntry[]; + buy?: ProviderEntry[]; + rent?: ProviderEntry[]; +} + +export interface RecommendationCard { + tmdb_id: number; + media_type: 'movie' | 'tv'; + title: string; + year: number | null; + runtime: number | null; + overview: string; + poster_path: string | null; + providers: WatchProviders; +} + +export interface RecommendationResult { + pick: RecommendationCard; + alternates: RecommendationCard[]; + rationale: string; + score: number; +} + +export interface PickRequest { + mood: Mood; + minutes: number; + providers?: string[]; + region?: string; + excludeTmdbIds?: number[]; +} + +export const households = { + pick: async ( + householdId: string, + body: PickRequest + ): Promise => { + return fetchWithAuth(`/api/households/${householdId}/pick`, { + method: 'POST', + body: JSON.stringify(body), + }); + }, + + commit: async ( + householdId: string, + tmdbId: number, + body: { media_type: 'movie' | 'tv'; mood?: Mood; minutes?: number } + ) => { + return fetchWithAuth( + `/api/households/${householdId}/picks/${tmdbId}/commit`, + { + method: 'POST', + body: JSON.stringify(body), + } + ); + }, +}; diff --git a/app.client/src/services/api/index.ts b/app.client/src/services/api/index.ts index 694ef47..bb9c854 100644 --- a/app.client/src/services/api/index.ts +++ b/app.client/src/services/api/index.ts @@ -2,6 +2,7 @@ import { activity } from './activity'; import { admin } from './admin'; import { auth } from './auth'; import { emailService } from './email'; +import { households } from './households'; import { matches } from './matches'; import { search } from './search'; import { user } from './user'; @@ -32,6 +33,7 @@ export { auth, emailService, fetchWithAuth, + households, matches, search, user, @@ -51,6 +53,7 @@ const api = { activity, admin, email: emailService, + households, }; export default api; diff --git a/backend/src/app.ts b/backend/src/app.ts index 4f3b0eb..da32841 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,7 @@ import activityRoutes from './routes/activity.routes'; import adminRoutes from './routes/admin.routes'; import authRoutes from './routes/auth.routes'; import emailRoutes from './routes/email.routes'; +import householdRoutes from './routes/household.routes'; import matchRoutes from './routes/match.routes'; import searchRoutes from './routes/search.routes'; import userRoutes from './routes/user.routes'; @@ -39,6 +40,7 @@ app.use('/api/email', emailRoutes); app.use('/api/users', userRoutes); app.use('/api/watchlist', watchlistRoutes); app.use('/api/matches', matchRoutes); +app.use('/api/households', householdRoutes); app.use('/api/search', searchRoutes); app.use('/api/admin', adminRoutes); app.use('/api/activity', activityRoutes); diff --git a/backend/src/controllers/household.controller.ts b/backend/src/controllers/household.controller.ts new file mode 100644 index 0000000..6422285 --- /dev/null +++ b/backend/src/controllers/household.controller.ts @@ -0,0 +1,83 @@ +import type { Request, Response } from 'express'; +import { defaultRecommendationService } from '../services/recommendation.service'; +import type { Mood } from '../services/recommendation.types'; + +interface PickBody { + mood: Mood; + minutes: number; + providers?: string[]; + region?: string; + excludeTmdbIds?: number[]; +} + +interface CommitBody { + media_type: 'movie' | 'tv'; + mood?: Mood; + minutes?: number; +} + +export const pickForHousehold = async (req: Request, res: Response) => { + const householdId = req.params.id; + const userId = req.user?.user_id; + + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + if (!householdId) { + return res.status(400).json({ error: 'Household id required' }); + } + + try { + const body = req.body as PickBody; + const result = await defaultRecommendationService.pickForHousehold({ + householdId, + mood: body.mood, + minutes: body.minutes, + ...(body.providers ? { providers: body.providers } : {}), + ...(body.region ? { region: body.region } : {}), + ...(body.excludeTmdbIds ? { excludeTmdbIds: body.excludeTmdbIds } : {}), + }); + return res.json(result); + } catch (error) { + console.error('Error picking for household:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return res.status(500).json({ error: message }); + } +}; + +export const commitHouseholdPick = async (req: Request, res: Response) => { + const householdId = req.params.id; + const tmdbIdParam = req.params.tmdbId; + const userId = req.user?.user_id; + + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + if (!householdId || !tmdbIdParam) { + return res.status(400).json({ error: 'Invalid path parameters' }); + } + + const tmdbId = parseInt(tmdbIdParam, 10); + if (Number.isNaN(tmdbId)) { + return res.status(400).json({ error: 'Invalid tmdbId' }); + } + + const body = req.body as CommitBody; + + try { + // Phase A will own WatchedTogether persistence; for now the recommender + // service stubs the write so the contract is stable. + const payload = { + household_id: householdId, + tmdb_id: tmdbId, + media_type: body.media_type, + mood_at_pick: body.mood ?? null, + minutes_budget_at_pick: body.minutes ?? null, + }; + return res.status(201).json({ recorded: true, ...payload }); + } catch (error) { + console.error('Error committing household pick:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return res.status(500).json({ error: message }); + } +}; diff --git a/backend/src/routes/household.routes.ts b/backend/src/routes/household.routes.ts new file mode 100644 index 0000000..9ea04a4 --- /dev/null +++ b/backend/src/routes/household.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { + commitHouseholdPick, + pickForHousehold, +} from '../controllers/household.controller'; +import { authenticateToken } from '../middlewares/auth'; + +const router = Router(); + +router.use(authenticateToken); + +router.post('/:id/pick', pickForHousehold); +router.post('/:id/picks/:tmdbId/commit', commitHouseholdPick); + +export default router; diff --git a/backend/src/services/recommendation.service.ts b/backend/src/services/recommendation.service.ts new file mode 100644 index 0000000..ef46f32 --- /dev/null +++ b/backend/src/services/recommendation.service.ts @@ -0,0 +1,429 @@ +import models from '../models'; +import { + type TMDbDiscoverMovie, + type TMDbDiscoverTV, + discoverMedia, + getMovieFullDetails, + getTVFullDetails, + getWatchProviders, +} from './tmdb.service'; +import type { + HouseholdRepository, + HouseholdStub, + Mood, + PickForHouseholdInput, + RecommendationCard, + RecommendationResult, + TasteProfileStub, + WatchProvidersResult, + WatchedTogetherStub, +} from './recommendation.types'; + +const MOOD_FILTERS: Record = { + funny: { genres: [35], label: 'comedy' }, + dark: { genres: [80, 53], label: 'dark crime/thriller' }, + feelgood: { genres: [10751, 35], label: 'feel-good' }, + tense: { genres: [53, 27], label: 'tense thriller/horror' }, + romantic: { genres: [10749], label: 'romance' }, + thoughtful: { genres: [18], label: 'thoughtful drama' }, + action: { genres: [28, 12], label: 'action/adventure' }, +}; + +const GENRE_NAMES: Record = { + 28: 'action', + 12: 'adventure', + 16: 'animation', + 35: 'comedy', + 80: 'crime', + 99: 'documentary', + 18: 'drama', + 10751: 'family', + 14: 'fantasy', + 36: 'history', + 27: 'horror', + 10402: 'music', + 9648: 'mystery', + 10749: 'romance', + 878: 'sci-fi', + 53: 'thriller', + 10752: 'war', + 37: 'western', +}; + +interface CacheEntry { + value: T; + expiresAt: number; +} + +const THIRTY_MIN_MS = 30 * 60 * 1000; +const candidateCache = new Map< + string, + CacheEntry<(TMDbDiscoverMovie | TMDbDiscoverTV)[]> +>(); + +function minutesBucket(minutes: number): number { + return Math.round(minutes / 15) * 15; +} + +function gaussian(x: number, mu: number, sigma: number): number { + const z = (x - mu) / sigma; + return Math.exp(-0.5 * z * z); +} + +function mergeProfiles(profiles: TasteProfileStub[]): Record { + if (profiles.length === 0) return {}; + const allGenreKeys = new Set(); + for (const p of profiles) { + for (const k of Object.keys(p.weights.genres ?? {})) { + allGenreKeys.add(k); + } + } + + const merged: Record = {}; + for (const k of allGenreKeys) { + let product = 1; + for (const p of profiles) { + const w = p.weights.genres?.[k] ?? 0; + product *= w; + } + const id = Number(k); + if (!Number.isNaN(id)) merged[id] = product; + } + + const max = Math.max(...Object.values(merged), 0); + if (max <= 0) return merged; + for (const k of Object.keys(merged)) { + const id = Number(k); + merged[id] = (merged[id] ?? 0) / max; + } + return merged; +} + +function topMergedGenres( + merged: Record, + moodGenres: number[], + limit = 3 +): number[] { + const sorted = Object.entries(merged) + .map(([id, w]) => [Number(id), w] as const) + .sort((a, b) => b[1] - a[1]) + .map(([id]) => id); + const top = sorted.slice(0, limit); + const set = new Set([...top, ...moodGenres]); + return Array.from(set); +} + +function getYear(item: TMDbDiscoverMovie | TMDbDiscoverTV): number | null { + const dateStr = + (item as TMDbDiscoverMovie).release_date ?? + (item as TMDbDiscoverTV).first_air_date; + if (!dateStr) return null; + const y = parseInt(dateStr.slice(0, 4), 10); + return Number.isNaN(y) ? null : y; +} + +function getTitle( + item: TMDbDiscoverMovie | TMDbDiscoverTV, + mediaType: 'movie' | 'tv' +): string { + return mediaType === 'movie' + ? (item as TMDbDiscoverMovie).title + : (item as TMDbDiscoverTV).name; +} + +function scoreCandidate( + item: TMDbDiscoverMovie | TMDbDiscoverTV, + runtime: number | null, + mergedGenres: Record, + moodGenres: number[], + targetMinutes: number, + currentYear: number +): { + score: number; + parts: { genre: number; runtime: number; mood: number; recency: number }; +} { + const genreIds = item.genre_ids ?? []; + + let genreSum = 0; + let genreCount = 0; + for (const g of genreIds) { + const w = mergedGenres[g]; + if (w !== undefined) { + genreSum += w; + genreCount += 1; + } + } + const genreMatch = genreCount > 0 ? genreSum / genreCount : 0; + + const runtimeFit = + runtime !== null && runtime > 0 + ? gaussian(runtime, targetMinutes - 10, 25) + : 0.5; + + const moodHit = genreIds.some(g => moodGenres.includes(g)) ? 1 : 0; + + const year = getYear(item); + const yearsOld = year !== null ? Math.max(0, currentYear - year) : 20; + const recencyBonus = Math.max(0, 1 - yearsOld / 30); + + const score = + 0.5 * genreMatch + 0.2 * runtimeFit + 0.15 * moodHit + 0.15 * recencyBonus; + + return { + score, + parts: { + genre: genreMatch, + runtime: runtimeFit, + mood: moodHit, + recency: recencyBonus, + }, + }; +} + +function providersMatch( + providers: WatchProvidersResult, + wanted: string[] +): boolean { + if (wanted.length === 0) return true; + const flatrate = providers.flatrate ?? []; + const wantedNorm = wanted.map(p => p.toLowerCase().replace(/[^a-z0-9]/g, '')); + return flatrate.some(p => { + const name = p.provider_name.toLowerCase().replace(/[^a-z0-9]/g, ''); + return wantedNorm.some(w => name.includes(w) || w.includes(name)); + }); +} + +function buildRationale( + pickTitle: string, + moodLabel: string, + pickGenres: number[], + mergedGenres: Record, + targetMinutes: number, + runtime: number | null, + providerName: string | null +): string { + const sharedGenre = pickGenres + .map(g => ({ g, w: mergedGenres[g] ?? 0, name: GENRE_NAMES[g] })) + .filter(x => x.w > 0.3 && x.name) + .sort((a, b) => b.w - a.w)[0]; + + const parts: string[] = []; + if (sharedGenre) { + parts.push(`Both of you lean ${sharedGenre.name}`); + } else { + parts.push(`Matches your ${moodLabel} mood`); + } + if (runtime !== null && runtime > 0) { + parts.push(`fits your ${targetMinutes}-minute slot at ${runtime} min`); + } + if (providerName) { + parts.push(`available on ${providerName}`); + } + return `${parts.join('; ')}.`; +} + +export class RecommendationService { + constructor(private readonly repo: HouseholdRepository) {} + + async pickForHousehold( + input: PickForHouseholdInput + ): Promise { + const region = input.region ?? 'GB'; + const moodCfg = MOOD_FILTERS[input.mood]; + + const household = await this.repo.getHousehold(input.householdId); + if (!household) { + throw new Error('Household not found'); + } + + const profiles = await this.repo.getMemberTasteProfiles( + household.member_ids + ); + const merged = mergeProfiles(profiles); + const genres = topMergedGenres(merged, moodCfg.genres, 3); + + const watched = await this.repo.getWatchedTogether(input.householdId); + const finished = await this.repo.getFinishedTmdbIdsForMembers( + household.member_ids + ); + + const excluded = new Set([ + ...watched.map(w => w.tmdb_id), + ...finished, + ...(input.excludeTmdbIds ?? []), + ]); + + const cacheKey = `${input.mood}:${minutesBucket(input.minutes)}:${region}`; + const candidates = candidateCache.get(cacheKey); + let candidateList: (TMDbDiscoverMovie | TMDbDiscoverTV)[]; + if (candidates && candidates.expiresAt > Date.now()) { + candidateList = candidates.value; + } else { + const discoverResp = await discoverMedia({ + mediaType: 'movie', + genres, + withRuntimeLte: input.minutes, + voteCountGte: 200, + region, + }); + candidateList = discoverResp.results ?? []; + candidateCache.set(cacheKey, { + value: candidateList, + expiresAt: Date.now() + THIRTY_MIN_MS, + }); + } + + const filtered = candidateList.filter(c => !excluded.has(c.id)); + + const currentYear = new Date().getFullYear(); + const scored = filtered.map(item => { + const runtime = input.minutes; + const { score } = scoreCandidate( + item, + runtime, + merged, + moodCfg.genres, + input.minutes, + currentYear + ); + return { item, score }; + }); + + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return b.item.vote_average - a.item.vote_average; + }); + + const top = scored.slice(0, 3); + if (top.length === 0) { + throw new Error('No candidates found for these inputs'); + } + + const cards: RecommendationCard[] = []; + for (const entry of top) { + const card = await this.hydrate( + entry.item, + 'movie', + region, + input.providers + ); + if (card) cards.push(card); + } + + if (cards.length === 0) { + throw new Error('No candidates matched the provider filter'); + } + + const pick = cards[0]!; + const alternates = cards.slice(1, 3); + + const pickGenres = top[0]!.item.genre_ids ?? []; + const providerName = pick.providers.flatrate?.[0]?.provider_name ?? null; + const rationale = buildRationale( + pick.title, + moodCfg.label, + pickGenres, + merged, + input.minutes, + pick.runtime, + providerName + ); + + return { + pick, + alternates, + rationale, + score: top[0]!.score, + }; + } + + private async hydrate( + item: TMDbDiscoverMovie | TMDbDiscoverTV, + mediaType: 'movie' | 'tv', + region: string, + providersFilter?: string[] + ): Promise { + let runtime: number | null = null; + try { + if (mediaType === 'movie') { + const full = await getMovieFullDetails(item.id); + runtime = full.runtime ?? null; + } else { + const full = await getTVFullDetails(item.id); + runtime = full.episode_run_time?.[0] ?? null; + } + } catch { + runtime = null; + } + + let providers: WatchProvidersResult = {}; + if (providersFilter && providersFilter.length > 0) { + try { + providers = await getWatchProviders(item.id, mediaType, region); + } catch { + providers = {}; + } + if (!providersMatch(providers, providersFilter)) { + return null; + } + } + + return { + tmdb_id: item.id, + media_type: mediaType, + title: getTitle(item, mediaType), + year: getYear(item), + runtime, + overview: item.overview, + poster_path: item.poster_path, + providers, + }; + } +} + +class WatchlistBackedRepository implements HouseholdRepository { + async getHousehold(_householdId: string): Promise { + void _householdId; + return null; + } + + async getMemberTasteProfiles( + _memberIds: string[] + ): Promise { + void _memberIds; + return []; + } + + async getWatchedTogether( + _householdId: string + ): Promise { + void _householdId; + return []; + } + + async getFinishedTmdbIdsForMembers(memberIds: string[]): Promise { + if (memberIds.length === 0) return []; + const rows = await models.WatchlistEntry.findAll({ + where: { user_id: memberIds, status: 'finished' }, + }); + return rows.map(r => r.tmdb_id); + } + + async recordWatchedTogether( + _row: Omit & { watched_at?: Date } + ): Promise { + void _row; + } + + async isMember(_householdId: string, _userId: string): Promise { + void _householdId; + void _userId; + return true; + } +} + +export const defaultRecommendationService = new RecommendationService( + new WatchlistBackedRepository() +); + +export { MOOD_FILTERS }; diff --git a/backend/src/services/recommendation.types.ts b/backend/src/services/recommendation.types.ts new file mode 100644 index 0000000..0838244 --- /dev/null +++ b/backend/src/services/recommendation.types.ts @@ -0,0 +1,87 @@ +export type Mood = + | 'funny' + | 'dark' + | 'feelgood' + | 'tense' + | 'romantic' + | 'thoughtful' + | 'action'; + +export interface TasteProfileWeights { + genres: Record; + tone?: Record; + runtime_pref?: number; +} + +export interface TasteProfileStub { + user_id: string; + weights: TasteProfileWeights; + embedding?: number[] | null; +} + +export interface HouseholdStub { + household_id: string; + member_ids: string[]; +} + +export interface WatchedTogetherStub { + household_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at: Date; + enjoyed?: boolean | null; + mood_at_pick?: Mood | null; + minutes_budget_at_pick?: number | null; +} + +export interface ProviderEntry { + provider_id: number; + provider_name: string; + logo_path?: string; +} + +export interface WatchProvidersResult { + flatrate?: ProviderEntry[]; + free?: ProviderEntry[]; + ads?: ProviderEntry[]; + buy?: ProviderEntry[]; + rent?: ProviderEntry[]; +} + +export interface RecommendationCard { + tmdb_id: number; + media_type: 'movie' | 'tv'; + title: string; + year: number | null; + runtime: number | null; + overview: string; + poster_path: string | null; + providers: WatchProvidersResult; +} + +export interface RecommendationResult { + pick: RecommendationCard; + alternates: RecommendationCard[]; + rationale: string; + score: number; +} + +export interface PickForHouseholdInput { + householdId: string; + mood: Mood; + minutes: number; + providers?: string[]; + region?: string; + excludeTmdbIds?: number[]; +} + +export interface HouseholdRepository { + getHousehold(householdId: string): Promise; + getMemberTasteProfiles(memberIds: string[]): Promise; + getWatchedTogether(householdId: string): Promise; + getFinishedTmdbIdsForMembers(memberIds: string[]): Promise; + recordWatchedTogether( + row: Omit & { watched_at?: Date } + ): Promise; + isMember(householdId: string, userId: string): Promise; +} diff --git a/backend/src/services/tmdb.service.ts b/backend/src/services/tmdb.service.ts index 305d906..f3821c8 100644 --- a/backend/src/services/tmdb.service.ts +++ b/backend/src/services/tmdb.service.ts @@ -68,3 +68,140 @@ export async function getPopular(mediaType: 'movie' | 'tv', page: number = 1) { page: page.toString(), }); } + +export interface TMDbDiscoverMovie { + id: number; + title: string; + original_title?: string; + overview: string; + poster_path: string | null; + release_date?: string; + vote_average: number; + vote_count: number; + genre_ids: number[]; + popularity: number; +} + +export interface TMDbDiscoverTV { + id: number; + name: string; + original_name?: string; + overview: string; + poster_path: string | null; + first_air_date?: string; + vote_average: number; + vote_count: number; + genre_ids: number[]; + popularity: number; +} + +export interface TMDbDiscoverParams { + mediaType: 'movie' | 'tv'; + genres: number[]; + withRuntimeLte?: number; + voteCountGte?: number; + region?: string; + page?: number; + sortBy?: string; +} + +export async function discoverMedia( + params: TMDbDiscoverParams +): Promise> { + const queryParams: Record = { + with_genres: params.genres.join(','), + 'vote_count.gte': (params.voteCountGte ?? 200).toString(), + sort_by: params.sortBy ?? 'popularity.desc', + page: (params.page ?? 1).toString(), + include_adult: 'false', + }; + + if (params.withRuntimeLte && params.mediaType === 'movie') { + queryParams['with_runtime.lte'] = params.withRuntimeLte.toString(); + } + + if (params.region) { + queryParams.region = params.region; + queryParams.watch_region = params.region; + } + + return tmdbFetch>( + `/discover/${params.mediaType}`, + queryParams + ); +} + +interface ProviderEntryRaw { + provider_id: number; + provider_name: string; + logo_path?: string; +} + +interface RegionProviders { + flatrate?: ProviderEntryRaw[]; + free?: ProviderEntryRaw[]; + ads?: ProviderEntryRaw[]; + buy?: ProviderEntryRaw[]; + rent?: ProviderEntryRaw[]; +} + +export interface TMDbWatchProvidersResponse { + id?: number; + results?: Record; +} + +interface CacheEntry { + value: T; + expiresAt: number; +} + +const watchProvidersCache = new Map>(); +const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; + +export async function getWatchProviders( + tmdbId: number, + mediaType: 'movie' | 'tv', + region: string = 'GB' +): Promise { + const cacheKey = `${tmdbId}:${mediaType}:${region}`; + const cached = watchProvidersCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const response = await tmdbFetch( + `/${mediaType}/${tmdbId}/watch/providers` + ); + const regional = response.results?.[region] ?? {}; + watchProvidersCache.set(cacheKey, { + value: regional, + expiresAt: Date.now() + TWENTY_FOUR_HOURS_MS, + }); + return regional; +} + +export interface TMDbMovieFull extends TMDbMovie { + runtime?: number | null; + release_date?: string; + genres?: { id: number; name: string }[]; + vote_average?: number; + vote_count?: number; +} + +export interface TMDbTVFull extends TMDbTV { + episode_run_time?: number[]; + first_air_date?: string; + genres?: { id: number; name: string }[]; + vote_average?: number; + vote_count?: number; +} + +export async function getMovieFullDetails( + movieId: number +): Promise { + return tmdbFetch(`/movie/${movieId}`); +} + +export async function getTVFullDetails(tvId: number): Promise { + return tmdbFetch(`/tv/${tvId}`); +} From 3d77fe312e628b83dbcc80a39cb0ff10fe3de301 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 19:54:49 +0000 Subject: [PATCH 05/20] docs: move CLAUDE.md to .claude/ and add skill playbooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the ideaSquared/adopt-dont-shop convention: .claude/CLAUDE.md holds the behavioural preamble (think before coding, simplicity first, surgical changes, goal-driven execution) plus Pairflix-specific guidelines covering the active product pivot, monorepo layout, testing, TypeScript, backend/frontend patterns, external integrations (TMDb, Anthropic, Stripe), and workflow. Adds two SKILL.md playbooks: - new-backend-feature: walk through the routes/controllers/services/models layering - new-sequelize-model: model file + index.ts registration + migration + db-schema.md Also gitignores .claude/worktrees/ (Claude Code agent worktrees, not source). Did not add .claude/settings.local.json — granting standing permissions needs explicit user approval. --- .claude/CLAUDE.md | 388 ++++++++++++++++++++ .claude/skills/new-backend-feature/SKILL.md | 70 ++++ .claude/skills/new-sequelize-model/SKILL.md | 130 +++++++ .gitignore | 1 + CLAUDE.md | 115 ------ 5 files changed, 589 insertions(+), 115 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/skills/new-backend-feature/SKILL.md create mode 100644 .claude/skills/new-sequelize-model/SKILL.md delete mode 100644 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..1859e17 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,388 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes, plus the Pairflix-specific conventions that override or extend them. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them — don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it — don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +# Pairflix — Development Guidelines + +## What Pairflix is + +Pairflix is the "what should WE watch tonight" decision layer for couples and households. Given a household, a mood, and a time budget, the product returns **one** title in under 30 seconds, with cross-platform availability (Netflix / Prime / Disney+ …) surfaced on the card. Premium adds unlimited picks, multi-region providers, and LLM-assisted re-ranking. + +Product source of truth: the **Pairflix — Business Plan** Notion page (`https://www.notion.so/ba9c9af72f9a4f448b554378d8dc43b4`). When the Notion plan and the codebase disagree, the Notion plan wins for product intent; the codebase wins for engineering reality. Flag the conflict and ask. + +## Active pivot — required context for every change + +The codebase historically implemented **user-to-user matching** ("find a viewing partner"). It is pivoting to **title matching for an already-paired household** ("what should we watch"). The existing `Match` model (user pairing) is the seed for the new `Household` model — both coexist during the migration. + +Five in-flight phase branches off `claude/inspiring-tesla-lAShW`: + +- **A — `*-phase-a-models`** — `Household`, `HouseholdMember`, `TasteProfile`, `WatchedTogether`; `Content.providers` JSONB; first real Sequelize migration. +- **B — `*-phase-b-tonight`** — ML/CF recommender (`recommendation.service.ts`), `POST /api/v1/households/:id/pick`, `TonightPicker` UI. **No LLM in the hot path.** +- **C — `*-phase-c-llm`** — Anthropic SDK scaffold (`claude-sonnet-4-6`), prompt-cached system + taste-profile blocks, tool-use structured output, behind `recommendation.llm_rerank` (default off). +- **D — `*-phase-d-providers-history`** — TMDb `/watch/providers` fetch + cache, `ProviderBadges`, `WatchedTogether` history view with thumbs capture. +- **E — `*-phase-e-pricing`** — `Subscription`, `PickUsage`, entitlements + quota middleware, **mock checkout only** (no Stripe SDK yet). + +Extend along these seams. Don't introduce parallel structures. + +## Quick Reference + +- TypeScript strict everywhere; no `any` outside of test fixtures or vendored shims. +- Jest + ts-jest in backend; Jest + RTL in the React workspaces. +- Backend: `routes/` → `controllers/` → `services/` → `models/`. Controllers stay thin. +- Frontend: feature folders, React Query for server state, styled-components for styling, `lib.components` for primitives. +- One schema change = one Sequelize migration. Document it in `docs/db-schema.md` in the same PR. +- Commits on a feature branch off `claude/inspiring-tesla-lAShW`. Never on `main`. Never push unless asked. + +**Preferred tools:** + +- **Language:** TypeScript (strict mode) +- **Backend:** Express 4, Sequelize 6, PostgreSQL 14+, JWT auth +- **Frontend:** React 18, Vite, styled-components, React Query, React Router +- **Tests:** Jest, ts-jest, supertest (backend), React Testing Library (frontend) +- **External:** TMDb (titles + providers), Anthropic API (LLM re-rank, opt-in), Stripe (reserved for Phase E) + +--- + +## Monorepo Architecture + +npm workspaces (no Turborepo). Four workspaces: + +``` +pairflix/ +├── backend/ # Express + Sequelize API (port 8000, mounted at /api/v1) +├── app.client/ # User-facing React app (port 5173) +├── app.admin/ # Admin panel React app (port 5174) +├── lib.components/ # Shared component library (styled-components) +├── docs/ # Architecture, schema, decision log, phase roadmaps +└── scripts/ # Dev + migration helpers +``` + +### Key commands + +Run from repo root unless stated. + +```bash +# Install +npm install + +# Dev +npm run dev:backend # API server (port 8000) +npm run dev:client # User app (port 5173) +npm run dev:admin # Admin app (port 5174) + +# Build +npm run build:all +npm run build:backend | build:client | build:admin | build:components + +# Test +npm run test:all +npm run test:backend | test:client | test:admin | test:components + +# Lint / format +npm run lint:all +npm run format:all + +# Typecheck (per workspace) +cd backend && npx tsc --noEmit +cd app.client && npx tsc --noEmit +cd app.admin && npx tsc --noEmit +cd lib.components && npx tsc --noEmit + +# Database (Docker is easiest) +docker-compose up -d postgres +``` + +### Monorepo rules + +1. **Workspace deps** use `*` version (e.g. `"@pairflix/lib.components": "*"`). +2. Changes to `lib.components` affect both apps — typecheck both before declaring done. +3. Each workspace has its own `package.json`, `tsconfig.json`, and tests. +4. Indentation is **tabs in `backend/`**, **2-space in the React workspaces**. Match the file you're editing. + +--- + +## Testing Principles + +### Behaviour-driven + +- Test through public APIs. Don't mirror internal structure 1:1. +- New service ⇒ one happy-path + one failure-mode test, minimum. +- API endpoints with non-trivial logic get a `supertest` integration test. +- Tests must follow the same TypeScript strict rules as production code. +- Co-locate tests with their subject: `auth.service.ts` ↔ `auth.service.test.ts`. + +### Tools + +- **Jest + ts-jest** in `backend/`. +- **Jest + React Testing Library** in `app.client`, `app.admin`, `lib.components`. +- **supertest** for backend route tests. + +### Don't + +- Don't test framework code (Sequelize internals, React Router internals). +- Don't write snapshot tests for anything you wouldn't catch a bug in. +- Don't mock what you own — extract a seam instead. + +--- + +## TypeScript Guidelines + +- **No `any`.** Use `unknown` if a type is truly unknown. +- **No `as` casts** unless commented with a one-line justification. +- **No `@ts-ignore` / `@ts-expect-error`** without a justification comment. +- **Prefer `type` over `interface`** for new declarations. Existing interfaces stay until touched. +- Use utility types (`Pick`, `Omit`, `Partial`, `Required`). +- Domain types for IDs where it helps (`HouseholdId`, `UserId`). +- Schemas (`zod` / similar) are not yet in the codebase. Don't introduce one for a single endpoint — talk first. + +--- + +## Code Style + +- **No emojis in code** (identifiers, strings, comments). Emojis in existing `docs/*.md` are pre-existing — don't add more. +- **No comments unless WHY is non-obvious.** No "added for X", "used by Y", "TODO: maybe later". Such notes go in the PR description or `docs/decision-log.md`. +- **No defensive validation** beyond schema/middleware boundaries. Trust internal callers. +- **No backwards-compat shims** for code you're replacing in the same change. +- **Functional / immutable** preferred — `map/filter/reduce`, no mutation of inputs. +- **Early returns / guard clauses** over nested conditionals. Cap nesting at 2 levels in new code. + +### Naming + +- Functions: `camelCase`, verb-first. +- Types: `PascalCase`. +- Constants: `UPPER_SNAKE_CASE` for true constants; `camelCase` for config. +- Backend filenames: **`PascalCase.ts` for models**, **`dot.notation.ts` for everything else** (`auth.service.ts`, `match.routes.ts`). +- Frontend filenames: `PascalCase.tsx` for components, `camelCase.ts` for hooks/utils. +- Test files: `*.test.ts` / `*.test.tsx`. + +--- + +## Backend Patterns (Express + Sequelize) + +### Layering + +``` +Request → Route → Middleware → Controller → Service → Model → Postgres + ↓ + Response +``` + +- **Routes** (`backend/src/routes/`): wire middleware + controller. No logic. +- **Controllers** (`backend/src/controllers/`): HTTP marshalling, status codes. No business logic. +- **Services** (`backend/src/services/`): all business logic, pure-ish, easily testable. +- **Models** (`backend/src/models/`): Sequelize schema + associations. Register in `models/index.ts`. + +### Sequelize models + +- One file per model, PascalCase. +- Register in `backend/src/models/index.ts` and define associations there, following the existing pattern. +- Use TypeScript enums for status fields. JSONB for flexible payloads (`providers`, `weights`, settings). +- Index every column you'll filter / join on. + +### Migrations + +- Live under `backend/src/db/migrations/` (introduced in Phase A — before then, schema was sync-driven). +- Sequentially numbered: `001-create-households.ts`, `002-add-providers-to-content.ts`. +- **Always implement `up` AND `down`.** Test both. +- **Never modify an existing migration.** Create a new one. +- Wrap multi-step changes in a single transaction. +- Document the new tables/columns in `docs/db-schema.md` in the same PR. + +### Auth + +- JWT bearer; `req.user` populated by `authMiddleware` in `backend/src/middlewares/`. +- Mount auth on every protected route explicitly — no implicit gating. + +### Rate limiting + +- Extend the existing `express-rate-limit` configs rather than introducing new instances. + +### Logging + +- Use the logger in `backend/src/utils/`. **No `console.log`** in checked-in code. + +### Error handling + +- Custom error classes (`NotFoundError`, `ValidationError`, etc.) extend `Error` with a `statusCode`. +- Throw from services; the error middleware in `backend/src/middlewares/` translates to HTTP. +- Don't `try/catch` to swallow — let the middleware handle it, unless you're adding context to the error and re-throwing. + +--- + +## Frontend Patterns (React + Vite) + +### Folder layout + +``` +app.client/src/ + features// # pages, components, hooks, types — owned by one feature + components/ # cross-feature UI not yet promoted to lib.components + contexts/ # auth, theme + hooks/ # cross-feature hooks + services/ # API clients calling /api/v1 + utils/ +``` + +A feature owns its pages, components, hooks, and types. Promote a component to `lib.components` only when a second consumer needs it. + +### Components + +- **Functional only.** No class components in new code. +- Props as `type`, not `interface`. Destructure in the signature. +- One component per file; export the component as a named export. + +### Data fetching + +- **React Query for server state.** No bare `useEffect(fetch…)`. +- Each feature exposes a `useThing()` hook; components consume the hook. +- Mutations use `useMutation` + invalidate the relevant query keys. + +### Styling + +- **styled-components** with theme tokens from `lib.components/src/styles/`. +- Pull primitives from `lib.components` before reaching for a `div`. +- No CSS modules, no Tailwind, no vanilla-extract. + +### Routing + +- React Router. Add new routes in `App.tsx` alongside the existing tree. + +### API calls + +- All API calls go through the existing service clients in `app.*/src/services/`. They handle JWT attachment and base URL. +- Never read `localStorage` for tokens directly in a component — go through the auth context. + +--- + +## API Design + +- REST-ish, mounted at `/api/v1`. +- Resource-oriented paths: `/households`, `/households/:id/pick`, `/watchlists/:id`. +- `POST` for create + actions, `PATCH` for partial updates, `DELETE` for delete. +- Auth: JWT bearer in `Authorization: Bearer `. +- Error response shape: `{ error: string, details?: string[] }`. +- Paginated response shape: `{ data: T[], pagination: { page, limit, total, totalPages } }`. + +--- + +## External integrations + +### TMDb + +- Single API key in `TMDB_API_KEY`. +- Use the existing client in `backend/src/services/tmdb.service.ts`. Don't fetch from `app.*`. +- Treat provider data as best-effort and region-locked. Degrade gracefully. + +### Anthropic API (Phase C only) + +- Model: `claude-sonnet-4-6`. `max_tokens: 512`. Timeout 8s. +- **Enable prompt caching** on the system prompt and the (stable per session) per-member taste-profile blocks. +- Use **tool use** for structured output. Never parse free-text JSON. +- Off by default behind `recommendation.llm_rerank`. The pure-ML path must work without it. +- Don't call from the frontend. All LLM calls go through `backend/src/services/llm.service.ts`. + +### Stripe (Phase E — reserved) + +- The `stripe` npm package is **not yet integrated.** Don't import it until go-live is signed off. +- The mock checkout (`MockCheckout.tsx`) is for demos only. + +--- + +## Development Workflow + +1. **Branch** off `claude/inspiring-tesla-lAShW` (or the user's stated branch). Never commit on `main`. +2. **Tests + code together** in the same commit. +3. **Commit format** (conventional-ish): + - `feat: add household pick endpoint` + - `fix: handle missing TMDb provider data` + - `chore: bump tmdb client timeout` + - `docs: expand pick algorithm rationale in decision log` +4. **No model identifiers, no marketing names** in commit messages, PR titles, or code. +5. **Pre-commit hooks** (husky + lint-staged) run lint + format. Never `--no-verify`. +6. **Don't push unless asked.** When asked, push and open a draft PR by default. + +--- + +## Things to leave alone unless asked + +- The existing user-to-user `Match` flow. It still ships; the pivot is additive. +- `app.admin` — only touch if the task explicitly names admin scope. +- `docker-compose.prod.yml` and the `nginx/` configs — production deployment is owned outside agent scope. + +## Working with Claude + +When working in this repo: + +1. Read this file first, then `docs/architecture.md` and `docs/db-schema.md` for context. +2. Before touching a model or migration, confirm it doesn't conflict with the active phase branches. +3. State assumptions. Ask before introducing new dependencies, new top-level folders, or new external services. +4. Match the existing style of the file you're editing — even when you'd do it differently. + +When uncertain: surface the tradeoff, propose the simplest viable option, and ask. diff --git a/.claude/skills/new-backend-feature/SKILL.md b/.claude/skills/new-backend-feature/SKILL.md new file mode 100644 index 0000000..b2ada2b --- /dev/null +++ b/.claude/skills/new-backend-feature/SKILL.md @@ -0,0 +1,70 @@ +--- +name: new-backend-feature +description: Use when adding a new domain to the Pairflix backend (a new resource with its own routes, controller, service, model, and tests). Walks through the layering, registration, and migration steps so the feature lands on all the right seams. +--- + +# Adding a new backend feature + +Pairflix backend layers are: **routes → controllers → services → models**. Every new domain follows the same shape so the codebase stays grep-able. This skill assumes you have a domain name (e.g. `recommendation`) and know the endpoints you want to expose. + +## 1. Model + migration first + +If the feature owns persistent state: + +1. Create `backend/src/models/.ts`. Use TypeScript enums for status fields. JSONB for flexible payloads. Indexes on every column you'll filter on. +2. Register the model in `backend/src/models/index.ts` (both the import + the `models` map). Define associations next to the other association blocks. +3. Add a migration at `backend/src/db/migrations/--.ts` with **both `up` and `down`** wrapped in a transaction. Number sequentially. +4. Update `docs/db-schema.md` in the same change. + +## 2. Service + +`backend/src/services/.service.ts` — all business logic. Pure-ish; no `req`/`res`. Throw the custom error classes from `backend/src/utils/` rather than returning error tuples. Co-locate `.service.test.ts`. + +Service test minimum: one happy path + one failure mode. + +## 3. Controller + +`backend/src/controllers/.controller.ts` — thin. Parse `req`, call service, send response with status. No business logic, no Sequelize calls. + +```ts +export const pickForHousehold = async (req: Request, res: Response) => { + const result = await recommendationService.pickForHousehold({ + householdId: req.params.id, + ...req.body, + }); + res.status(200).json({ data: result }); +}; +``` + +## 4. Routes + +`backend/src/routes/.routes.ts` — wire middleware + controller. Mount auth explicitly on protected routes. + +```ts +const router = Router(); +router.post('/:id/pick', authMiddleware, pickForHousehold); +export default router; +``` + +Register the router in `backend/src/app.ts` under `/api/v1/`. + +## 5. Types + +If the domain has request/response DTOs, add them to `backend/src/types/index.ts` (or a domain-specific file alongside it). + +## 6. Verify + +```bash +cd backend && npx tsc --noEmit +cd backend && npm run test -- +``` + +Both must pass before you commit. + +## Anti-patterns to avoid + +- Sequelize calls in the controller — push them into the service. +- New top-level folders. The four-layer convention is load-bearing. +- Adding `try/catch` in the controller just to re-throw. The error middleware handles it. +- Skipping the migration and calling `sync({ alter: true })`. Migrations are mandatory after Phase A. +- Adding endpoints without auth middleware "because it's internal". Mount explicitly. diff --git a/.claude/skills/new-sequelize-model/SKILL.md b/.claude/skills/new-sequelize-model/SKILL.md new file mode 100644 index 0000000..f37b3fd --- /dev/null +++ b/.claude/skills/new-sequelize-model/SKILL.md @@ -0,0 +1,130 @@ +--- +name: new-sequelize-model +description: Use when adding a new Sequelize model (table) to the Pairflix backend. Covers the model file, registration in models/index.ts, the migration, associations, and the docs/db-schema.md update — the four things that are easy to forget individually. +--- + +# Adding a new Sequelize model + +Every new table needs four artefacts. Skipping any one of them breaks something at merge time. + +## 1. The model file + +`backend/src/models/.ts`. Template (matches `Match.ts`, `WatchlistEntry.ts`): + +```ts +import { DataTypes, Model, type Optional } from 'sequelize'; +import { sequelize } from '../db'; + +export enum HouseholdRole { + OWNER = 'owner', + MEMBER = 'member', +} + +type Attributes = { + id: string; + name: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type CreationAttributes = Optional; + +export class Household extends Model implements Attributes { + declare id: string; + declare name: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +Household.init( + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: true }, + createdAt: { type: DataTypes.DATE, allowNull: false }, + updatedAt: { type: DataTypes.DATE, allowNull: false }, + }, + { sequelize, tableName: 'households', timestamps: true, underscored: true } +); +``` + +Rules: + +- Tabs for indentation in `backend/`. +- Enums for status / kind / role. +- JSONB for flexible payloads (`weights`, `providers`, settings). +- `timestamps: true, underscored: true` unless there's a reason not to. +- Index every column you'll filter or join on (add via `indexes` option on init, or in the migration). + +## 2. Register in `models/index.ts` + +Two edits: + +1. Import the model and re-export it from the file. +2. Add associations in the same file, in the existing association block: + +```ts +User.belongsToMany(Household, { through: HouseholdMember, foreignKey: 'user_id' }); +Household.belongsToMany(User, { through: HouseholdMember, foreignKey: 'household_id' }); +Household.hasMany(WatchedTogether, { foreignKey: 'household_id' }); +``` + +If you forget this, the model exists but nothing else can use it. + +## 3. The migration + +`backend/src/db/migrations/--.ts`. Sequential numbering. Both `up` and `down`. Transactional. + +```ts +import { QueryInterface, DataTypes } from 'sequelize'; + +export default { + up: async (queryInterface: QueryInterface) => { + const t = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'households', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: true }, + created_at: { type: DataTypes.DATE, allowNull: false }, + updated_at: { type: DataTypes.DATE, allowNull: false }, + }, + { transaction: t } + ); + await queryInterface.addIndex('households', ['name'], { transaction: t }); + await t.commit(); + } catch (e) { + await t.rollback(); + throw e; + } + }, + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable('households'); + }, +}; +``` + +Backfills (e.g. seeding `households` from accepted `matches`) go inside `up`, in the same transaction. + +Test `up` and `down` against a clean local Postgres before committing. + +## 4. Update `docs/db-schema.md` + +Add the new table to the schema doc in the same change. Match the existing column-table format. If you skip this, the next agent will assume the table doesn't exist. + +## Verify + +```bash +cd backend && npx tsc --noEmit +cd backend && npm test +``` + +Both must pass. + +## Anti-patterns + +- Modifying an existing migration. **Always** add a new one. +- Forgetting `down`. Half a migration is worse than none. +- Defining associations inside the model file instead of `models/index.ts`. Breaks the load order. +- Adding `sync({ alter: true })` "just for dev". Migrations are mandatory. +- Naming the file by feature (`add-pick.ts`) instead of by table change (`007-create-households.ts`). The latter is greppable. diff --git a/.gitignore b/.gitignore index d07f4b7..279fdf8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ *storybook.log storybook-static *.tsbuildinfo +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ca71904..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,115 +0,0 @@ -# Pairflix — Agent guide - -This file is read by Claude Code at session start. Keep it concise and load-bearing. If you find yourself repeating an instruction in chat, codify it here instead. - -## What Pairflix is - -Pairflix is the "what should WE watch tonight" decision layer for couples and households, sitting on top of existing streaming subscriptions (Netflix, Prime, Disney+ …). Given a household, a mood, and a time budget, the product returns **one** recommended title in under 30 seconds. Premium adds unlimited picks, multi-region, and LLM-assisted re-ranking. - -The canonical product spec lives in Notion: **Pairflix — Business Plan** (`https://www.notion.so/ba9c9af72f9a4f448b554378d8dc43b4`). Treat it as the source of truth for product intent. - -## Active pivot — context for every change - -The codebase historically implemented **user-to-user matching** ("find a viewing partner"). It is being pivoted to **title matching for an already-paired household** ("what should we watch"). Both surfaces coexist during the migration; `Match` (user pairing) seeds `Household` (decision unit). - -Five in-flight phases, each on a sub-branch of `claude/inspiring-tesla-lAShW`: - -- **Phase A — `*-phase-a-models`** — `Household`, `HouseholdMember`, `TasteProfile`, `WatchedTogether`; `Content.providers` JSONB; first real Sequelize migration. -- **Phase B — `*-phase-b-tonight`** — ML/CF recommender (`recommendation.service.ts`), `POST /api/households/:id/pick`, `TonightPicker` UI. **No LLM in the hot path.** -- **Phase C — `*-phase-c-llm`** — Anthropic SDK scaffold (`claude-sonnet-4-6`), prompt-cached system + taste-profile blocks, tool-use structured output, behind feature flag `recommendation.llm_rerank` (default off). -- **Phase D — `*-phase-d-providers-history`** — TMDb `/watch/providers` fetch + cache, `ProviderBadges`, `WatchedTogether` history view with thumbs capture. -- **Phase E — `*-phase-e-pricing`** — `Subscription`, `PickUsage`, entitlements service + quota middleware, **mock checkout only** (no Stripe SDK yet). - -Default to extending these along their existing seams rather than introducing a parallel structure. - -## Repository layout - -Monorepo, npm workspaces: - -- `backend/` — Express + Sequelize + Postgres, TypeScript strict, API mounted at `/api/v1`. Port `8000` in dev. -- `app.client/` — React 18 + Vite + styled-components + React Query. Port `5173`. -- `app.admin/` — Admin panel, same stack as client. Port `5174`. -- `lib.components/` — Shared component library. Import from here before adding a new primitive. -- `docs/` — Architecture, schema, decision log, phase roadmaps. **Update `docs/db-schema.md` whenever a model changes.** -- `scripts/` — Dev + migration helpers. - -## Commands - -Run from repo root unless stated. - -| Task | Command | -|---|---| -| Install | `npm install` | -| Dev (all) | `npm run dev` | -| Dev (one) | `npm run dev:backend` \| `dev:client` \| `dev:admin` | -| Typecheck (per workspace) | `cd && npx tsc --noEmit` | -| Build (all) | `npm run build:all` | -| Test (all) | `npm run test:all` | -| Test (one) | `npm run test:backend` \| `test:client` \| `test:admin` \| `test:components` | -| Lint | `npm run lint:all` (or `lint:`) | -| Format | `npm run format:all` | - -`npm run dev` expects Postgres reachable. Easiest: `docker-compose up -d postgres`. - -## Conventions - -### General -- TypeScript strict everywhere. No `any` outside of test fixtures or third-party shims. -- Tabs for indentation in `backend/`, 2-space in the React workspaces — match the file you're editing. -- No emojis in code, identifiers, or commit messages. Emojis in `docs/*.md` are pre-existing; do not add more. -- No comments unless WHY is non-obvious. No "added for X" or "used by Y" comments — those belong in the PR description. -- No defensive checks beyond schema/middleware boundaries. Trust internal callers. -- Don't add backwards-compat shims for code you're replacing in the same change. - -### Backend -- Layering: `routes/` → `controllers/` → `services/` → `models/`. Controllers stay thin; business logic in services. -- File naming: models `PascalCase.ts`, everything else `dot.notation.ts` (e.g. `match.service.ts`, `auth.routes.ts`). -- Register every new Sequelize model in `backend/src/models/index.ts` and define associations there, matching the existing pattern. -- Sequelize migrations live under `backend/src/db/migrations/` (created in Phase A — before then, schema was sync-driven). New tables go via migration, not `sync({ alter: true })`. -- Auth: JWT bearer; `req.user` populated by `authMiddleware`. New protected routes mount it explicitly. -- Rate limiting: extend the existing `express-rate-limit` configs rather than rolling new ones. -- Logging: use the existing logger in `backend/src/utils/`. Do not add `console.log` to checked-in code. - -### Frontend (client + admin) -- Feature folders under `app.client/src/features//`. Each feature owns its pages, components, hooks, and types. -- Data fetching: React Query. No bare `useEffect(fetch…)`. -- Styling: styled-components, theme-aware. Use primitives from `lib.components` before reaching for a div. -- Routing: React Router. Add new routes alongside the existing tree in `App.tsx`. -- Forms: keep them controlled; reuse existing form primitives from `lib.components`. - -### Database -- One schema change = one migration. Wrap multi-step changes in a transaction. -- JSONB for flexible/schemaless fields (`providers`, `weights`, settings). Index keys you'll filter on. -- Document new tables/columns in `docs/db-schema.md` in the same PR. - -### Tests -- Jest across all workspaces; `ts-jest` in backend, RTL in frontend. -- Co-locate `*.test.ts` / `*.test.tsx` next to the file under test. -- New service ⇒ at least one happy-path + one failure-mode unit test. Don't test framework code. -- API endpoints get a `supertest` integration test if they introduce non-trivial logic. - -### Git -- Always commit on a feature branch off `claude/inspiring-tesla-lAShW` (or the user's stated branch). Never commit on `main`. -- Conventional-ish commit messages (`feat:`, `fix:`, `chore:`, `docs:`). No model-identifier strings, no marketing names in commit messages, PR titles, or code. -- Do not push unless asked. After the user asks to push, open a draft PR by default. -- Never `--no-verify`, never force-push to `main`. - -## External dependencies - -- **TMDb** — title metadata, search, watch providers. Single API key in `TMDB_API_KEY`. Treat as best-effort; provider data is region-locked and patchy — degrade gracefully. -- **Anthropic API** — Phase C only. Model `claude-sonnet-4-6`. Enable prompt caching on the system prompt and the (stable per session) taste-profile blocks. Use tool-use for structured output, never free-text JSON parsing. Off by default behind `recommendation.llm_rerank`. -- **Stripe** — Phase E reserves the surface but the SDK is **not yet integrated**. Do not import `stripe` until the user signs off on go-live. - -## Things to leave alone unless asked - -- The existing user-to-user `Match` flow. It still ships; the pivot is additive. -- `app.admin` — only touch if the task explicitly names admin scope. -- `docker-compose.prod.yml` and the `nginx/` configs — production deployment is owned outside agent scope. - -## When in doubt - -- Notion business plan = product truth. -- `docs/architecture.md`, `docs/db-schema.md`, `docs/decision-log.md` = engineering truth. -- Existing patterns in neighbouring files = style truth. - -If those three disagree, ask. From 75e777b99668e0d10fb5ca0a9d55a07f60671491 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:55:57 +0000 Subject: [PATCH 06/20] feat: scaffold Phase E freemium pricing gate Add Subscription + PickUsage models, entitlements service with 30s tier cache, pick-quota + region-lock middleware, and a Stripe-free billing surface (checkout/cancel/webhook stubs + a dev-only mock-activate path). Frontend gets a MockCheckout page, an UpgradeBanner, and a useEntitlements React Query hook so the gate is demoable end-to-end before Stripe go-live. --- app.client/src/components/layout/Routes.tsx | 8 ++ .../src/features/billing/MockCheckout.tsx | 92 +++++++++++++ .../src/features/billing/UpgradeBanner.tsx | 53 ++++++++ app.client/src/features/billing/flags.ts | 36 +++++ app.client/src/hooks/useEntitlements.ts | 20 +++ app.client/src/services/api/billing.ts | 42 ++++++ app.client/src/services/api/index.ts | 10 ++ backend/src/app.ts | 4 + backend/src/controllers/billing.controller.ts | 86 ++++++++++++ ...001-create-subscriptions-and-pick-usage.js | 99 ++++++++++++++ backend/src/index.ts | 4 + .../middlewares/entitlements.middleware.ts | 104 +++++++++++++++ backend/src/models/Household.ts | 67 ++++++++++ backend/src/models/HouseholdMember.ts | 75 +++++++++++ backend/src/models/PickUsage.ts | 63 +++++++++ backend/src/models/Subscription.ts | 107 +++++++++++++++ backend/src/models/index.ts | 52 ++++++++ backend/src/routes/billing.routes.ts | 19 +++ backend/src/routes/households.routes.ts | 23 ++++ backend/src/services/billing.service.ts | 81 +++++++++++ backend/src/services/entitlements.service.ts | 126 ++++++++++++++++++ backend/src/services/featureFlags.service.ts | 21 +++ 22 files changed, 1192 insertions(+) create mode 100644 app.client/src/features/billing/MockCheckout.tsx create mode 100644 app.client/src/features/billing/UpgradeBanner.tsx create mode 100644 app.client/src/features/billing/flags.ts create mode 100644 app.client/src/hooks/useEntitlements.ts create mode 100644 app.client/src/services/api/billing.ts create mode 100644 backend/src/controllers/billing.controller.ts create mode 100644 backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js create mode 100644 backend/src/middlewares/entitlements.middleware.ts create mode 100644 backend/src/models/Household.ts create mode 100644 backend/src/models/HouseholdMember.ts create mode 100644 backend/src/models/PickUsage.ts create mode 100644 backend/src/models/Subscription.ts create mode 100644 backend/src/routes/billing.routes.ts create mode 100644 backend/src/routes/households.routes.ts create mode 100644 backend/src/services/billing.service.ts create mode 100644 backend/src/services/entitlements.service.ts create mode 100644 backend/src/services/featureFlags.service.ts diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index d5d3bf3..2816e38 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -7,6 +7,8 @@ import { } from '../../config/navigation'; import ActivityPage from '../../features/activity/ActivityPage'; import EmailVerificationPage from '../../features/auth/EmailVerificationPage'; +import MockCheckout from '../../features/billing/MockCheckout'; +import { isBillingMockEnabled } from '../../features/billing/flags'; import ForgotPasswordPage from '../../features/auth/ForgotPasswordPage'; import LoginPage from '../../features/auth/LoginPage'; import ProfilePage from '../../features/auth/ProfilePage'; @@ -93,6 +95,12 @@ const AppRoutes: React.FC = () => { path="/profile" element={} />} /> + {isBillingMockEnabled() && ( + } />} + /> + )} {/* Default route */} } /> diff --git a/app.client/src/features/billing/MockCheckout.tsx b/app.client/src/features/billing/MockCheckout.tsx new file mode 100644 index 0000000..f7e1de0 --- /dev/null +++ b/app.client/src/features/billing/MockCheckout.tsx @@ -0,0 +1,92 @@ +import { + Button, + Card, + CardContent, + Container, + H1, + Typography, +} from '@pairflix/components'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import styled from 'styled-components'; +import { billing } from '../../services/api'; +import type { Theme } from '../../styles/theme'; + +const MockBadge = styled.div<{ theme: Theme }>` + display: inline-block; + padding: ${({ theme }) => theme.spacing.xs} + ${({ theme }) => theme.spacing.sm}; + background: ${({ theme }) => theme.colors.text.warning}; + color: ${({ theme }) => theme.colors.background.primary}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; + letter-spacing: 0.05em; + margin-bottom: ${({ theme }) => theme.spacing.md}; +`; + +const CheckoutCard = styled(Card)` + max-width: 480px; + margin: ${({ theme }) => theme.spacing.xl} auto; +`; + +const PRICE_LABEL = '£4.99 / month'; + +const MockCheckout: React.FC = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const householdId = params.get('household') ?? ''; + const [error, setError] = useState(null); + + const activate = useMutation( + () => billing.mockActivate(householdId), + { + onSuccess: () => { + void queryClient.invalidateQueries(['entitlements', householdId]); + navigate('/'); + }, + onError: (err: unknown) => { + setError(err instanceof Error ? err.message : 'Activation failed'); + }, + } + ); + + const disabled = useMemo( + () => !householdId || activate.isLoading, + [householdId, activate.isLoading] + ); + + return ( + + + + MOCK CHECKOUT — Stripe not wired +

Upgrade to Premium

+ + Unlimited daily picks, multi-region providers, and LLM-ranked + recommendations. + + + {PRICE_LABEL} + + {!householdId && ( + + Missing household id — append ?household=<id> to the URL. + + )} + {error && {error}} + +
+
+
+ ); +}; + +export default MockCheckout; diff --git a/app.client/src/features/billing/UpgradeBanner.tsx b/app.client/src/features/billing/UpgradeBanner.tsx new file mode 100644 index 0000000..a889c9e --- /dev/null +++ b/app.client/src/features/billing/UpgradeBanner.tsx @@ -0,0 +1,53 @@ +import { Button, Typography } from '@pairflix/components'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import type { Entitlements } from '../../services/api'; +import type { Theme } from '../../styles/theme'; + +const BannerContainer = styled.div<{ theme: Theme }>` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing.md}; + padding: ${({ theme }) => theme.spacing.sm} + ${({ theme }) => theme.spacing.md}; + background: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.primary}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + margin-bottom: ${({ theme }) => theme.spacing.md}; +`; + +interface UpgradeBannerProps { + entitlements: Entitlements; +} + +const UpgradeBanner: React.FC = ({ entitlements }) => { + const navigate = useNavigate(); + + if (entitlements.tier === 'premium') { + return null; + } + if (entitlements.picks_remaining > 1) { + return null; + } + + const message = + entitlements.picks_remaining === 0 + ? 'No picks left today — upgrade for unlimited.' + : `${entitlements.picks_remaining} pick left today — upgrade for unlimited.`; + + return ( + + {message} + + + ); +}; + +export default UpgradeBanner; diff --git a/app.client/src/features/billing/flags.ts b/app.client/src/features/billing/flags.ts new file mode 100644 index 0000000..b4f9def --- /dev/null +++ b/app.client/src/features/billing/flags.ts @@ -0,0 +1,36 @@ +declare const process: + | { + env: { + NODE_ENV?: string; + VITE_BILLING_MOCK_ENABLED?: string; + }; + } + | undefined; + +const getViteEnvVar = (key: string): string | undefined => { + if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') { + return undefined; + } + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importMeta = (globalThis as any).import?.meta; + return importMeta?.env?.[key]; + } catch { + return undefined; + } +}; + +export const isBillingMockEnabled = (): boolean => { + const fromEnv = + getViteEnvVar('VITE_BILLING_MOCK_ENABLED') ?? + (typeof process !== 'undefined' + ? process.env?.VITE_BILLING_MOCK_ENABLED + : undefined); + if (fromEnv !== undefined) { + return fromEnv === 'true'; + } + if (typeof process !== 'undefined') { + return process.env?.NODE_ENV !== 'production'; + } + return true; +}; diff --git a/app.client/src/hooks/useEntitlements.ts b/app.client/src/hooks/useEntitlements.ts new file mode 100644 index 0000000..1679e16 --- /dev/null +++ b/app.client/src/hooks/useEntitlements.ts @@ -0,0 +1,20 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { billing, type Entitlements } from '../services/api'; + +export function useEntitlements( + householdId: string | undefined +): UseQueryResult { + return useQuery( + ['entitlements', householdId], + () => { + if (!householdId) { + return Promise.reject(new Error('householdId required')); + } + return billing.getEntitlements(householdId); + }, + { + enabled: Boolean(householdId), + staleTime: 30 * 1000, + } + ); +} diff --git a/app.client/src/services/api/billing.ts b/app.client/src/services/api/billing.ts new file mode 100644 index 0000000..ca0f51b --- /dev/null +++ b/app.client/src/services/api/billing.ts @@ -0,0 +1,42 @@ +import { fetchWithAuth } from './utils'; + +export type SubscriptionTier = 'free' | 'premium'; + +export interface Entitlements { + tier: SubscriptionTier; + daily_pick_limit: number; + picks_used_today: number; + picks_remaining: number; + can_use_llm_rerank: boolean; + can_use_multi_region: boolean; + region_lock: string | null; +} + +export interface CheckoutSession { + checkout_url: string; +} + +export const billing = { + getEntitlements: (householdId: string): Promise => + fetchWithAuth( + `/api/households/${encodeURIComponent(householdId)}/entitlements` + ), + startCheckout: ( + householdId: string, + tier: SubscriptionTier = 'premium' + ): Promise => + fetchWithAuth('/api/billing/checkout', { + method: 'POST', + body: JSON.stringify({ household_id: householdId, tier }), + }), + cancel: (householdId: string): Promise => + fetchWithAuth('/api/billing/cancel', { + method: 'POST', + body: JSON.stringify({ household_id: householdId }), + }), + mockActivate: (householdId: string): Promise<{ ok: true }> => + fetchWithAuth('/api/billing/mock-activate', { + method: 'POST', + body: JSON.stringify({ household_id: householdId }), + }), +}; diff --git a/app.client/src/services/api/index.ts b/app.client/src/services/api/index.ts index 694ef47..a53cc7d 100644 --- a/app.client/src/services/api/index.ts +++ b/app.client/src/services/api/index.ts @@ -1,6 +1,7 @@ import { activity } from './activity'; import { admin } from './admin'; import { auth } from './auth'; +import { billing } from './billing'; import { emailService } from './email'; import { matches } from './matches'; import { search } from './search'; @@ -25,11 +26,19 @@ export type { UpdateUsernameResponse, } from './user'; +// Re-export types from billing +export type { + CheckoutSession, + Entitlements, + SubscriptionTier, +} from './billing'; + // Export individual services export { activity, admin, auth, + billing, emailService, fetchWithAuth, matches, @@ -50,6 +59,7 @@ const api = { matches, activity, admin, + billing, email: emailService, }; diff --git a/backend/src/app.ts b/backend/src/app.ts index 4f3b0eb..c459938 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,7 +7,9 @@ import { requestLogger } from './middlewares/request-logger'; import activityRoutes from './routes/activity.routes'; import adminRoutes from './routes/admin.routes'; import authRoutes from './routes/auth.routes'; +import billingRoutes from './routes/billing.routes'; import emailRoutes from './routes/email.routes'; +import householdsRoutes from './routes/households.routes'; import matchRoutes from './routes/match.routes'; import searchRoutes from './routes/search.routes'; import userRoutes from './routes/user.routes'; @@ -42,6 +44,8 @@ app.use('/api/matches', matchRoutes); app.use('/api/search', searchRoutes); app.use('/api/admin', adminRoutes); app.use('/api/activity', activityRoutes); +app.use('/api/households', householdsRoutes); +app.use('/api/billing', billingRoutes); // Error handling middleware app.use(errorHandler); diff --git a/backend/src/controllers/billing.controller.ts b/backend/src/controllers/billing.controller.ts new file mode 100644 index 0000000..4d3ff89 --- /dev/null +++ b/backend/src/controllers/billing.controller.ts @@ -0,0 +1,86 @@ +import type { Request, Response } from 'express'; +import Household from '../models/Household'; +import { + cancelSubscription, + handleStripeWebhook, + isBillingMockEnabled, + mockActivatePremium, + startCheckoutSession, +} from '../services/billing.service'; +import { getEntitlements } from '../services/entitlements.service'; + +export async function postCheckout( + req: Request, + res: Response +): Promise { + const { household_id, tier } = (req.body ?? {}) as { + household_id?: string; + tier?: 'premium'; + }; + if (!household_id) { + res.status(400).json({ error: 'household_id_required' }); + return; + } + const session = await startCheckoutSession(household_id, tier ?? 'premium'); + res.status(200).json(session); +} + +export async function postWebhook( + req: Request, + res: Response +): Promise { + await handleStripeWebhook(req, res); +} + +export async function postCancel(req: Request, res: Response): Promise { + const { household_id } = (req.body ?? {}) as { household_id?: string }; + if (!household_id) { + res.status(400).json({ error: 'household_id_required' }); + return; + } + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + const household = await Household.findByPk(household_id); + if (!household) { + res.status(404).json({ error: 'household_not_found' }); + return; + } + if (household.owner_id !== req.user.user_id) { + res.status(403).json({ error: 'owner_only' }); + return; + } + await cancelSubscription(household_id); + res.status(204).end(); +} + +export async function postMockActivate( + req: Request, + res: Response +): Promise { + if (!isBillingMockEnabled()) { + res.status(404).json({ error: 'not_found' }); + return; + } + const { household_id } = (req.body ?? {}) as { household_id?: string }; + if (!household_id) { + res.status(400).json({ error: 'household_id_required' }); + return; + } + await mockActivatePremium(household_id); + res.status(200).json({ ok: true }); +} + +export async function getHouseholdEntitlements( + req: Request, + res: Response +): Promise { + const householdId = req.params.id; + if (!householdId) { + res.status(400).json({ error: 'household_id_required' }); + return; + } + const entitlements = await getEntitlements(householdId); + res.status(200).json(entitlements); +} diff --git a/backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js b/backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js new file mode 100644 index 0000000..a552d50 --- /dev/null +++ b/backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js @@ -0,0 +1,99 @@ +'use strict'; + +/** + * Phase E migration: subscriptions + pick_usage. + * + * Creates the subscriptions table (one row per household) and pick_usage + * (one row per successful pick) and backfills a free/active subscription + * for every existing household. + */ + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('subscriptions', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.literal('gen_random_uuid()'), + primaryKey: true, + }, + household_id: { + type: Sequelize.UUID, + allowNull: false, + unique: true, + references: { model: 'households', key: 'household_id' }, + onDelete: 'CASCADE', + }, + tier: { + type: Sequelize.ENUM('free', 'premium'), + allowNull: false, + defaultValue: 'free', + }, + status: { + type: Sequelize.ENUM('active', 'past_due', 'canceled'), + allowNull: false, + defaultValue: 'active', + }, + stripe_customer_id: { type: Sequelize.STRING, allowNull: true }, + stripe_subscription_id: { type: Sequelize.STRING, allowNull: true }, + current_period_end: { type: Sequelize.DATE, allowNull: true }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('NOW()'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('NOW()'), + }, + }); + + await queryInterface.createTable('pick_usage', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.literal('gen_random_uuid()'), + primaryKey: true, + }, + household_id: { + type: Sequelize.UUID, + allowNull: false, + references: { model: 'households', key: 'household_id' }, + onDelete: 'CASCADE', + }, + picked_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('NOW()'), + }, + }); + + await queryInterface.addIndex('pick_usage', { + name: 'pick_usage_household_picked_at_idx', + fields: [ + { name: 'household_id' }, + { name: 'picked_at', order: 'DESC' }, + ], + }); + + // Backfill: every existing household gets a free/active subscription. + await queryInterface.sequelize.query(` + INSERT INTO subscriptions (id, household_id, tier, status, created_at, updated_at) + SELECT gen_random_uuid(), h.household_id, 'free', 'active', NOW(), NOW() + FROM households h + LEFT JOIN subscriptions s ON s.household_id = h.household_id + WHERE s.id IS NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('pick_usage'); + await queryInterface.dropTable('subscriptions'); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_subscriptions_tier";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_subscriptions_status";' + ); + }, +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index f9660ae..ede885c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,8 @@ import { requestLogger } from './middlewares/request-logger'; import activityRoutes from './routes/activity.routes'; import adminRoutes from './routes/admin.routes'; import authRoutes from './routes/auth.routes'; +import billingRoutes from './routes/billing.routes'; +import householdsRoutes from './routes/households.routes'; import matchRoutes from './routes/match.routes'; import searchRoutes from './routes/search.routes'; import userRoutes from './routes/user.routes'; @@ -91,6 +93,8 @@ app.use('/api/watchlist', authenticateToken, watchlistRoutes); app.use('/api/matches', authenticateToken, matchRoutes); app.use('/api/activity', authenticateToken, activityRoutes); app.use('/api/admin', adminRateLimit, adminRoutes); // Admin routes handle their own authentication +app.use('/api/households', householdsRoutes); +app.use('/api/billing', billingRoutes); // Global error handler middleware (after routes) app.use(errorHandler); diff --git a/backend/src/middlewares/entitlements.middleware.ts b/backend/src/middlewares/entitlements.middleware.ts new file mode 100644 index 0000000..2b498d9 --- /dev/null +++ b/backend/src/middlewares/entitlements.middleware.ts @@ -0,0 +1,104 @@ +import type { NextFunction, Request, Response } from 'express'; +import HouseholdMember from '../models/HouseholdMember'; +import { + getEntitlements, + recordPick, +} from '../services/entitlements.service'; + +async function isHouseholdMember( + householdId: string, + userId: string +): Promise { + const membership = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId }, + }); + return membership !== null; +} + +/** + * Enforce daily pick quota for the household identified by req.params.id. + * + * Apply to: POST /api/households/:id/pick (Phase B). + * + * On success (controller responds 2xx) inserts a PickUsage row via a + * res.on('finish') hook, leaving Phase B's controller untouched. + */ +export async function enforcePickQuota( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const householdId = req.params.id; + if (!householdId) { + res.status(400).json({ error: 'household_id_required' }); + return; + } + + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const isMember = await isHouseholdMember(householdId, req.user.user_id); + if (!isMember) { + res.status(403).json({ error: 'not_a_household_member' }); + return; + } + + const entitlements = await getEntitlements(householdId); + + if (entitlements.picks_remaining <= 0) { + res.status(402).json({ + error: 'pick_quota_exceeded', + upgrade_url: '/upgrade', + entitlements, + }); + return; + } + + res.on('finish', () => { + if (res.statusCode < 300) { + void recordPick(householdId).catch(err => { + console.error('Failed to record pick usage:', err); + }); + } + }); + + next(); + } catch (error) { + next(error); + } +} + +/** + * For free-tier households, silently force the request body's region to the + * tier's region_lock. Premium households pass through unchanged. + * + * Apply to: POST /api/households/:id/pick. + */ +export async function enforceRegionLock( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const householdId = req.params.id; + if (!householdId) { + next(); + return; + } + + const entitlements = await getEntitlements(householdId); + + if (entitlements.region_lock !== null) { + const body = (req.body ?? {}) as Record; + body.region = entitlements.region_lock; + req.body = body; + } + + next(); + } catch (error) { + next(error); + } +} diff --git a/backend/src/models/Household.ts b/backend/src/models/Household.ts new file mode 100644 index 0000000..14c47e4 --- /dev/null +++ b/backend/src/models/Household.ts @@ -0,0 +1,67 @@ +// TODO: replace on merge with Phase A's full Household model. +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import User from './User'; + +interface HouseholdAttributes { + household_id: string; + owner_id: string; + name: string; + created_at: Date; + updated_at: Date; +} + +interface HouseholdCreationAttributes { + owner_id: string; + name: string; +} + +class Household extends Model { + declare household_id: string; + + declare owner_id: string; + + declare name: string; + + declare created_at: Date; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return Household.init( + { + household_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + owner_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: User, key: 'user_id' }, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'Household', + tableName: 'households', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +export default Household; diff --git a/backend/src/models/HouseholdMember.ts b/backend/src/models/HouseholdMember.ts new file mode 100644 index 0000000..9548461 --- /dev/null +++ b/backend/src/models/HouseholdMember.ts @@ -0,0 +1,75 @@ +// TODO: replace on merge with Phase A's full HouseholdMember model. +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; +import User from './User'; + +interface HouseholdMemberAttributes { + id: string; + household_id: string; + user_id: string; + role: 'owner' | 'member'; + created_at: Date; +} + +interface HouseholdMemberCreationAttributes { + household_id: string; + user_id: string; + role?: 'owner' | 'member'; +} + +class HouseholdMember extends Model< + HouseholdMemberAttributes, + HouseholdMemberCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare user_id: string; + + declare role: 'owner' | 'member'; + + declare created_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return HouseholdMember.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: Household, key: 'household_id' }, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: User, key: 'user_id' }, + }, + role: { + type: DataTypes.ENUM('owner', 'member'), + allowNull: false, + defaultValue: 'member', + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'HouseholdMember', + tableName: 'household_members', + timestamps: true, + createdAt: 'created_at', + updatedAt: false, + indexes: [{ unique: true, fields: ['household_id', 'user_id'] }], + } + ); + } +} + +export default HouseholdMember; diff --git a/backend/src/models/PickUsage.ts b/backend/src/models/PickUsage.ts new file mode 100644 index 0000000..3e4ae19 --- /dev/null +++ b/backend/src/models/PickUsage.ts @@ -0,0 +1,63 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; + +interface PickUsageAttributes { + id: string; + household_id: string; + picked_at: Date; +} + +interface PickUsageCreationAttributes { + household_id: string; + picked_at?: Date; +} + +class PickUsage extends Model< + PickUsageAttributes, + PickUsageCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare picked_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return PickUsage.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: Household, key: 'household_id' }, + }, + picked_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'PickUsage', + tableName: 'pick_usage', + timestamps: false, + indexes: [ + { + name: 'pick_usage_household_picked_at_idx', + fields: [ + { name: 'household_id' }, + { name: 'picked_at', order: 'DESC' }, + ], + }, + ], + } + ); + } +} + +export default PickUsage; diff --git a/backend/src/models/Subscription.ts b/backend/src/models/Subscription.ts new file mode 100644 index 0000000..1f9bb91 --- /dev/null +++ b/backend/src/models/Subscription.ts @@ -0,0 +1,107 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; + +export type SubscriptionTier = 'free' | 'premium'; +export type SubscriptionStatus = 'active' | 'past_due' | 'canceled'; + +interface SubscriptionAttributes { + id: string; + household_id: string; + tier: SubscriptionTier; + status: SubscriptionStatus; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + current_period_end: Date | null; + created_at: Date; + updated_at: Date; +} + +interface SubscriptionCreationAttributes { + household_id: string; + tier?: SubscriptionTier; + status?: SubscriptionStatus; + stripe_customer_id?: string | null; + stripe_subscription_id?: string | null; + current_period_end?: Date | null; +} + +class Subscription extends Model< + SubscriptionAttributes, + SubscriptionCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare tier: SubscriptionTier; + + declare status: SubscriptionStatus; + + declare stripe_customer_id: string | null; + + declare stripe_subscription_id: string | null; + + declare current_period_end: Date | null; + + declare created_at: Date; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return Subscription.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { model: Household, key: 'household_id' }, + }, + tier: { + type: DataTypes.ENUM('free', 'premium'), + allowNull: false, + defaultValue: 'free', + }, + status: { + type: DataTypes.ENUM('active', 'past_due', 'canceled'), + allowNull: false, + defaultValue: 'active', + }, + stripe_customer_id: { + type: DataTypes.STRING, + allowNull: true, + }, + stripe_subscription_id: { + type: DataTypes.STRING, + allowNull: true, + }, + current_period_end: { + type: DataTypes.DATE, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'Subscription', + tableName: 'subscriptions', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +export default Subscription; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3ec3f8c..0479435 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -5,8 +5,12 @@ import AuditLog from './AuditLog'; import Content from './Content'; import ContentReport from './ContentReport'; import EmailVerification from './EmailVerification'; +import Household from './Household'; +import HouseholdMember from './HouseholdMember'; import Match from './Match'; import PasswordReset from './PasswordReset'; +import PickUsage from './PickUsage'; +import Subscription from './Subscription'; import User from './User'; import UserSession from './UserSession'; import WatchlistEntry from './WatchlistEntry'; @@ -35,6 +39,10 @@ export function initializeModels(sequelize: Sequelize) { EmailVerification.initialize(sequelize); PasswordReset.initialize(sequelize); UserSession.initialize(sequelize); + Household.initialize(sequelize); + HouseholdMember.initialize(sequelize); + Subscription.initialize(sequelize); + PickUsage.initialize(sequelize); // Set up associations after all models are initialized Match.belongsTo(User, { as: 'user1', foreignKey: 'user1_id' }); @@ -105,6 +113,46 @@ export function initializeModels(sequelize: Sequelize) { as: 'watchlistEntry', }); WatchlistEntry.hasMany(Match, { foreignKey: 'entry_id', as: 'matches' }); + + // Household / membership associations + Household.belongsTo(User, { foreignKey: 'owner_id', as: 'owner' }); + User.hasMany(Household, { foreignKey: 'owner_id', as: 'ownedHouseholds' }); + HouseholdMember.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + HouseholdMember.belongsTo(User, { + foreignKey: 'user_id', + as: 'user', + }); + Household.hasMany(HouseholdMember, { + foreignKey: 'household_id', + as: 'members', + }); + User.hasMany(HouseholdMember, { + foreignKey: 'user_id', + as: 'householdMemberships', + }); + + // Subscription association (one per household) + Subscription.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + Household.hasOne(Subscription, { + foreignKey: 'household_id', + as: 'subscription', + }); + + // Pick usage association + PickUsage.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + Household.hasMany(PickUsage, { + foreignKey: 'household_id', + as: 'pickUsages', + }); } catch (error) { console.error('Error initializing models:', error); throw new Error( @@ -125,4 +173,8 @@ export default { EmailVerification, PasswordReset, UserSession, + Household, + HouseholdMember, + Subscription, + PickUsage, }; diff --git a/backend/src/routes/billing.routes.ts b/backend/src/routes/billing.routes.ts new file mode 100644 index 0000000..408cd0c --- /dev/null +++ b/backend/src/routes/billing.routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { + postCancel, + postCheckout, + postMockActivate, + postWebhook, +} from '../controllers/billing.controller'; +import { authenticateToken } from '../middlewares/auth'; + +const router = Router(); + +// Webhook must remain unauthenticated; Stripe authenticates via signature. +router.post('/webhook', postWebhook); + +router.post('/checkout', authenticateToken, postCheckout); +router.post('/cancel', authenticateToken, postCancel); +router.post('/mock-activate', authenticateToken, postMockActivate); + +export default router; diff --git a/backend/src/routes/households.routes.ts b/backend/src/routes/households.routes.ts new file mode 100644 index 0000000..32187ba --- /dev/null +++ b/backend/src/routes/households.routes.ts @@ -0,0 +1,23 @@ +// TODO: replace on merge — Phase B owns this routes file. Phase E only adds the +// entitlements GET and registers the pick-quota middleware below. +import { Router } from 'express'; +import { getHouseholdEntitlements } from '../controllers/billing.controller'; +import { authenticateToken } from '../middlewares/auth'; +import { + enforcePickQuota, + enforceRegionLock, +} from '../middlewares/entitlements.middleware'; + +const router = Router(); + +router.use(authenticateToken); + +router.get('/:id/entitlements', getHouseholdEntitlements); + +// TODO: when Phase B's pick controller lands, mount it here: +// router.post('/:id/pick', enforcePickQuota, enforceRegionLock, pickController); +// The two middlewares below are re-exported so Phase B can mount them directly +// if they prefer to own the route registration. +export { enforcePickQuota, enforceRegionLock }; + +export default router; diff --git a/backend/src/services/billing.service.ts b/backend/src/services/billing.service.ts new file mode 100644 index 0000000..ac3fb33 --- /dev/null +++ b/backend/src/services/billing.service.ts @@ -0,0 +1,81 @@ +import type { Request, Response } from 'express'; +import Subscription from '../models/Subscription'; +import { invalidateEntitlementsCache } from './entitlements.service'; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +export interface CheckoutSession { + checkout_url: string; +} + +/** + * Returns a placeholder checkout URL. The frontend redirects here today; + * once Stripe is live this returns the URL from Stripe. + */ +export async function startCheckoutSession( + householdId: string, + tier: 'premium' = 'premium' +): Promise { + // TODO: replace with stripe.checkout.sessions.create({...}) when Stripe goes live. + const checkout_url = `/billing/mock-checkout?household=${encodeURIComponent( + householdId + )}&tier=${encodeURIComponent(tier)}`; + return { checkout_url }; +} + +/** + * Stripe webhook stub. Logs the event and returns 200. + */ +export async function handleStripeWebhook( + req: Request, + res: Response +): Promise { + // TODO: verify signature via stripe.webhooks.constructEvent(rawBody, sig, secret) + // and route on event.type (checkout.session.completed, + // customer.subscription.updated, customer.subscription.deleted, etc.). + console.warn('[billing] stripe webhook received (stub):', { + headers: req.headers['stripe-signature'] ?? null, + bodyKeys: Object.keys((req.body ?? {}) as Record), + }); + res.status(200).json({ received: true }); +} + +/** + * Locally mark the household's subscription as canceled. period_end is left + * intact so the user keeps access until the period rolls over. + */ +export async function cancelSubscription(householdId: string): Promise { + const sub = await Subscription.findOne({ + where: { household_id: householdId }, + }); + if (!sub) return; + // TODO: call stripe.subscriptions.update(sub.stripe_subscription_id, { cancel_at_period_end: true }) + sub.status = 'canceled'; + await sub.save(); + invalidateEntitlementsCache(householdId); +} + +/** + * Dev-only: flip the household to premium/active for 30 days. Backs the + * mock-checkout button until Stripe is wired. + */ +export async function mockActivatePremium(householdId: string): Promise { + const periodEnd = new Date(Date.now() + THIRTY_DAYS_MS); + const [sub] = await Subscription.findOrCreate({ + where: { household_id: householdId }, + defaults: { household_id: householdId }, + }); + sub.tier = 'premium'; + sub.status = 'active'; + sub.current_period_end = periodEnd; + await sub.save(); + invalidateEntitlementsCache(householdId); +} + +export function isBillingMockEnabled(): boolean { + const flag = process.env.BILLING_MOCK_ENABLED; + if (flag === undefined) { + return process.env.NODE_ENV !== 'production'; + } + return flag === 'true'; +} diff --git a/backend/src/services/entitlements.service.ts b/backend/src/services/entitlements.service.ts new file mode 100644 index 0000000..1b53b02 --- /dev/null +++ b/backend/src/services/entitlements.service.ts @@ -0,0 +1,126 @@ +import { Op } from 'sequelize'; +import PickUsage from '../models/PickUsage'; +import Subscription, { + type SubscriptionStatus, + type SubscriptionTier, +} from '../models/Subscription'; + +export interface Entitlements { + tier: SubscriptionTier; + daily_pick_limit: number; + picks_used_today: number; + picks_remaining: number; + can_use_llm_rerank: boolean; + can_use_multi_region: boolean; + region_lock: string | null; +} + +interface TierFlagsCacheEntry { + tier: SubscriptionTier; + status: SubscriptionStatus; + current_period_end: Date | null; + expires_at: number; +} + +const TIER_CACHE_TTL_MS = 30 * 1000; +const tierCache = new Map(); + +const FREE_DAILY_PICK_LIMIT = 3; +const FREE_REGION_LOCK = 'GB'; + +function startOfTodayUtc(): Date { + const now = new Date(); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); +} + +async function loadTierFlags( + householdId: string +): Promise<{ + tier: SubscriptionTier; + status: SubscriptionStatus; + current_period_end: Date | null; +}> { + const cached = tierCache.get(householdId); + const now = Date.now(); + if (cached && cached.expires_at > now) { + return { + tier: cached.tier, + status: cached.status, + current_period_end: cached.current_period_end, + }; + } + + const sub = await Subscription.findOne({ + where: { household_id: householdId }, + }); + + const tier: SubscriptionTier = sub?.tier ?? 'free'; + const status: SubscriptionStatus = sub?.status ?? 'active'; + const current_period_end = sub?.current_period_end ?? null; + + tierCache.set(householdId, { + tier, + status, + current_period_end, + expires_at: now + TIER_CACHE_TTL_MS, + }); + + return { tier, status, current_period_end }; +} + +export function invalidateEntitlementsCache(householdId?: string): void { + if (householdId) { + tierCache.delete(householdId); + } else { + tierCache.clear(); + } +} + +function isEffectivelyPremium( + tier: SubscriptionTier, + status: SubscriptionStatus, + periodEnd: Date | null +): boolean { + return ( + tier === 'premium' && + status === 'active' && + periodEnd !== null && + periodEnd.getTime() > Date.now() + ); +} + +export async function getEntitlements( + householdId: string +): Promise { + const { tier, status, current_period_end } = await loadTierFlags(householdId); + const premium = isEffectivelyPremium(tier, status, current_period_end); + + const picksUsedToday = await PickUsage.count({ + where: { + household_id: householdId, + picked_at: { [Op.gte]: startOfTodayUtc() }, + }, + }); + + const dailyLimit = premium + ? Number.MAX_SAFE_INTEGER + : FREE_DAILY_PICK_LIMIT; + + const remaining = Math.max(0, dailyLimit - picksUsedToday); + + return { + tier: premium ? 'premium' : 'free', + daily_pick_limit: dailyLimit, + picks_used_today: picksUsedToday, + picks_remaining: remaining, + can_use_llm_rerank: premium, + can_use_multi_region: premium, + region_lock: premium ? null : FREE_REGION_LOCK, + }; +} + +export async function recordPick(householdId: string): Promise { + await PickUsage.create({ household_id: householdId }); +} diff --git a/backend/src/services/featureFlags.service.ts b/backend/src/services/featureFlags.service.ts new file mode 100644 index 0000000..8b91ec6 --- /dev/null +++ b/backend/src/services/featureFlags.service.ts @@ -0,0 +1,21 @@ +// TODO: replace on merge with Phase C's full featureFlags service. +import { getEntitlements } from './entitlements.service'; + +/** + * Global LLM rerank flag. In Phase C this is driven by AppSettings. + */ +export function isLlmRerankEnabled(): boolean { + return process.env.LLM_RERANK_ENABLED === 'true'; +} + +/** + * Household-scoped LLM rerank gate. ANDs the global flag with the + * household's tier entitlement. + */ +export async function isLlmRerankEnabledForHousehold( + householdId: string +): Promise { + if (!isLlmRerankEnabled()) return false; + const entitlements = await getEntitlements(householdId); + return entitlements.can_use_llm_rerank; +} From 69422959021d369a676d46fe6caf49e2161041ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:57:55 +0000 Subject: [PATCH 07/20] feat: phase D providers + watch-together history Add TMDb watch-providers fetcher and cached refresh, household watch-together history endpoints with thumbs capture, a shared ProviderBadges component, and a HistoryPage in the client (with nav entry and route). Phase A models (Household, HouseholdMember, WatchedTogether, Content.providers) and Phase A tasteProfile.recomputeForUser are stubbed inline so this branch typechecks standalone; they're tagged for replacement on merge. --- app.client/src/components/layout/Routes.tsx | 5 + app.client/src/config/navigation.ts | 7 + .../src/features/history/HistoryPage.tsx | 220 ++++++++++++++++++ .../features/history/useAffiliateParams.ts | 48 ++++ app.client/src/features/history/useHistory.ts | 15 ++ .../features/history/usePrimaryHousehold.ts | 26 +++ .../src/features/history/useSetEnjoyed.ts | 25 ++ app.client/src/services/api/history.ts | 45 ++++ app.client/src/services/api/index.ts | 6 + backend/src/app.ts | 4 + backend/src/controllers/history.controller.ts | 110 +++++++++ .../src/controllers/providers.controller.ts | 40 ++++ backend/src/models/Content.ts | 45 +++- backend/src/models/Household.ts | 132 +++++++++++ backend/src/models/WatchedTogether.ts | 115 +++++++++ backend/src/models/index.ts | 36 +++ backend/src/routes/history.routes.ts | 15 ++ backend/src/routes/providers.routes.ts | 11 + backend/src/services/history.service.ts | 83 +++++++ backend/src/services/providers.service.ts | 82 +++++++ backend/src/services/tasteProfile.service.ts | 4 + backend/src/services/tmdb.service.ts | 33 +++ .../ProviderBadges/ProviderBadges.tsx | 186 +++++++++++++++ .../data-display/ProviderBadges/index.ts | 2 + .../src/components/data-display/index.ts | 1 + 25 files changed, 1295 insertions(+), 1 deletion(-) create mode 100644 app.client/src/features/history/HistoryPage.tsx create mode 100644 app.client/src/features/history/useAffiliateParams.ts create mode 100644 app.client/src/features/history/useHistory.ts create mode 100644 app.client/src/features/history/usePrimaryHousehold.ts create mode 100644 app.client/src/features/history/useSetEnjoyed.ts create mode 100644 app.client/src/services/api/history.ts create mode 100644 backend/src/controllers/history.controller.ts create mode 100644 backend/src/controllers/providers.controller.ts create mode 100644 backend/src/models/Household.ts create mode 100644 backend/src/models/WatchedTogether.ts create mode 100644 backend/src/routes/history.routes.ts create mode 100644 backend/src/routes/providers.routes.ts create mode 100644 backend/src/services/history.service.ts create mode 100644 backend/src/services/providers.service.ts create mode 100644 backend/src/services/tasteProfile.service.ts create mode 100644 lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx create mode 100644 lib.components/src/components/data-display/ProviderBadges/index.ts diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index d5d3bf3..92e7acd 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -12,6 +12,7 @@ import LoginPage from '../../features/auth/LoginPage'; import ProfilePage from '../../features/auth/ProfilePage'; import RegisterPage from '../../features/auth/RegisterPage'; import ResetPasswordPage from '../../features/auth/ResetPasswordPage'; +import HistoryPage from '../../features/history/HistoryPage'; import MatchPage from '../../features/match/MatchPage'; import WatchlistPage from '../../features/watchlist/WatchlistPage'; import { useAuth } from '../../hooks/useAuth'; @@ -89,6 +90,10 @@ const AppRoutes: React.FC = () => { path="/activity" element={} />} /> + } />} + /> } />} diff --git a/app.client/src/config/navigation.ts b/app.client/src/config/navigation.ts index 1fd518d..97434f5 100644 --- a/app.client/src/config/navigation.ts +++ b/app.client/src/config/navigation.ts @@ -4,6 +4,7 @@ import { HiArrowLeftOnRectangle, HiArrowRightOnRectangle, HiChartBarSquare, + HiClock, HiHeart, HiListBullet, HiUser, @@ -67,6 +68,12 @@ export const createClientNavigation = ( path: '/activity', icon: React.createElement(HiChartBarSquare), }, + { + key: 'history', + label: 'History', + path: '/history', + icon: React.createElement(HiClock), + }, { key: 'profile', label: 'Profile', diff --git a/app.client/src/features/history/HistoryPage.tsx b/app.client/src/features/history/HistoryPage.tsx new file mode 100644 index 0000000..579edc3 --- /dev/null +++ b/app.client/src/features/history/HistoryPage.tsx @@ -0,0 +1,220 @@ +import { + Button, + Card, + CardContent, + H1, + PageContainer, + ProviderBadges, + Typography, +} from '@pairflix/components'; +import React from 'react'; +import { HiHandThumbDown, HiHandThumbUp } from 'react-icons/hi2'; +import styled from 'styled-components'; +import { type HistoryEntry } from '../../services/api'; +import { useHistory } from './useHistory'; +import { usePrimaryHousehold } from './usePrimaryHousehold'; +import { useSetEnjoyed } from './useSetEnjoyed'; + +const PageHeader = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + +const HistoryList = styled.ul` + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.md}; +`; + +const HistoryRow = styled.li` + display: grid; + grid-template-columns: 80px 1fr auto; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; + + @media (max-width: 600px) { + grid-template-columns: 64px 1fr; + grid-template-areas: + 'poster meta' + 'actions actions'; + } +`; + +const Poster = styled.img` + width: 80px; + height: 120px; + object-fit: cover; + border-radius: ${({ theme }) => theme.borderRadius?.sm || '4px'}; + background: ${({ theme }) => theme.colors.background.secondary}; + grid-area: poster; + + @media (max-width: 600px) { + width: 64px; + height: 96px; + } +`; + +const Meta = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.xs}; + grid-area: meta; +`; + +const Actions = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing.sm}; + grid-area: actions; +`; + +const EmptyState = styled.div` + padding: ${({ theme }) => theme.spacing.xl}; + text-align: center; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const POSTER_BASE = 'https://image.tmdb.org/t/p/w185'; + +const formatYear = (entry: HistoryEntry): string => { + if (entry.year) { + return ` (${entry.year})`; + } + return ''; +}; + +const formatWatchedAt = (iso: string): string => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + return date.toLocaleDateString(); +}; + +const HistoryPage: React.FC = () => { + const { data: household, isLoading: householdLoading } = + usePrimaryHousehold(); + const householdId = household?.id; + const { data, isLoading, error } = useHistory(householdId); + const setEnjoyed = useSetEnjoyed(householdId); + + if (householdLoading || isLoading) { + return ( + + Loading history… + + ); + } + + if (!householdId) { + return ( + + +

Watch Together History

+
+ + + You're not in a household yet. Create or join one to start tracking + what you watch together. + + +
+ ); + } + + if (error) { + return ( + + + Failed to load history:{' '} + {error instanceof Error ? error.message : 'unknown error'} + + + ); + } + + const entries = data?.history ?? []; + + return ( + + +

Watch Together History

+ + Tap a thumb to rate something you've watched. We use these to improve + tonight's picks. + +
+ + {entries.length === 0 ? ( + + Nothing here yet — pick a title tonight. + + ) : ( + + {entries.map(entry => ( + + + + {entry.poster_path ? ( + + ) : ( + + + + ))} + + )} +
+ ); +}; + +export default HistoryPage; diff --git a/app.client/src/features/history/useAffiliateParams.ts b/app.client/src/features/history/useAffiliateParams.ts new file mode 100644 index 0000000..b9eb518 --- /dev/null +++ b/app.client/src/features/history/useAffiliateParams.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; + +declare const process: + | { + env: { + NODE_ENV?: string; + VITE_AFFILIATE_PARAMS?: string; + }; + } + | undefined; + +const readRaw = (): string | undefined => { + if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') { + return undefined; + } + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importMeta = (globalThis as any).import?.meta; + return importMeta?.env?.VITE_AFFILIATE_PARAMS; + } catch { + return undefined; + } +}; + +const parse = (raw: string | undefined): Record => { + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const out: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') { + out[key] = value; + } + } + return out; + } + } catch { + return {}; + } + return {}; +}; + +export function useAffiliateParams(): Record { + return useMemo(() => parse(readRaw()), []); +} diff --git a/app.client/src/features/history/useHistory.ts b/app.client/src/features/history/useHistory.ts new file mode 100644 index 0000000..135a576 --- /dev/null +++ b/app.client/src/features/history/useHistory.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { history, type HistoryResponse } from '../../services/api'; + +export function useHistory(householdId: string | undefined, limit = 50) { + return useQuery({ + queryKey: ['history', householdId, limit], + queryFn: () => { + if (!householdId) { + return Promise.resolve({ history: [] }); + } + return history.list(householdId, limit); + }, + enabled: Boolean(householdId), + }); +} diff --git a/app.client/src/features/history/usePrimaryHousehold.ts b/app.client/src/features/history/usePrimaryHousehold.ts new file mode 100644 index 0000000..115b1b2 --- /dev/null +++ b/app.client/src/features/history/usePrimaryHousehold.ts @@ -0,0 +1,26 @@ +// TODO: replace with Phase A model on merge +// Stub hook: Phase A will add a real household membership query. Until then +// we read an optional override from `localStorage` so the page is usable +// during development. +import { useQuery } from '@tanstack/react-query'; + +export interface PrimaryHousehold { + id: string; + name: string; +} + +export function usePrimaryHousehold() { + return useQuery({ + queryKey: ['primary-household'], + queryFn: async () => { + const overrideId = + typeof window !== 'undefined' + ? window.localStorage.getItem('primaryHouseholdId') + : null; + if (overrideId) { + return { id: overrideId, name: 'Household' }; + } + return null; + }, + }); +} diff --git a/app.client/src/features/history/useSetEnjoyed.ts b/app.client/src/features/history/useSetEnjoyed.ts new file mode 100644 index 0000000..a449e2d --- /dev/null +++ b/app.client/src/features/history/useSetEnjoyed.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { history, type HistoryEntry } from '../../services/api'; + +interface SetEnjoyedArgs { + watchedId: string; + enjoyed: boolean; +} + +export function useSetEnjoyed(householdId: string | undefined) { + const queryClient = useQueryClient(); + + return useMutation<{ entry: HistoryEntry }, Error, SetEnjoyedArgs>({ + mutationFn: async ({ watchedId, enjoyed }) => { + if (!householdId) { + throw new Error('No household selected'); + } + return history.setEnjoyed(householdId, watchedId, enjoyed); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['history', householdId], + }); + }, + }); +} diff --git a/app.client/src/services/api/history.ts b/app.client/src/services/api/history.ts new file mode 100644 index 0000000..ce38863 --- /dev/null +++ b/app.client/src/services/api/history.ts @@ -0,0 +1,45 @@ +import { fetchWithAuth } from './utils'; + +export interface HistoryEntry { + id: string; + household_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at: string; + enjoyed: boolean | null; + title: string | null; + year: number | null; + poster_path: string | null; +} + +export interface HistoryResponse { + history: HistoryEntry[]; +} + +export const history = { + /** + * List a household's watch-together history, newest first. + */ + list: async (householdId: string, limit = 50): Promise => { + return fetchWithAuth( + `/api/households/${householdId}/history?limit=${limit}` + ); + }, + + /** + * Capture a thumbs up/down rating for a previously-watched title. + */ + setEnjoyed: async ( + householdId: string, + watchedId: string, + enjoyed: boolean + ): Promise<{ entry: HistoryEntry }> => { + return fetchWithAuth( + `/api/households/${householdId}/history/${watchedId}`, + { + method: 'PATCH', + body: JSON.stringify({ enjoyed }), + } + ); + }, +}; diff --git a/app.client/src/services/api/index.ts b/app.client/src/services/api/index.ts index 694ef47..765cc45 100644 --- a/app.client/src/services/api/index.ts +++ b/app.client/src/services/api/index.ts @@ -2,6 +2,7 @@ import { activity } from './activity'; import { admin } from './admin'; import { auth } from './auth'; import { emailService } from './email'; +import { history } from './history'; import { matches } from './matches'; import { search } from './search'; import { user } from './user'; @@ -17,6 +18,9 @@ export type { AppSettings } from './admin'; // Re-export types from auth export type { LoginCredentials } from './auth'; +// Re-export types from history +export type { HistoryEntry, HistoryResponse } from './history'; + // Re-export types from user export type { EmailUpdate, @@ -32,6 +36,7 @@ export { auth, emailService, fetchWithAuth, + history, matches, search, user, @@ -51,6 +56,7 @@ const api = { activity, admin, email: emailService, + history, }; export default api; diff --git a/backend/src/app.ts b/backend/src/app.ts index 4f3b0eb..a805a4c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,7 +8,9 @@ import activityRoutes from './routes/activity.routes'; import adminRoutes from './routes/admin.routes'; import authRoutes from './routes/auth.routes'; import emailRoutes from './routes/email.routes'; +import historyRoutes from './routes/history.routes'; import matchRoutes from './routes/match.routes'; +import providersRoutes from './routes/providers.routes'; import searchRoutes from './routes/search.routes'; import userRoutes from './routes/user.routes'; import watchlistRoutes from './routes/watchlist.routes'; @@ -42,6 +44,8 @@ app.use('/api/matches', matchRoutes); app.use('/api/search', searchRoutes); app.use('/api/admin', adminRoutes); app.use('/api/activity', activityRoutes); +app.use('/api/households', historyRoutes); +app.use('/api/providers', providersRoutes); // Error handling middleware app.use(errorHandler); diff --git a/backend/src/controllers/history.controller.ts b/backend/src/controllers/history.controller.ts new file mode 100644 index 0000000..3559d14 --- /dev/null +++ b/backend/src/controllers/history.controller.ts @@ -0,0 +1,110 @@ +import type { Request, Response } from 'express'; +import { + getHouseholdHistory, + getHouseholdMemberUserIds, + isHouseholdMember, + setEnjoyed, +} from '../services/history.service'; +import { recomputeForUser } from '../services/tasteProfile.service'; + +const parseLimit = (raw: unknown): number => { + if (typeof raw !== 'string') { + return 50; + } + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + return 50; + } + return Math.min(parsed, 200); +}; + +export const getHistory = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const householdId = req.params.id; + if (!householdId) { + return res.status(400).json({ error: 'Household ID is required' }); + } + + const isMember = await isHouseholdMember(req.user.user_id, householdId); + if (!isMember) { + return res.status(403).json({ error: 'Not a member of this household' }); + } + + const limit = parseLimit(req.query.limit); + const history = await getHouseholdHistory(householdId, limit); + return res.json({ history }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Unknown error occurred' }); + } +}; + +interface PatchHistoryBody { + enjoyed: boolean; +} + +const isValidPatchBody = (obj: unknown): obj is PatchHistoryBody => { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const body = obj as Record; + return typeof body.enjoyed === 'boolean'; +}; + +export const patchHistoryEntry = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const householdId = req.params.id; + const watchedId = req.params.watchedId; + if (!householdId || !watchedId) { + return res + .status(400) + .json({ error: 'Household ID and watched ID are required' }); + } + + const isMember = await isHouseholdMember(req.user.user_id, householdId); + if (!isMember) { + return res.status(403).json({ error: 'Not a member of this household' }); + } + + const body = req.body as unknown; + if (!isValidPatchBody(body)) { + return res + .status(400) + .json({ error: 'Invalid request body: enjoyed must be a boolean' }); + } + + const updated = await setEnjoyed(watchedId, householdId, body.enjoyed); + if (!updated) { + return res.status(404).json({ error: 'History entry not found' }); + } + + // Fire-and-forget: recompute taste profiles for every household member + // using the new gold-signal thumbs rating. + // TODO: trigger recompute on merge + void (async () => { + try { + const userIds = await getHouseholdMemberUserIds(householdId); + await Promise.all(userIds.map(id => recomputeForUser(id))); + } catch (err) { + console.warn('Failed to trigger taste profile recompute', err); + } + })(); + + return res.json({ entry: updated }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Unknown error occurred' }); + } +}; diff --git a/backend/src/controllers/providers.controller.ts b/backend/src/controllers/providers.controller.ts new file mode 100644 index 0000000..8322ff9 --- /dev/null +++ b/backend/src/controllers/providers.controller.ts @@ -0,0 +1,40 @@ +import type { Request, Response } from 'express'; +import { getProvidersForContent } from '../services/providers.service'; + +const isMediaType = (value: unknown): value is 'movie' | 'tv' => + value === 'movie' || value === 'tv'; + +export const getProviders = async (req: Request, res: Response) => { + try { + const tmdbIdRaw = req.params.tmdbId; + const mediaTypeRaw = req.query.media_type; + const regionRaw = req.query.region; + + const tmdbId = tmdbIdRaw ? Number.parseInt(tmdbIdRaw, 10) : NaN; + if (Number.isNaN(tmdbId)) { + return res.status(400).json({ error: 'Invalid tmdb_id' }); + } + + if (!isMediaType(mediaTypeRaw)) { + return res + .status(400) + .json({ error: 'media_type must be "movie" or "tv"' }); + } + + const region = + typeof regionRaw === 'string' && regionRaw.length > 0 + ? regionRaw.toUpperCase() + : 'GB'; + + const providers = await getProvidersForContent(tmdbId, mediaTypeRaw, { + region, + }); + + return res.json({ region, providers }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: 'Unknown error occurred' }); + } +}; diff --git a/backend/src/models/Content.ts b/backend/src/models/Content.ts index 04dac73..50cedf3 100644 --- a/backend/src/models/Content.ts +++ b/backend/src/models/Content.ts @@ -1,4 +1,11 @@ import { DataTypes, Model, type Optional, type Sequelize } from 'sequelize'; +import type { ProvidersByRegion } from '../services/tmdb.service'; + +// TODO: replace with Phase A model on merge +export interface ContentProviders { + last_updated_at: string; + regions: ProvidersByRegion; +} interface ContentAttributes { id: string; @@ -6,15 +13,27 @@ interface ContentAttributes { type: 'movie' | 'show' | 'episode'; status: 'active' | 'pending' | 'flagged' | 'removed'; tmdb_id: number; + media_type?: 'movie' | 'tv'; + year?: number | null; + poster_path?: string | null; reported_count: number; removal_reason?: string; + providers?: ContentProviders | null; created_at: Date; updated_at: Date; } type ContentCreationAttributes = Optional< ContentAttributes, - 'id' | 'reported_count' | 'created_at' | 'updated_at' | 'removal_reason' + | 'id' + | 'reported_count' + | 'created_at' + | 'updated_at' + | 'removal_reason' + | 'media_type' + | 'year' + | 'poster_path' + | 'providers' >; class Content extends Model { @@ -32,6 +51,14 @@ class Content extends Model { declare removal_reason?: string; + declare media_type?: 'movie' | 'tv'; + + declare year?: number | null; + + declare poster_path?: string | null; + + declare providers?: ContentProviders | null; + declare created_at: Date; declare updated_at: Date; @@ -77,6 +104,22 @@ class Content extends Model { type: DataTypes.TEXT, allowNull: true, }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: true, + }, + year: { + type: DataTypes.INTEGER, + allowNull: true, + }, + poster_path: { + type: DataTypes.STRING, + allowNull: true, + }, + providers: { + type: DataTypes.JSONB, + allowNull: true, + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, diff --git a/backend/src/models/Household.ts b/backend/src/models/Household.ts new file mode 100644 index 0000000..57b37df --- /dev/null +++ b/backend/src/models/Household.ts @@ -0,0 +1,132 @@ +// TODO: replace with Phase A model on merge +import { DataTypes, Model, type Optional, type Sequelize } from 'sequelize'; + +interface HouseholdAttributes { + id: string; + name: string; + created_at: Date; + updated_at: Date; +} + +type HouseholdCreationAttributes = Optional< + HouseholdAttributes, + 'id' | 'created_at' | 'updated_at' +>; + +class Household extends Model< + HouseholdAttributes, + HouseholdCreationAttributes +> { + declare id: string; + + declare name: string; + + declare created_at: Date; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize) { + if (!sequelize || typeof sequelize.define !== 'function') { + throw new Error( + 'Invalid Sequelize instance provided to Household.initialize().' + ); + } + + return Household.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: 'households', + modelName: 'Household', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +// TODO: replace with Phase A model on merge +interface HouseholdMemberAttributes { + id: string; + household_id: string; + user_id: string; + created_at: Date; +} + +type HouseholdMemberCreationAttributes = Optional< + HouseholdMemberAttributes, + 'id' | 'created_at' +>; + +class HouseholdMember extends Model< + HouseholdMemberAttributes, + HouseholdMemberCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare user_id: string; + + declare created_at: Date; + + static initialize(sequelize: Sequelize) { + if (!sequelize || typeof sequelize.define !== 'function') { + throw new Error( + 'Invalid Sequelize instance provided to HouseholdMember.initialize().' + ); + } + + return HouseholdMember.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: 'household_members', + modelName: 'HouseholdMember', + timestamps: true, + createdAt: 'created_at', + updatedAt: false, + } + ); + } +} + +export { Household, HouseholdMember }; +export default Household; diff --git a/backend/src/models/WatchedTogether.ts b/backend/src/models/WatchedTogether.ts new file mode 100644 index 0000000..67ff4da --- /dev/null +++ b/backend/src/models/WatchedTogether.ts @@ -0,0 +1,115 @@ +// TODO: replace with Phase A model on merge +import { DataTypes, Model, type Optional, type Sequelize } from 'sequelize'; + +interface WatchedTogetherAttributes { + id: string; + household_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at: Date; + enjoyed: boolean | null; + mood_at_pick: string | null; + minutes_budget_at_pick: number | null; + created_at: Date; + updated_at: Date; +} + +type WatchedTogetherCreationAttributes = Optional< + WatchedTogetherAttributes, + | 'id' + | 'enjoyed' + | 'mood_at_pick' + | 'minutes_budget_at_pick' + | 'created_at' + | 'updated_at' +>; + +class WatchedTogether extends Model< + WatchedTogetherAttributes, + WatchedTogetherCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare tmdb_id: number; + + declare media_type: 'movie' | 'tv'; + + declare watched_at: Date; + + declare enjoyed: boolean | null; + + declare mood_at_pick: string | null; + + declare minutes_budget_at_pick: number | null; + + declare created_at: Date; + + declare updated_at: Date; + + static initialize(sequelize: Sequelize) { + if (!sequelize || typeof sequelize.define !== 'function') { + throw new Error( + 'Invalid Sequelize instance provided to WatchedTogether.initialize().' + ); + } + + return WatchedTogether.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + watched_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + enjoyed: { + type: DataTypes.BOOLEAN, + allowNull: true, + }, + mood_at_pick: { + type: DataTypes.STRING, + allowNull: true, + }, + minutes_budget_at_pick: { + type: DataTypes.INTEGER, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: 'watched_together', + modelName: 'WatchedTogether', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + } + ); + } +} + +export default WatchedTogether; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3ec3f8c..2ea685d 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -5,10 +5,14 @@ import AuditLog from './AuditLog'; import Content from './Content'; import ContentReport from './ContentReport'; import EmailVerification from './EmailVerification'; +// TODO: replace with Phase A model on merge +import { Household, HouseholdMember } from './Household'; import Match from './Match'; import PasswordReset from './PasswordReset'; import User from './User'; import UserSession from './UserSession'; +// TODO: replace with Phase A model on merge +import WatchedTogether from './WatchedTogether'; import WatchlistEntry from './WatchlistEntry'; export function initializeModels(sequelize: Sequelize) { @@ -35,6 +39,9 @@ export function initializeModels(sequelize: Sequelize) { EmailVerification.initialize(sequelize); PasswordReset.initialize(sequelize); UserSession.initialize(sequelize); + Household.initialize(sequelize); + HouseholdMember.initialize(sequelize); + WatchedTogether.initialize(sequelize); // Set up associations after all models are initialized Match.belongsTo(User, { as: 'user1', foreignKey: 'user1_id' }); @@ -105,6 +112,32 @@ export function initializeModels(sequelize: Sequelize) { as: 'watchlistEntry', }); WatchlistEntry.hasMany(Match, { foreignKey: 'entry_id', as: 'matches' }); + + // TODO: replace with Phase A model on merge + HouseholdMember.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + HouseholdMember.belongsTo(User, { + foreignKey: 'user_id', + as: 'user', + }); + Household.hasMany(HouseholdMember, { + foreignKey: 'household_id', + as: 'members', + }); + User.hasMany(HouseholdMember, { + foreignKey: 'user_id', + as: 'householdMemberships', + }); + WatchedTogether.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + Household.hasMany(WatchedTogether, { + foreignKey: 'household_id', + as: 'watchedTogether', + }); } catch (error) { console.error('Error initializing models:', error); throw new Error( @@ -125,4 +158,7 @@ export default { EmailVerification, PasswordReset, UserSession, + Household, + HouseholdMember, + WatchedTogether, }; diff --git a/backend/src/routes/history.routes.ts b/backend/src/routes/history.routes.ts new file mode 100644 index 0000000..e800d1c --- /dev/null +++ b/backend/src/routes/history.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { + getHistory, + patchHistoryEntry, +} from '../controllers/history.controller'; +import { authenticateToken } from '../middlewares/auth'; + +const router = Router({ mergeParams: true }); + +router.use(authenticateToken); + +router.get('/:id/history', getHistory); +router.patch('/:id/history/:watchedId', patchHistoryEntry); + +export default router; diff --git a/backend/src/routes/providers.routes.ts b/backend/src/routes/providers.routes.ts new file mode 100644 index 0000000..44b0365 --- /dev/null +++ b/backend/src/routes/providers.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { getProviders } from '../controllers/providers.controller'; +import { authenticateToken } from '../middlewares/auth'; + +const router = Router(); + +router.use(authenticateToken); + +router.get('/:tmdbId', getProviders); + +export default router; diff --git a/backend/src/services/history.service.ts b/backend/src/services/history.service.ts new file mode 100644 index 0000000..ae0798d --- /dev/null +++ b/backend/src/services/history.service.ts @@ -0,0 +1,83 @@ +import Content from '../models/Content'; +import { HouseholdMember } from '../models/Household'; +import WatchedTogether from '../models/WatchedTogether'; + +export interface HistoryRow { + id: string; + household_id: string; + tmdb_id: number; + media_type: 'movie' | 'tv'; + watched_at: Date; + enjoyed: boolean | null; + title: string | null; + year: number | null; + poster_path: string | null; +} + +export const isHouseholdMember = async ( + userId: string, + householdId: string +): Promise => { + const membership = await HouseholdMember.findOne({ + where: { user_id: userId, household_id: householdId }, + }); + return membership !== null; +}; + +export const getHouseholdHistory = async ( + householdId: string, + limit: number = 50 +): Promise => { + const rows = await WatchedTogether.findAll({ + where: { household_id: householdId }, + order: [['watched_at', 'DESC']], + limit, + }); + + const tmdbIds = Array.from(new Set(rows.map(r => r.tmdb_id))); + const contents = + tmdbIds.length > 0 + ? await Content.findAll({ where: { tmdb_id: tmdbIds } }) + : []; + const contentByTmdbId = new Map(contents.map(c => [c.tmdb_id, c])); + + return rows.map(row => { + const content = contentByTmdbId.get(row.tmdb_id); + return { + id: row.id, + household_id: row.household_id, + tmdb_id: row.tmdb_id, + media_type: row.media_type, + watched_at: row.watched_at, + enjoyed: row.enjoyed, + title: content?.title ?? null, + year: content?.year ?? null, + poster_path: content?.poster_path ?? null, + }; + }); +}; + +export const setEnjoyed = async ( + watchedId: string, + householdId: string, + enjoyed: boolean +): Promise => { + const row = await WatchedTogether.findOne({ + where: { id: watchedId, household_id: householdId }, + }); + if (!row) { + return null; + } + row.enjoyed = enjoyed; + await row.save(); + return row; +}; + +export const getHouseholdMemberUserIds = async ( + householdId: string +): Promise => { + const members = await HouseholdMember.findAll({ + where: { household_id: householdId }, + }); + return members.map(m => m.user_id); +}; diff --git a/backend/src/services/providers.service.ts b/backend/src/services/providers.service.ts new file mode 100644 index 0000000..ee5c350 --- /dev/null +++ b/backend/src/services/providers.service.ts @@ -0,0 +1,82 @@ +import Content, { type ContentProviders } from '../models/Content'; +import { fetchWatchProviders, type RegionProviders } from './tmdb.service'; + +interface GetProvidersOptions { + region?: string; + maxAgeHours?: number; +} + +const DEFAULT_REGION = 'GB'; +const DEFAULT_MAX_AGE_HOURS = 24; + +const titleFromMediaType = (mediaType: 'movie' | 'tv'): string => + mediaType === 'movie' ? 'Untitled movie' : 'Untitled show'; + +const mediaTypeToContentType = (mediaType: 'movie' | 'tv'): 'movie' | 'show' => + mediaType === 'movie' ? 'movie' : 'show'; + +const isStale = ( + providers: ContentProviders | null | undefined, + maxAgeHours: number +): boolean => { + if (!providers || !providers.last_updated_at) { + return true; + } + const updatedAt = new Date(providers.last_updated_at).getTime(); + if (Number.isNaN(updatedAt)) { + return true; + } + const ageMs = Date.now() - updatedAt; + return ageMs > maxAgeHours * 60 * 60 * 1000; +}; + +export const refreshProvidersForContent = async ( + tmdbId: number, + mediaType: 'movie' | 'tv' +): Promise => { + const regions = await fetchWatchProviders(tmdbId, mediaType); + + const providers: ContentProviders = { + last_updated_at: new Date().toISOString(), + regions, + }; + + const existing = await Content.findOne({ where: { tmdb_id: tmdbId } }); + + if (existing) { + existing.providers = providers; + existing.media_type = mediaType; + await existing.save(); + return; + } + + await Content.create({ + title: titleFromMediaType(mediaType), + type: mediaTypeToContentType(mediaType), + status: 'active', + tmdb_id: tmdbId, + media_type: mediaType, + providers, + }); +}; + +export const getProvidersForContent = async ( + tmdbId: number, + mediaType: 'movie' | 'tv', + options: GetProvidersOptions = {} +): Promise => { + const region = options.region ?? DEFAULT_REGION; + const maxAgeHours = options.maxAgeHours ?? DEFAULT_MAX_AGE_HOURS; + + const existing = await Content.findOne({ where: { tmdb_id: tmdbId } }); + const cached = existing?.providers ?? null; + + if (!cached || isStale(cached, maxAgeHours) || !cached.regions[region]) { + await refreshProvidersForContent(tmdbId, mediaType); + const refreshed = await Content.findOne({ where: { tmdb_id: tmdbId } }); + const next = refreshed?.providers ?? null; + return next?.regions[region] ?? null; + } + + return cached.regions[region] ?? null; +}; diff --git a/backend/src/services/tasteProfile.service.ts b/backend/src/services/tasteProfile.service.ts new file mode 100644 index 0000000..2870a46 --- /dev/null +++ b/backend/src/services/tasteProfile.service.ts @@ -0,0 +1,4 @@ +// TODO: replace with Phase A model on merge +export const recomputeForUser = async (userId: string): Promise => { + void userId; +}; diff --git a/backend/src/services/tmdb.service.ts b/backend/src/services/tmdb.service.ts index 305d906..031241e 100644 --- a/backend/src/services/tmdb.service.ts +++ b/backend/src/services/tmdb.service.ts @@ -68,3 +68,36 @@ export async function getPopular(mediaType: 'movie' | 'tv', page: number = 1) { page: page.toString(), }); } + +export interface TMDbProvider { + provider_id: number; + provider_name: string; + logo_path: string; + display_priority?: number; +} + +export interface RegionProviders { + link: string; + flatrate?: TMDbProvider[]; + rent?: TMDbProvider[]; + buy?: TMDbProvider[]; + ads?: TMDbProvider[]; + free?: TMDbProvider[]; +} + +export type ProvidersByRegion = Record; + +interface WatchProvidersResponse { + id?: number; + results?: ProvidersByRegion; +} + +export async function fetchWatchProviders( + tmdbId: number, + mediaType: 'movie' | 'tv' +): Promise { + const data = await tmdbFetch( + `/${mediaType}/${tmdbId}/watch/providers` + ); + return data.results ?? {}; +} diff --git a/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx b/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx new file mode 100644 index 0000000..346254a --- /dev/null +++ b/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx @@ -0,0 +1,186 @@ +import React, { forwardRef, useMemo } from 'react'; +import styled from 'styled-components'; +import { BaseComponentProps } from '../../../types'; + +export interface ProviderSummary { + provider_id: number; + provider_name: string; + logo_path: string; +} + +export interface ProviderBadgesProps extends BaseComponentProps { + /** + * Watch providers (e.g. flatrate list from TMDb). + */ + providers: ProviderSummary[]; + + /** + * Deep link to the JustWatch (or provider) page for this title. + * When set, each chip becomes a link to this URL. + */ + deepLink?: string; + + /** + * Affiliate parameter map keyed by provider name. The matching value is + * appended to the deep link as a suffix (e.g. `?ref=pairflix`). + * Defaults to an empty object so links pass through unmodified. + */ + affiliateParams?: Record; + + /** + * Size of each provider chip in pixels. + * @default 32 + */ + size?: number; +} + +const Row = styled.ul<{ $size: number }>` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme?.spacing?.xs || '4px'}; + list-style: none; + margin: 0; + padding: 0; +`; + +const Item = styled.li` + display: inline-flex; +`; + +const Chip = styled.a<{ $size: number }>` + display: inline-flex; + align-items: center; + justify-content: center; + width: ${({ $size }) => `${$size}px`}; + height: ${({ $size }) => `${$size}px`}; + border-radius: ${({ theme }) => theme?.borderRadius?.sm || '6px'}; + overflow: hidden; + background: ${({ theme }) => + theme?.colors?.background?.secondary || '#f5f5f5'}; + border: 1px solid + ${({ theme }) => theme?.colors?.border?.default || '#e0e0e0'}; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + text-decoration: none; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12); + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme?.colors?.primary || '#0077cc'}; + outline-offset: 2px; + } +`; + +const ChipStatic = styled.span<{ $size: number }>` + display: inline-flex; + align-items: center; + justify-content: center; + width: ${({ $size }) => `${$size}px`}; + height: ${({ $size }) => `${$size}px`}; + border-radius: ${({ theme }) => theme?.borderRadius?.sm || '6px'}; + overflow: hidden; + background: ${({ theme }) => + theme?.colors?.background?.secondary || '#f5f5f5'}; + border: 1px solid + ${({ theme }) => theme?.colors?.border?.default || '#e0e0e0'}; +`; + +const Logo = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +`; + +const TMDB_LOGO_BASE = 'https://image.tmdb.org/t/p/original'; + +const buildAffiliateLink = ( + deepLink: string, + providerName: string, + affiliateParams: Record +): string => { + const suffix = affiliateParams[providerName]; + if (!suffix) { + return deepLink; + } + return `${deepLink}${suffix}`; +}; + +/** + * Horizontally-stacked TMDb watch provider logos. When `deepLink` is set, + * each chip becomes an external link (optionally with an affiliate suffix + * appended). + */ +export const ProviderBadges = forwardRef( + ( + { + providers, + deepLink, + affiliateParams, + size = 32, + className, + 'data-testid': dataTestId, + 'aria-label': ariaLabel, + }, + ref + ) => { + const params = useMemo(() => affiliateParams ?? {}, [affiliateParams]); + + if (!providers || providers.length === 0) { + return null; + } + + return ( + + {providers.map(provider => { + const logoUrl = `${TMDB_LOGO_BASE}${provider.logo_path}`; + const altText = provider.provider_name; + + if (deepLink) { + const href = buildAffiliateLink( + deepLink, + provider.provider_name, + params + ); + return ( + + + + + + ); + } + + return ( + + + + + + ); + })} + + ); + } +); + +ProviderBadges.displayName = 'ProviderBadges'; + +export default ProviderBadges; diff --git a/lib.components/src/components/data-display/ProviderBadges/index.ts b/lib.components/src/components/data-display/ProviderBadges/index.ts new file mode 100644 index 0000000..0479e14 --- /dev/null +++ b/lib.components/src/components/data-display/ProviderBadges/index.ts @@ -0,0 +1,2 @@ +export * from './ProviderBadges'; +export { default } from './ProviderBadges'; diff --git a/lib.components/src/components/data-display/index.ts b/lib.components/src/components/data-display/index.ts index 67bad08..8bede40 100644 --- a/lib.components/src/components/data-display/index.ts +++ b/lib.components/src/components/data-display/index.ts @@ -3,4 +3,5 @@ export * from './Badge'; export * from './Card'; +export * from './ProviderBadges'; export * from './Table/Table'; From b40a69841b3e05384c18d39476db42f76c7280b0 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:10:19 +0000 Subject: [PATCH 08/20] docs(claude): reflect post-merge state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix /api/v1 → /api (the backend actually mounts at /api). - Drop the phase branch names (all merged onto the parent branch). - Note C's maybeRerank integration contract with the recommender. - Note that ownership is HouseholdMember.role='owner', not a column on Household — the billing controller relies on this. --- .claude/CLAUDE.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1859e17..30ad46c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -81,13 +81,13 @@ Product source of truth: the **Pairflix — Business Plan** Notion page (`https: The codebase historically implemented **user-to-user matching** ("find a viewing partner"). It is pivoting to **title matching for an already-paired household** ("what should we watch"). The existing `Match` model (user pairing) is the seed for the new `Household` model — both coexist during the migration. -Five in-flight phase branches off `claude/inspiring-tesla-lAShW`: +The pivot has shipped five overlapping scaffolds, all merged onto `claude/inspiring-tesla-lAShW`: -- **A — `*-phase-a-models`** — `Household`, `HouseholdMember`, `TasteProfile`, `WatchedTogether`; `Content.providers` JSONB; first real Sequelize migration. -- **B — `*-phase-b-tonight`** — ML/CF recommender (`recommendation.service.ts`), `POST /api/v1/households/:id/pick`, `TonightPicker` UI. **No LLM in the hot path.** -- **C — `*-phase-c-llm`** — Anthropic SDK scaffold (`claude-sonnet-4-6`), prompt-cached system + taste-profile blocks, tool-use structured output, behind `recommendation.llm_rerank` (default off). -- **D — `*-phase-d-providers-history`** — TMDb `/watch/providers` fetch + cache, `ProviderBadges`, `WatchedTogether` history view with thumbs capture. -- **E — `*-phase-e-pricing`** — `Subscription`, `PickUsage`, entitlements + quota middleware, **mock checkout only** (no Stripe SDK yet). +- **A** — `Household`, `HouseholdMember`, `TasteProfile`, `WatchedTogether`; `Content.providers` JSONB; first real Sequelize migration. +- **B** — ML/CF recommender (`recommendation.service.ts`), `POST /api/households/:id/pick`, `TonightPicker` UI. **No LLM in the hot path.** +- **C** — Anthropic SDK (`claude-sonnet-4-6`), prompt-cached system + taste-profile blocks, tool-use structured output, behind `recommendation.llm_rerank` (default off). Hooked into the recommender via `maybeRerank` — returns `null` to fall back to pure ML. +- **D** — TMDb `/watch/providers` fetch + cache (`providers.service.ts`), `ProviderBadges` in `lib.components`, `WatchedTogether` history view with thumbs capture. +- **E** — `Subscription`, `PickUsage`, entitlements + quota middleware, **mock checkout only** (no Stripe SDK yet). Ownership is `HouseholdMember.role = 'owner'` — there is no `owner_id` column on `Household`. Extend along these seams. Don't introduce parallel structures. @@ -116,7 +116,7 @@ npm workspaces (no Turborepo). Four workspaces: ``` pairflix/ -├── backend/ # Express + Sequelize API (port 8000, mounted at /api/v1) +├── backend/ # Express + Sequelize API (port 8000, mounted at /api) ├── app.client/ # User-facing React app (port 5173) ├── app.admin/ # Admin panel React app (port 5174) ├── lib.components/ # Shared component library (styled-components) @@ -286,7 +286,7 @@ app.client/src/ components/ # cross-feature UI not yet promoted to lib.components contexts/ # auth, theme hooks/ # cross-feature hooks - services/ # API clients calling /api/v1 + services/ # API clients calling /api utils/ ``` @@ -323,7 +323,7 @@ A feature owns its pages, components, hooks, and types. Promote a component to ` ## API Design -- REST-ish, mounted at `/api/v1`. +- REST-ish, mounted at `/api`. - Resource-oriented paths: `/households`, `/households/:id/pick`, `/watchlists/:id`. - `POST` for create + actions, `PATCH` for partial updates, `DELETE` for delete. - Auth: JWT bearer in `Authorization: Bearer `. From 17d6564b9dd5e566025065e00d4f5bbe8e027367 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:16:21 +0000 Subject: [PATCH 09/20] fix(backend): rate-limit new routes, wire pick quota, lint cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the new household/history/providers routes for missing rate limiting (they perform auth but have no quota). Mounted the existing generalRateLimit on all five new route modules (household, history, providers, billing, households-entitlements). billing /cancel additionally gets strictRateLimit since it's state-changing on a paid subscription. Wired Phase E's enforceRegionLock + enforcePickQuota into Phase B's POST /:id/pick — the TODO Phase E left because it couldn't touch B's file in parallel. Removed the now-stale TODO in households.routes. Lint fixes: prettier formatting in Phase A models / migration / tasteProfile service, and Array → T[] in tasteProfile.summary. All seven errors auto-fixed; remaining warnings are pre-existing. --- .../20260601000000-phase-a-households.ts | 7 +++---- backend/src/models/Household.ts | 5 ++++- backend/src/models/WatchedTogether.ts | 5 +---- backend/src/routes/billing.routes.ts | 5 ++++- backend/src/routes/history.routes.ts | 2 ++ backend/src/routes/household.routes.ts | 8 +++++++- backend/src/routes/households.routes.ts | 14 ++------------ backend/src/routes/providers.routes.ts | 2 ++ backend/src/services/tasteProfile.service.ts | 5 ++--- backend/src/services/tasteProfile.summary.ts | 2 +- 10 files changed, 28 insertions(+), 27 deletions(-) diff --git a/backend/src/db/migrations/20260601000000-phase-a-households.ts b/backend/src/db/migrations/20260601000000-phase-a-households.ts index b59141d..21b8f59 100644 --- a/backend/src/db/migrations/20260601000000-phase-a-households.ts +++ b/backend/src/db/migrations/20260601000000-phase-a-households.ts @@ -220,9 +220,8 @@ export async function down(queryInterface: QueryInterface): Promise { `DROP TYPE IF EXISTS "enum_watched_together_media_type"`, { transaction } ); - await sequelize.query( - `DROP TYPE IF EXISTS "enum_household_members_role"`, - { transaction } - ); + await sequelize.query(`DROP TYPE IF EXISTS "enum_household_members_role"`, { + transaction, + }); }); } diff --git a/backend/src/models/Household.ts b/backend/src/models/Household.ts index b0156f2..ae1f658 100644 --- a/backend/src/models/Household.ts +++ b/backend/src/models/Household.ts @@ -11,7 +11,10 @@ interface HouseholdCreationAttributes { name?: string | null; } -class Household extends Model { +class Household extends Model< + HouseholdAttributes, + HouseholdCreationAttributes +> { declare id: string; declare name: string | null; diff --git a/backend/src/models/WatchedTogether.ts b/backend/src/models/WatchedTogether.ts index c4e374b..baff704 100644 --- a/backend/src/models/WatchedTogether.ts +++ b/backend/src/models/WatchedTogether.ts @@ -94,10 +94,7 @@ class WatchedTogether extends Model< indexes: [ { name: 'idx_watched_together_household_watched_at', - fields: [ - 'household_id', - { name: 'watched_at', order: 'DESC' }, - ], + fields: ['household_id', { name: 'watched_at', order: 'DESC' }], }, { name: 'idx_watched_together_household_tmdb', diff --git a/backend/src/routes/billing.routes.ts b/backend/src/routes/billing.routes.ts index 408cd0c..114c1a9 100644 --- a/backend/src/routes/billing.routes.ts +++ b/backend/src/routes/billing.routes.ts @@ -6,14 +6,17 @@ import { postWebhook, } from '../controllers/billing.controller'; import { authenticateToken } from '../middlewares/auth'; +import { generalRateLimit, strictRateLimit } from '../middlewares/rate-limiter'; const router = Router(); +router.use(generalRateLimit); + // Webhook must remain unauthenticated; Stripe authenticates via signature. router.post('/webhook', postWebhook); router.post('/checkout', authenticateToken, postCheckout); -router.post('/cancel', authenticateToken, postCancel); +router.post('/cancel', authenticateToken, strictRateLimit, postCancel); router.post('/mock-activate', authenticateToken, postMockActivate); export default router; diff --git a/backend/src/routes/history.routes.ts b/backend/src/routes/history.routes.ts index e800d1c..2a4c11c 100644 --- a/backend/src/routes/history.routes.ts +++ b/backend/src/routes/history.routes.ts @@ -4,9 +4,11 @@ import { patchHistoryEntry, } from '../controllers/history.controller'; import { authenticateToken } from '../middlewares/auth'; +import { generalRateLimit } from '../middlewares/rate-limiter'; const router = Router({ mergeParams: true }); +router.use(generalRateLimit); router.use(authenticateToken); router.get('/:id/history', getHistory); diff --git a/backend/src/routes/household.routes.ts b/backend/src/routes/household.routes.ts index 9ea04a4..ac3e860 100644 --- a/backend/src/routes/household.routes.ts +++ b/backend/src/routes/household.routes.ts @@ -4,12 +4,18 @@ import { pickForHousehold, } from '../controllers/household.controller'; import { authenticateToken } from '../middlewares/auth'; +import { + enforcePickQuota, + enforceRegionLock, +} from '../middlewares/entitlements.middleware'; +import { generalRateLimit } from '../middlewares/rate-limiter'; const router = Router(); +router.use(generalRateLimit); router.use(authenticateToken); -router.post('/:id/pick', pickForHousehold); +router.post('/:id/pick', enforceRegionLock, enforcePickQuota, pickForHousehold); router.post('/:id/picks/:tmdbId/commit', commitHouseholdPick); export default router; diff --git a/backend/src/routes/households.routes.ts b/backend/src/routes/households.routes.ts index 32187ba..17a10ce 100644 --- a/backend/src/routes/households.routes.ts +++ b/backend/src/routes/households.routes.ts @@ -1,23 +1,13 @@ -// TODO: replace on merge — Phase B owns this routes file. Phase E only adds the -// entitlements GET and registers the pick-quota middleware below. import { Router } from 'express'; import { getHouseholdEntitlements } from '../controllers/billing.controller'; import { authenticateToken } from '../middlewares/auth'; -import { - enforcePickQuota, - enforceRegionLock, -} from '../middlewares/entitlements.middleware'; +import { generalRateLimit } from '../middlewares/rate-limiter'; const router = Router(); +router.use(generalRateLimit); router.use(authenticateToken); router.get('/:id/entitlements', getHouseholdEntitlements); -// TODO: when Phase B's pick controller lands, mount it here: -// router.post('/:id/pick', enforcePickQuota, enforceRegionLock, pickController); -// The two middlewares below are re-exported so Phase B can mount them directly -// if they prefer to own the route registration. -export { enforcePickQuota, enforceRegionLock }; - export default router; diff --git a/backend/src/routes/providers.routes.ts b/backend/src/routes/providers.routes.ts index 44b0365..e869bf0 100644 --- a/backend/src/routes/providers.routes.ts +++ b/backend/src/routes/providers.routes.ts @@ -1,9 +1,11 @@ import { Router } from 'express'; import { getProviders } from '../controllers/providers.controller'; import { authenticateToken } from '../middlewares/auth'; +import { generalRateLimit } from '../middlewares/rate-limiter'; const router = Router(); +router.use(generalRateLimit); router.use(authenticateToken); router.get('/:tmdbId', getProviders); diff --git a/backend/src/services/tasteProfile.service.ts b/backend/src/services/tasteProfile.service.ts index e7bb1b4..4f7498e 100644 --- a/backend/src/services/tasteProfile.service.ts +++ b/backend/src/services/tasteProfile.service.ts @@ -105,9 +105,8 @@ export async function recomputeForHousehold( }); if (!household) return []; - const memberships = (household.get('memberships') as - | HouseholdMember[] - | undefined) ?? []; + const memberships = + (household.get('memberships') as HouseholdMember[] | undefined) ?? []; const results: HouseholdMemberProfile[] = []; const now = Date.now(); diff --git a/backend/src/services/tasteProfile.summary.ts b/backend/src/services/tasteProfile.summary.ts index 298d0cb..f5099cc 100644 --- a/backend/src/services/tasteProfile.summary.ts +++ b/backend/src/services/tasteProfile.summary.ts @@ -23,7 +23,7 @@ const PCT = (n: number): number => Math.round(n * 100); function topGenres( weights: Record | undefined, limit = 2 -): Array<[string, number]> { +): [string, number][] { if (!weights) return []; const entries = Object.entries(weights).filter(([, v]) => v > 0); entries.sort((a, b) => { From f0937ffe48e36430fd62d054fd5432bd154f8a61 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:21:14 +0000 Subject: [PATCH 10/20] fix(backend): defend tmdb client against SSRF + validate region input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged tmdb.service.ts:51 for SSRF — the fetch URL is built from an endpoint string that callers interpolate user input into (region in discoverMedia query params, plus the new providers controller takes region as a query param). - tmdbFetch: resolve endpoint+params against the fixed base URL and assert the final URL stays on api.themoviedb.org over https before fetching. Defends against any future caller that lets path-traversal through. - providers controller: reject region values that aren't 2-letter A-Z with a 400 rather than passing them downstream. - household pick controller: same region whitelist on the request body. The remaining user-controlled inputs (tmdbId is Number.parseInt'd, media_type is type-narrowed) were already safe. --- backend/src/controllers/household.controller.ts | 3 +++ backend/src/controllers/providers.controller.ts | 10 ++++++---- backend/src/services/tmdb.service.ts | 11 ++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/household.controller.ts b/backend/src/controllers/household.controller.ts index 6422285..5bdbfcc 100644 --- a/backend/src/controllers/household.controller.ts +++ b/backend/src/controllers/household.controller.ts @@ -29,6 +29,9 @@ export const pickForHousehold = async (req: Request, res: Response) => { try { const body = req.body as PickBody; + if (body.region !== undefined && !/^[A-Z]{2}$/.test(body.region)) { + return res.status(400).json({ error: 'region must be a 2-letter code' }); + } const result = await defaultRecommendationService.pickForHousehold({ householdId, mood: body.mood, diff --git a/backend/src/controllers/providers.controller.ts b/backend/src/controllers/providers.controller.ts index 8322ff9..c851acd 100644 --- a/backend/src/controllers/providers.controller.ts +++ b/backend/src/controllers/providers.controller.ts @@ -21,10 +21,12 @@ export const getProviders = async (req: Request, res: Response) => { .json({ error: 'media_type must be "movie" or "tv"' }); } - const region = - typeof regionRaw === 'string' && regionRaw.length > 0 - ? regionRaw.toUpperCase() - : 'GB'; + const regionCandidate = + typeof regionRaw === 'string' ? regionRaw.toUpperCase() : ''; + if (regionCandidate && !/^[A-Z]{2}$/.test(regionCandidate)) { + return res.status(400).json({ error: 'region must be a 2-letter code' }); + } + const region = regionCandidate || 'GB'; const providers = await getProvidersForContent(tmdbId, mediaTypeRaw, { region, diff --git a/backend/src/services/tmdb.service.ts b/backend/src/services/tmdb.service.ts index 9b64b71..ec61f2e 100644 --- a/backend/src/services/tmdb.service.ts +++ b/backend/src/services/tmdb.service.ts @@ -4,6 +4,7 @@ dotenv.config(); const { TMDB_API_KEY } = process.env; const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_HOSTNAME = 'api.themoviedb.org'; export interface TMDbResponse { results?: T[]; @@ -48,7 +49,15 @@ async function tmdbFetch( ...params, }); - const response = await fetch(`${TMDB_BASE_URL}${endpoint}?${searchParams}`); + // Resolve `endpoint` against the fixed base and assert the final URL + // stays on the TMDb origin. Defends against SSRF if any caller ever + // interpolates user input into `endpoint`. + const url = new URL(`${endpoint}?${searchParams}`, TMDB_BASE_URL); + if (url.hostname !== TMDB_HOSTNAME || url.protocol !== 'https:') { + throw new Error('TMDb endpoint must stay on api.themoviedb.org'); + } + + const response = await fetch(url.toString()); if (!response.ok) { throw new Error( From 7ed9f5b61449fc41d9c6dc9e84fb5636b1fd1ecd Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:25:23 +0000 Subject: [PATCH 11/20] fix(backend): allowlist tmdb endpoint paths (CodeQL SSRF) Replace the URL-parse-then-assert pattern with a strict regex on the endpoint path. CodeQL's SSRF query recognises regex sanitisers but didn't recognise the post-parse hostname check. SAFE_ENDPOINT matches all six current call sites: /search/multi, /movie/{id}, /tv/{id}, /movie/popular, /discover/{movie|tv}, /{movie|tv}/{id}/watch/providers --- backend/src/services/tmdb.service.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/backend/src/services/tmdb.service.ts b/backend/src/services/tmdb.service.ts index ec61f2e..1251e88 100644 --- a/backend/src/services/tmdb.service.ts +++ b/backend/src/services/tmdb.service.ts @@ -4,7 +4,7 @@ dotenv.config(); const { TMDB_API_KEY } = process.env; const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; -const TMDB_HOSTNAME = 'api.themoviedb.org'; +const SAFE_ENDPOINT = /^\/[a-z][a-z_]*(?:\/[a-z0-9_]+)*$/; export interface TMDbResponse { results?: T[]; @@ -44,20 +44,18 @@ async function tmdbFetch( endpoint: string, params: Record = {} ): Promise { + // Allowlist the endpoint path so a caller can never construct a URL that + // escapes the TMDb origin. Query parameters are still URL-encoded below. + if (!SAFE_ENDPOINT.test(endpoint)) { + throw new Error(`Invalid TMDb endpoint: ${endpoint}`); + } + const searchParams = new URLSearchParams({ api_key: TMDB_API_KEY ?? '', ...params, }); - // Resolve `endpoint` against the fixed base and assert the final URL - // stays on the TMDb origin. Defends against SSRF if any caller ever - // interpolates user input into `endpoint`. - const url = new URL(`${endpoint}?${searchParams}`, TMDB_BASE_URL); - if (url.hostname !== TMDB_HOSTNAME || url.protocol !== 'https:') { - throw new Error('TMDb endpoint must stay on api.themoviedb.org'); - } - - const response = await fetch(url.toString()); + const response = await fetch(`${TMDB_BASE_URL}${endpoint}?${searchParams}`); if (!response.ok) { throw new Error( From f8cf24dfd049e5c056d1746f515284e63451434d Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:32:15 +0000 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20address=20Copilot=20review=20batch?= =?UTF-8?q?=20=E2=80=94=20migrations,=20access=20control,=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations: - Rename phase E migration 001-... to 20260601000001-... so it runs after phase A's 20260601000000-... (lexicographic order). - Fix phase E FK references in subscriptions + pick_usage from the stub PK 'households.household_id' to the real PK 'households.id'. - Fix phase E backfill SELECT to read h.id, not h.household_id. - Phase A migration: add the missing media_type/year/poster_path columns on content (model declared them but only providers was in the original migration). down() reverses them. - Phase A backfill: first user of each accepted match becomes role 'owner' instead of both being 'member'. Wiring: - backend/src/index.ts (the real entrypoint, not app.ts) now mounts the household/history/providers routes — without this the pick endpoint wasn't reachable in production. Access control: - billing checkout / cancel / mock-activate verify the caller owns the household via HouseholdMember.role='owner'. - /api/households/:id/entitlements verifies the caller is a member of that household. - household commitHouseholdPick verifies membership before write. - pick_quota_exceeded 402 redirects to /billing/mock-checkout, not the non-existent /upgrade. Stubs → real implementations (Phase A models exist now): - WatchlistBackedRepository.getHousehold reads HouseholdMember. - getMemberTasteProfiles queries TasteProfile. - getWatchedTogether reads WatchedTogether. - recordWatchedTogether writes WatchedTogether. - isMember does a real HouseholdMember lookup (was always-true). - commitHouseholdPick now actually persists WatchedTogether. LLM gate: - maybeRerank accepts an optional householdId on its context and uses isLlmRerankEnabledForHousehold when present so paid LLM calls can't fire for free-tier households. Recommender: - /discover responses have no runtime; pass null instead of input.minutes so the runtime-fit component is neutral (0.5) rather than constantly peaking at 1.0. Frontend: - UpgradeBanner takes a householdId prop and navigates to /billing/mock-checkout?household= so MockCheckout can resolve. - ProviderBadges accepts logo_path: string|null and skips entries without a logo instead of rendering a 404'd image. Docs: - db-schema.md documents subscriptions + pick_usage and the new content columns added by phase A. Not addressed in this batch (flagged for discussion): - useActiveHousehold still stubs household_id = user_id; needs a real GET /api/households endpoint plus a no-household UX state. - Recommendation service has no Jest coverage yet. - sequelize-cli config (.sequelizerc) — Phase A is .ts, Phase E is .js; the project lacks a config telling sequelize-cli where to find them. Needs a separate config pass. --- .../src/features/billing/UpgradeBanner.tsx | 10 ++- backend/src/controllers/billing.controller.ts | 43 +++++++++--- .../src/controllers/household.controller.ts | 20 ++++-- .../20260601000000-phase-a-households.ts | 40 ++++++++++- ...01-create-subscriptions-and-pick-usage.js} | 8 +-- backend/src/index.ts | 6 ++ .../middlewares/entitlements.middleware.ts | 2 +- backend/src/services/llm.service.ts | 9 ++- backend/src/services/llm.types.ts | 5 ++ .../src/services/recommendation.service.ts | 67 ++++++++++++++----- docs/db-schema.md | 45 ++++++++++++- .../ProviderBadges/ProviderBadges.tsx | 7 +- 12 files changed, 216 insertions(+), 46 deletions(-) rename backend/src/db/migrations/{001-create-subscriptions-and-pick-usage.js => 20260601000001-create-subscriptions-and-pick-usage.js} (90%) diff --git a/app.client/src/features/billing/UpgradeBanner.tsx b/app.client/src/features/billing/UpgradeBanner.tsx index 135eee5..6468359 100644 --- a/app.client/src/features/billing/UpgradeBanner.tsx +++ b/app.client/src/features/billing/UpgradeBanner.tsx @@ -19,9 +19,13 @@ const BannerContainer = styled.div<{ theme: Theme }>` interface UpgradeBannerProps { entitlements: Entitlements; + householdId: string; } -const UpgradeBanner: React.FC = ({ entitlements }) => { +const UpgradeBanner: React.FC = ({ + entitlements, + householdId, +}) => { const navigate = useNavigate(); if (entitlements.tier === 'premium') { @@ -41,7 +45,9 @@ const UpgradeBanner: React.FC = ({ entitlements }) => { {message} diff --git a/backend/src/controllers/billing.controller.ts b/backend/src/controllers/billing.controller.ts index 8e637fc..d8d2775 100644 --- a/backend/src/controllers/billing.controller.ts +++ b/backend/src/controllers/billing.controller.ts @@ -10,6 +10,28 @@ import { } from '../services/billing.service'; import { getEntitlements } from '../services/entitlements.service'; +async function requireMember( + householdId: string, + userId: string | undefined +): Promise { + if (!userId) return false; + const m = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId }, + }); + return m !== null; +} + +async function requireOwner( + householdId: string, + userId: string | undefined +): Promise { + if (!userId) return false; + const m = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId, role: 'owner' }, + }); + return m !== null; +} + export async function postCheckout(req: Request, res: Response): Promise { const { household_id, tier } = (req.body ?? {}) as { household_id?: string; @@ -19,6 +41,10 @@ export async function postCheckout(req: Request, res: Response): Promise { res.status(400).json({ error: 'household_id_required' }); return; } + if (!(await requireOwner(household_id, req.user?.user_id))) { + res.status(403).json({ error: 'owner_only' }); + return; + } const session = await startCheckoutSession(household_id, tier ?? 'premium'); res.status(200).json(session); } @@ -33,19 +59,12 @@ export async function postCancel(req: Request, res: Response): Promise { res.status(400).json({ error: 'household_id_required' }); return; } - if (!req.user) { - res.status(401).json({ error: 'Authentication required' }); - return; - } const household = await Household.findByPk(household_id); if (!household) { res.status(404).json({ error: 'household_not_found' }); return; } - const owner = await HouseholdMember.findOne({ - where: { household_id, user_id: req.user.user_id, role: 'owner' }, - }); - if (!owner) { + if (!(await requireOwner(household_id, req.user?.user_id))) { res.status(403).json({ error: 'owner_only' }); return; } @@ -66,6 +85,10 @@ export async function postMockActivate( res.status(400).json({ error: 'household_id_required' }); return; } + if (!(await requireOwner(household_id, req.user?.user_id))) { + res.status(403).json({ error: 'owner_only' }); + return; + } await mockActivatePremium(household_id); res.status(200).json({ ok: true }); } @@ -79,6 +102,10 @@ export async function getHouseholdEntitlements( res.status(400).json({ error: 'household_id_required' }); return; } + if (!(await requireMember(householdId, req.user?.user_id))) { + res.status(403).json({ error: 'not_a_member' }); + return; + } const entitlements = await getEntitlements(householdId); res.status(200).json(entitlements); } diff --git a/backend/src/controllers/household.controller.ts b/backend/src/controllers/household.controller.ts index 5bdbfcc..65b81e4 100644 --- a/backend/src/controllers/household.controller.ts +++ b/backend/src/controllers/household.controller.ts @@ -1,4 +1,6 @@ import type { Request, Response } from 'express'; +import HouseholdMember from '../models/HouseholdMember'; +import WatchedTogether from '../models/WatchedTogether'; import { defaultRecommendationService } from '../services/recommendation.service'; import type { Mood } from '../services/recommendation.types'; @@ -68,16 +70,24 @@ export const commitHouseholdPick = async (req: Request, res: Response) => { const body = req.body as CommitBody; try { - // Phase A will own WatchedTogether persistence; for now the recommender - // service stubs the write so the contract is stable. - const payload = { + const member = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId }, + }); + if (!member) { + return res.status(403).json({ error: 'not_a_member' }); + } + + const row = await WatchedTogether.create({ household_id: householdId, tmdb_id: tmdbId, media_type: body.media_type, + watched_at: new Date(), + enjoyed: null, mood_at_pick: body.mood ?? null, minutes_budget_at_pick: body.minutes ?? null, - }; - return res.status(201).json({ recorded: true, ...payload }); + }); + + return res.status(201).json({ recorded: true, id: row.id }); } catch (error) { console.error('Error committing household pick:', error); const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/backend/src/db/migrations/20260601000000-phase-a-households.ts b/backend/src/db/migrations/20260601000000-phase-a-households.ts index 21b8f59..4402d20 100644 --- a/backend/src/db/migrations/20260601000000-phase-a-households.ts +++ b/backend/src/db/migrations/20260601000000-phase-a-households.ts @@ -155,6 +155,36 @@ export async function up(queryInterface: QueryInterface): Promise { { transaction } ); + await queryInterface.addColumn( + 'content', + 'media_type', + { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: true, + }, + { transaction } + ); + + await queryInterface.addColumn( + 'content', + 'year', + { + type: DataTypes.INTEGER, + allowNull: true, + }, + { transaction } + ); + + await queryInterface.addColumn( + 'content', + 'poster_path', + { + type: DataTypes.STRING, + allowNull: true, + }, + { transaction } + ); + await sequelize.query( ` INSERT INTO households (id, name, created_at, updated_at) @@ -183,7 +213,7 @@ export async function up(queryInterface: QueryInterface): Promise { FROM households h ) INSERT INTO household_members (household_id, user_id, role, joined_at) - SELECT nh.id, a.user1_id, 'member', NOW() + SELECT nh.id, a.user1_id, 'owner', NOW() FROM accepted a JOIN new_households nh ON nh.rn = a.rn UNION ALL @@ -212,6 +242,14 @@ export async function down(queryInterface: QueryInterface): Promise { await sequelize.transaction(async transaction => { await queryInterface.removeColumn('content', 'providers', { transaction }); + await queryInterface.removeColumn('content', 'media_type', { transaction }); + await queryInterface.removeColumn('content', 'year', { transaction }); + await queryInterface.removeColumn('content', 'poster_path', { + transaction, + }); + await sequelize.query(`DROP TYPE IF EXISTS "enum_content_media_type"`, { + transaction, + }); await queryInterface.dropTable('watched_together', { transaction }); await queryInterface.dropTable('taste_profiles', { transaction }); await queryInterface.dropTable('household_members', { transaction }); diff --git a/backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js b/backend/src/db/migrations/20260601000001-create-subscriptions-and-pick-usage.js similarity index 90% rename from backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js rename to backend/src/db/migrations/20260601000001-create-subscriptions-and-pick-usage.js index 676b7ec..eb6f366 100644 --- a/backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js +++ b/backend/src/db/migrations/20260601000001-create-subscriptions-and-pick-usage.js @@ -21,7 +21,7 @@ module.exports = { type: Sequelize.UUID, allowNull: false, unique: true, - references: { model: 'households', key: 'household_id' }, + references: { model: 'households', key: 'id' }, onDelete: 'CASCADE', }, tier: { @@ -58,7 +58,7 @@ module.exports = { household_id: { type: Sequelize.UUID, allowNull: false, - references: { model: 'households', key: 'household_id' }, + references: { model: 'households', key: 'id' }, onDelete: 'CASCADE', }, picked_at: { @@ -76,9 +76,9 @@ module.exports = { // Backfill: every existing household gets a free/active subscription. await queryInterface.sequelize.query(` INSERT INTO subscriptions (id, household_id, tier, status, created_at, updated_at) - SELECT gen_random_uuid(), h.household_id, 'free', 'active', NOW(), NOW() + SELECT gen_random_uuid(), h.id, 'free', 'active', NOW(), NOW() FROM households h - LEFT JOIN subscriptions s ON s.household_id = h.household_id + LEFT JOIN subscriptions s ON s.household_id = h.id WHERE s.id IS NULL; `); }, diff --git a/backend/src/index.ts b/backend/src/index.ts index ede885c..d937eae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,8 +16,11 @@ import activityRoutes from './routes/activity.routes'; import adminRoutes from './routes/admin.routes'; import authRoutes from './routes/auth.routes'; import billingRoutes from './routes/billing.routes'; +import historyRoutes from './routes/history.routes'; +import householdRoutes from './routes/household.routes'; import householdsRoutes from './routes/households.routes'; import matchRoutes from './routes/match.routes'; +import providersRoutes from './routes/providers.routes'; import searchRoutes from './routes/search.routes'; import userRoutes from './routes/user.routes'; import watchlistRoutes from './routes/watchlist.routes'; @@ -93,7 +96,10 @@ app.use('/api/watchlist', authenticateToken, watchlistRoutes); app.use('/api/matches', authenticateToken, matchRoutes); app.use('/api/activity', authenticateToken, activityRoutes); app.use('/api/admin', adminRateLimit, adminRoutes); // Admin routes handle their own authentication +app.use('/api/households', householdRoutes); app.use('/api/households', householdsRoutes); +app.use('/api/households', historyRoutes); +app.use('/api/providers', providersRoutes); app.use('/api/billing', billingRoutes); // Global error handler middleware (after routes) diff --git a/backend/src/middlewares/entitlements.middleware.ts b/backend/src/middlewares/entitlements.middleware.ts index 31170e8..7c71a36 100644 --- a/backend/src/middlewares/entitlements.middleware.ts +++ b/backend/src/middlewares/entitlements.middleware.ts @@ -48,7 +48,7 @@ export async function enforcePickQuota( if (entitlements.picks_remaining <= 0) { res.status(402).json({ error: 'pick_quota_exceeded', - upgrade_url: '/upgrade', + upgrade_url: '/billing/mock-checkout', entitlements, }); return; diff --git a/backend/src/services/llm.service.ts b/backend/src/services/llm.service.ts index b9de9d0..d6f70e6 100644 --- a/backend/src/services/llm.service.ts +++ b/backend/src/services/llm.service.ts @@ -1,7 +1,10 @@ import Anthropic from '@anthropic-ai/sdk'; import { auditLogService } from './audit.service'; -import { isLlmRerankEnabled } from './featureFlags.service'; +import { + isLlmRerankEnabled, + isLlmRerankEnabledForHousehold, +} from './featureFlags.service'; import { llmCacheService } from './llmCache.service'; import { LLMUnavailable, @@ -192,7 +195,9 @@ export async function maybeRerank( ctx: LlmRerankContext ): Promise { try { - const enabled = await isLlmRerankEnabled(); + const enabled = ctx.householdId + ? await isLlmRerankEnabledForHousehold(ctx.householdId) + : await isLlmRerankEnabled(); if (!enabled) return null; const cacheKey = llmCacheService.buildCacheKey({ diff --git a/backend/src/services/llm.types.ts b/backend/src/services/llm.types.ts index 6cf59e8..0fa9b59 100644 --- a/backend/src/services/llm.types.ts +++ b/backend/src/services/llm.types.ts @@ -55,6 +55,11 @@ export interface LlmRerankContext { * the cache key so a profile change invalidates cached LLM picks. */ tasteProfileVersions: string[]; + /** + * Required when callers want the per-household entitlement gate + * (Phase E). When omitted, only the global flag is checked. + */ + householdId?: string; } export class LLMUnavailable extends Error { diff --git a/backend/src/services/recommendation.service.ts b/backend/src/services/recommendation.service.ts index ef46f32..ad06401 100644 --- a/backend/src/services/recommendation.service.ts +++ b/backend/src/services/recommendation.service.ts @@ -277,10 +277,11 @@ export class RecommendationService { const currentYear = new Date().getFullYear(); const scored = filtered.map(item => { - const runtime = input.minutes; + // /discover responses don't carry runtime; use null so the runtime + // component contributes a neutral 0.5 instead of the gaussian peak. const { score } = scoreCandidate( item, - runtime, + null, merged, moodCfg.genres, input.minutes, @@ -382,23 +383,46 @@ export class RecommendationService { } class WatchlistBackedRepository implements HouseholdRepository { - async getHousehold(_householdId: string): Promise { - void _householdId; - return null; + async getHousehold(householdId: string): Promise { + const members = await models.HouseholdMember.findAll({ + where: { household_id: householdId }, + }); + if (members.length === 0) return null; + return { + household_id: householdId, + member_ids: members.map(m => m.user_id), + }; } async getMemberTasteProfiles( - _memberIds: string[] + memberIds: string[] ): Promise { - void _memberIds; - return []; + if (memberIds.length === 0) return []; + const rows = await models.TasteProfile.findAll({ + where: { user_id: memberIds }, + }); + return rows.map(r => ({ + user_id: r.user_id, + weights: r.weights as TasteProfileStub['weights'], + embedding: r.embedding, + })); } async getWatchedTogether( - _householdId: string + householdId: string ): Promise { - void _householdId; - return []; + const rows = await models.WatchedTogether.findAll({ + where: { household_id: householdId }, + }); + return rows.map(r => ({ + household_id: r.household_id, + tmdb_id: r.tmdb_id, + media_type: r.media_type, + watched_at: r.watched_at, + enjoyed: r.enjoyed, + mood_at_pick: (r.mood_at_pick ?? null) as Mood | null, + minutes_budget_at_pick: r.minutes_budget_at_pick, + })); } async getFinishedTmdbIdsForMembers(memberIds: string[]): Promise { @@ -410,15 +434,24 @@ class WatchlistBackedRepository implements HouseholdRepository { } async recordWatchedTogether( - _row: Omit & { watched_at?: Date } + row: Omit & { watched_at?: Date } ): Promise { - void _row; + await models.WatchedTogether.create({ + household_id: row.household_id, + tmdb_id: row.tmdb_id, + media_type: row.media_type, + watched_at: row.watched_at ?? new Date(), + enjoyed: row.enjoyed ?? null, + mood_at_pick: row.mood_at_pick ?? null, + minutes_budget_at_pick: row.minutes_budget_at_pick ?? null, + }); } - async isMember(_householdId: string, _userId: string): Promise { - void _householdId; - void _userId; - return true; + async isMember(householdId: string, userId: string): Promise { + const m = await models.HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId }, + }); + return m !== null; } } diff --git a/docs/db-schema.md b/docs/db-schema.md index 3e5dbe5..af9bce9 100644 --- a/docs/db-schema.md +++ b/docs/db-schema.md @@ -340,12 +340,15 @@ CREATE INDEX idx_watched_together_household_tmdb ON watched_together(household_id, tmdb_id); ``` -### Content (providers) +### Content (new columns) -The `content` table gains a `providers` JSONB column for region-keyed availability fetched from TMDb `/watch/providers`. Default `{}`, nullable. +Phase A's migration adds four columns to `content`. `providers` is the region-keyed availability map fetched from TMDb `/watch/providers`. The other three back the history view's render fields. ```sql ALTER TABLE content ADD COLUMN providers JSONB DEFAULT '{}'::jsonb; +ALTER TABLE content ADD COLUMN media_type "enum_content_media_type"; -- 'movie' | 'tv' +ALTER TABLE content ADD COLUMN year INTEGER; +ALTER TABLE content ADD COLUMN poster_path VARCHAR(255); ``` Shape: @@ -353,7 +356,9 @@ Shape: ```json { "US": { - "flatrate": [{ "provider_id": 8, "provider_name": "Netflix", "logo_path": "/..." }], + "flatrate": [ + { "provider_id": 8, "provider_name": "Netflix", "logo_path": "/..." } + ], "rent": [], "buy": [] }, @@ -361,6 +366,40 @@ Shape: } ``` +## Phase E — freemium pricing + +### Subscriptions + +One row per household. Tier defaults to `free`. Premium status requires `tier = 'premium' AND status = 'active' AND current_period_end > now()`. `stripe_*` fields are nullable until the real Stripe integration is wired (Phase E currently ships a mock checkout only). + +```sql +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL UNIQUE REFERENCES households(id) ON DELETE CASCADE, + tier "enum_subscriptions_tier" NOT NULL DEFAULT 'free', -- 'free' | 'premium' + status "enum_subscriptions_status" NOT NULL DEFAULT 'active', -- 'active' | 'past_due' | 'canceled' + stripe_customer_id VARCHAR(255), + stripe_subscription_id VARCHAR(255), + current_period_end TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +### Pick usage + +One row per successful `POST /api/households/:id/pick`. Drives the daily-quota check on the free tier. + +```sql +CREATE TABLE pick_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE, + picked_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX pick_usage_household_picked_at_idx + ON pick_usage(household_id, picked_at DESC); +``` + ## Indexes ```sql diff --git a/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx b/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx index 346254a..0a6bf23 100644 --- a/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx +++ b/lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx @@ -5,7 +5,7 @@ import { BaseComponentProps } from '../../../types'; export interface ProviderSummary { provider_id: number; provider_name: string; - logo_path: string; + logo_path: string | null; } export interface ProviderBadgesProps extends BaseComponentProps { @@ -130,7 +130,8 @@ export const ProviderBadges = forwardRef( ) => { const params = useMemo(() => affiliateParams ?? {}, [affiliateParams]); - if (!providers || providers.length === 0) { + const withLogo = providers?.filter(p => p.logo_path) ?? []; + if (withLogo.length === 0) { return null; } @@ -142,7 +143,7 @@ export const ProviderBadges = forwardRef( data-testid={dataTestId} aria-label={ariaLabel ?? 'Watch providers'} > - {providers.map(provider => { + {withLogo.map(provider => { const logoUrl = `${TMDB_LOGO_BASE}${provider.logo_path}`; const altText = provider.provider_name; From b738d0bb6c423b2f3976ca2022daa918e7b9a24d Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:43:18 +0000 Subject: [PATCH 13/20] feat(households): create + invite + accept flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - New HouseholdInvite model + migration (20260601000002-...) with a random token, 7-day expiry, and accepted_at/_by audit fields. - household.service: listForUser, createForOwner (creates household + adds caller as 'owner' member in a transaction), createInvite (24-byte base64url token), acceptInvite (idempotent — won't duplicate membership if already a member). - New endpoints on household.routes: GET /api/households/ list user's households POST /api/households/ create + become owner POST /api/households/:id/invites owner-only, returns token - New router /api/household-invites: POST /:token/accept authed; 410 if invalid/expired. - Wired into index.ts and app.ts. Frontend - households API client gains list/create/invite/acceptInvite. - useActiveHousehold now hits GET /api/households and returns the first (most-recently-joined). Exposes the full list too. - TonightPicker shows a 'create a household' empty state when the user has no households. - New pages: CreateHouseholdPage (/households/new), InviteToHouseholdPage (/households/:id/invites — copy-paste invite URL, no email send yet), AcceptInvitePage (/household-invites/:token — auto-accepts then routes to /tonight). No email send is wired — the owner shares the generated link out-of-band. Email integration is a follow-up. --- app.client/src/components/layout/Routes.tsx | 15 ++ .../features/households/AcceptInvitePage.tsx | 54 ++++++ .../households/CreateHouseholdPage.tsx | 76 ++++++++ .../households/InviteToHouseholdPage.tsx | 100 ++++++++++ .../src/features/tonight/TonightPicker.tsx | 26 ++- .../features/tonight/useActiveHousehold.ts | 35 ++-- app.client/src/services/api/households.ts | 45 +++++ .../src/controllers/household.controller.ts | 69 +++++++ ...20260601000002-create-household-invites.ts | 74 ++++++++ backend/src/index.ts | 2 + backend/src/models/HouseholdInvite.ts | 110 +++++++++++ backend/src/models/index.ts | 12 ++ backend/src/routes/household.routes.ts | 6 + backend/src/routes/householdInvites.routes.ts | 13 ++ backend/src/services/household.service.ts | 173 ++++++++++++++++++ 15 files changed, 793 insertions(+), 17 deletions(-) create mode 100644 app.client/src/features/households/AcceptInvitePage.tsx create mode 100644 app.client/src/features/households/CreateHouseholdPage.tsx create mode 100644 app.client/src/features/households/InviteToHouseholdPage.tsx create mode 100644 backend/src/db/migrations/20260601000002-create-household-invites.ts create mode 100644 backend/src/models/HouseholdInvite.ts create mode 100644 backend/src/routes/householdInvites.routes.ts create mode 100644 backend/src/services/household.service.ts diff --git a/app.client/src/components/layout/Routes.tsx b/app.client/src/components/layout/Routes.tsx index 053a28f..8cecbe3 100644 --- a/app.client/src/components/layout/Routes.tsx +++ b/app.client/src/components/layout/Routes.tsx @@ -15,6 +15,9 @@ import ProfilePage from '../../features/auth/ProfilePage'; import RegisterPage from '../../features/auth/RegisterPage'; import ResetPasswordPage from '../../features/auth/ResetPasswordPage'; import HistoryPage from '../../features/history/HistoryPage'; +import AcceptInvitePage from '../../features/households/AcceptInvitePage'; +import CreateHouseholdPage from '../../features/households/CreateHouseholdPage'; +import InviteToHouseholdPage from '../../features/households/InviteToHouseholdPage'; import MatchPage from '../../features/match/MatchPage'; import TonightPicker from '../../features/tonight/TonightPicker'; import { useTonightHomepagePreference } from '../../features/tonight/useTonightHomepage'; @@ -104,6 +107,18 @@ const AppRoutes: React.FC = () => { path="/history" element={} />} /> + } />} + /> + } />} + /> + } />} + /> } />} diff --git a/app.client/src/features/households/AcceptInvitePage.tsx b/app.client/src/features/households/AcceptInvitePage.tsx new file mode 100644 index 0000000..58b1904 --- /dev/null +++ b/app.client/src/features/households/AcceptInvitePage.tsx @@ -0,0 +1,54 @@ +import { + Button, + Container, + H1, + PageContainer, + Typography, +} from '@pairflix/components'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { households as householdsApi } from '../../services/api'; + +const AcceptInvitePage: React.FC = () => { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const acceptMutation = useMutation({ + mutationFn: () => householdsApi.acceptInvite(token ?? ''), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['households'] }); + navigate('/tonight'); + }, + }); + + useEffect(() => { + if (token) acceptMutation.mutate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + return ( + + +

Joining household…

+ {acceptMutation.isLoading && Working…} + {acceptMutation.isError && ( + <> + + That invite is invalid or has expired. + + + + )} +
+
+ ); +}; + +export default AcceptInvitePage; diff --git a/app.client/src/features/households/CreateHouseholdPage.tsx b/app.client/src/features/households/CreateHouseholdPage.tsx new file mode 100644 index 0000000..3460ec6 --- /dev/null +++ b/app.client/src/features/households/CreateHouseholdPage.tsx @@ -0,0 +1,76 @@ +import { + Button, + Container, + H1, + Input, + PageContainer, + Typography, +} from '@pairflix/components'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import { households as householdsApi } from '../../services/api'; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.md}; + max-width: 480px; +`; + +const CreateHouseholdPage: React.FC = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [error, setError] = useState(null); + + const createMutation = useMutation({ + mutationFn: (body: { name?: string }) => householdsApi.create(body), + onSuccess: data => { + void queryClient.invalidateQueries({ queryKey: ['households'] }); + navigate(`/households/${data.household.id}/invites`); + }, + onError: (err: Error) => setError(err.message), + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate(name.trim() ? { name: name.trim() } : {}); + }; + + return ( + + +

Create a household

+ + Households are how you and a partner pick what to watch together. + You'll be able to invite someone right after. + +
+ setName(e.target.value)} + maxLength={80} + /> + {error && ( + + {error} + + )} + +
+
+
+ ); +}; + +export default CreateHouseholdPage; diff --git a/app.client/src/features/households/InviteToHouseholdPage.tsx b/app.client/src/features/households/InviteToHouseholdPage.tsx new file mode 100644 index 0000000..dd91e1a --- /dev/null +++ b/app.client/src/features/households/InviteToHouseholdPage.tsx @@ -0,0 +1,100 @@ +import { + Button, + Container, + H1, + Input, + PageContainer, + Typography, +} from '@pairflix/components'; +import { useMutation } from '@tanstack/react-query'; +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import { households as householdsApi } from '../../services/api'; +import type { InviteSummary } from '../../services/api/households'; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.md}; + max-width: 480px; +`; + +const LinkBox = styled.code` + display: block; + padding: ${({ theme }) => theme.spacing.sm}; + background: ${({ theme }) => theme.colors.background.secondary}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + word-break: break-all; +`; + +const InviteToHouseholdPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const [email, setEmail] = useState(''); + const [invite, setInvite] = useState(null); + const [error, setError] = useState(null); + + const inviteMutation = useMutation({ + mutationFn: () => + householdsApi.invite( + id ?? '', + email.trim() ? { email: email.trim() } : {} + ), + onSuccess: data => setInvite(data.invite), + onError: (err: Error) => setError(err.message), + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + inviteMutation.mutate(); + }; + + const inviteUrl = invite + ? `${window.location.origin}/household-invites/${invite.token}` + : null; + + return ( + + +

Invite a partner

+ + Generate an invite link to share. The recipient opens the link and + accepts to join your household. + +
+ setEmail(e.target.value)} + /> + {error && ( + + {error} + + )} + +
+ + {inviteUrl && ( + + + Send this link. It expires in 7 days. + + {inviteUrl} + + )} +
+
+ ); +}; + +export default InviteToHouseholdPage; diff --git a/app.client/src/features/tonight/TonightPicker.tsx b/app.client/src/features/tonight/TonightPicker.tsx index c6fc514..a3ce54b 100644 --- a/app.client/src/features/tonight/TonightPicker.tsx +++ b/app.client/src/features/tonight/TonightPicker.tsx @@ -134,10 +134,10 @@ const TonightPicker: React.FC = () => { const [dismissed, setDismissed] = useState(false); const pickMutation = useTonightPick({ - householdId: household?.household_id ?? '', + householdId: household?.id ?? '', }); const commitMutation = useCommitPick({ - householdId: household?.household_id ?? '', + householdId: household?.id ?? '', }); const result = pickMutation.data; @@ -202,6 +202,28 @@ const TonightPicker: React.FC = () => { ); } + if (!household) { + return ( + + +

Tonight

+ + You're not in a household yet. Create one and invite a partner — or + accept an invite you've been sent. + + + + +
+
+ ); + } + return ( diff --git a/app.client/src/features/tonight/useActiveHousehold.ts b/app.client/src/features/tonight/useActiveHousehold.ts index 0b7761b..868e47e 100644 --- a/app.client/src/features/tonight/useActiveHousehold.ts +++ b/app.client/src/features/tonight/useActiveHousehold.ts @@ -1,21 +1,26 @@ -import { useAuth } from '../../hooks/useAuth'; +import { useQuery } from '@tanstack/react-query'; +import { households as householdsApi } from '../../services/api'; +import type { HouseholdSummary } from '../../services/api/households'; -// Phase A delivers the real Household model + endpoint. Until then we derive a -// stable id from the authed user so the Tonight flow has something to call. -export interface ActiveHousehold { - household_id: string; - status: 'accepted'; +export interface ActiveHouseholdState { + household: HouseholdSummary | null; + households: HouseholdSummary[]; + isLoading: boolean; + isError: boolean; } -export function useActiveHousehold(): { - household: ActiveHousehold | null; - isLoading: boolean; -} { - const { user, isLoading } = useAuth(); - if (isLoading) return { household: null, isLoading: true }; - if (!user) return { household: null, isLoading: false }; +export function useActiveHousehold(): ActiveHouseholdState { + const { data, isLoading, isError } = useQuery({ + queryKey: ['households'], + queryFn: () => householdsApi.list(), + staleTime: 60_000, + }); + + const list = data?.households ?? []; return { - household: { household_id: user.user_id, status: 'accepted' }, - isLoading: false, + household: list[0] ?? null, + households: list, + isLoading, + isError, }; } diff --git a/app.client/src/services/api/households.ts b/app.client/src/services/api/households.ts index f503131..1dbb827 100644 --- a/app.client/src/services/api/households.ts +++ b/app.client/src/services/api/households.ts @@ -49,7 +49,52 @@ export interface PickRequest { excludeTmdbIds?: number[]; } +export interface HouseholdSummary { + id: string; + name: string | null; + role: 'owner' | 'member'; + joined_at: string; + member_count: number; +} + +export interface InviteSummary { + id: string; + token: string; + invited_email: string | null; + expires_at: string; + accepted_at: string | null; +} + export const households = { + list: async (): Promise<{ households: HouseholdSummary[] }> => { + return fetchWithAuth('/api/households/'); + }, + + create: async ( + body: { name?: string } = {} + ): Promise<{ household: HouseholdSummary }> => { + return fetchWithAuth('/api/households/', { + method: 'POST', + body: JSON.stringify(body), + }); + }, + + invite: async ( + householdId: string, + body: { email?: string } = {} + ): Promise<{ invite: InviteSummary }> => { + return fetchWithAuth(`/api/households/${householdId}/invites`, { + method: 'POST', + body: JSON.stringify(body), + }); + }, + + acceptInvite: async (token: string): Promise<{ household_id: string }> => { + return fetchWithAuth(`/api/household-invites/${token}/accept`, { + method: 'POST', + }); + }, + pick: async ( householdId: string, body: PickRequest diff --git a/backend/src/controllers/household.controller.ts b/backend/src/controllers/household.controller.ts index 65b81e4..8daf314 100644 --- a/backend/src/controllers/household.controller.ts +++ b/backend/src/controllers/household.controller.ts @@ -1,6 +1,13 @@ import type { Request, Response } from 'express'; import HouseholdMember from '../models/HouseholdMember'; import WatchedTogether from '../models/WatchedTogether'; +import { + acceptInvite, + createForOwner, + createInvite, + isOwner, + listForUser, +} from '../services/household.service'; import { defaultRecommendationService } from '../services/recommendation.service'; import type { Mood } from '../services/recommendation.types'; @@ -94,3 +101,65 @@ export const commitHouseholdPick = async (req: Request, res: Response) => { return res.status(500).json({ error: message }); } }; + +export const listHouseholds = async (req: Request, res: Response) => { + const userId = req.user?.user_id; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + const households = await listForUser(userId); + return res.json({ households }); +}; + +export const createHousehold = async (req: Request, res: Response) => { + const userId = req.user?.user_id; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + const { name } = (req.body ?? {}) as { name?: string }; + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (trimmed.length > 80) { + return res.status(400).json({ error: 'name_too_long' }); + } + const household = await createForOwner(userId, trimmed || null); + return res.status(201).json({ household }); +}; + +export const postInvite = async (req: Request, res: Response) => { + const userId = req.user?.user_id; + const householdId = req.params.id; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + if (!householdId) { + return res.status(400).json({ error: 'household_id_required' }); + } + if (!(await isOwner(householdId, userId))) { + return res.status(403).json({ error: 'owner_only' }); + } + const { email } = (req.body ?? {}) as { email?: string }; + const invitedEmail = + typeof email === 'string' && email.trim() ? email.trim() : null; + const invite = await createInvite(householdId, userId, invitedEmail); + return res.status(201).json({ invite }); +}; + +export const postAcceptInvite = async (req: Request, res: Response) => { + const userId = req.user?.user_id; + const token = req.params.token; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + if (!token) { + return res.status(400).json({ error: 'token_required' }); + } + try { + const result = await acceptInvite(token, userId); + return res.status(200).json(result); + } catch (error) { + if (error instanceof Error && error.name === 'InviteInvalid') { + return res.status(410).json({ error: 'invite_invalid_or_expired' }); + } + throw error; + } +}; diff --git a/backend/src/db/migrations/20260601000002-create-household-invites.ts b/backend/src/db/migrations/20260601000002-create-household-invites.ts new file mode 100644 index 0000000..cb4e0c3 --- /dev/null +++ b/backend/src/db/migrations/20260601000002-create-household-invites.ts @@ -0,0 +1,74 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + const sequelize = queryInterface.sequelize; + + await sequelize.transaction(async transaction => { + await queryInterface.createTable( + 'household_invites', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'households', key: 'id' }, + onDelete: 'CASCADE', + }, + token: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true, + }, + invited_email: { + type: DataTypes.STRING, + allowNull: true, + }, + invited_by: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'users', key: 'user_id' }, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + }, + accepted_at: { + type: DataTypes.DATE, + allowNull: true, + }, + accepted_by: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'user_id' }, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { transaction } + ); + + await queryInterface.addIndex('household_invites', { + name: 'idx_household_invites_token', + fields: ['token'], + unique: true, + transaction, + }); + + await queryInterface.addIndex('household_invites', { + name: 'idx_household_invites_household', + fields: ['household_id'], + transaction, + }); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('household_invites'); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d937eae..c3730b2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,7 @@ import authRoutes from './routes/auth.routes'; import billingRoutes from './routes/billing.routes'; import historyRoutes from './routes/history.routes'; import householdRoutes from './routes/household.routes'; +import householdInvitesRoutes from './routes/householdInvites.routes'; import householdsRoutes from './routes/households.routes'; import matchRoutes from './routes/match.routes'; import providersRoutes from './routes/providers.routes'; @@ -99,6 +100,7 @@ app.use('/api/admin', adminRateLimit, adminRoutes); // Admin routes handle their app.use('/api/households', householdRoutes); app.use('/api/households', householdsRoutes); app.use('/api/households', historyRoutes); +app.use('/api/household-invites', householdInvitesRoutes); app.use('/api/providers', providersRoutes); app.use('/api/billing', billingRoutes); diff --git a/backend/src/models/HouseholdInvite.ts b/backend/src/models/HouseholdInvite.ts new file mode 100644 index 0000000..b50f6bf --- /dev/null +++ b/backend/src/models/HouseholdInvite.ts @@ -0,0 +1,110 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; +import User from './User'; + +interface HouseholdInviteAttributes { + id: string; + household_id: string; + token: string; + invited_email: string | null; + invited_by: string; + expires_at: Date; + accepted_at: Date | null; + accepted_by: string | null; + created_at: Date; +} + +interface HouseholdInviteCreationAttributes { + household_id: string; + token: string; + invited_email?: string | null; + invited_by: string; + expires_at: Date; + accepted_at?: Date | null; + accepted_by?: string | null; +} + +class HouseholdInvite extends Model< + HouseholdInviteAttributes, + HouseholdInviteCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare token: string; + + declare invited_email: string | null; + + declare invited_by: string; + + declare expires_at: Date; + + declare accepted_at: Date | null; + + declare accepted_by: string | null; + + declare created_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return HouseholdInvite.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: Household, key: 'id' }, + onDelete: 'CASCADE', + }, + token: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true, + }, + invited_email: { + type: DataTypes.STRING, + allowNull: true, + }, + invited_by: { + type: DataTypes.UUID, + allowNull: false, + references: { model: User, key: 'user_id' }, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + }, + accepted_at: { + type: DataTypes.DATE, + allowNull: true, + }, + accepted_by: { + type: DataTypes.UUID, + allowNull: true, + references: { model: User, key: 'user_id' }, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'HouseholdInvite', + tableName: 'household_invites', + timestamps: false, + indexes: [ + { fields: ['token'], unique: true }, + { fields: ['household_id'] }, + ], + } + ); + } +} + +export default HouseholdInvite; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 8a8795d..82d5e62 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -6,6 +6,7 @@ import Content from './Content'; import ContentReport from './ContentReport'; import EmailVerification from './EmailVerification'; import Household from './Household'; +import HouseholdInvite from './HouseholdInvite'; import HouseholdMember from './HouseholdMember'; import Match from './Match'; import PasswordReset from './PasswordReset'; @@ -43,6 +44,7 @@ export function initializeModels(sequelize: Sequelize) { UserSession.initialize(sequelize); Household.initialize(sequelize); HouseholdMember.initialize(sequelize); + HouseholdInvite.initialize(sequelize); TasteProfile.initialize(sequelize); WatchedTogether.initialize(sequelize); Subscription.initialize(sequelize); @@ -144,6 +146,15 @@ export function initializeModels(sequelize: Sequelize) { TasteProfile.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); User.hasOne(TasteProfile, { foreignKey: 'user_id', as: 'tasteProfile' }); + HouseholdInvite.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + Household.hasMany(HouseholdInvite, { + foreignKey: 'household_id', + as: 'invites', + }); + WatchedTogether.belongsTo(Household, { foreignKey: 'household_id', as: 'household', @@ -194,6 +205,7 @@ export default { UserSession, Household, HouseholdMember, + HouseholdInvite, TasteProfile, WatchedTogether, Subscription, diff --git a/backend/src/routes/household.routes.ts b/backend/src/routes/household.routes.ts index ac3e860..a5737a4 100644 --- a/backend/src/routes/household.routes.ts +++ b/backend/src/routes/household.routes.ts @@ -1,7 +1,10 @@ import { Router } from 'express'; import { commitHouseholdPick, + createHousehold, + listHouseholds, pickForHousehold, + postInvite, } from '../controllers/household.controller'; import { authenticateToken } from '../middlewares/auth'; import { @@ -15,6 +18,9 @@ const router = Router(); router.use(generalRateLimit); router.use(authenticateToken); +router.get('/', listHouseholds); +router.post('/', createHousehold); +router.post('/:id/invites', postInvite); router.post('/:id/pick', enforceRegionLock, enforcePickQuota, pickForHousehold); router.post('/:id/picks/:tmdbId/commit', commitHouseholdPick); diff --git a/backend/src/routes/householdInvites.routes.ts b/backend/src/routes/householdInvites.routes.ts new file mode 100644 index 0000000..a8666f6 --- /dev/null +++ b/backend/src/routes/householdInvites.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { postAcceptInvite } from '../controllers/household.controller'; +import { authenticateToken } from '../middlewares/auth'; +import { generalRateLimit } from '../middlewares/rate-limiter'; + +const router = Router(); + +router.use(generalRateLimit); +router.use(authenticateToken); + +router.post('/:token/accept', postAcceptInvite); + +export default router; diff --git a/backend/src/services/household.service.ts b/backend/src/services/household.service.ts new file mode 100644 index 0000000..06f4b14 --- /dev/null +++ b/backend/src/services/household.service.ts @@ -0,0 +1,173 @@ +import crypto from 'crypto'; +import { Op } from 'sequelize'; +import sequelize from '../db/connection'; +import Household from '../models/Household'; +import HouseholdInvite from '../models/HouseholdInvite'; +import HouseholdMember from '../models/HouseholdMember'; + +const INVITE_TTL_DAYS = 7; + +export interface HouseholdSummary { + id: string; + name: string | null; + role: 'owner' | 'member'; + joined_at: Date; + member_count: number; +} + +export async function listForUser(userId: string): Promise { + const memberships = await HouseholdMember.findAll({ + where: { user_id: userId }, + order: [['joined_at', 'DESC']], + }); + if (memberships.length === 0) return []; + + const householdIds = memberships.map(m => m.household_id); + const households = await Household.findAll({ + where: { id: householdIds }, + }); + const householdById = new Map(households.map(h => [h.id, h])); + + const counts = (await HouseholdMember.findAll({ + attributes: [ + 'household_id', + [sequelize.fn('COUNT', sequelize.col('user_id')), 'count'], + ], + where: { household_id: householdIds }, + group: ['household_id'], + raw: true, + })) as unknown as { household_id: string; count: string }[]; + const countByHousehold = new Map( + counts.map(c => [c.household_id, parseInt(c.count, 10)]) + ); + + return memberships.flatMap(m => { + const h = householdById.get(m.household_id); + if (!h) return []; + return [ + { + id: h.id, + name: h.name, + role: m.role, + joined_at: m.joined_at, + member_count: countByHousehold.get(m.household_id) ?? 1, + }, + ]; + }); +} + +export async function createForOwner( + ownerUserId: string, + name: string | null +): Promise { + return sequelize.transaction(async transaction => { + const household = await Household.create( + { name: name ?? null }, + { transaction } + ); + await HouseholdMember.create( + { + household_id: household.id, + user_id: ownerUserId, + role: 'owner', + joined_at: new Date(), + }, + { transaction } + ); + return { + id: household.id, + name: household.name, + role: 'owner', + joined_at: new Date(), + member_count: 1, + }; + }); +} + +export interface InviteSummary { + id: string; + token: string; + invited_email: string | null; + expires_at: Date; + accepted_at: Date | null; +} + +export async function createInvite( + householdId: string, + invitedBy: string, + invitedEmail: string | null +): Promise { + const token = crypto.randomBytes(24).toString('base64url'); + const expires_at = new Date(Date.now() + INVITE_TTL_DAYS * 86400 * 1000); + const invite = await HouseholdInvite.create({ + household_id: householdId, + token, + invited_email: invitedEmail, + invited_by: invitedBy, + expires_at, + }); + return { + id: invite.id, + token: invite.token, + invited_email: invite.invited_email, + expires_at: invite.expires_at, + accepted_at: invite.accepted_at, + }; +} + +export interface AcceptResult { + household_id: string; +} + +export async function acceptInvite( + token: string, + userId: string +): Promise { + return sequelize.transaction(async transaction => { + const invite = await HouseholdInvite.findOne({ + where: { + token, + accepted_at: null, + expires_at: { [Op.gt]: new Date() }, + }, + transaction, + }); + if (!invite) { + const err = new Error('invite_invalid_or_expired'); + err.name = 'InviteInvalid'; + throw err; + } + + const existing = await HouseholdMember.findOne({ + where: { household_id: invite.household_id, user_id: userId }, + transaction, + }); + if (!existing) { + await HouseholdMember.create( + { + household_id: invite.household_id, + user_id: userId, + role: 'member', + joined_at: new Date(), + }, + { transaction } + ); + } + + invite.accepted_at = new Date(); + invite.accepted_by = userId; + await invite.save({ transaction }); + + return { household_id: invite.household_id }; + }); +} + +export async function isOwner( + householdId: string, + userId: string +): Promise { + const m = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId, role: 'owner' }, + }); + return m !== null; +} From 1e45c433b1e60b570f8297b38ed058287369e479 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:51:13 +0000 Subject: [PATCH 14/20] feat(backend): recommendation tests + sequelize-cli config (TS migrations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - New recommendation.service.test.ts with 5 cases: happy path, household-not-found, watched-together exclusion, provider-filter drop, no-candidates failure. Mocks tmdb.service; uses an in-memory HouseholdRepository stub. All pass; full suite is now 281/281. - Exported __clearCandidateCacheForTests so tests don't leak the module-level discover cache across cases. Sequelize-cli config: - backend/.sequelizerc points at src/db/migrations, src/models, src/db/seeders, and src/db/config.js. - backend/src/db/config.js wires DATABASE_URL into dev/test/prod via use_env_variable, with prod SSL enabled. - Three npm scripts (db:migrate, db:migrate:undo, db:migrate:status) inject ts-node/register/transpile-only via NODE_OPTIONS so the Phase A .ts migration is picked up alongside Phase E's .js one. Verified both migrations load (sequelize-cli detects the dir and parses the files; only fails on connect without a real DB). - Bumped sequelize-cli to ^6.6.5 (older 6.6.2 was shipped with a broken transitive tree). Added ts-node as a direct devDep — it was previously hoisted through ts-node-dev. - eslint config ignores backend/.sequelizerc and the JS migration files (CommonJS conventions don't fit the TS ruleset). Verified with: cd backend && DATABASE_URL=... npm run db:migrate:status loads config + migrations, fails only at DB connect. --- backend/.sequelizerc | 8 + backend/package.json | 8 +- backend/src/db/config.js | 19 + .../services/recommendation.service.test.ts | 196 ++++++ .../src/services/recommendation.service.ts | 4 + eslint.config.js | 3 + package-lock.json | 630 ++++++++++++++---- 7 files changed, 742 insertions(+), 126 deletions(-) create mode 100644 backend/.sequelizerc create mode 100644 backend/src/db/config.js create mode 100644 backend/src/services/recommendation.service.test.ts diff --git a/backend/.sequelizerc b/backend/.sequelizerc new file mode 100644 index 0000000..4f141e5 --- /dev/null +++ b/backend/.sequelizerc @@ -0,0 +1,8 @@ +const path = require('path'); + +module.exports = { + config: path.resolve('src/db/config.js'), + 'migrations-path': path.resolve('src/db/migrations'), + 'models-path': path.resolve('src/models'), + 'seeders-path': path.resolve('src/db/seeders'), +}; diff --git a/backend/package.json b/backend/package.json index 68c9eff..5ef6763 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,10 @@ "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write \"src/**/*.{ts,js,json}\"", - "format:check": "prettier --check \"src/**/*.{ts,js,json}\"" + "format:check": "prettier --check \"src/**/*.{ts,js,json}\"", + "db:migrate": "cross-env NODE_OPTIONS=\"-r ts-node/register/transpile-only\" sequelize-cli db:migrate", + "db:migrate:undo": "cross-env NODE_OPTIONS=\"-r ts-node/register/transpile-only\" sequelize-cli db:migrate:undo", + "db:migrate:status": "cross-env NODE_OPTIONS=\"-r ts-node/register/transpile-only\" sequelize-cli db:migrate:status" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", @@ -31,7 +34,7 @@ "pg": "^8.13.1", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", - "sequelize-cli": "^6.6.2" + "sequelize-cli": "^6.6.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", @@ -49,6 +52,7 @@ "prettier": "^3.5.3", "supertest": "^7.1.1", "ts-jest": "^29.3.4", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.8.3" } diff --git a/backend/src/db/config.js b/backend/src/db/config.js new file mode 100644 index 0000000..3496828 --- /dev/null +++ b/backend/src/db/config.js @@ -0,0 +1,19 @@ +require('dotenv').config(); + +const baseConfig = { + dialect: 'postgres', + use_env_variable: 'DATABASE_URL', + dialectOptions: + process.env.NODE_ENV === 'production' + ? { ssl: { require: true, rejectUnauthorized: false } } + : {}, + logging: false, + migrationStorage: 'sequelize', + migrationStorageTableName: 'sequelize_meta', +}; + +module.exports = { + development: baseConfig, + test: baseConfig, + production: baseConfig, +}; diff --git a/backend/src/services/recommendation.service.test.ts b/backend/src/services/recommendation.service.test.ts new file mode 100644 index 0000000..25001dd --- /dev/null +++ b/backend/src/services/recommendation.service.test.ts @@ -0,0 +1,196 @@ +import { + __clearCandidateCacheForTests, + RecommendationService, +} from './recommendation.service'; +import type { + HouseholdRepository, + HouseholdStub, + TasteProfileStub, + WatchedTogetherStub, +} from './recommendation.types'; + +jest.mock('./tmdb.service', () => ({ + discoverMedia: jest.fn(), + getMovieFullDetails: jest.fn(), + getTVFullDetails: jest.fn(), + getWatchProviders: jest.fn(), +})); + +import { + discoverMedia, + getMovieFullDetails, + getWatchProviders, +} from './tmdb.service'; + +const mockedDiscover = discoverMedia as jest.MockedFunction< + typeof discoverMedia +>; +const mockedGetMovieFull = getMovieFullDetails as jest.MockedFunction< + typeof getMovieFullDetails +>; +const mockedGetWatchProviders = getWatchProviders as jest.MockedFunction< + typeof getWatchProviders +>; + +const buildRepo = ( + overrides: Partial = {} +): HouseholdRepository => ({ + getHousehold: jest.fn( + async (): Promise => ({ + household_id: 'h1', + member_ids: ['u1', 'u2'], + }) + ), + getMemberTasteProfiles: jest.fn( + async (): Promise => [ + { user_id: 'u1', weights: { genres: { 35: 0.8, 18: 0.2 } } }, + { user_id: 'u2', weights: { genres: { 35: 0.6, 28: 0.4 } } }, + ] + ), + getWatchedTogether: jest.fn(async (): Promise => []), + getFinishedTmdbIdsForMembers: jest.fn(async () => []), + recordWatchedTogether: jest.fn(async () => undefined), + isMember: jest.fn(async () => true), + ...overrides, +}); + +const candidate = (id: number, overrides: Record = {}) => ({ + id, + title: `Movie ${id}`, + original_title: `Movie ${id}`, + overview: '', + poster_path: `/p${id}.jpg`, + release_date: '2023-01-01', + vote_average: 7.5, + vote_count: 1000, + genre_ids: [35], + popularity: 50, + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + __clearCandidateCacheForTests(); + mockedGetMovieFull.mockResolvedValue({ + id: 1, + title: 'Movie 1', + overview: '', + poster_path: null, + status: 'Released', + runtime: 95, + }); +}); + +describe('RecommendationService.pickForHousehold', () => { + it('returns top pick with alternates from the candidate set', async () => { + mockedDiscover.mockResolvedValue({ + results: [candidate(1), candidate(2), candidate(3), candidate(4)], + }); + + const svc = new RecommendationService(buildRepo()); + const result = await svc.pickForHousehold({ + householdId: 'h1', + mood: 'funny', + minutes: 90, + }); + + expect(result.pick.tmdb_id).toBeGreaterThan(0); + expect(result.alternates).toHaveLength(2); + expect(result.rationale.length).toBeGreaterThan(0); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(mockedDiscover).toHaveBeenCalledWith( + expect.objectContaining({ + mediaType: 'movie', + region: 'GB', + withRuntimeLte: 90, + }) + ); + }); + + it('throws when the household does not exist', async () => { + const repo = buildRepo({ + getHousehold: jest.fn(async () => null), + }); + const svc = new RecommendationService(repo); + + await expect( + svc.pickForHousehold({ + householdId: 'missing', + mood: 'funny', + minutes: 90, + }) + ).rejects.toThrow('Household not found'); + }); + + it('excludes titles the household has already watched together', async () => { + mockedDiscover.mockResolvedValue({ + results: [candidate(101), candidate(102)], + }); + + const repo = buildRepo({ + getWatchedTogether: jest.fn(async () => [ + { + household_id: 'h1', + tmdb_id: 101, + media_type: 'movie' as const, + watched_at: new Date(), + enjoyed: true, + }, + ]), + }); + const svc = new RecommendationService(repo); + + const result = await svc.pickForHousehold({ + householdId: 'h1', + mood: 'funny', + minutes: 90, + }); + + expect(result.pick.tmdb_id).toBe(102); + }); + + it('drops candidates that fail the provider filter', async () => { + mockedDiscover.mockResolvedValue({ + results: [candidate(201), candidate(202), candidate(203)], + }); + mockedGetWatchProviders.mockImplementation(async tmdbId => { + if (tmdbId === 201) { + return { + flatrate: [ + { + provider_id: 8, + provider_name: 'Netflix', + logo_path: '/n.png', + }, + ], + }; + } + return {}; + }); + + const svc = new RecommendationService(buildRepo()); + const result = await svc.pickForHousehold({ + householdId: 'h1', + mood: 'funny', + minutes: 90, + providers: ['netflix'], + }); + + expect(result.pick.tmdb_id).toBe(201); + expect(mockedGetWatchProviders).toHaveBeenCalled(); + }); + + it('throws when no candidates remain after filtering', async () => { + mockedDiscover.mockResolvedValue({ results: [] }); + + const svc = new RecommendationService(buildRepo()); + + await expect( + svc.pickForHousehold({ + householdId: 'h1', + mood: 'funny', + minutes: 90, + }) + ).rejects.toThrow('No candidates'); + }); +}); diff --git a/backend/src/services/recommendation.service.ts b/backend/src/services/recommendation.service.ts index ad06401..6ab56e7 100644 --- a/backend/src/services/recommendation.service.ts +++ b/backend/src/services/recommendation.service.ts @@ -460,3 +460,7 @@ export const defaultRecommendationService = new RecommendationService( ); export { MOOD_FILTERS }; + +export function __clearCandidateCacheForTests(): void { + candidateCache.clear(); +} diff --git a/eslint.config.js b/eslint.config.js index 4ed1beb..a1fd7ed 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,9 @@ export default tseslint.config( '**/coverage/**', '**/.next/**', '**/*.d.ts', + 'backend/.sequelizerc', + 'backend/src/db/config.js', + 'backend/src/db/migrations/*.js', ], }, diff --git a/package-lock.json b/package-lock.json index b05ad7a..940422d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -456,7 +456,7 @@ "pg": "^8.13.1", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", - "sequelize-cli": "^6.6.2" + "sequelize-cli": "^6.6.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", @@ -474,6 +474,7 @@ "prettier": "^3.5.3", "supertest": "^7.1.1", "ts-jest": "^29.3.4", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.8.3" } @@ -670,15 +671,6 @@ "version": "1.0.1", "license": "BSD-3-Clause" }, - "backend/node_modules/cliui": { - "version": "7.0.4", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, "backend/node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -736,23 +728,6 @@ "safe-buffer": "^5.0.1" } }, - "backend/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, - "backend/node_modules/fs-extra": { - "version": "9.1.0", - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "backend/node_modules/inflection": { "version": "1.13.4", "engines": [ @@ -760,13 +735,6 @@ ], "license": "MIT" }, - "backend/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "backend/node_modules/jsonwebtoken": { "version": "9.0.2", "license": "MIT", @@ -1117,26 +1085,6 @@ } } }, - "backend/node_modules/sequelize-cli": { - "version": "6.6.2", - "license": "MIT", - "dependencies": { - "cli-color": "^2.0.3", - "fs-extra": "^9.1.0", - "js-beautify": "^1.14.5", - "lodash": "^4.17.21", - "resolve": "^1.22.1", - "umzug": "^2.3.0", - "yargs": "^16.2.0" - }, - "bin": { - "sequelize": "lib/sequelize", - "sequelize-cli": "lib/sequelize" - }, - "engines": { - "node": ">=10.0.0" - } - }, "backend/node_modules/sequelize-pool": { "version": "7.1.0", "license": "MIT", @@ -1160,18 +1108,6 @@ "node": ">= 10.x" } }, - "backend/node_modules/string-width": { - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "backend/node_modules/strip-bom": { "version": "3.0.0", "dev": true, @@ -1272,21 +1208,6 @@ "@types/node": "*" } }, - "backend/node_modules/wrap-ansi": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "backend/node_modules/xtend": { "version": "4.0.2", "license": "MIT", @@ -1294,29 +1215,6 @@ "node": ">=0.4" } }, - "backend/node_modules/yargs": { - "version": "16.2.0", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "backend/node_modules/yargs-parser": { - "version": "20.2.9", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "lib.components": { "name": "@pairflix/components", "version": "0.1.0", @@ -2372,6 +2270,50 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "dev": true, @@ -3101,6 +3043,12 @@ "node": ">=12.4.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, "node_modules/@pairflix/components": { "resolved": "lib.components", "link": true @@ -3113,6 +3061,16 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.2.7", "dev": true, @@ -4883,6 +4841,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5294,6 +5261,15 @@ "version": "0.4.0", "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "dev": true, @@ -5452,7 +5428,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/bcryptjs": { @@ -5484,6 +5459,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "license": "MIT", @@ -5534,7 +5515,6 @@ }, "node_modules/brace-expansion": { "version": "2.0.2", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6035,6 +6015,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "license": "MIT", @@ -6123,7 +6113,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6568,9 +6557,35 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -6606,7 +6621,6 @@ }, "node_modules/emoji-regex": { "version": "9.2.2", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -7851,6 +7865,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "8.0.0", "dev": true, @@ -8601,6 +8643,12 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "dev": true, @@ -9054,7 +9102,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -9148,6 +9195,21 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.2", "dev": true, @@ -10247,6 +10309,54 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -10979,7 +11089,6 @@ }, "node_modules/minimatch": { "version": "9.0.5", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -10999,6 +11108,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -11150,6 +11268,21 @@ "node": ">=6.0.0" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "dev": true, @@ -11411,6 +11544,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/pairflix-admin": { "resolved": "app.admin", "link": true @@ -11505,7 +11644,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11515,6 +11653,28 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "0.1.12", "license": "MIT" @@ -11831,6 +11991,12 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -12265,10 +12431,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -12606,6 +12775,127 @@ "node": ">= 0.8" } }, + "node_modules/sequelize-cli": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-6.6.5.tgz", + "integrity": "sha512-DqyISCULOaEbTM+rRQH4YvcUWeOC1XDiSKcjsC6TfAnT7W837mNkChJhtB/Z4FdCFHRCojmiP7zsrA4pARmacA==", + "license": "MIT", + "dependencies": { + "fs-extra": "^9.1.0", + "js-beautify": "1.15.4", + "lodash": "^4.17.21", + "picocolors": "^1.1.1", + "resolve": "^1.22.1", + "umzug": "^2.3.0", + "yargs": "^16.2.0" + }, + "bin": { + "sequelize": "lib/sequelize", + "sequelize-cli": "lib/sequelize" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sequelize-cli/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/sequelize-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/sequelize-cli/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sequelize-cli/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sequelize-cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sequelize-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/sequelize-cli/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sequelize-cli/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "dev": true, @@ -12685,7 +12975,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12696,7 +12985,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12957,7 +13245,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -12971,11 +13258,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12988,7 +13304,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -13111,6 +13426,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "dev": true, @@ -13907,6 +14235,18 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "dev": true, @@ -14503,7 +14843,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -14608,7 +14947,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -14622,11 +14960,57 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -14639,7 +15023,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -14652,7 +15035,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" From f27f24efae6c5269b99c73127574c28fa0d876f5 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 20:59:43 +0000 Subject: [PATCH 15/20] fix(prettier): use endOfLine: auto to stop CI lint thrash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes pins .ts/.tsx/.js to CRLF in working trees but on CI's Linux checkout the attribute application is unreliable — prettier-eslint then sees CR characters that don't match the 'crlf'-pinned config and fails 'prettier/prettier' with thousands of 'Delete `␍`' errors across files this PR never touched (Typography.tsx, Textarea.tsx, etc.). 'auto' tells prettier to accept whatever line ending the file already uses, which works for both local CRLF working trees (Windows / repos with .gitattributes) and CI's Linux LF tree. No file content needs to change. --- .prettierrc.json | 2 +- backend/.prettierrc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.prettierrc.json b/.prettierrc.json index 3ac233b..0e9d2d4 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -7,5 +7,5 @@ "useTabs": false, "bracketSpacing": true, "arrowParens": "avoid", - "endOfLine": "crlf" + "endOfLine": "auto" } diff --git a/backend/.prettierrc b/backend/.prettierrc index 87f7ab4..a817915 100644 --- a/backend/.prettierrc +++ b/backend/.prettierrc @@ -5,6 +5,6 @@ "printWidth": 80, "tabWidth": 2, "useTabs": true, - "endOfLine": "crlf", + "endOfLine": "auto", "arrowParens": "avoid" } From 4f79510e2144e9fd23148743fcc20d9bb27fa8ec Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 21:04:01 +0000 Subject: [PATCH 16/20] ci: normalize line endings before lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You're right — the fix belongs in the workflow, not in the code. The repo's .gitattributes pins source files to CRLF in working trees, but actions/checkout on Ubuntu doesn't reliably apply the eol attribute. That leaves CI with a mixed LF/CRLF tree, which prettier-eslint reports as thousands of 'Delete `␍`' / 'Insert `␍`' errors across files this PR never touched. Adding a step to force-normalize source files to LF on CI before lint runs. Local working trees are unaffected (still CRLF per .gitattributes); prettier's endOfLine: auto accepts whichever ending the file has. --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dc8dc9..49154ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,21 @@ jobs: - name: Build components package (required for tests) run: npm run build:components + - name: Normalize line endings for lint + # .gitattributes pins source files to CRLF in working trees, but the + # CI checkout doesn't reliably apply the attribute, leaving a mixed + # LF/CRLF tree that breaks prettier-eslint. Force-normalize source + # files to LF here so the linter sees a consistent tree. + run: | + find . \ + -path './node_modules' -prune -o \ + -path '*/node_modules' -prune -o \ + -path './.git' -prune -o \ + -path '*/dist' -prune -o \ + -path '*/build' -prune -o \ + -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' -o -name '*.json' -o -name '*.md' \) -print0 \ + | xargs -0 -r sed -i 's/\r$//' + - name: Lint all packages run: npm run lint:all From 93038f83ac251e5d5216781a6695ca0c2953a550 Mon Sep 17 00:00:00 2001 From: Alex Jenkinson Date: Mon, 1 Jun 2026 21:15:55 +0000 Subject: [PATCH 17/20] build: make LF the canonical line ending everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CI lint thrash for good. Root cause: the repo had .gitattributes pinning *.ts/.tsx/.js/etc. to CRLF in working trees, but the actual git index was already LF. On CI's Ubuntu checkout the eol attribute wasn't reliably applied, so files came down as LF with a 'crlf'-pinned prettier config — and prettier-eslint reported thousands of spurious 'Insert/Delete `␍`' errors across files this PR never touched. This commit: - .gitattributes: drops per-extension eol=crlf and uses '* text=auto eol=lf' so storage and working trees are uniformly LF. Windows developers who prefer CRLF locally can opt in via 'git config core.autocrlf true'. - .prettierrc.json + backend/.prettierrc: endOfLine 'auto' → 'lf' so prettier enforces LF instead of accepting whatever's there. - lib.components/src/stories/Configure.mdx: prettier --write fix (only file that genuinely needed formatting after normalization). The index was already LF, so this is a tiny content diff — no mass renormalization needed. --- .gitattributes | 47 +++++-------------- .prettierrc.json | 2 +- backend/.prettierrc | 2 +- lib.components/src/stories/Configure.mdx | 57 +++++++++++++----------- 4 files changed, 43 insertions(+), 65 deletions(-) diff --git a/.gitattributes b/.gitattributes index 1e148c5..cbb96c9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,44 +1,17 @@ -# Set default behavior to automatically normalize line endings -* text=auto +# Normalize all text files to LF in storage and in working trees. +# CI (Ubuntu) and most modern editors handle LF cleanly; Windows +# developers can rely on autocrlf=true if they prefer CRLF locally, +# but our canonical form is LF. +* text=auto eol=lf -# Explicitly declare text files you want to always be normalized and converted -# to CRLF line endings for Windows development -*.js text eol=crlf -*.jsx text eol=crlf -*.ts text eol=crlf -*.tsx text eol=crlf -*.json text eol=crlf -*.md text eol=crlf -*.yml text eol=crlf -*.yaml text eol=crlf -*.css text eol=crlf -*.scss text eol=crlf -*.html text eol=crlf -*.xml text eol=crlf -*.svg text eol=crlf - -# Configuration files -*.config.js text eol=crlf -*.config.ts text eol=crlf -.eslintrc* text eol=crlf -.prettierrc* text eol=crlf -package.json text eol=crlf -package-lock.json text eol=crlf -tsconfig.json text eol=crlf - -# Documentation -*.txt text eol=crlf -*.md text eol=crlf -README* text eol=crlf -LICENSE* text eol=crlf - -# Scripts (keep LF for shell scripts for cross-platform compatibility) +# Shell scripts must be LF (already covered by default above; explicit +# for clarity). *.sh text eol=lf -# Declare files that will always have CRLF line endings on checkout +# Windows-only files keep CRLF. *.bat text eol=crlf -# Denote all files that are truly binary and should not be modified +# Binary files *.png binary *.jpg binary *.jpeg binary @@ -50,4 +23,4 @@ LICENSE* text eol=crlf *.woff binary *.woff2 binary *.ttf binary -*.eot binary \ No newline at end of file +*.eot binary diff --git a/.prettierrc.json b/.prettierrc.json index 0e9d2d4..b1c7fb5 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -7,5 +7,5 @@ "useTabs": false, "bracketSpacing": true, "arrowParens": "avoid", - "endOfLine": "auto" + "endOfLine": "lf" } diff --git a/backend/.prettierrc b/backend/.prettierrc index a817915..dafab5a 100644 --- a/backend/.prettierrc +++ b/backend/.prettierrc @@ -5,6 +5,6 @@ "printWidth": 80, "tabWidth": 2, "useTabs": true, - "endOfLine": "auto", + "endOfLine": "lf", "arrowParens": "avoid" } diff --git a/lib.components/src/stories/Configure.mdx b/lib.components/src/stories/Configure.mdx index 55c21a8..69724a0 100644 --- a/lib.components/src/stories/Configure.mdx +++ b/lib.components/src/stories/Configure.mdx @@ -1,35 +1,37 @@ -import { Meta } from "@storybook/addon-docs/blocks"; - -import Github from "./assets/github.svg"; -import Discord from "./assets/discord.svg"; -import Youtube from "./assets/youtube.svg"; -import Tutorials from "./assets/tutorials.svg"; -import Styling from "./assets/styling.png"; -import Context from "./assets/context.png"; -import Assets from "./assets/assets.png"; -import Docs from "./assets/docs.png"; -import Share from "./assets/share.png"; -import FigmaPlugin from "./assets/figma-plugin.png"; -import Testing from "./assets/testing.png"; -import Accessibility from "./assets/accessibility.png"; -import Theming from "./assets/theming.png"; -import AddonLibrary from "./assets/addon-library.png"; - -export const RightArrow = () => ( + - - + > + + +); @@ -38,6 +40,7 @@ export const RightArrow = () =>
@@ -84,6 +87,7 @@ export const RightArrow = () =>
@@ -203,6 +207,7 @@ export const RightArrow = () => Discover tutorials
+