feat(households): pick events + provider launch tracking (Phase F)#60
Merged
Conversation
Adds the recommender-evaluation and affiliate-attribution seam that was missing from the Tonight flow. Every pick now records its lifecycle (proposed/accepted/swapped/dismissed) and "Watch on X" buttons resolve the TMDb/JustWatch redirect link while logging a provider_launched event. - new pick_events table + migration with up/down - pickEvent.service records events and computes 30-day stats including first-pick acceptance rate and per-provider click counts - providerLaunch.service resolves a provider slug + region to a deep link, throwing ProviderNotAvailableError when there's no match - POST /api/households/:id/pick-events, GET .../stats, and POST /api/households/:id/picks/:tmdbId/launch - pick controller auto-emits a proposed event after each successful pick - TonightPicker wires accept/swap/dismiss to events and turns provider badges into launch buttons that open the redirect in a new tab
Contributor
There was a problem hiding this comment.
Pull request overview
Adds Phase F pick-event logging to measure “30-second decision” outcomes (accepted/swapped/dismissed) and track provider launch clicks for affiliate attribution, spanning DB schema, backend APIs/services, and Tonight UI instrumentation.
Changes:
- Introduces
pick_eventspersistence (migration + Sequelize model + associations) and documents the schema. - Adds backend services/controllers/routes to record events, compute 30-day stats, and resolve provider launch deep-links.
- Updates Tonight picker UI and client API layer to emit pick outcome events and launch-provider events.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/db-schema.md | Documents the new pick_events table and indexes. |
| backend/src/db/migrations/20260601000003-create-pick-events.ts | Creates/drops pick_events with enums + indexes. |
| backend/src/models/PickEvent.ts | Adds Sequelize model for pick events. |
| backend/src/models/index.ts | Registers PickEvent model and household/user associations. |
| backend/src/services/pickEvent.service.ts | Implements event recording and 30-day stats aggregation. |
| backend/src/services/pickEvent.service.test.ts | Adds unit tests for pick event service logic (includes a failing assertion as written). |
| backend/src/services/providerLaunch.service.ts | Resolves provider availability and returns TMDb/JustWatch link. |
| backend/src/services/providerLaunch.service.test.ts | Adds unit tests for provider launch resolution. |
| backend/src/controllers/pickEvent.controller.ts | Adds member-gated endpoints for recording events, stats, and provider launch. |
| backend/src/routes/household.routes.ts | Wires new pick-event and provider-launch routes under households. |
| backend/src/controllers/household.controller.ts | Emits a backend proposed event after a successful pick (fire-and-forget). |
| app.client/src/services/api/households.ts | Adds typed client API calls for pick events and provider launch. |
| app.client/src/features/tonight/TonightPicker.tsx | Emits accept/swap/dismiss events and turns provider badges into launch buttons. |
Comment on lines
+126
to
+133
| const call = mockedFindAll.mock.calls[0]?.[0] as | ||
| | { where?: { occurred_at?: Record<string, Date> } } | ||
| | undefined; | ||
| const bound = call?.where?.occurred_at?.gte; | ||
| expect(bound).toBeInstanceOf(Date); | ||
| const ageMs = Date.now() - (bound as Date).getTime(); | ||
| const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; | ||
| expect(Math.abs(ageMs - sevenDaysMs)).toBeLessThan(5000); |
Comment on lines
+51
to
+52
| export const postPickEvent = async (req: Request, res: Response) => { | ||
| const householdId = req.params.id; |
Comment on lines
+72
to
+74
| if (!VALID_KINDS.includes(body.kind)) { | ||
| return res.status(400).json({ error: 'invalid_kind' }); | ||
| } |
Comment on lines
+75
to
+89
| if (body.region !== undefined && !/^[A-Z]{2}$/.test(body.region)) { | ||
| return res.status(400).json({ error: 'invalid_region' }); | ||
| } | ||
|
|
||
| const event = await recordPickEvent({ | ||
| household_id: householdId, | ||
| user_id: userId, | ||
| tmdb_id: body.tmdb_id, | ||
| media_type: body.media_type, | ||
| kind: body.kind, | ||
| mood: body.mood ?? null, | ||
| minutes_budget: body.minutes_budget ?? null, | ||
| provider_slug: body.provider_slug ?? null, | ||
| region: body.region ?? null, | ||
| }); |
Comment on lines
+165
to
+175
| await recordPickEvent({ | ||
| household_id: householdId, | ||
| user_id: userId, | ||
| tmdb_id: tmdbId, | ||
| media_type: body.media_type, | ||
| kind: 'provider_launched', | ||
| mood: body.mood ?? null, | ||
| minutes_budget: body.minutes_budget ?? null, | ||
| provider_slug: body.provider_slug, | ||
| region, | ||
| }); |
Comment on lines
+226
to
+241
| const onLaunchProvider = async ( | ||
| card: RecommendationCard, | ||
| providerSlug: string | ||
| ) => { | ||
| if (!household) return; | ||
| try { | ||
| const { url } = await households.launchProvider( | ||
| household.id, | ||
| card.tmdb_id, | ||
| { | ||
| provider_slug: providerSlug, | ||
| media_type: card.media_type, | ||
| mood, | ||
| minutes_budget: minutes, | ||
| } | ||
| ); |
Comment on lines
+53
to
+58
| const events = await PickEvent.findAll({ | ||
| where: { | ||
| household_id: householdId, | ||
| occurred_at: { [Op.gte]: since }, | ||
| }, | ||
| }); |
Comment on lines
+1
to
+34
| import { fetchWatchProviders } from './tmdb.service'; | ||
|
|
||
| export interface ResolveLaunchInput { | ||
| tmdb_id: number; | ||
| media_type: 'movie' | 'tv'; | ||
| provider_slug: string; | ||
| region: string; | ||
| } | ||
|
|
||
| export interface ResolvedLaunch { | ||
| url: string; | ||
| provider_name: string; | ||
| region: string; | ||
| } | ||
|
|
||
| const normalize = (value: string): string => | ||
| value.toLowerCase().replace(/[^a-z0-9]/g, ''); | ||
|
|
||
| export class ProviderNotAvailableError extends Error { | ||
| constructor(slug: string, region: string) { | ||
| super(`Provider ${slug} not available in ${region}`); | ||
| this.name = 'ProviderNotAvailableError'; | ||
| } | ||
| } | ||
|
|
||
| export const resolveProviderLaunch = async ( | ||
| input: ResolveLaunchInput | ||
| ): Promise<ResolvedLaunch> => { | ||
| const region = input.region.toUpperCase(); | ||
| const wanted = normalize(input.provider_slug); | ||
|
|
||
| const all = await fetchWatchProviders(input.tmdb_id, input.media_type); | ||
| const regional = all[region]; | ||
|
|
Comment on lines
+151
to
+155
| const region = | ||
| typeof body.region === 'string' && body.region ? body.region : 'GB'; | ||
| if (!/^[A-Z]{2}$/.test(region)) { | ||
| return res.status(400).json({ error: 'invalid_region' }); | ||
| } |
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
Audit against the Notion business plan turned up two missing seams in the Tonight flow:
ProviderBadgeswere decorative, so the "rev-share on signups" stream had nowhere to land.enjoyedthumbs post-watch, but not whether the first pick was accepted vs. swapped vs. dismissed, so the "30-second decision" KPI was unmeasurable.These ship together because a provider click is a pick outcome. This PR adds Phase F: a single
pick_eventslog that powers both.What
pick_eventstable + migration (20260601000003-create-pick-events.ts, up/down)PickEventmodel, registered inmodels/index.tswithHousehold/UserassociationspickEvent.service.ts—recordPickEvent+getPickEventStats(30-day window, first-pick acceptance rate, per-provider click totals)providerLaunch.service.ts— resolves{ tmdb_id, media_type, provider_slug, region }to the TMDb/JustWatch redirect link, throwsProviderNotAvailableErroron misspickEvent.controller.ts— three member-gated endpoints:POST /api/households/:id/pick-eventsGET /api/households/:id/pick-events/stats?window_days=30POST /api/households/:id/picks/:tmdbId/launch(returns deep link + recordsprovider_launched)household.controller.ts— auto-emits aproposedevent after each successful pick (fire-and-forget, logged on failure)TonightPicker.tsx— accept / swap / dismiss now fire events; provider badges becomeWatch on Xlaunch buttons that open the redirect in a new tabdocs/db-schema.mdupdated with the new tableOut of scope (deferred)
discoverMedia({ mediaType: 'movie' }). Separate PR.Test plan
npx jest— 37 suites, 292 tests green (11 new)npx tsc --noEmit— backend andapp.clientboth cleannpm run build:components— greenpick_eventsrows appear forproposed/accepted/swapped/dismissedWatch on Netflix, confirm new tab opens with JustWatch redirect and aprovider_launchedrow is writtenGET /api/households/:id/pick-events/statsand verifyfirst_pick_acceptance_rate+provider_clicks_by_slughttps://claude.ai/code/session_01RM3zxZtSjLdMFGFeNv6fq9
Generated by Claude Code