feat(functions): privacy-safe outcome-stat aggregation

aggregateOutcomeStats (scheduled) rolls up per-couple check-in deltas
(couples/{id}/outcomes/day_30|60|90) into the "X% feel closer in N weeks" stat
WITHOUT reading any E2EE content — only the self-reported deltas.

Privacy: counts/percentages only (no couple id or individual scores);
minimum-cohort suppression (N<50 → window omitted, no percentages); EXPORT-ONLY
via the top-level aggregate_stats collection, now explicitly deny-all in
firestore.rules (owner reads via console).

Pure aggregate()/extractCoupleOutcome() unit-tested incl. below-threshold
suppression + a no-PII assertion (6 tests); rules deny client read/write of
aggregate_stats (2 tests). Full functions 53/53, rules 121/121.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 21:06:57 -05:00
parent 95c3bc21fa
commit 943507b72a
5 changed files with 218 additions and 0 deletions

View File

@ -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 })
);
});
});

View File

@ -935,5 +935,12 @@ service cloud.firestore {
match /entitlement_events/{eventId} { match /entitlement_events/{eventId} {
allow read, write: if false; 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;
}
} }
} }

View File

@ -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<string, unknown>).day_0).toBeUndefined()
expect(out.deltas.day_30).toEqual({ connection: 2, communication: 1 })
expect((out.deltas as Record<string, unknown>).random).toBeUndefined()
})
})

View File

@ -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<Record<FollowUpDayKey, Partial<Record<ScoreKey, number>>>>
}
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<ScoreKey, number>
}
export interface AggregateStats {
minCohort: number
windows: Record<FollowUpDayKey, WindowStat>
}
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<FollowUpDayKey, WindowStat>
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<ScoreKey, number>
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<string, unknown> }[]
): 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<Record<ScoreKey, number>> | 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
})

View File

@ -41,6 +41,7 @@ export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
export { acceptInviteCallable } from './couples/acceptInviteCallable' export { acceptInviteCallable } from './couples/acceptInviteCallable'
export { createInviteCallable } from './couples/createInviteCallable' export { createInviteCallable } from './couples/createInviteCallable'
export { submitOutcomeCallable } from './couples/submitOutcomeCallable' export { submitOutcomeCallable } from './couples/submitOutcomeCallable'
export { aggregateOutcomeStats } from './couples/aggregateOutcomes'
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder' export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
export { onUserDelete } from './users/onUserDelete' export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate' export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate'