diff --git a/app.client/src/features/tonight/TonightPicker.tsx b/app.client/src/features/tonight/TonightPicker.tsx index a3ce54b..28d5830 100644 --- a/app.client/src/features/tonight/TonightPicker.tsx +++ b/app.client/src/features/tonight/TonightPicker.tsx @@ -16,6 +16,7 @@ import type { Theme } from '../../styles/theme'; import { useActiveHousehold } from './useActiveHousehold'; import { useTonightHomepagePreference } from './useTonightHomepage'; import { useCommitPick, useTonightPick } from './useTonightPick'; +import { households } from '../../services/api/households'; import type { Mood, RecommendationCard } from '../../services/api/households'; const MOODS: { id: Mood; label: string }[] = [ @@ -100,6 +101,22 @@ const Rationale = styled(Typography)<{ theme: Theme }>` color: ${({ theme }) => theme.colors.text.secondary}; `; +const ProviderLaunchButton = styled.button<{ theme: Theme }>` + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.sm}; + border-radius: 999px; + border: 1px solid ${({ theme }) => theme.colors.primary}; + background: ${({ theme }) => theme.colors.primary}; + color: #ffffff; + cursor: pointer; + font-size: ${({ theme }) => theme.typography.fontSize.sm}; + font-weight: ${({ theme }) => theme.typography.fontWeight.medium}; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.85; + } +`; + const ActionRow = styled(Flex)<{ theme: Theme }>` margin-top: ${({ theme }) => theme.spacing.lg}; gap: ${({ theme }) => theme.spacing.md}; @@ -155,7 +172,24 @@ const TonightPicker: React.FC = () => { }); }; + const recordEvent = ( + card: RecommendationCard, + kind: 'accepted' | 'swapped' | 'dismissed' + ) => { + if (!household) return; + void households + .recordPickEvent(household.id, { + tmdb_id: card.tmdb_id, + media_type: card.media_type, + kind, + mood, + minutes_budget: minutes, + }) + .catch(() => undefined); + }; + const onSwap = (card: RecommendationCard) => { + recordEvent(card, 'swapped'); const next = [...excludedTmdbIds, card.tmdb_id]; setExcludedTmdbIds(next); pickMutation.mutate({ @@ -167,6 +201,7 @@ const TonightPicker: React.FC = () => { }; const onCommit = (card: RecommendationCard) => { + recordEvent(card, 'accepted'); commitMutation.mutate( { tmdbId: card.tmdb_id, @@ -183,6 +218,33 @@ const TonightPicker: React.FC = () => { ); }; + const onDismiss = (card: RecommendationCard) => { + recordEvent(card, 'dismissed'); + setDismissed(true); + }; + + 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, + } + ); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch { + // Launch failed (provider not available in region); silently no-op + } + }; + const toggleProvider = (id: string) => { setProviders(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id] @@ -316,9 +378,15 @@ const TonightPicker: React.FC = () => { )} {providerBadges.map(p => ( - - {p.provider_name} - + + onLaunchProvider(result.pick, p.provider_name) + } + > + Watch on {p.provider_name} + ))} {result.rationale} @@ -341,7 +409,10 @@ const TonightPicker: React.FC = () => { > Swap - diff --git a/app.client/src/services/api/households.ts b/app.client/src/services/api/households.ts index 1dbb827..1f09807 100644 --- a/app.client/src/services/api/households.ts +++ b/app.client/src/services/api/households.ts @@ -118,4 +118,59 @@ export const households = { } ); }, + + recordPickEvent: async ( + householdId: string, + body: PickEventBody + ): Promise<{ id: string }> => { + return fetchWithAuth(`/api/households/${householdId}/pick-events`, { + method: 'POST', + body: JSON.stringify(body), + }); + }, + + launchProvider: async ( + householdId: string, + tmdbId: number, + body: LaunchProviderBody + ): Promise => { + return fetchWithAuth( + `/api/households/${householdId}/picks/${tmdbId}/launch`, + { + method: 'POST', + body: JSON.stringify(body), + } + ); + }, }; + +export type PickEventKind = + | 'proposed' + | 'accepted' + | 'swapped' + | 'dismissed' + | 'provider_launched'; + +export interface PickEventBody { + tmdb_id: number; + media_type: 'movie' | 'tv'; + kind: PickEventKind; + mood?: Mood; + minutes_budget?: number; + provider_slug?: string; + region?: string; +} + +export interface LaunchProviderBody { + provider_slug: string; + media_type: 'movie' | 'tv'; + region?: string; + mood?: Mood; + minutes_budget?: number; +} + +export interface LaunchProviderResult { + url: string; + provider_name: string; + region: string; +} diff --git a/backend/src/controllers/household.controller.ts b/backend/src/controllers/household.controller.ts index 8daf314..69ee129 100644 --- a/backend/src/controllers/household.controller.ts +++ b/backend/src/controllers/household.controller.ts @@ -8,6 +8,7 @@ import { isOwner, listForUser, } from '../services/household.service'; +import { recordPickEvent } from '../services/pickEvent.service'; import { defaultRecommendationService } from '../services/recommendation.service'; import type { Mood } from '../services/recommendation.types'; @@ -49,6 +50,20 @@ export const pickForHousehold = async (req: Request, res: Response) => { ...(body.region ? { region: body.region } : {}), ...(body.excludeTmdbIds ? { excludeTmdbIds: body.excludeTmdbIds } : {}), }); + + void recordPickEvent({ + household_id: householdId, + user_id: userId, + tmdb_id: result.pick.tmdb_id, + media_type: result.pick.media_type, + kind: 'proposed', + mood: body.mood, + minutes_budget: body.minutes, + region: body.region ?? null, + }).catch(err => { + console.warn('Failed to record proposed pick event', err); + }); + return res.json(result); } catch (error) { console.error('Error picking for household:', error); diff --git a/backend/src/controllers/pickEvent.controller.ts b/backend/src/controllers/pickEvent.controller.ts new file mode 100644 index 0000000..656d741 --- /dev/null +++ b/backend/src/controllers/pickEvent.controller.ts @@ -0,0 +1,184 @@ +import type { Request, Response } from 'express'; +import HouseholdMember from '../models/HouseholdMember'; +import type { PickEventKind } from '../models/PickEvent'; +import { + getPickEventStats, + recordPickEvent, +} from '../services/pickEvent.service'; +import { + ProviderNotAvailableError, + resolveProviderLaunch, +} from '../services/providerLaunch.service'; + +const VALID_KINDS: PickEventKind[] = [ + 'proposed', + 'accepted', + 'swapped', + 'dismissed', + 'provider_launched', +]; + +const VALID_MEDIA_TYPES = ['movie', 'tv'] as const; + +interface PickEventBody { + tmdb_id: number; + media_type: 'movie' | 'tv'; + kind: PickEventKind; + mood?: string; + minutes_budget?: number; + provider_slug?: string; + region?: string; +} + +interface LaunchBody { + provider_slug: string; + media_type: 'movie' | 'tv'; + region?: string; + mood?: string; + minutes_budget?: number; +} + +const requireMembership = async ( + householdId: string, + userId: string +): Promise => { + const m = await HouseholdMember.findOne({ + where: { household_id: householdId, user_id: userId }, + }); + return m !== null; +}; + +export const postPickEvent = 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' }); + } + if (!(await requireMembership(householdId, userId))) { + return res.status(403).json({ error: 'not_a_member' }); + } + + const body = req.body as PickEventBody; + if (typeof body?.tmdb_id !== 'number' || Number.isNaN(body.tmdb_id)) { + return res.status(400).json({ error: 'tmdb_id_required' }); + } + if (!VALID_MEDIA_TYPES.includes(body.media_type)) { + return res.status(400).json({ error: 'invalid_media_type' }); + } + if (!VALID_KINDS.includes(body.kind)) { + return res.status(400).json({ error: 'invalid_kind' }); + } + 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, + }); + + return res.status(201).json({ id: event.id }); +}; + +export const getPickEventStatsController = 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' }); + } + if (!(await requireMembership(householdId, userId))) { + return res.status(403).json({ error: 'not_a_member' }); + } + + const windowParam = req.query.window_days; + let windowDays = 30; + if (typeof windowParam === 'string') { + const parsed = Number.parseInt(windowParam, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + windowDays = Math.min(parsed, 365); + } + } + + const stats = await getPickEventStats(householdId, windowDays); + return res.json(stats); +}; + +export const postProviderLaunch = 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 = Number.parseInt(tmdbIdParam, 10); + if (Number.isNaN(tmdbId)) { + return res.status(400).json({ error: 'invalid_tmdb_id' }); + } + if (!(await requireMembership(householdId, userId))) { + return res.status(403).json({ error: 'not_a_member' }); + } + + const body = req.body as LaunchBody; + if (typeof body?.provider_slug !== 'string' || !body.provider_slug.trim()) { + return res.status(400).json({ error: 'provider_slug_required' }); + } + if (!VALID_MEDIA_TYPES.includes(body.media_type)) { + return res.status(400).json({ error: 'invalid_media_type' }); + } + 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' }); + } + + try { + const resolved = await resolveProviderLaunch({ + tmdb_id: tmdbId, + media_type: body.media_type, + provider_slug: body.provider_slug, + region, + }); + + 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, + }); + + return res.json(resolved); + } catch (error) { + if (error instanceof ProviderNotAvailableError) { + return res.status(404).json({ error: 'provider_not_available' }); + } + throw error; + } +}; diff --git a/backend/src/db/migrations/20260601000003-create-pick-events.ts b/backend/src/db/migrations/20260601000003-create-pick-events.ts new file mode 100644 index 0000000..dc7e9f4 --- /dev/null +++ b/backend/src/db/migrations/20260601000003-create-pick-events.ts @@ -0,0 +1,95 @@ +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( + 'pick_events', + { + id: { + type: DataTypes.UUID, + defaultValue: sequelize.literal('gen_random_uuid()'), + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'households', key: 'id' }, + onDelete: 'CASCADE', + }, + user_id: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'user_id' }, + onDelete: 'SET NULL', + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + kind: { + type: DataTypes.ENUM( + 'proposed', + 'accepted', + 'swapped', + 'dismissed', + 'provider_launched' + ), + allowNull: false, + }, + mood: { + type: DataTypes.STRING, + allowNull: true, + }, + minutes_budget: { + type: DataTypes.INTEGER, + allowNull: true, + }, + provider_slug: { + type: DataTypes.STRING, + allowNull: true, + }, + region: { + type: DataTypes.STRING(2), + allowNull: true, + }, + occurred_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: sequelize.literal('NOW()'), + }, + }, + { transaction } + ); + + await queryInterface.addIndex('pick_events', { + name: 'pick_events_household_occurred_at_idx', + fields: [ + { name: 'household_id' }, + { name: 'occurred_at', order: 'DESC' }, + ], + transaction, + }); + + await queryInterface.addIndex('pick_events', { + name: 'pick_events_household_kind_idx', + fields: ['household_id', 'kind'], + transaction, + }); + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('pick_events'); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_pick_events_kind";' + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_pick_events_media_type";' + ); +} diff --git a/backend/src/models/PickEvent.ts b/backend/src/models/PickEvent.ts new file mode 100644 index 0000000..fd08602 --- /dev/null +++ b/backend/src/models/PickEvent.ts @@ -0,0 +1,146 @@ +import { DataTypes, Model, type ModelStatic, type Sequelize } from 'sequelize'; +import Household from './Household'; +import User from './User'; + +export type PickEventKind = + | 'proposed' + | 'accepted' + | 'swapped' + | 'dismissed' + | 'provider_launched'; + +interface PickEventAttributes { + id: string; + household_id: string; + user_id: string | null; + tmdb_id: number; + media_type: 'movie' | 'tv'; + kind: PickEventKind; + mood: string | null; + minutes_budget: number | null; + provider_slug: string | null; + region: string | null; + occurred_at: Date; +} + +interface PickEventCreationAttributes { + household_id: string; + user_id?: string | null; + tmdb_id: number; + media_type: 'movie' | 'tv'; + kind: PickEventKind; + mood?: string | null; + minutes_budget?: number | null; + provider_slug?: string | null; + region?: string | null; + occurred_at?: Date; +} + +class PickEvent extends Model< + PickEventAttributes, + PickEventCreationAttributes +> { + declare id: string; + + declare household_id: string; + + declare user_id: string | null; + + declare tmdb_id: number; + + declare media_type: 'movie' | 'tv'; + + declare kind: PickEventKind; + + declare mood: string | null; + + declare minutes_budget: number | null; + + declare provider_slug: string | null; + + declare region: string | null; + + declare occurred_at: Date; + + static initialize(sequelize: Sequelize): ModelStatic { + return PickEvent.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + household_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: Household, key: 'id' }, + }, + user_id: { + type: DataTypes.UUID, + allowNull: true, + references: { model: User, key: 'user_id' }, + }, + tmdb_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + media_type: { + type: DataTypes.ENUM('movie', 'tv'), + allowNull: false, + }, + kind: { + type: DataTypes.ENUM( + 'proposed', + 'accepted', + 'swapped', + 'dismissed', + 'provider_launched' + ), + allowNull: false, + }, + mood: { + type: DataTypes.STRING, + allowNull: true, + }, + minutes_budget: { + type: DataTypes.INTEGER, + allowNull: true, + }, + provider_slug: { + type: DataTypes.STRING, + allowNull: true, + }, + region: { + type: DataTypes.STRING(2), + allowNull: true, + }, + occurred_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: 'PickEvent', + tableName: 'pick_events', + timestamps: false, + indexes: [ + { + name: 'pick_events_household_occurred_at_idx', + fields: [ + { name: 'household_id' }, + { name: 'occurred_at', order: 'DESC' }, + ], + }, + { + name: 'pick_events_household_kind_idx', + fields: ['household_id', 'kind'], + }, + ], + } + ); + } +} + +export default PickEvent; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 82d5e62..5b3bff1 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -10,6 +10,7 @@ import HouseholdInvite from './HouseholdInvite'; import HouseholdMember from './HouseholdMember'; import Match from './Match'; import PasswordReset from './PasswordReset'; +import PickEvent from './PickEvent'; import PickUsage from './PickUsage'; import Subscription from './Subscription'; import TasteProfile from './TasteProfile'; @@ -49,6 +50,7 @@ export function initializeModels(sequelize: Sequelize) { WatchedTogether.initialize(sequelize); Subscription.initialize(sequelize); PickUsage.initialize(sequelize); + PickEvent.initialize(sequelize); // Set up associations after all models are initialized Match.belongsTo(User, { as: 'user1', foreignKey: 'user1_id' }); @@ -183,6 +185,19 @@ export function initializeModels(sequelize: Sequelize) { foreignKey: 'household_id', as: 'pickUsages', }); + + PickEvent.belongsTo(Household, { + foreignKey: 'household_id', + as: 'household', + }); + PickEvent.belongsTo(User, { + foreignKey: 'user_id', + as: 'user', + }); + Household.hasMany(PickEvent, { + foreignKey: 'household_id', + as: 'pickEvents', + }); } catch (error) { console.error('Error initializing models:', error); throw new Error( @@ -210,4 +225,5 @@ export default { WatchedTogether, Subscription, PickUsage, + PickEvent, }; diff --git a/backend/src/routes/household.routes.ts b/backend/src/routes/household.routes.ts index a5737a4..0174df8 100644 --- a/backend/src/routes/household.routes.ts +++ b/backend/src/routes/household.routes.ts @@ -6,6 +6,11 @@ import { pickForHousehold, postInvite, } from '../controllers/household.controller'; +import { + getPickEventStatsController, + postPickEvent, + postProviderLaunch, +} from '../controllers/pickEvent.controller'; import { authenticateToken } from '../middlewares/auth'; import { enforcePickQuota, @@ -23,5 +28,8 @@ router.post('/', createHousehold); router.post('/:id/invites', postInvite); router.post('/:id/pick', enforceRegionLock, enforcePickQuota, pickForHousehold); router.post('/:id/picks/:tmdbId/commit', commitHouseholdPick); +router.post('/:id/picks/:tmdbId/launch', postProviderLaunch); +router.post('/:id/pick-events', postPickEvent); +router.get('/:id/pick-events/stats', getPickEventStatsController); export default router; diff --git a/backend/src/services/pickEvent.service.test.ts b/backend/src/services/pickEvent.service.test.ts new file mode 100644 index 0000000..2186f17 --- /dev/null +++ b/backend/src/services/pickEvent.service.test.ts @@ -0,0 +1,147 @@ +jest.mock('../models/PickEvent', () => ({ + __esModule: true, + default: { + create: jest.fn(), + findAll: jest.fn(), + }, +})); + +import PickEvent from '../models/PickEvent'; +import { getPickEventStats, recordPickEvent } from './pickEvent.service'; + +const mockedCreate = PickEvent.create as jest.MockedFunction< + typeof PickEvent.create +>; +const mockedFindAll = PickEvent.findAll as jest.MockedFunction< + typeof PickEvent.findAll +>; + +describe('pickEvent.service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('recordPickEvent', () => { + it('passes through all fields and nulls optionals when omitted', async () => { + mockedCreate.mockResolvedValueOnce({ id: 'evt-1' } as never); + + await recordPickEvent({ + household_id: 'h1', + tmdb_id: 42, + media_type: 'movie', + kind: 'proposed', + }); + + expect(mockedCreate).toHaveBeenCalledWith({ + household_id: 'h1', + user_id: null, + tmdb_id: 42, + media_type: 'movie', + kind: 'proposed', + mood: null, + minutes_budget: null, + provider_slug: null, + region: null, + }); + }); + + it('persists provided optional fields', async () => { + mockedCreate.mockResolvedValueOnce({ id: 'evt-2' } as never); + + await recordPickEvent({ + household_id: 'h1', + user_id: 'u1', + tmdb_id: 7, + media_type: 'tv', + kind: 'provider_launched', + mood: 'funny', + minutes_budget: 90, + provider_slug: 'netflix', + region: 'GB', + }); + + expect(mockedCreate).toHaveBeenCalledWith({ + household_id: 'h1', + user_id: 'u1', + tmdb_id: 7, + media_type: 'tv', + kind: 'provider_launched', + mood: 'funny', + minutes_budget: 90, + provider_slug: 'netflix', + region: 'GB', + }); + }); + }); + + describe('getPickEventStats', () => { + const makeEvent = ( + kind: string, + provider_slug: string | null = null + ): { kind: string; provider_slug: string | null } => ({ + kind, + provider_slug, + }); + + it('aggregates totals and first-pick acceptance rate', async () => { + mockedFindAll.mockResolvedValueOnce([ + makeEvent('proposed'), + makeEvent('accepted'), + makeEvent('proposed'), + makeEvent('accepted'), + makeEvent('proposed'), + makeEvent('swapped'), + makeEvent('provider_launched', 'netflix'), + makeEvent('provider_launched', 'netflix'), + makeEvent('provider_launched', 'prime'), + ] as never); + + const stats = await getPickEventStats('h1'); + + expect(stats.window_days).toBe(30); + expect(stats.totals.proposed).toBe(3); + expect(stats.totals.accepted).toBe(2); + expect(stats.totals.swapped).toBe(1); + expect(stats.totals.dismissed).toBe(0); + expect(stats.totals.provider_launched).toBe(3); + expect(stats.first_pick_acceptance_rate).toBeCloseTo(2 / 3); + expect(stats.provider_clicks_by_slug).toEqual({ + netflix: 2, + prime: 1, + }); + }); + + it('returns null acceptance rate when there are no responses', async () => { + mockedFindAll.mockResolvedValueOnce([makeEvent('proposed')] as never); + + const stats = await getPickEventStats('h1'); + expect(stats.totals.proposed).toBe(1); + expect(stats.first_pick_acceptance_rate).toBeNull(); + }); + + it('passes window_days through to the query bound', async () => { + mockedFindAll.mockResolvedValueOnce([] as never); + await getPickEventStats('h1', 7); + + const call = mockedFindAll.mock.calls[0]?.[0] as + | { where?: { occurred_at?: Record } } + | 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); + }); + + it('ignores provider_launched without provider_slug', async () => { + mockedFindAll.mockResolvedValueOnce([ + makeEvent('provider_launched', null), + makeEvent('provider_launched', 'netflix'), + ] as never); + + const stats = await getPickEventStats('h1'); + expect(stats.totals.provider_launched).toBe(2); + expect(stats.provider_clicks_by_slug).toEqual({ netflix: 1 }); + }); + }); +}); diff --git a/backend/src/services/pickEvent.service.ts b/backend/src/services/pickEvent.service.ts new file mode 100644 index 0000000..1fda2c1 --- /dev/null +++ b/backend/src/services/pickEvent.service.ts @@ -0,0 +1,89 @@ +import { Op } from 'sequelize'; +import PickEvent, { type PickEventKind } from '../models/PickEvent'; + +export interface RecordPickEventInput { + household_id: string; + user_id?: string | null; + tmdb_id: number; + media_type: 'movie' | 'tv'; + kind: PickEventKind; + mood?: string | null; + minutes_budget?: number | null; + provider_slug?: string | null; + region?: string | null; +} + +export interface PickEventStats { + window_days: number; + totals: Record; + first_pick_acceptance_rate: number | null; + provider_clicks_by_slug: Record; +} + +const ALL_KINDS: PickEventKind[] = [ + 'proposed', + 'accepted', + 'swapped', + 'dismissed', + 'provider_launched', +]; + +export const recordPickEvent = async ( + input: RecordPickEventInput +): Promise => { + return PickEvent.create({ + household_id: input.household_id, + user_id: input.user_id ?? null, + tmdb_id: input.tmdb_id, + media_type: input.media_type, + kind: input.kind, + mood: input.mood ?? null, + minutes_budget: input.minutes_budget ?? null, + provider_slug: input.provider_slug ?? null, + region: input.region ?? null, + }); +}; + +export const getPickEventStats = async ( + householdId: string, + windowDays: number = 30 +): Promise => { + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + + const events = await PickEvent.findAll({ + where: { + household_id: householdId, + occurred_at: { [Op.gte]: since }, + }, + }); + + const totals = ALL_KINDS.reduce>( + (acc, k) => { + acc[k] = 0; + return acc; + }, + {} as Record + ); + const providerClicks: Record = {}; + + for (const event of events) { + totals[event.kind] += 1; + if (event.kind === 'provider_launched' && event.provider_slug) { + providerClicks[event.provider_slug] = + (providerClicks[event.provider_slug] ?? 0) + 1; + } + } + + // First-pick acceptance: of proposals that received any user response + // (accepted/swapped/dismissed), how many were accepted? + const responded = totals.accepted + totals.swapped + totals.dismissed; + const firstPickAcceptanceRate = + responded > 0 ? totals.accepted / responded : null; + + return { + window_days: windowDays, + totals, + first_pick_acceptance_rate: firstPickAcceptanceRate, + provider_clicks_by_slug: providerClicks, + }; +}; diff --git a/backend/src/services/providerLaunch.service.test.ts b/backend/src/services/providerLaunch.service.test.ts new file mode 100644 index 0000000..e89fc13 --- /dev/null +++ b/backend/src/services/providerLaunch.service.test.ts @@ -0,0 +1,120 @@ +jest.mock('./tmdb.service', () => ({ + fetchWatchProviders: jest.fn(), +})); + +import { fetchWatchProviders } from './tmdb.service'; +import { + ProviderNotAvailableError, + resolveProviderLaunch, +} from './providerLaunch.service'; + +const mockedFetch = fetchWatchProviders as jest.MockedFunction< + typeof fetchWatchProviders +>; + +describe('providerLaunch.service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the JustWatch link when the provider is available in the region', async () => { + mockedFetch.mockResolvedValueOnce({ + GB: { + link: 'https://www.themoviedb.org/movie/42/watch?locale=GB', + flatrate: [ + { provider_id: 8, provider_name: 'Netflix', logo_path: '/n.png' }, + ], + }, + }); + + const result = await resolveProviderLaunch({ + tmdb_id: 42, + media_type: 'movie', + provider_slug: 'netflix', + region: 'GB', + }); + + expect(result.url).toBe( + 'https://www.themoviedb.org/movie/42/watch?locale=GB' + ); + expect(result.provider_name).toBe('Netflix'); + expect(result.region).toBe('GB'); + }); + + it('normalises slug to match provider names with punctuation', async () => { + mockedFetch.mockResolvedValueOnce({ + GB: { + link: 'https://x', + flatrate: [ + { + provider_id: 337, + provider_name: 'Disney Plus', + logo_path: '/x.png', + }, + ], + }, + }); + + const result = await resolveProviderLaunch({ + tmdb_id: 1, + media_type: 'movie', + provider_slug: 'disney_plus', + region: 'GB', + }); + + expect(result.provider_name).toBe('Disney Plus'); + }); + + it('throws when the region has no providers', async () => { + mockedFetch.mockResolvedValueOnce({}); + + await expect( + resolveProviderLaunch({ + tmdb_id: 1, + media_type: 'movie', + provider_slug: 'netflix', + region: 'GB', + }) + ).rejects.toBeInstanceOf(ProviderNotAvailableError); + }); + + it('throws when the provider is not in the region', async () => { + mockedFetch.mockResolvedValueOnce({ + GB: { + link: 'https://x', + flatrate: [ + { provider_id: 9, provider_name: 'Prime Video', logo_path: '/x.png' }, + ], + }, + }); + + await expect( + resolveProviderLaunch({ + tmdb_id: 1, + media_type: 'movie', + provider_slug: 'netflix', + region: 'GB', + }) + ).rejects.toBeInstanceOf(ProviderNotAvailableError); + }); + + it('searches across flatrate, rent and buy', async () => { + mockedFetch.mockResolvedValueOnce({ + GB: { + link: 'https://x', + rent: [ + { provider_id: 100, provider_name: 'Apple TV', logo_path: '/x.png' }, + ], + }, + }); + + const result = await resolveProviderLaunch({ + tmdb_id: 1, + media_type: 'movie', + provider_slug: 'apple_tv', + region: 'GB', + }); + + expect(result.provider_name).toBe('Apple TV'); + }); +}); diff --git a/backend/src/services/providerLaunch.service.ts b/backend/src/services/providerLaunch.service.ts new file mode 100644 index 0000000..aba6d19 --- /dev/null +++ b/backend/src/services/providerLaunch.service.ts @@ -0,0 +1,61 @@ +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 => { + 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]; + + if (!regional) { + throw new ProviderNotAvailableError(input.provider_slug, region); + } + + const candidates = [ + ...(regional.flatrate ?? []), + ...(regional.free ?? []), + ...(regional.ads ?? []), + ...(regional.rent ?? []), + ...(regional.buy ?? []), + ]; + + const match = candidates.find(p => { + const name = normalize(p.provider_name); + return name === wanted || name.includes(wanted) || wanted.includes(name); + }); + + if (!match || !regional.link) { + throw new ProviderNotAvailableError(input.provider_slug, region); + } + + return { + url: regional.link, + provider_name: match.provider_name, + region, + }; +}; diff --git a/docs/db-schema.md b/docs/db-schema.md index af9bce9..aa72ce0 100644 --- a/docs/db-schema.md +++ b/docs/db-schema.md @@ -400,6 +400,36 @@ CREATE INDEX pick_usage_household_picked_at_idx ON pick_usage(household_id, picked_at DESC); ``` +## Phase F — pick events and provider launch tracking + +### Pick events + +Captures every interaction with a recommendation: when the recommender proposes a title (`proposed`), what the household decides (`accepted` / `swapped` / `dismissed`), and which streaming provider they launch into (`provider_launched`). Feeds the "first-pick acceptance rate" KPI and the affiliate-revenue attribution loop. + +```sql +CREATE TABLE pick_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(user_id) ON DELETE SET NULL, + tmdb_id INTEGER NOT NULL, + media_type "enum_pick_events_media_type" NOT NULL, -- 'movie' | 'tv' + kind "enum_pick_events_kind" NOT NULL, + -- 'proposed' | 'accepted' | 'swapped' | 'dismissed' | 'provider_launched' + mood TEXT, + minutes_budget INTEGER, + provider_slug TEXT, + region VARCHAR(2), + occurred_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX pick_events_household_occurred_at_idx + ON pick_events(household_id, occurred_at DESC); +CREATE INDEX pick_events_household_kind_idx + ON pick_events(household_id, kind); +``` + +`provider_slug` is non-null only for `provider_launched` events. `user_id` records who performed the action; nullable so that backend-emitted `proposed` events stay valid if the user is later deleted. + ## Indexes ```sql