Skip to content

Pivot to household title-matching: 5-phase scaffold (Tonight pick, providers, history, pricing, LLM rerank)#59

Merged
ParagonJenko merged 25 commits into
masterfrom
claude/inspiring-tesla-lAShW
Jun 1, 2026
Merged

Pivot to household title-matching: 5-phase scaffold (Tonight pick, providers, history, pricing, LLM rerank)#59
ParagonJenko merged 25 commits into
masterfrom
claude/inspiring-tesla-lAShW

Conversation

@ParagonJenko

Copy link
Copy Markdown
Member

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 Match flow 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/member role enum), TasteProfile (PK = user_id, JSONB weights), WatchedTogether (the gold signal). Adds Content.providers JSONB. First real migration at backend/src/db/migrations/20260601000000-phase-a-households.ts — transactional, with backfill from accepted matches into households and empty taste_profiles for every user.

Phase B — Tonight pick flow (ML, no LLM)

recommendation.service.ts does intersection-favoured taste-profile merge → TMDb /discover candidate set (cached 30 min by mood/runtime/region) → filters WatchedTogether and finished entries → weighted score (genre 0.5, runtime-Gaussian 0.2, mood 0.15, recency 0.15) → templated rationale. Endpoints POST /api/households/:id/pick and POST /api/households/:id/picks/:tmdbId/commit. New TonightPicker screen routed at /tonight with mood chips, time slider, provider checkboxes, single result card, swap / commit / dismiss actions.

Phase C — LLM re-ranker scaffold (off by default)

llm.service.ts calls claude-sonnet-4-6 with prompt-cached system block + per-member taste-profile blurbs, candidate list uncached, structured output via forced submit_pick tool 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 setting recommendation.llm_rerank + env LLM_RERANK_ENABLED, both default false. Integration contract: maybeRerank(candidates, ctx) returns null to fall back to pure ML; never throws.

Phase D — providers + history

fetchWatchProviders / getWatchProviders (24h in-memory cache) extend tmdb.service.ts. providers.service.ts upserts Content.providers and reads with cache-or-refresh. ProviderBadges component in lib.components (logo chips, deep links, optional affiliate query suffix). History page at /history lists a household's WatchedTogether rows, captures thumbs up/down → async TasteProfile recompute.

Phase E — freemium pricing gate (mock checkout only)

Subscription (one per household, free/premium) + PickUsage log models. entitlements.service.ts exposes {tier, daily_pick_limit, picks_used_today, picks_remaining, can_use_llm_rerank, can_use_multi_region, region_lock}. Middleware enforcePickQuota returns 402 Payment Required on free-tier exhaustion and inserts PickUsage on res.on('finish') for 2xx responses; enforceRegionLock silently downgrades free tier to GB. 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's can_use_llm_rerank.

Notable merge resolutions

  • Phases B/D/E each stubbed Phase A's models in parallel; A's real implementations won everywhere. The billing cancel handler was rewritten to check HouseholdMember.role = 'owner' (no owner_id column on Household). Subscription/PickUsage FK references were corrected from the stub PK household_id to A's actual PK id.
  • tmdb.service.ts ended up with both B's getWatchProviders(region) (single-region, cached, for recommender filtering) and D's fetchWatchProviders() (all regions, uncached — providers.service.ts does the caching at the Content.providers layer).
  • featureFlags.service.ts keeps C's full implementation (AppSettings + env override + 60s in-process cache) and gains E's isLlmRerankEnabledForHousehold.

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

  • Real Stripe integration (env-gated TODO).
  • Wiring C's maybeRerank into B's recommender (integration contract documented; one-line call site once we A/B it).
  • TonightPicker rendering <UpgradeBanner /> (Phase E left a TODO note rather than touching Phase B's screen).
  • Cron / scheduled TasteProfile recomputation.

Test plan

  • npm install in repo root
  • cd backend && npx tsc --noEmit clean
  • cd lib.components && npx tsc --emitDeclarationOnly && cd ../app.client && npx tsc --noEmit clean
  • docker-compose up -d postgres then run the new migration; verify backfill produced one household per accepted match and an empty taste_profiles row per user
  • npm 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 title
  • Manually flip recommendation.llm_rerank true and confirm maybeRerank is invoked once ANTHROPIC_API_KEY is set (or returns null if not — pure-ML path still works)
  • Free-tier quota: hit /pick four times in a row with a fresh free household → fourth should 402
  • /history lists committed picks; thumbs PATCH updates enjoyed
  • GET /api/providers/:tmdbId?media_type=tv&region=GB returns provider data or empty

Generated by Claude Code

claude and others added 13 commits June 1, 2026 19:49
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.
@ParagonJenko ParagonJenko marked this pull request as ready for review June 1, 2026 20:12
Copilot AI review requested due to automatic review settings June 1, 2026 20:12
Comment thread backend/src/routes/history.routes.ts Fixed
Comment thread backend/src/routes/household.routes.ts Fixed
Comment thread backend/src/routes/providers.routes.ts Fixed
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.
Comment thread backend/src/services/tmdb.service.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread backend/src/services/llm.service.ts Outdated
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.
@github-actions github-actions Bot added the admin label Jun 1, 2026
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.
@ParagonJenko ParagonJenko merged commit e84485c into master Jun 1, 2026
8 checks passed
@ParagonJenko ParagonJenko deleted the claude/inspiring-tesla-lAShW branch June 1, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants