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:
parent
95c3bc21fa
commit
943507b72a
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue