Pivot to household title-matching: 5-phase scaffold (Tonight pick, providers, history, pricing, LLM rerank)#59
Merged
Conversation
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.
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.
…e 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.
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.
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.
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.
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.
Conflict resolutions: - Household / HouseholdMember / WatchedTogether / tasteProfile.service: Phase A's real models win over Phase D's stubs. - Content.ts: keep A's ContentProviders shape (flat region map), add D's new columns (media_type, year, poster_path). - tmdb.service.ts: keep both helpers — D's fetchWatchProviders (all regions, uncached) for providers.service caching, and B's getWatchProviders (single region, in-memory cached) for the recommender filter step. Unified TMDbProvider / RegionProviders types. - providers.service: rewritten to read/write the flat ContentProviders shape; tmdbToStored maps logo_path nullability. - history.service: imports HouseholdMember from its own file (A) not from Household (D's stub). - app.ts: keeps both household + history route registrations.
Conflict resolutions: - Household / HouseholdMember: Phase A's real models win over E's stubs. - featureFlags.service: Phase C's full implementation (AppSettings + env override + 60s cache) is the base; merged in E's isLlmRerankEnabledForHousehold which ANDs the global flag with the household's entitlement. - models/index: keeps A's full association block, registers and associates E's Subscription + PickUsage. Dropped E's Household.belongsTo(User, owner_id) — A's Household has no owner_id; ownership lives on HouseholdMember.role = 'owner'. - Fixed Subscription + PickUsage foreign-key references from Household.household_id (E's stub PK) to Household.id (A's PK). - billing.controller cancel handler: replaced the household.owner_id check with a HouseholdMember lookup for role='owner'. - app.ts: keeps all six new route modules (B's household, D's history + providers, E's householdsRoutes + billing). All three workspaces (backend, app.client, lib.components) typecheck clean.
- 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.
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> → T[] in tasteProfile.summary. All seven errors auto-fixed; remaining warnings are pre-existing.
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.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR scaffolds PairFlix’s pivot from user-to-user matching toward household “Tonight” title picking, adding new backend domain models (households/taste/history), early recommendation and provider plumbing, an opt-in LLM rerank scaffold, and a freemium entitlements layer with mock billing—while keeping the existing Match flow intact.
Changes:
- Adds Phase A persistence (Household/TasteProfile/WatchedTogether) + schema docs updates, plus provider availability storage on
Content. - Introduces Phase B/D flows: recommendation service + household pick/commit endpoints, provider fetching/caching, and history endpoints/UI.
- Adds Phase C/E scaffolds: Anthropic LLM rerank + cache types/services, and subscriptions/entitlements/quota middleware + mock checkout.
Reviewed changes
Copilot reviewed 67 out of 69 changed files in this pull request and generated 27 comments.
Show a summary per file
| File | Description |
|---|---|
| package-lock.json | Adds Anthropic SDK + lru-cache dependency locks. |
| lib.components/src/components/data-display/ProviderBadges/ProviderBadges.tsx | New provider-logo chip component for watch providers and deep links. |
| lib.components/src/components/data-display/ProviderBadges/index.ts | Barrel export for ProviderBadges. |
| lib.components/src/components/data-display/index.ts | Re-exports ProviderBadges from data-display index. |
| docs/db-schema.md | Documents new household/taste/history tables and content.providers. |
| backend/src/types/index.ts | Adds DTO/types for Household/TasteProfile/WatchedTogether. |
| backend/src/services/tmdb.service.ts | Extends TMDb types; adds discover + watch providers helpers with caching. |
| backend/src/services/tasteProfile.summary.ts | Deterministic taste-profile summariser for LLM prompt caching. |
| backend/src/services/tasteProfile.summary.test.ts | Jest coverage for deterministic taste summary output. |
| backend/src/services/tasteProfile.service.ts | Taste-profile recomputation service (watchlist-backed). |
| backend/src/services/settings.service.ts | Adds recommendation.llm_rerank feature flag default. |
| backend/src/services/recommendation.types.ts | Recommendation domain types + repository interface scaffold. |
| backend/src/services/recommendation.service.ts | ML-style candidate scoring, caching, hydration, provider filtering scaffold. |
| backend/src/services/providers.service.ts | Upserts/reads Content.providers with cache-or-refresh behavior. |
| backend/src/services/llmCache.service.ts | LRU-backed cache for LLM rerank results + sha256 keying. |
| backend/src/services/llmCache.service.test.ts | Jest tests for cache-key bucketing/stability. |
| backend/src/services/llm.types.ts | Local types and error for Phase C reranker contract. |
| backend/src/services/llm.service.ts | Anthropic rerank call + tool-use parsing + maybeRerank contract. |
| backend/src/services/history.service.ts | Household watch-together history retrieval and thumbs update. |
| backend/src/services/featureFlags.service.ts | Settings/env feature-flag reads + cache; adds household entitlements gate helper. |
| backend/src/services/entitlements.service.ts | Computes tier-based entitlements + pick usage counting. |
| backend/src/services/billing.service.ts | Mock checkout + Stripe-stub handlers + premium activation helper. |
| backend/src/routes/providers.routes.ts | Adds authenticated providers endpoint router. |
| backend/src/routes/households.routes.ts | Adds authenticated household entitlements route; exports quota middleware. |
| backend/src/routes/household.routes.ts | Adds authenticated household pick + commit routes. |
| backend/src/routes/history.routes.ts | Adds authenticated household history routes. |
| backend/src/routes/billing.routes.ts | Adds billing routes (webhook unauthenticated; others authenticated). |
| backend/src/models/WatchedTogether.ts | New Sequelize model for watched-together gold signal. |
| backend/src/models/TasteProfile.ts | New Sequelize model for per-user taste profile JSONB. |
| backend/src/models/Subscription.ts | New Sequelize model for household subscriptions. |
| backend/src/models/PickUsage.ts | New Sequelize model for pick usage logging. |
| backend/src/models/index.ts | Registers new models + sets up new associations. |
| backend/src/models/HouseholdMember.ts | New join model for household membership with roles. |
| backend/src/models/Household.ts | New household model. |
| backend/src/models/Content.ts | Adds providers + metadata fields to Content model typings/schema. |
| backend/src/middlewares/entitlements.middleware.ts | Adds pick quota enforcement + region lock middleware. |
| backend/src/index.ts | Mounts new billing/households routes in the main server entrypoint. |
| backend/src/db/migrations/20260601000000-phase-a-households.ts | Phase A migration creating household/taste/watched tables and content.providers column + backfill. |
| backend/src/db/migrations/001-create-subscriptions-and-pick-usage.js | Phase E migration creating subscriptions + pick_usage and backfilling free subs. |
| backend/src/controllers/providers.controller.ts | GET providers controller (tmdbId/media_type/region). |
| backend/src/controllers/household.controller.ts | Household pick + commit controllers. |
| backend/src/controllers/history.controller.ts | Household history list + thumbs patch endpoint. |
| backend/src/controllers/billing.controller.ts | Checkout/cancel/webhook/mock-activate + entitlements controller endpoints. |
| backend/src/app.ts | Mounts new household/history/providers/billing routes on app instance. |
| backend/package.json | Adds Anthropic SDK + lru-cache dependencies. |
| backend/.env.example | Adds env vars for LLM rerank + Anthropic API key. |
| app.client/src/services/api/index.ts | Registers new client API services (billing/households/history) and types. |
| app.client/src/services/api/households.ts | Client calls for household pick + commit. |
| app.client/src/services/api/history.ts | Client calls for household history list + enjoyed patch. |
| app.client/src/services/api/billing.ts | Client calls for entitlements + checkout/cancel/mock-activate. |
| app.client/src/hooks/useEntitlements.ts | React Query hook for household entitlements. |
| app.client/src/features/tonight/useTonightPick.ts | React Query mutations for pick/commit. |
| app.client/src/features/tonight/useTonightHomepage.ts | Reads “Tonight as homepage” + provider prefs from user preferences (stub). |
| app.client/src/features/tonight/useActiveHousehold.ts | Stub household selection hook for Tonight flow. |
| app.client/src/features/tonight/TonightPicker.tsx | New Tonight picker UI (mood/time/providers + swap/commit/dismiss). |
| app.client/src/features/history/useSetEnjoyed.ts | React Query mutation hook for thumbs up/down. |
| app.client/src/features/history/usePrimaryHousehold.ts | Stub household selection for history page (localStorage override). |
| app.client/src/features/history/useHistory.ts | React Query hook for household history listing. |
| app.client/src/features/history/useAffiliateParams.ts | Reads affiliate params from Vite env with safe parsing. |
| app.client/src/features/history/HistoryPage.tsx | New household watch-together history UI with thumbs actions. |
| app.client/src/features/billing/UpgradeBanner.tsx | Upgrade CTA banner based on picks remaining. |
| app.client/src/features/billing/MockCheckout.tsx | Mock checkout page to activate premium for demos. |
| app.client/src/features/billing/flags.ts | Frontend billing-mock feature flag reader. |
| app.client/src/config/navigation.ts | Adds Tonight + History nav items. |
| app.client/src/components/layout/Routes.tsx | Adds routes for Tonight/History and mock checkout; sets default route preference. |
| .gitignore | Ignores .claude/worktrees/. |
| .claude/skills/new-sequelize-model/SKILL.md | Playbook for adding Sequelize models/migrations/docs. |
| .claude/skills/new-backend-feature/SKILL.md | Playbook for adding a new backend domain with layered structure. |
| .claude/CLAUDE.md | Repo-specific behavioral + engineering guidelines for the pivot/scaffolds. |
Comment on lines
+20
to
+26
| household_id: { | ||
| type: Sequelize.UUID, | ||
| allowNull: false, | ||
| unique: true, | ||
| references: { model: 'households', key: 'household_id' }, | ||
| onDelete: 'CASCADE', | ||
| }, |
Comment on lines
+58
to
+63
| household_id: { | ||
| type: Sequelize.UUID, | ||
| allowNull: false, | ||
| references: { model: 'households', key: 'household_id' }, | ||
| onDelete: 'CASCADE', | ||
| }, |
Comment on lines
+77
to
+83
| 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; | ||
| `); |
Comment on lines
+4
to
+9
| * 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. | ||
| */ |
Comment on lines
+185
to
+192
| 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 |
Comment on lines
+145
to
+147
| {providers.map(provider => { | ||
| const logoUrl = `${TMDB_LOGO_BASE}${provider.logo_path}`; | ||
| const altText = provider.provider_name; |
Comment on lines
+195
to
+197
| const enabled = await isLlmRerankEnabled(); | ||
| if (!enabled) return null; | ||
|
|
Comment on lines
+49
to
+53
| res.status(402).json({ | ||
| error: 'pick_quota_exceeded', | ||
| upgrade_url: '/upgrade', | ||
| entitlements, | ||
| }); |
Comment on lines
+42
to
+45
| <Button | ||
| variant="primary" | ||
| onClick={() => navigate('/billing/mock-checkout')} | ||
| > |
Comment on lines
+3
to
+19
| // 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, |
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
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=<id> 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.
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.
…ions) 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.
.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.
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.
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.
eslint-plugin-prettier was reporting formatting errors on CI that don't reproduce locally — same prettier version, same config, same content. The most likely cause is a transitive resolution gap (npm install can pull a newer minor of a prettier-internals dep than what local has cached). Rather than keep chasing the drift, just have CI run `prettier --write` across all workspaces before the lint step. The lint then sees a tree that matches whatever CI's prettier produces — no false positives. Replaces the earlier line-ending normalization step (which is now unnecessary since .gitattributes makes LF canonical).
The format scripts globbed 'src/**/*.{ts,js,json}' only — every
React component (.tsx), every JSX (.jsx), every story (.mdx) was
silently skipped by prettier --write.
That meant the new 'format before lint' step in CI didn't touch
Typography.tsx / Badge.tsx / Input.tsx / Select.tsx / Textarea.tsx
/ Table.tsx and the subsequent lint kept reporting prettier errors
on them. Local lint passed because those files happened to already
match local prettier's output; CI's slightly different prettier
resolution wanted different formatting that --write never got a
chance to apply.
Expanded the glob to '{ts,tsx,js,jsx,json,md,mdx}' in all four
workspace package.json files (backend, app.client, app.admin,
lib.components). format:all now actually formats everything.
npm audit --audit-level=high now exits 0 (down from 19 high vulns). Non-breaking fixes applied to transitive deps via 'npm audit fix' — no production-dep major bumps. The 6 remaining moderates need --force and would either downgrade sequelize to v3 (catastrophic) or bump lint-staged/nodemailer majors with config changes; deferred. Also removed @types/sequelize from backend/package.json. It was the outdated community types for Sequelize v3/v4 and was leaking through after the audit-fix dedup, hiding all of Sequelize v6's bundled types (1004 ts errors on findAll/findByPk/create/...). Sequelize v6 ships its own types — no @types/ package needed. Verified: backend tsc --noEmit clean, 281/281 tests pass, lint:all 0 errors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Notion business plan (Pairflix — Business Plan) describes a product the codebase didn't yet implement: "what should WE watch tonight" — given a household, a mood, and a time budget, return one title in under 30 seconds, with cross-platform availability, and learn from what the household actually agrees on.
The existing
Matchflow does the inverse — it pairs users with overlapping watchlists. This PR scaffolds the title-matching product alongside it. Both coexist.What's in this PR
Five overlapping scaffolds, built in parallel on isolated worktrees and merged onto this branch in dependency order. All three workspaces (
backend,app.client,lib.components) typecheck clean.Phase A — pair / taste / watched-together data model
New Sequelize models:
Household,HouseholdMember(composite PK,owner/memberrole enum),TasteProfile(PK =user_id, JSONB weights),WatchedTogether(the gold signal). AddsContent.providersJSONB. First real migration atbackend/src/db/migrations/20260601000000-phase-a-households.ts— transactional, with backfill from acceptedmatchesintohouseholdsand emptytaste_profilesfor every user.Phase B — Tonight pick flow (ML, no LLM)
recommendation.service.tsdoes intersection-favoured taste-profile merge → TMDb/discovercandidate set (cached 30 min by mood/runtime/region) → filtersWatchedTogetherandfinishedentries → weighted score (genre 0.5, runtime-Gaussian 0.2, mood 0.15, recency 0.15) → templated rationale. EndpointsPOST /api/households/:id/pickandPOST /api/households/:id/picks/:tmdbId/commit. NewTonightPickerscreen routed at/tonightwith mood chips, time slider, provider checkboxes, single result card, swap / commit / dismiss actions.Phase C — LLM re-ranker scaffold (off by default)
llm.service.tscallsclaude-sonnet-4-6with prompt-cached system block + per-member taste-profile blurbs, candidate list uncached, structured output via forcedsubmit_picktool use.lru-cache(24h, 1000 entries) keyed by sha256 of{mood, minutes_bucket=round(min/15)*15, sorted candidate ids, sorted profile versions}— buckets minutes so 88 and 92 hit the same entry. Behind settingrecommendation.llm_rerank+ envLLM_RERANK_ENABLED, both defaultfalse. Integration contract:maybeRerank(candidates, ctx)returnsnullto fall back to pure ML; never throws.Phase D — providers + history
fetchWatchProviders/getWatchProviders(24h in-memory cache) extendtmdb.service.ts.providers.service.tsupsertsContent.providersand reads with cache-or-refresh.ProviderBadgescomponent inlib.components(logo chips, deep links, optional affiliate query suffix). History page at/historylists a household'sWatchedTogetherrows, captures thumbs up/down → async TasteProfile recompute.Phase E — freemium pricing gate (mock checkout only)
Subscription(one per household, free/premium) +PickUsagelog models.entitlements.service.tsexposes{tier, daily_pick_limit, picks_used_today, picks_remaining, can_use_llm_rerank, can_use_multi_region, region_lock}. MiddlewareenforcePickQuotareturns402 Payment Requiredon free-tier exhaustion and insertsPickUsageonres.on('finish')for 2xx responses;enforceRegionLocksilently downgrades free tier toGB. Mock checkout page activates premium for 30 days for demos. No Stripe SDK — surfaces (startCheckoutSession,handleStripeWebhook,cancelSubscription) all have placeholder bodies with TODO markers naming the exact Stripe calls that go in when go-live is signed off.isLlmRerankEnabledForHousehold(householdId)ANDs C's global flag with the household'scan_use_llm_rerank.Notable merge resolutions
HouseholdMember.role = 'owner'(noowner_idcolumn onHousehold). Subscription/PickUsage FK references were corrected from the stub PKhousehold_idto A's actual PKid.tmdb.service.tsended up with both B'sgetWatchProviders(region)(single-region, cached, for recommender filtering) and D'sfetchWatchProviders()(all regions, uncached —providers.service.tsdoes the caching at theContent.providerslayer).featureFlags.service.tskeeps C's full implementation (AppSettings + env override + 60s in-process cache) and gains E'sisLlmRerankEnabledForHousehold.Also in this PR
.claude/CLAUDE.md— moved from repo root to.claude/to mirror the ideaSquared/adopt-dont-shop convention; two-part doc (behavioral preamble + Pairflix-specific guidelines covering the active pivot, monorepo layout, conventions, external integrations)..claude/skills/{new-backend-feature,new-sequelize-model}/SKILL.md— playbooks for the two most-easily-forgotten workflows..gitignore— excludes.claude/worktrees/.Out of scope
maybeRerankinto B's recommender (integration contract documented; one-line call site once we A/B it).<UpgradeBanner />(Phase E left a TODO note rather than touching Phase B's screen).Test plan
npm installin repo rootcd backend && npx tsc --noEmitcleancd lib.components && npx tsc --emitDeclarationOnly && cd ../app.client && npx tsc --noEmitcleandocker-compose up -d postgresthen run the new migration; verify backfill produced one household per accepted match and an emptytaste_profilesrow per usernpm run dev— log in, hit/tonight, pick a mood + time, confirm a single card returns; "Watching it" should not throw; "Swap" should return a different titlerecommendation.llm_reranktrue and confirmmaybeRerankis invoked onceANTHROPIC_API_KEYis set (or returnsnullif not — pure-ML path still works)/pickfour times in a row with a fresh free household → fourth should 402/historylists committed picks; thumbs PATCH updatesenjoyedGET /api/providers/:tmdbId?media_type=tv®ion=GBreturns provider data or emptyGenerated by Claude Code