diff --git a/firestore-tests/rules.test.ts b/firestore-tests/rules.test.ts index 11b98f69..cc24ae71 100644 --- a/firestore-tests/rules.test.ts +++ b/firestore-tests/rules.test.ts @@ -1287,3 +1287,19 @@ describe("entitlement_events/{eventId}", () => { ); }); }); + +// ── aggregate_stats/{doc} ──────────────────────────────────────────────────── + +describe("aggregate_stats/{doc}", () => { + test("no client read — denied (export-only cross-couple rollups)", async () => { + await assertFails( + getDoc(doc(alice().firestore(), "aggregate_stats/outcomes")) + ); + }); + + test("no client write — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), "aggregate_stats/outcomes"), { feltCloserPct: 99 }) + ); + }); +}); diff --git a/firestore.rules b/firestore.rules index 41f08c87..cc9d1c83 100644 --- a/firestore.rules +++ b/firestore.rules @@ -935,5 +935,12 @@ service cloud.firestore { match /entitlement_events/{eventId} { allow read, write: if false; } + + // ── aggregate_stats ─────────────────────────────────────────────────────── + // Privacy-safe cross-couple rollups written by aggregateOutcomeStats (Admin SDK). + // EXPORT-ONLY: no client may read cross-couple aggregates — the owner reads via console. + match /aggregate_stats/{doc} { + allow read, write: if false; + } } } diff --git a/functions/src/couples/aggregateOutcomes.test.ts b/functions/src/couples/aggregateOutcomes.test.ts new file mode 100644 index 00000000..754bed08 --- /dev/null +++ b/functions/src/couples/aggregateOutcomes.test.ts @@ -0,0 +1,77 @@ +import { + aggregate, + extractCoupleOutcome, + CoupleOutcome, + MIN_COHORT, +} from './aggregateOutcomes' + +/** Builds N couples that completed day_30 with the given connection delta. */ +function couplesWithConnectionDelta(n: number, connectionDelta: number): CoupleOutcome[] { + return Array.from({ length: n }, () => ({ + deltas: { day_30: { connection: connectionDelta, communication: 0, intimacy: 0, happiness: 0 } }, + })) +} + +describe('aggregate', () => { + it('suppresses a window below the minimum cohort', () => { + const stats = aggregate(couplesWithConnectionDelta(MIN_COHORT - 1, 3)) + expect(stats.windows.day_30.suppressed).toBe(true) + expect(stats.windows.day_30.feltCloserPct).toBeUndefined() + expect(stats.windows.day_30.cohort).toBe(MIN_COHORT - 1) + }) + + it('reports percentages once the cohort meets the minimum', () => { + // 60 couples: 45 improved connection, 15 did not. + const improved = couplesWithConnectionDelta(45, 2) + const flat = couplesWithConnectionDelta(15, 0) + const stats = aggregate([...improved, ...flat]) + expect(stats.windows.day_30.suppressed).toBe(false) + expect(stats.windows.day_30.cohort).toBe(60) + expect(stats.windows.day_30.feltCloserPct).toBe(75) // 45/60 + }) + + it('counts only couples eligible for each window', () => { + // day_60 has zero couples → suppressed; day_30 has enough. + const stats = aggregate(couplesWithConnectionDelta(MIN_COHORT, 1)) + expect(stats.windows.day_30.suppressed).toBe(false) + expect(stats.windows.day_60.suppressed).toBe(true) + expect(stats.windows.day_60.cohort).toBe(0) + }) + + it('computes improved percentages per metric', () => { + const couples: CoupleOutcome[] = Array.from({ length: MIN_COHORT }, (_, i) => ({ + deltas: { + day_30: { + connection: 1, + communication: i < MIN_COHORT / 2 ? 1 : 0, // exactly half improved + intimacy: 0, + happiness: -1, + }, + }, + })) + const stats = aggregate(couples) + expect(stats.windows.day_30.improvedPctByMetric?.connection).toBe(100) + expect(stats.windows.day_30.improvedPctByMetric?.communication).toBe(50) + expect(stats.windows.day_30.improvedPctByMetric?.intimacy).toBe(0) + expect(stats.windows.day_30.improvedPctByMetric?.happiness).toBe(0) + }) + + it('emits no couple-identifying data — only counts and percentages', () => { + const stats = aggregate(couplesWithConnectionDelta(MIN_COHORT, 2)) + const json = JSON.stringify(stats) + expect(json).not.toMatch(/couple|uid|userId|score/i) + }) +}) + +describe('extractCoupleOutcome', () => { + it('pulls only the follow-up deltas, ignoring day_0 and stray docs', () => { + const out = extractCoupleOutcome([ + { id: 'day_0', data: { baseline: { connection: 5 } } }, + { id: 'day_30', data: { delta: { connection: 2, communication: 1 } } }, + { id: 'random', data: { delta: { connection: 9 } } }, + ]) + expect((out.deltas as Record).day_0).toBeUndefined() + expect(out.deltas.day_30).toEqual({ connection: 2, communication: 1 }) + expect((out.deltas as Record).random).toBeUndefined() + }) +}) diff --git a/functions/src/couples/aggregateOutcomes.ts b/functions/src/couples/aggregateOutcomes.ts new file mode 100644 index 00000000..7d6c2a82 --- /dev/null +++ b/functions/src/couples/aggregateOutcomes.ts @@ -0,0 +1,117 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * Cloud Function: aggregateOutcomeStats + * + * Rolls up per-couple check-in outcomes (couples/{id}/outcomes/day_30|60|90) into a single + * privacy-safe aggregate for the "X% feel closer in N weeks" store stat — WITHOUT reading any + * relationship content (E2EE), only the self-reported delta scores the couples submitted. + * + * Privacy guarantees: + * - Counts + percentages only; no couple id, no individual scores. + * - Minimum-cohort suppression: a window with fewer than MIN_COHORT couples is omitted + * (`suppressed: true`, no percentages) so nothing about a small group is inferable. + * - EXPORT-ONLY: written to the top-level `aggregate_stats` collection, which has NO client read + * rule (default-deny) — the owner reads it via console. Clients can never read cross-couple data. + * + * Cron: daily. Reads are O(couples). + */ + +export type ScoreKey = 'connection' | 'communication' | 'intimacy' | 'happiness' +export type FollowUpDayKey = 'day_30' | 'day_60' | 'day_90' + +export const SCORE_KEYS: ScoreKey[] = ['connection', 'communication', 'intimacy', 'happiness'] +export const FOLLOWUP_DAYS: FollowUpDayKey[] = ['day_30', 'day_60', 'day_90'] +export const MIN_COHORT = 50 + +/** One couple's follow-up deltas (only the windows they've completed are present). */ +export interface CoupleOutcome { + deltas: Partial>>> +} + +export interface WindowStat { + cohort: number + suppressed: boolean + /** % of the cohort whose connection score improved (delta > 0). The headline "feel closer" number. */ + feltCloserPct?: number + /** % improved per metric. */ + improvedPctByMetric?: Record +} + +export interface AggregateStats { + minCohort: number + windows: Record +} + +function pct(n: number, total: number): number { + return total === 0 ? 0 : Math.round((n / total) * 100) +} + +/** + * Pure aggregation — no I/O, unit-tested. A window below [minCohort] couples is suppressed. + */ +export function aggregate(couples: CoupleOutcome[], minCohort: number = MIN_COHORT): AggregateStats { + const windows = {} as Record + for (const day of FOLLOWUP_DAYS) { + const eligible = couples.filter((c) => c.deltas[day] != null) + const cohort = eligible.length + if (cohort < minCohort) { + windows[day] = { cohort, suppressed: true } + continue + } + const feltCloser = eligible.filter((c) => (c.deltas[day]?.connection ?? 0) > 0).length + const byMetric = {} as Record + for (const metric of SCORE_KEYS) { + const improved = eligible.filter((c) => (c.deltas[day]?.[metric] ?? 0) > 0).length + byMetric[metric] = pct(improved, cohort) + } + windows[day] = { + cohort, + suppressed: false, + feltCloserPct: pct(feltCloser, cohort), + improvedPctByMetric: byMetric, + } + } + return { minCohort, windows } +} + +/** Extracts the follow-up deltas from a couple's outcomes subcollection docs. */ +export function extractCoupleOutcome( + outcomeDocs: { id: string; data: Record }[] +): CoupleOutcome { + const deltas: CoupleOutcome['deltas'] = {} + for (const doc of outcomeDocs) { + if ((FOLLOWUP_DAYS as string[]).includes(doc.id)) { + const delta = doc.data.delta as Partial> | undefined + if (delta && typeof delta === 'object') deltas[doc.id as FollowUpDayKey] = delta + } + } + return { deltas } +} + +export const aggregateOutcomeStats = functions.pubsub.schedule('every 24 hours').onRun(async () => { + const db = admin.firestore() + + const couples: CoupleOutcome[] = [] + const couplesSnap = await db.collection('couples').get() + for (const couple of couplesSnap.docs) { + const outcomesSnap = await couple.ref.collection('outcomes').get() + couples.push( + extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() }))) + ) + } + + const stats = aggregate(couples) + await db.collection('aggregate_stats').doc('outcomes').set({ + ...stats, + totalCouples: couples.length, + generatedAt: admin.firestore.Timestamp.now(), + }) + + console.log( + `[aggregateOutcomeStats] ${couples.length} couples → ` + + FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' ') + ) + return null +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index e65ed772..47c6bfed 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -41,6 +41,7 @@ export { leaveCoupleCallable } from './couples/leaveCoupleCallable' export { acceptInviteCallable } from './couples/acceptInviteCallable' export { createInviteCallable } from './couples/createInviteCallable' export { submitOutcomeCallable } from './couples/submitOutcomeCallable' +export { aggregateOutcomeStats } from './couples/aggregateOutcomes' export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder' export { onUserDelete } from './users/onUserDelete' export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate'