chore(build): stop committing functions/dist; delete stale schema cruft (kill the artifact-drift class)

The dist-stale bug (deployed onCoupleKeyRotated lacked committed src twice) was
one instance of a class: a tracked build artifact whose source is elsewhere,
with no guard they match. Fix it at the root — don't track the artifact.

- functions/dist: git rm --cached (104 files) + gitignored. src is truth; dist
  is tsc output that ships (main: dist/index.js). The predeploy hook already
  builds it at deploy and backend-ci builds it for tests, so the old
  "committed for reproducibility" rationale is dead — committing it only hid
  drift behind noisy .js/.js.map diffs. You cannot ship stale what you don't
  track; a deploy on a fresh clone now fails loudly (missing dist) instead of
  shipping old code.
- app/schemas: deleted the stale com.couplesconnect.app.data.local dir — dead
  cruft from the package rename to app.closer, and living proof the class isn't
  hypothetical. The live app.closer schema stays committed (legitimate Room
  migration provenance, validated by AssetDatabaseVerifyTest).
- ERM rewritten: the dist-committed convention is reversed; the app.db + schema
  cousins documented as same-class-but-guarded.

No behavior change — dist remains on disk (untracked) so local/predeploy/CI
builds and deploys are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-16 02:58:38 -05:00
parent fc5d709174
commit 884cf9f614
107 changed files with 19 additions and 6387 deletions

5
.gitignore vendored
View File

@ -110,3 +110,8 @@ __pycache__/
# Asset-db backups must never live under assets/ — everything there ships in the APK
app/src/main/assets/database/*.bak*
# Compiled Cloud Functions output — generated from functions/src by tsc.
# NEVER commit: it is what deploys (main: dist/index.js), and a stale committed copy
# silently ships old code (FUNCTIONS-DIST-STALE). The predeploy hook + CI build it.
functions/dist/

View File

@ -1,138 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "4c0a60329b23e0bc0526d7cb7e7269b9",
"entities": [
{
"tableName": "question",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `text` TEXT NOT NULL, `category_id` TEXT NOT NULL, `depth_level` INTEGER NOT NULL, `is_premium` INTEGER NOT NULL, `type` TEXT NOT NULL, `tags` TEXT NOT NULL, `answer_config` TEXT NOT NULL, `pack_id` TEXT, `created_at` INTEGER NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "depthLevel",
"columnName": "depth_level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "is_premium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "answerConfig",
"columnName": "answer_config",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packId",
"columnName": "pack_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "question_category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `description` TEXT NOT NULL, `access` TEXT NOT NULL, `icon_name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "access",
"columnName": "access",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconName",
"columnName": "icon_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c0a60329b23e0bc0526d7cb7e7269b9')"
]
}
}

View File

@ -1171,7 +1171,20 @@ npm run serve # local emulator
firebase deploy --only functions
```
`dist/` is committed so the deployed function code is reproducible without running `npm run build` at deploy time.
**`functions/dist/` is generated, NOT committed** (changed 2026-07-16). `src` is the source of truth;
`dist` is `tsc` output that ships (`main: dist/index.js`). It was committed for "reproducibility without
building at deploy" — that rationale is dead: the `firebase.json` `predeploy` hook (`npm run build`) now
compiles on every deploy, and `backend-ci` builds it for tests. Committing it only hid drift (a stale
`dist` shipped twice — FUNCTIONS-DIST-STALE) behind 104 noisy `.js`/`.js.map` diffs. Rule: **src is
truth, dist is built by deploy/CI, never committed.** When verifying "is X deployed", compare against a
build of `src` (or the deploy-provenance SHA), never a committed artifact.
Same class, elsewhere in the repo: `app/schemas/**` (Room-exported schema, generated by KSP) stays
committed — it's legitimate migration provenance and `AssetDatabaseVerifyTest` validates the live one —
but a stale `com.couplesconnect.app…` dir from the package rename was deleted (2026-07-16), proof the
class bites. `app/src/main/assets/database/app.db` (output of `seed/build_db.py`) also stays committed
(it ships in the APK) and is guarded by `AssetDatabaseVerifyTest`; editing `seed/questions/*.json`
requires re-running the generator + that test.
---

View File

@ -1,159 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.cleanupExpiredRestoreRequests = exports.NOTIFY_WINDOW_MS = exports.FALLBACK_TTL_MS = exports.GRACE_MS = void 0;
exports.shouldReapRestoreRequest = shouldReapRestoreRequest;
exports.shouldNotifyExpiry = shouldNotifyExpiry;
exports.sweepExpiredRestoreRequests = sweepExpiredRestoreRequests;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const queueAndPush_1 = require("../notifications/queueAndPush");
const log_1 = require("../log");
/**
* Cloud Function: cleanupExpiredRestoreRequests
*
* A `restore_requests` doc left behind (partner wrapped the couple key but the recipient never
* completed, or nobody ever answered) keeps its ECIES `keybox` ciphertext sealed only to the
* recipient, useless to anyone else, but key material has no business lying around. Expiry is
* otherwise enforced only client-side (fulfil-time check, delete-before-re-request, delete-on-
* complete), so a request whose device disappeared lives forever.
*
* Hourly sweep: requests expire 30 minutes after creation, so this bounds a stranded keybox's life
* to ~1.5 h. Uses a collectionGroup query on purpose it also reaps requests orphaned under
* already-deleted couple docs, which an iterate-the-couples sweep can never see (deleted parents
* don't list). Needs the COLLECTION_GROUP fieldOverride on `restore_requests.expiresAt` in
* `firestore.indexes.json`.
*
* Admin SDK bypasses the recipient-only delete rule, which is the point: nobody else CAN clean these.
*/
/** Never race a restore that is mid-completion: the client treats these as expired well before us. */
exports.GRACE_MS = 5 * 60 * 1000;
/** Reap docs with no usable `expiresAt` once they are unambiguously ancient. */
exports.FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Only tell the requester about expiries that just happened (~2 sweep cycles). Without this bound,
* the first deploy would blast a notification for every ancient doc in the backlog at once.
*/
exports.NOTIFY_WINDOW_MS = 2 * 60 * 60 * 1000;
const asMillis = (v) => (typeof v === 'number' && v > 0 ? v : 0);
/**
* Pure reap decision, re-verified per doc even though the query already filtered the query is an
* index scan, this is the contract. Deletes only on positive evidence of staleness:
* a real `expiresAt` past the grace window, or (defensively the create rule doesn't validate field
* values) no usable `expiresAt` but a `createdAt` at least a day old. A doc with neither is left
* alone for a human to wonder about.
*/
function shouldReapRestoreRequest(data, nowMs) {
const expiresAt = asMillis(data.expiresAt);
if (expiresAt > 0)
return nowMs > expiresAt + exports.GRACE_MS;
const createdAt = asMillis(data.createdAt);
return createdAt > 0 && nowMs > createdAt + exports.FALLBACK_TTL_MS;
}
/**
* Pure notify decision: only for a request that expired *recently* while still waiting on someone
* (REQUESTED: partner never approved; READY: recipient never completed). Terminal states are debris
* RESTORED already succeeded and DECLINED was answered; telling anyone again is noise.
*/
function shouldNotifyExpiry(data, nowMs) {
const expiresAt = asMillis(data.expiresAt);
if (expiresAt <= 0 || nowMs <= expiresAt + exports.GRACE_MS)
return false;
if (nowMs - expiresAt > exports.NOTIFY_WINDOW_MS)
return false;
return data.status === 'REQUESTED' || data.status === 'READY';
}
/**
* The sweep, with `db` injectable for tests. Per-doc failures are isolated (one bad doc can't stop
* the pass); the doc is deleted BEFORE the requester is notified so a notify failure costs a nudge,
* never a duplicate (the doc it would re-trigger on is already gone).
*/
async function sweepExpiredRestoreRequests(db, nowMs) {
const snap = await db
.collectionGroup('restore_requests')
.where('expiresAt', '<=', nowMs - exports.GRACE_MS)
.limit(200)
.get();
const counts = { scanned: snap.size, reaped: 0, notified: 0, skipped: 0, failed: 0 };
const results = await Promise.allSettled(snap.docs.map(async (doc) => {
var _a;
const data = ((_a = doc.data()) !== null && _a !== void 0 ? _a : {});
if (!shouldReapRestoreRequest(data, nowMs)) {
counts.skipped++;
log_1.logger.warn('[cleanupRestoreRequests] query hit failed re-verify; leaving doc', { path: doc.ref.path });
return;
}
const notify = shouldNotifyExpiry(data, nowMs);
await doc.ref.delete();
counts.reaped++;
if (!notify)
return;
// Best-effort: a missed nudge is fine, a failed delete is not — hence delete-first above.
// Skip when the couple itself is gone (an orphan reap has nobody meaningful to notify).
try {
const coupleRef = doc.ref.parent.parent;
const recipientUid = typeof data.recipientUid === 'string' && data.recipientUid ? data.recipientUid : doc.id;
if (!coupleRef)
return;
const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists)
return;
await (0, queueAndPush_1.queueAndPush)(db, recipientUid, {
type: 'restore_request_expired',
title: 'Your restore request expired',
body: 'No worries — start a new one whenever youre ready.',
coupleId: coupleRef.id,
});
counts.notified++;
}
catch (e) {
log_1.logger.warn('[cleanupRestoreRequests] expiry notice failed', { path: doc.ref.path, error: String(e) });
}
}));
counts.failed = results.filter((r) => r.status === 'rejected').length;
return counts;
}
exports.cleanupExpiredRestoreRequests = (0, scheduler_1.onSchedule)({ schedule: 'every 1 hours', timeoutSeconds: 180 }, async () => {
try {
const counts = await sweepExpiredRestoreRequests(admin.firestore(), Date.now());
log_1.logger.log(`[cleanupRestoreRequests] scanned ${counts.scanned}; reaped ${counts.reaped}; ` +
`notified ${counts.notified}; skipped ${counts.skipped}; failed ${counts.failed}`);
}
catch (e) {
// Never rethrow: a scheduled-function failure retries in a storm, and the next hourly run IS the retry.
log_1.logger.error('[cleanupRestoreRequests] sweep failed', { error: String(e) });
}
});
//# sourceMappingURL=cleanupRestoreRequests.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"cleanupRestoreRequests.js","sourceRoot":"","sources":["../../src/backup/cleanupRestoreRequests.ts"],"names":[],"mappings":"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"}

View File

@ -1,129 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const cleanupRestoreRequests_1 = require("./cleanupRestoreRequests");
const queueAndPush_1 = require("../notifications/queueAndPush");
jest.mock('../notifications/queueAndPush', () => ({ queueAndPush: jest.fn().mockResolvedValue(undefined) }));
const NOW = 1800000000000;
describe('shouldReapRestoreRequest', () => {
it('keeps a fresh request', () => {
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ status: 'REQUESTED', expiresAt: NOW + 60000 }, NOW)).toBe(false);
});
it('keeps an expired request still inside the grace window', () => {
// The client already treats it as expired; we wait out the grace so we can never race a
// mid-completion restore.
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ status: 'READY', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS + 1000 }, NOW)).toBe(false);
});
it('reaps expired requests regardless of status — DECLINED-after-READY still carries the keybox', () => {
const expiresAt = NOW - cleanupRestoreRequests_1.GRACE_MS - 1000;
for (const status of ['REQUESTED', 'READY', 'DECLINED', 'RESTORED', 'EXPIRED']) {
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ status, expiresAt }, NOW)).toBe(true);
}
});
it('falls back to createdAt when expiresAt is unusable', () => {
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ createdAt: NOW - cleanupRestoreRequests_1.FALLBACK_TTL_MS - 1 }, NOW)).toBe(true);
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ expiresAt: 0, createdAt: NOW - cleanupRestoreRequests_1.FALLBACK_TTL_MS - 1 }, NOW)).toBe(true);
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ createdAt: NOW - 60000 }, NOW)).toBe(false);
});
it('leaves a doc with neither field — delete only on positive evidence', () => {
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({}, NOW)).toBe(false);
expect((0, cleanupRestoreRequests_1.shouldReapRestoreRequest)({ expiresAt: 'soon', createdAt: null }, NOW)).toBe(false);
});
});
describe('shouldNotifyExpiry', () => {
const justExpired = NOW - cleanupRestoreRequests_1.GRACE_MS - 60000;
it('notifies for a recently expired request still waiting on someone', () => {
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'REQUESTED', expiresAt: justExpired }, NOW)).toBe(true);
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'READY', expiresAt: justExpired }, NOW)).toBe(true);
});
it('stays silent for terminal states — RESTORED succeeded, DECLINED was answered', () => {
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'RESTORED', expiresAt: justExpired }, NOW)).toBe(false);
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'DECLINED', expiresAt: justExpired }, NOW)).toBe(false);
});
it('stays silent for backlog debris — first deploy must not blast ancient expiries', () => {
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'REQUESTED', expiresAt: NOW - cleanupRestoreRequests_1.NOTIFY_WINDOW_MS - cleanupRestoreRequests_1.GRACE_MS - 1000 }, NOW)).toBe(false);
});
it('stays silent for the createdAt-fallback branch (no real expiry to report)', () => {
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'REQUESTED', createdAt: NOW - cleanupRestoreRequests_1.FALLBACK_TTL_MS - 1 }, NOW)).toBe(false);
});
it('never notifies for something it would not reap', () => {
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'REQUESTED', expiresAt: NOW + 60000 }, NOW)).toBe(false);
expect((0, cleanupRestoreRequests_1.shouldNotifyExpiry)({ status: 'REQUESTED', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS + 1000 }, NOW)).toBe(false);
});
});
describe('sweepExpiredRestoreRequests', () => {
const mockQueueAndPush = queueAndPush_1.queueAndPush;
function fakeDoc(opts) {
var _a;
const coupleRef = {
id: `couple_${opts.id}`,
get: jest.fn().mockResolvedValue({ exists: (_a = opts.coupleExists) !== null && _a !== void 0 ? _a : true }),
};
return {
id: opts.id,
data: () => opts.data,
ref: {
path: `couples/couple_${opts.id}/restore_requests/${opts.id}`,
parent: { parent: coupleRef },
delete: opts.deleteError
? jest.fn().mockRejectedValue(opts.deleteError)
: jest.fn().mockResolvedValue(undefined),
},
};
}
function fakeDb(docs) {
return {
collectionGroup: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
get: jest.fn().mockResolvedValue({ size: docs.length, docs }),
}),
}),
}),
};
}
beforeEach(() => mockQueueAndPush.mockClear());
it('deletes the expired doc and nudges the requester', async () => {
const doc = fakeDoc({ id: 'uidA', data: { status: 'READY', recipientUid: 'uidA', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS - 60000 } });
const counts = await (0, cleanupRestoreRequests_1.sweepExpiredRestoreRequests)(fakeDb([doc]), NOW);
expect(doc.ref.delete).toHaveBeenCalledTimes(1);
expect(mockQueueAndPush).toHaveBeenCalledWith(expect.anything(), 'uidA', expect.objectContaining({ type: 'restore_request_expired' }));
expect(counts).toMatchObject({ scanned: 1, reaped: 1, notified: 1, failed: 0 });
});
it('leaves a query hit that fails re-verify', async () => {
// The index says expired; the doc says otherwise (clock skew, weird data). The predicate wins.
const doc = fakeDoc({ id: 'uidB', data: { status: 'REQUESTED', expiresAt: NOW + 60000 } });
const counts = await (0, cleanupRestoreRequests_1.sweepExpiredRestoreRequests)(fakeDb([doc]), NOW);
expect(doc.ref.delete).not.toHaveBeenCalled();
expect(counts).toMatchObject({ reaped: 0, skipped: 1 });
});
it('skips the nudge for an orphan reap (couple gone) but still deletes', async () => {
const doc = fakeDoc({
id: 'uidC',
data: { status: 'READY', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS - 60000 },
coupleExists: false,
});
const counts = await (0, cleanupRestoreRequests_1.sweepExpiredRestoreRequests)(fakeDb([doc]), NOW);
expect(doc.ref.delete).toHaveBeenCalledTimes(1);
expect(mockQueueAndPush).not.toHaveBeenCalled();
expect(counts).toMatchObject({ reaped: 1, notified: 0 });
});
it('one failing doc never stops the sweep', async () => {
const bad = fakeDoc({
id: 'uidD',
data: { status: 'READY', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS - 60000 },
deleteError: new Error('firestore unavailable'),
});
const good = fakeDoc({ id: 'uidE', data: { status: 'REQUESTED', recipientUid: 'uidE', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS - 60000 } });
const counts = await (0, cleanupRestoreRequests_1.sweepExpiredRestoreRequests)(fakeDb([bad, good]), NOW);
expect(good.ref.delete).toHaveBeenCalledTimes(1);
expect(counts).toMatchObject({ scanned: 2, reaped: 1, failed: 1 });
});
it('a notify failure costs the nudge, never the reap or the pass', async () => {
mockQueueAndPush.mockRejectedValueOnce(new Error('fcm down'));
const doc = fakeDoc({ id: 'uidF', data: { status: 'READY', recipientUid: 'uidF', expiresAt: NOW - cleanupRestoreRequests_1.GRACE_MS - 60000 } });
const counts = await (0, cleanupRestoreRequests_1.sweepExpiredRestoreRequests)(fakeDb([doc]), NOW);
expect(doc.ref.delete).toHaveBeenCalledTimes(1);
expect(counts).toMatchObject({ reaped: 1, notified: 0, failed: 0 });
});
});
//# sourceMappingURL=cleanupRestoreRequests.test.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,158 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onRestoreFulfilled = exports.onRestoreRequested = exports.SELF_ALERT_DEDUPE_MS = void 0;
exports.selfAlertAllowed = selfAlertAllowed;
exports.isRestoreReadyTransition = isRestoreReadyTransition;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const queueAndPush_1 = require("../notifications/queueAndPush");
const log_1 = require("../log");
// Suppress duplicate pushes when a request doc is rapidly deleted+recreated (a compromised account
// could loop that to spam both partners) and when an event is redelivered. The in-app queue entry is
// still written every time. A genuine re-request after the window still notifies — so this is a time
// window, NOT a permanent per-recipient marker (which would block legitimate re-requests).
exports.SELF_ALERT_DEDUPE_MS = 60 * 1000;
/** Pure dedupe decision: allow an alert push only if none was sent within the window. */
function selfAlertAllowed(lastAlertAt, now) {
if (typeof lastAlertAt !== 'number')
return true;
return now - lastAlertAt >= exports.SELF_ALERT_DEDUPE_MS;
}
/** Pure guard: fire the completion alert only on the single REQUESTED→READY status transition. */
function isRestoreReadyTransition(beforeStatus, afterStatus) {
return afterStatus === 'READY' && beforeStatus !== 'READY';
}
/**
* Fires when a member starts a partner-assisted restore
* (`couples/{coupleId}/restore_requests/{recipientUid}` created on a new/wiped device). Sends TWO
* notifications, isolated so one failing never blocks the other:
* 1. To the OTHER partner "help them restore" (high-signal; only quiet hours suppress it).
* 2. To the RECIPIENT'S OWN devices a security "was this you?" alert. If the real owner still holds a
* device (a phished password without device loss), this is how they learn a restore is happening.
* No key material is read or logged the request carries only a public key + a nonce.
*/
exports.onRestoreRequested = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => {
var _a, _b, _c, _d, _e, _f;
const { coupleId, recipientUid } = event.params;
const db = admin.firestore();
// The device that created the request identifies itself (create-only field, best-effort on the
// client). Used ONLY to spare that device its own "was this you?" — all other devices still get it.
const requesterToken = (_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.data()) === null || _b === void 0 ? void 0 : _b.requesterFcmToken;
const excludeTokens = typeof requesterToken === 'string' && requesterToken ? [requesterToken] : undefined;
const coupleRef = db.collection('couples').doc(coupleId);
const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists)
return;
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
// The recipient must be a member; notify the OTHER member (the partner who can help).
if (!userIds.includes(recipientUid))
return;
const partnerId = userIds.find((u) => u !== recipientUid);
if (!partnerId)
return;
// Audit trail (no key material — actor/recipient/timestamp only).
log_1.logger.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`);
// 1) Partner "help them restore" — routine quiet hours apply. Deduped against redelivery and
// request recreate-loops with the same window used for the self-alert.
try {
const now = Date.now();
if (selfAlertAllowed((_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.lastRestorePartnerAlertAt, now)) {
await coupleRef.set({ lastRestorePartnerAlertAt: now }, { merge: true });
await (0, queueAndPush_1.queueAndPush)(db, partnerId, {
type: 'restore_requested',
title: 'Help your partner restore 💜',
body: 'Theyre setting up on a new device. Tap to help.',
coupleId,
});
}
}
catch (e) {
log_1.logger.warn('[onRestoreRequested] partner notify failed', { error: String(e) });
}
// 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops.
try {
const now = Date.now();
if (selfAlertAllowed((_f = coupleDoc.data()) === null || _f === void 0 ? void 0 : _f.lastRestoreSelfAlertAt, now)) {
await coupleRef.set({ lastRestoreSelfAlertAt: now }, { merge: true });
await (0, queueAndPush_1.queueAndPush)(db, recipientUid, {
type: 'restore_self_alert',
title: 'New device is restoring your history',
body: 'If this wasnt you, secure your account now.',
coupleId,
bypassQuietHours: true,
excludeTokens,
});
}
}
catch (e) {
log_1.logger.warn('[onRestoreRequested] self-alert failed', { error: String(e) });
}
});
/**
* Fires when the partner writes the keybox (status flips to READY) the moment the couple key actually
* transfers. Alerts the RECIPIENT'S OWN devices that a restore just completed, the strongest "it happened"
* signal for the real owner. Guarded to the single REQUESTEDREADY transition (ignores decline/expire).
*/
exports.onRestoreFulfilled = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/restore_requests/{recipientUid}', async (event) => {
var _a, _b, _c, _d;
const before = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data();
const after = (_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data();
if (!isRestoreReadyTransition(before === null || before === void 0 ? void 0 : before.status, after === null || after === void 0 ? void 0 : after.status))
return;
const requesterToken = after === null || after === void 0 ? void 0 : after.requesterFcmToken;
const excludeTokens = typeof requesterToken === 'string' && requesterToken ? [requesterToken] : undefined;
const { coupleId, recipientUid } = event.params;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
if (!userIds.includes(recipientUid))
return;
log_1.logger.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`);
try {
await (0, queueAndPush_1.queueAndPush)(db, recipientUid, {
type: 'restore_self_alert',
title: 'Your history was just restored',
body: 'A new device now has access. If this wasnt you, secure your account now.',
coupleId,
bypassQuietHours: true,
excludeTokens,
});
}
catch (e) {
log_1.logger.warn('[onRestoreFulfilled] self-alert failed', { error: String(e) });
}
});
//# sourceMappingURL=onRestoreRequested.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onRestoreRequested.js","sourceRoot":"","sources":["../../src/backup/onRestoreRequested.ts"],"names":[],"mappings":"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"}

View File

@ -1,35 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const onRestoreRequested_1 = require("./onRestoreRequested");
// Both helpers are pure decisions extracted from the restore triggers, so no firebase-admin mock is needed.
describe('selfAlertAllowed (self-alert spam dedupe)', () => {
const now = 1000000000000;
it('allows the alert when no previous alert timestamp exists', () => {
expect((0, onRestoreRequested_1.selfAlertAllowed)(undefined, now)).toBe(true);
expect((0, onRestoreRequested_1.selfAlertAllowed)(null, now)).toBe(true);
expect((0, onRestoreRequested_1.selfAlertAllowed)('not-a-number', now)).toBe(true);
});
it('suppresses a second alert inside the dedupe window', () => {
expect((0, onRestoreRequested_1.selfAlertAllowed)(now - (onRestoreRequested_1.SELF_ALERT_DEDUPE_MS - 1), now)).toBe(false);
expect((0, onRestoreRequested_1.selfAlertAllowed)(now - 1, now)).toBe(false);
});
it('allows again once the window has elapsed', () => {
expect((0, onRestoreRequested_1.selfAlertAllowed)(now - onRestoreRequested_1.SELF_ALERT_DEDUPE_MS, now)).toBe(true);
expect((0, onRestoreRequested_1.selfAlertAllowed)(now - (onRestoreRequested_1.SELF_ALERT_DEDUPE_MS + 5000), now)).toBe(true);
});
});
describe('isRestoreReadyTransition (completion-alert guard)', () => {
it('fires only on the REQUESTED→READY edge', () => {
expect((0, onRestoreRequested_1.isRestoreReadyTransition)('REQUESTED', 'READY')).toBe(true);
});
it('does not fire when already READY (no repeat on unrelated updates)', () => {
expect((0, onRestoreRequested_1.isRestoreReadyTransition)('READY', 'READY')).toBe(false);
});
it('does not fire for non-READY outcomes', () => {
expect((0, onRestoreRequested_1.isRestoreReadyTransition)('REQUESTED', 'DECLINED')).toBe(false);
expect((0, onRestoreRequested_1.isRestoreReadyTransition)('REQUESTED', 'EXPIRED')).toBe(false);
expect((0, onRestoreRequested_1.isRestoreReadyTransition)('READY', 'RESTORED')).toBe(false);
expect((0, onRestoreRequested_1.isRestoreReadyTransition)(undefined, undefined)).toBe(false);
});
});
//# sourceMappingURL=onRestoreRequested.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onRestoreRequested.test.js","sourceRoot":"","sources":["../../src/backup/onRestoreRequested.test.ts"],"names":[],"mappings":";;AAAA,6DAAuG;AAEvG,4GAA4G;AAC5G,QAAQ,CAAC,2CAA2C,EAAE,GAAG,EAAE;IACzD,MAAM,GAAG,GAAG,aAAiB,CAAA;IAE7B,EAAE,CAAC,0DAA0D,EAAE,GAAG,EAAE;QAClE,MAAM,CAAC,IAAA,qCAAgB,EAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACnD,MAAM,CAAC,IAAA,qCAAgB,EAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC9C,MAAM,CAAC,IAAA,qCAAgB,EAAC,cAAc,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC1D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,CAAC,IAAA,qCAAgB,EAAC,GAAG,GAAG,CAAC,yCAAoB,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC3E,MAAM,CAAC,IAAA,qCAAgB,EAAC,GAAG,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACpD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,IAAA,qCAAgB,EAAC,GAAG,GAAG,yCAAoB,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACpE,MAAM,CAAC,IAAA,qCAAgB,EAAC,GAAG,GAAG,CAAC,yCAAoB,GAAG,IAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAChF,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,mDAAmD,EAAE,GAAG,EAAE;IACjE,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QAChD,MAAM,CAAC,IAAA,6CAAwB,EAAC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACnE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC3E,MAAM,CAAC,IAAA,6CAAwB,EAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAChE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,CAAC,IAAA,6CAAwB,EAAC,WAAW,EAAE,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACrE,MAAM,CAAC,IAAA,6CAAwB,EAAC,WAAW,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACpE,MAAM,CAAC,IAAA,6CAAwB,EAAC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjE,MAAM,CAAC,IAAA,6CAAwB,EAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -1,162 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PREMIUM_REVOKED_TYPES = exports.PREMIUM_ACTIVE_TYPES = void 0;
exports.isPremiumEntitlement = isPremiumEntitlement;
exports.applyEntitlementEvent = applyEntitlementEvent;
exports.applyEntitlementSync = applyEntitlementSync;
const admin = __importStar(require("firebase-admin"));
const log_1 = require("../log");
// Events that should grant or keep premium access active.
exports.PREMIUM_ACTIVE_TYPES = new Set([
'INITIAL_PURCHASE',
'RENEWAL',
'PRODUCT_CHANGE',
'TRANSFER',
'UNCANCELLATION',
]);
// Events that remove premium access.
exports.PREMIUM_REVOKED_TYPES = new Set([
'EXPIRATION',
'CANCELLATION',
'BILLING_ISSUE',
'SUBSCRIBER_ALIAS',
]);
// Premium entitlement identifier used by the app.
const PREMIUM_ENTITLEMENT_ID = 'closer_premium';
function getDb() {
return admin.firestore();
}
function entitlementsRef(userId) {
return getDb().collection('users').doc(userId).collection('entitlements').doc('premium');
}
function now() {
return admin.firestore.Timestamp.now();
}
function isPremiumEntitlement(event) {
var _a;
const entitlementId = event.entitlement_id;
const entitlementIds = (_a = event.entitlement_ids) !== null && _a !== void 0 ? _a : [];
if (entitlementId === PREMIUM_ENTITLEMENT_ID)
return true;
if (entitlementIds.includes(PREMIUM_ENTITLEMENT_ID))
return true;
return false;
}
/**
* Apply a RevenueCat entitlement event to Firestore.
* Writes state to users/{userId}/entitlements.
*/
async function applyEntitlementEvent(event) {
var _a;
const { type, app_user_id: userId, id: eventId, product_id: productId } = event;
// Idempotency: create a processed event marker; if it exists, skip.
const eventRef = getDb().collection('entitlement_events').doc(eventId);
try {
await eventRef.create({ processedAt: now() });
}
catch (err) {
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('ALREADY_EXISTS'))) {
log_1.logger.log(`[entitlement] skipping duplicate event: ${eventId}`);
return;
}
throw err;
}
if (!isPremiumEntitlement(event)) {
log_1.logger.log(`[entitlement] ignored event for non-premium entitlement: ${eventId}`);
return;
}
const ref = entitlementsRef(userId);
if (exports.PREMIUM_ACTIVE_TYPES.has(type)) {
const expiresAt = event.expiration_at_ms
? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms)
: null;
await ref.set({
premium: true,
expiresAt,
updatedAt: now(),
productId,
eventType: type,
});
log_1.logger.log(`[entitlement] premium=true for ${userId} (${type})`);
return;
}
if (exports.PREMIUM_REVOKED_TYPES.has(type)) {
await ref.set({
premium: false,
expiresAt: null,
updatedAt: now(),
productId,
eventType: type,
});
log_1.logger.log(`[entitlement] premium=false for ${userId} (${type})`);
return;
}
log_1.logger.log(`[entitlement] ignored event type: ${type}`);
}
/**
* Recompute and rewrite the entitlement document for a user.
*
* In production this should query RevenueCat; for now it ensures the
* Firestore document is consistent and reflects the latest stored values.
*/
async function applyEntitlementSync(userId) {
var _a;
const ref = entitlementsRef(userId);
const snap = await ref.get();
const data = snap.data();
const updatedAt = now();
let premium = false;
let expiresAt = null;
if ((data === null || data === void 0 ? void 0 : data.premium) === true) {
const storedExpiresAt = (_a = data.expiresAt) !== null && _a !== void 0 ? _a : null;
if (storedExpiresAt instanceof admin.firestore.Timestamp) {
premium = storedExpiresAt.toMillis() > Date.now();
expiresAt = storedExpiresAt;
}
else {
// No expiration means a currently active, non-expiring entitlement.
premium = true;
}
}
const state = {
premium,
expiresAt: premium ? expiresAt : null,
updatedAt,
};
await ref.set(state, { merge: true });
return state;
}
//# sourceMappingURL=entitlementLogic.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"}

View File

@ -1,137 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
// Mock firebase-admin before any module under test is imported.
// `admin.firestore` must work as both a callable (admin.firestore()) and a
// namespace (admin.firestore.Timestamp). Object.assign achieves both.
jest.mock('firebase-admin', () => {
const firestoreFn = jest.fn();
firestoreFn.Timestamp = {
now: jest.fn(() => ({ toMillis: () => 1000000000000 })),
fromMillis: jest.fn((ms) => ({ toMillis: () => ms })),
};
return {
firestore: firestoreFn,
apps: [],
initializeApp: jest.fn(),
};
});
const admin = __importStar(require("firebase-admin"));
const entitlementLogic_1 = require("./entitlementLogic");
// ── Helpers ───────────────────────────────────────────────────────────────────
function event(overrides = {}) {
return Object.assign({ id: 'evt-001', type: 'INITIAL_PURCHASE', app_user_id: 'user-123', product_id: 'closer_premium_monthly', entitlement_id: 'closer_premium' }, overrides);
}
function buildFirestoreMock(opts = {}) {
const mockSet = jest.fn().mockResolvedValue(undefined);
const mockCreate = jest.fn().mockImplementation(() => opts.createShouldFail
? Promise.reject(Object.assign(new Error('ALREADY_EXISTS'), { code: 6 }))
: Promise.resolve(undefined));
// Leaf doc ref (e.g. users/{uid}/entitlements/premium)
const deepDocRef = { set: mockSet };
const deepDocFn = jest.fn().mockReturnValue(deepDocRef);
const subCollectionRef = { doc: deepDocFn };
const subCollectionFn = jest.fn().mockReturnValue(subCollectionRef);
// Top-level doc ref: supports create (idempotency marker) and subcollection access
const topDocRef = { create: mockCreate, set: mockSet, collection: subCollectionFn };
const mockDoc = jest.fn().mockReturnValue(topDocRef);
const mockCollection = jest.fn().mockReturnValue({ doc: mockDoc });
const mockInstance = { collection: mockCollection };
admin.firestore.mockReturnValue(mockInstance);
return { mockSet, mockCreate };
}
// ── isPremiumEntitlement ──────────────────────────────────────────────────────
describe('isPremiumEntitlement', () => {
it('returns true when entitlement_id matches', () => {
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: 'closer_premium' }))).toBe(true);
});
it('returns true when entitlement_ids array includes the id', () => {
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: undefined, entitlement_ids: ['closer_premium', 'other'] }))).toBe(true);
});
it('returns false for a different entitlement_id', () => {
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: 'other_entitlement', entitlement_ids: [] }))).toBe(false);
});
it('returns false when neither id nor ids reference the premium entitlement', () => {
expect((0, entitlementLogic_1.isPremiumEntitlement)(event({ entitlement_id: undefined, entitlement_ids: ['not_premium'] }))).toBe(false);
});
});
// ── Event type sets ───────────────────────────────────────────────────────────
describe('PREMIUM_ACTIVE_TYPES', () => {
it.each(['INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'TRANSFER', 'UNCANCELLATION'])('contains %s', (type) => expect(entitlementLogic_1.PREMIUM_ACTIVE_TYPES.has(type)).toBe(true));
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE'])('does not contain revocation event %s', (type) => expect(entitlementLogic_1.PREMIUM_ACTIVE_TYPES.has(type)).toBe(false));
});
describe('PREMIUM_REVOKED_TYPES', () => {
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE', 'SUBSCRIBER_ALIAS'])('contains %s', (type) => expect(entitlementLogic_1.PREMIUM_REVOKED_TYPES.has(type)).toBe(true));
it.each(['INITIAL_PURCHASE', 'RENEWAL'])('does not contain active event %s', (type) => expect(entitlementLogic_1.PREMIUM_REVOKED_TYPES.has(type)).toBe(false));
});
// ── applyEntitlementEvent ─────────────────────────────────────────────────────
describe('applyEntitlementEvent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('grants premium on INITIAL_PURCHASE', async () => {
const { mockSet } = buildFirestoreMock();
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'INITIAL_PURCHASE' }));
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: true }));
});
it('revokes premium on EXPIRATION', async () => {
const { mockSet } = buildFirestoreMock();
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'EXPIRATION' }));
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: false, expiresAt: null }));
});
it('revokes premium on CANCELLATION', async () => {
const { mockSet } = buildFirestoreMock();
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'CANCELLATION' }));
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: false }));
});
it('is idempotent — skips duplicate event ids', async () => {
const { mockSet } = buildFirestoreMock({ createShouldFail: true });
await (0, entitlementLogic_1.applyEntitlementEvent)(event());
// The idempotency guard fires (create threw ALREADY_EXISTS), so set is never called.
expect(mockSet).not.toHaveBeenCalled();
});
it('stores expiresAt when expiration_at_ms is present', async () => {
const { mockSet } = buildFirestoreMock();
const expiresAtMs = Date.now() + 86400000;
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ type: 'RENEWAL', expiration_at_ms: expiresAtMs }));
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ premium: true }));
});
it('ignores non-premium entitlement events', async () => {
const { mockSet } = buildFirestoreMock();
await (0, entitlementLogic_1.applyEntitlementEvent)(event({ entitlement_id: 'some_other_entitlement', entitlement_ids: [] }));
expect(mockSet).not.toHaveBeenCalled();
});
});
//# sourceMappingURL=entitlementLogic.test.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,122 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onEntitlementChanged = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Notifies the OTHER partner when a user GAINS premium, so couple-shared premium unlock isn't silent
* for the non-subscriber. (Future.md: `subscription_entitlement_changed`.)
*
* Path: users/{userId}/entitlements/premium (onWrite)
* Edge-triggered: fires only on the inactiveactive transition, so renewals / repeated writes don't
* re-notify. Skips if the partner already had premium (the couple was already unlocked).
*/
function isActive(data) {
if (!data)
return false;
if (data.premium !== true)
return false;
const expiresAt = data.expiresAt;
if (expiresAt && expiresAt.toMillis() <= Date.now())
return false;
return true;
}
exports.onEntitlementChanged = (0, firestore_1.onDocumentWritten)('users/{userId}/entitlements/premium', async (event) => {
var _a, _b, _c, _d;
const { userId } = event.params;
const before = isActive((_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data());
const after = isActive((_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data());
if (before || !after)
return; // only a genuine inactive→active gain
const db = admin.firestore();
// Resolve this user's couple + partner.
const coupleSnap = await db
.collection('couples')
.where('userIds', 'array-contains', userId)
.limit(1)
.get();
if (coupleSnap.empty) {
log_1.logger.log(`[onEntitlementChanged] no couple for ${userId}`);
return;
}
const coupleDoc = coupleSnap.docs[0];
const coupleId = coupleDoc.id;
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== userId);
if (!partnerId) {
log_1.logger.log(`[onEntitlementChanged] no partner for ${userId} in ${coupleId}`);
return;
}
// If the partner already has premium the couple was already unlocked — nothing new to announce.
const partnerEnt = await db.doc(`users/${partnerId}/entitlements/premium`).get();
if (isActive(partnerEnt.data())) {
log_1.logger.log(`[onEntitlementChanged] partner ${partnerId} already premium; skip`);
return;
}
// Dedupe redelivery of this onWrite (at-least-once) so the partner isn't double-notified and
// the in-app record isn't duplicated.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `entitlement-${userId}`)))) {
log_1.logger.log(`[onEntitlementChanged] already announced premium for ${userId}; skip`);
return;
}
// displayName is E2EE in users/{uid}, so use a generic label (this copy is stored server-side in
// notification_queue + the push, neither of which can decrypt).
const payload = {
type: 'subscription_entitlement_changed',
title: 'Premium unlocked ✨',
body: 'Your partner upgraded — you both have Premium now.',
};
// In-app record for the partner.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add(Object.assign(Object.assign({}, payload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { title: payload.title, body: payload.body },
android: { notification: { channelId: 'partner_activity' } },
data: { type: payload.type, couple_id: coupleId },
});
if (res.sent === 0 && res.failed === 0) {
log_1.logger.log(`[onEntitlementChanged] no FCM tokens for ${partnerId}`);
return;
}
log_1.logger.log(`[onEntitlementChanged] notified ${partnerId} of couple premium (${coupleId})`);
});
//# sourceMappingURL=onEntitlementChanged.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"}

View File

@ -1,179 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0;
exports.verifyWebhookSignature = verifyWebhookSignature;
const crypto = __importStar(require("crypto"));
const https_1 = require("firebase-functions/v2/https");
const params_1 = require("firebase-functions/params");
const entitlementLogic_1 = require("./entitlementLogic");
const log_1 = require("../log");
/**
* RevenueCat webhook handler.
*
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events.
*
* Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* body bytes exactly as received (before any JSON parsing / re-serialization).
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* dashboard (Integrations Webhooks enable signature verification). It is bound as a
* Secret Manager secret (below) and injected into process.env at runtime.
* - Missing secret 500 (our config error). Missing/invalid signature, or a timestamp
* outside the tolerance window (replay guard) 401.
*
* NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme HMAC-SHA256 with a
* shared secret is the only cryptographic option (plus the optional static Authorization header).
*
* Processing:
* - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/
const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET');
/** Reject signatures whose timestamp is further than this from now (replay protection). */
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => {
var _a;
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' });
return;
}
try {
verifyRequest(req, Date.now());
}
catch (err) {
if (err instanceof ConfigError) {
log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message });
res.status(500).json({ error: 'internal_error' });
return;
}
res.status(401).json({ error: 'unauthorized' });
return;
}
const event = (_a = req.body) === null || _a === void 0 ? void 0 : _a.event;
if (!event || !event.type || !event.app_user_id) {
res.status(400).json({ error: 'malformed_payload' });
return;
}
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
// so returning 500 on failure recovers the event safely.
try {
await (0, entitlementLogic_1.applyEntitlementEvent)(event);
}
catch (err) {
log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) });
res.status(500).json({ error: 'processing_failed' });
return;
}
res.status(200).json({ received: true });
});
class ConfigError extends Error {
}
exports.ConfigError = ConfigError;
class AuthError extends Error {
}
exports.AuthError = AuthError;
/**
* Reads the secret + signature header off the request and delegates to the pure verifier.
* Throws ConfigError when our secret is unset (500); AuthError for any auth failure (401).
*/
function verifyRequest(req, nowMs) {
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (!secret) {
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
}
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = req.rawBody;
const sigHeader = req.headers['x-revenuecat-webhook-signature'];
const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
verifyWebhookSignature({ rawBody, header, secret, nowMs });
}
/**
* Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature.
*
* Parses `t=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* with the signing secret, constant-time-compares against v1, and rejects timestamps outside
* the tolerance window. Throws AuthError on any failure (caller maps to 401).
*/
function verifyWebhookSignature(opts) {
var _a;
const { rawBody, header, secret, nowMs } = opts;
const toleranceMs = (_a = opts.toleranceMs) !== null && _a !== void 0 ? _a : SIGNATURE_TOLERANCE_MS;
if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing');
}
if (!header) {
throw new AuthError('signature header missing');
}
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
let t;
let v1;
for (const part of header.split(',')) {
const seg = part.trim();
const eq = seg.indexOf('=');
if (eq < 0)
continue;
const key = seg.slice(0, eq);
const val = seg.slice(eq + 1);
if (key === 't')
t = val;
else if (key === 'v1')
v1 = val;
}
if (!t || !v1) {
throw new AuthError('signature header malformed');
}
const tsMs = Number(t) * 1000;
if (!Number.isFinite(tsMs)) {
throw new AuthError('signature timestamp invalid');
}
if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('signature timestamp outside tolerance');
}
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
const expectedBuf = Buffer.from(expected, 'utf8');
const providedBuf = Buffer.from(v1, 'utf8');
if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
throw new AuthError('signature mismatch');
}
}
//# sourceMappingURL=revenueCatWebhook.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}

View File

@ -1,90 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
// The verifier under test is pure (crypto only), so we stub the Firebase side-effect
// imports that run at module load rather than exercising them.
jest.mock('firebase-functions/v2/https', () => ({ onRequest: jest.fn() }));
jest.mock('firebase-functions/params', () => ({ defineSecret: jest.fn(() => ({})) }));
jest.mock('../log', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
jest.mock('./entitlementLogic', () => ({ applyEntitlementEvent: jest.fn() }));
const crypto = __importStar(require("crypto"));
const revenueCatWebhook_1 = require("./revenueCatWebhook");
const SECRET = 'whsec_test_shared_secret';
const NOW = 1700000000000; // fixed "now" in ms
const nowSec = Math.floor(NOW / 1000);
const BODY = JSON.stringify({ event: { type: 'INITIAL_PURCHASE', app_user_id: 'uid1' } });
/** Build the header RevenueCat would send: `t=<sec>,v1=<hmac_sha256_hex of "<sec>.<body>">`. */
function sign(body, tsSec, secret = SECRET) {
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
const payload = Buffer.concat([Buffer.from(`${tsSec}.`, 'utf8'), raw]);
const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return `t=${tsSec},v1=${v1}`;
}
function verify(overrides = {}) {
return (0, revenueCatWebhook_1.verifyWebhookSignature)(Object.assign({ rawBody: Buffer.from(BODY), header: sign(BODY, nowSec), secret: SECRET, nowMs: NOW }, overrides));
}
describe('verifyWebhookSignature', () => {
it('accepts a valid signature', () => {
expect(() => verify()).not.toThrow();
});
it('accepts a timestamp within the tolerance window', () => {
expect(() => verify({ header: sign(BODY, nowSec - 4 * 60) })).not.toThrow();
});
it('rejects a tampered body', () => {
expect(() => verify({ rawBody: Buffer.from(BODY + ' ') })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a signature made with the wrong secret', () => {
expect(() => verify({ header: sign(BODY, nowSec, 'wrong-secret') })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a missing header', () => {
expect(() => verify({ header: undefined })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a missing body', () => {
expect(() => verify({ rawBody: undefined })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a header missing v1', () => {
expect(() => verify({ header: `t=${nowSec}` })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a stale timestamp (replay guard)', () => {
expect(() => verify({ header: sign(BODY, nowSec - 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a far-future timestamp', () => {
expect(() => verify({ header: sign(BODY, nowSec + 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError);
});
it('rejects a wrong-length signature without crashing (timingSafeEqual guard)', () => {
expect(() => verify({ header: `t=${nowSec},v1=deadbeef` })).toThrow(revenueCatWebhook_1.AuthError);
});
});
//# sourceMappingURL=revenueCatWebhook.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,35 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.syncEntitlement = void 0;
const https_1 = require("firebase-functions/v2/https");
const entitlementLogic_1 = require("./entitlementLogic");
const log_1 = require("../log");
/**
* Callable function that forces a re-sync of entitlements for the
* authenticated caller.
*
* Request body: { } (auth context supplies uid)
* Response: EntitlementState
*
* The client can invoke this after a purchase/restore or when local
* entitlement state appears stale. In production this should fetch the
* latest entitlement state from RevenueCat; for now it computes the
* state from the existing Firestore document and rewrites it, ensuring
* consistent field shapes.
*/
exports.syncEntitlement = (0, https_1.onCall)(async (request) => {
var _a;
if (!((_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid)) {
throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
const userId = request.auth.uid;
try {
const state = await (0, entitlementLogic_1.applyEntitlementSync)(userId);
return state;
}
catch (err) {
log_1.logger.error('[syncEntitlement] failed', { error: String(err) });
throw new https_1.HttpsError('internal', 'Entitlement sync failed.');
}
});
//# sourceMappingURL=syncEntitlement.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"syncEntitlement.js","sourceRoot":"","sources":["../../src/billing/syncEntitlement.ts"],"names":[],"mappings":";;;AAAA,uDAAgE;AAChE,yDAA2E;AAC3E,gCAA+B;AAE/B;;;;;;;;;;;;GAYG;AACU,QAAA,eAAe,GAAG,IAAA,cAAM,EAAC,KAAK,EAAE,OAAO,EAAE,EAAE;;IACtD,IAAI,CAAC,CAAA,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA,EAAE,CAAC;QACvB,MAAM,IAAI,kBAAU,CAAC,iBAAiB,EAAE,+BAA+B,CAAC,CAAA;IAC1E,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,CAAA;IAE/B,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,IAAA,uCAAoB,EAAC,MAAM,CAAC,CAAA;QAChD,OAAO,KAAgC,CAAA;IACzC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,YAAM,CAAC,KAAK,CAAC,0BAA0B,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,kBAAU,CAAC,UAAU,EAAE,0BAA0B,CAAC,CAAA;IAC9D,CAAC;AACH,CAAC,CAAC,CAAA"}

View File

@ -1,213 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.acceptInviteCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const push_1 = require("../notifications/push");
const log_1 = require("../log");
/**
* HTTPS callable that mediates invite acceptance.
*
* Issue #9 fix: clients are no longer allowed to read invites directly, because
* the 6-character document ID was enumerable. The invite is looked up by code
* server-side, validated, and accepted atomically here.
*
* Request body: { code: string }
* - code: the 6-character invite code the partner shared.
*
* The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually.
*
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
* decrypts it client-side using the invite code they entered. The server never sees
* the plaintext phrase.
*
* Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired.
* 2. Rate-limit accept attempts per UID.
* 3. Look up the invite document by code.
* 4. Validate status == 'pending' and not expired.
* 5. Prevent self-acceptance.
* 6. Create the couple document with the wrapped couple key from the invite.
* 7. Update both user documents with the new coupleId.
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
*/
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000; // 25h: rate window + 1h buffer; Firestore TTL cleans up after
exports.acceptInviteCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const code = (_b = request.data) === null || _b === void 0 ? void 0 : _b.code;
if (!code || typeof code !== 'string') {
throw new https_1.HttpsError('invalid-argument', 'code is required.');
}
const db = admin.firestore();
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
const windowStart = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS);
const recentAttempts = await db
.collection('users').doc(callerId)
.collection('invite_attempts')
.where('attemptedAt', '>=', windowStart)
.count()
.get();
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
throw new https_1.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.');
}
// Record this attempt before doing any work, so failures also count.
// expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides.
const attemptExpiresAt = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() + ACCEPT_ATTEMPT_TTL_MS);
// The code is the KDF seed for the couple's recovery phrase — never store it raw.
await db.collection('users').doc(callerId).collection('invite_attempts').add({
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt: attemptExpiresAt,
});
// Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get();
if (callerDoc.exists && ((_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.coupleId) != null) {
throw new https_1.HttpsError('failed-precondition', 'Caller is already paired.');
}
const inviteRef = db.collection('invites').doc(code);
const inviteDoc = await inviteRef.get();
if (!inviteDoc.exists) {
throw new https_1.HttpsError('not-found', 'Invite not found.');
}
const invite = (_d = inviteDoc.data()) !== null && _d !== void 0 ? _d : {};
const inviterUserId = invite.inviterUserId;
const status = invite.status;
const expiresAt = invite.expiresAt;
const wrappedCoupleKey = invite.wrappedCoupleKey;
const kdfSalt = invite.kdfSalt;
const kdfParams = invite.kdfParams;
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase;
if (status !== 'pending') {
throw new https_1.HttpsError('failed-precondition', 'Invite has already been used.');
}
const now = admin.firestore.Timestamp.now();
if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) {
throw new https_1.HttpsError('failed-precondition', 'Invite has expired.');
}
if (!inviterUserId) {
throw new https_1.HttpsError('failed-precondition', 'Invite is missing inviterUserId.');
}
if (inviterUserId === callerId) {
throw new https_1.HttpsError('permission-denied', 'Cannot accept your own invite.');
}
const coupleId = db.collection('couples').doc().id;
const coupleRef = db.collection('couples').doc(coupleId);
// Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// broken plaintext couple the client can't use.
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
throw new https_1.HttpsError('failed-precondition', 'Invite is missing encryption material. Ask your partner to create a new invite.');
}
const encryptionVersion = 2;
const batch = db.batch();
batch.set(coupleRef, {
id: coupleId,
userIds: [inviterUserId, callerId],
inviteCode: code,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
streakCount: 0,
encryptionVersion,
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
});
batch.update(db.collection('users').doc(inviterUserId), { coupleId });
batch.update(db.collection('users').doc(callerId), { coupleId });
// Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
// who will decrypt it client-side. No reason to keep it in Firestore after use.
batch.update(inviteRef, {
status: 'accepted',
acceptedByUserId: callerId,
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
coupleId,
encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
// Don't let the plaintext inviter name linger once the couple exists (the profile name is E2EE
// from here on). It was only needed for the pre-accept "X invited you" preview.
inviterDisplayName: admin.firestore.FieldValue.delete(),
});
try {
await batch.commit();
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[acceptInviteCallable] couple creation failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to accept invite. Please try again.');
}
log_1.logger.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`);
// Notify the inviter that their partner joined. Await so gen 2 doesn't freeze the instance
// before the push completes; swallow errors so a failed push never fails the accept itself.
await notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) => log_1.logger.warn('[acceptInviteCallable] partner_joined FCM failed', { error: String(e) }));
return {
coupleId,
inviterUserId,
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
};
});
async function notifyPartnerJoined(db, inviterUserId, coupleId) {
await db.collection('users').doc(inviterUserId).collection('notification_queue').add({
type: 'partner_joined',
title: 'Your partner joined!',
body: "You're connected. Time to answer tonight's question together.",
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, admin.messaging(), inviterUserId, {
notification: {
title: 'Your partner joined!',
body: "You're connected. Time to answer tonight's question together.",
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: 'partner_joined',
couple_id: coupleId,
},
});
}
//# sourceMappingURL=acceptInviteCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,116 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.aggregateOutcomeStats = exports.MIN_COHORT = exports.FOLLOWUP_DAYS = exports.SCORE_KEYS = void 0;
exports.aggregate = aggregate;
exports.extractCoupleOutcome = extractCoupleOutcome;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const log_1 = require("../log");
exports.SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness'];
exports.FOLLOWUP_DAYS = ['day_30', 'day_60', 'day_90'];
exports.MIN_COHORT = 50;
function pct(n, total) {
return total === 0 ? 0 : Math.round((n / total) * 100);
}
/**
* Pure aggregation no I/O, unit-tested. A window below [minCohort] couples is suppressed.
*/
function aggregate(couples, minCohort = exports.MIN_COHORT) {
const windows = {};
for (const day of exports.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) => { var _a, _b; return ((_b = (_a = c.deltas[day]) === null || _a === void 0 ? void 0 : _a.connection) !== null && _b !== void 0 ? _b : 0) > 0; }).length;
const byMetric = {};
for (const metric of exports.SCORE_KEYS) {
const improved = eligible.filter((c) => { var _a, _b; return ((_b = (_a = c.deltas[day]) === null || _a === void 0 ? void 0 : _a[metric]) !== null && _b !== void 0 ? _b : 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. */
function extractCoupleOutcome(outcomeDocs) {
const deltas = {};
for (const doc of outcomeDocs) {
if (exports.FOLLOWUP_DAYS.includes(doc.id)) {
const delta = doc.data.delta;
if (delta && typeof delta === 'object')
deltas[doc.id] = delta;
}
}
return { deltas };
}
exports.aggregateOutcomeStats = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', memory: '512MiB', timeoutSeconds: 300 }, async () => {
const db = admin.firestore();
// Paginate the couple scan (was an unbounded couples.get()) and read each page's outcomes in
// parallel (was one serial outcomes.get() per couple → O(couples) round-trips). couples.length
// still counts every couple, so the aggregate + totalCouples are unchanged.
const couples = [];
const PAGE_SIZE = 200;
let lastDoc;
for (;;) {
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE);
if (lastDoc)
query = query.startAfter(lastDoc);
const page = await query.get();
if (page.empty)
break;
const pageOutcomes = await Promise.all(page.docs.map(async (couple) => {
const outcomesSnap = await couple.ref.collection('outcomes').get();
return extractCoupleOutcome(outcomesSnap.docs.map((d) => ({ id: d.id, data: d.data() })));
}));
couples.push(...pageOutcomes);
lastDoc = page.docs[page.docs.length - 1];
if (page.size < PAGE_SIZE)
break;
}
const stats = aggregate(couples);
await db.collection('aggregate_stats').doc('outcomes').set(Object.assign(Object.assign({}, stats), { totalCouples: couples.length, generatedAt: admin.firestore.Timestamp.now() }));
log_1.logger.log(`[aggregateOutcomeStats] ${couples.length} couples → ` +
exports.FOLLOWUP_DAYS.map((d) => `${d}:${stats.windows[d].suppressed ? 'suppressed' : stats.windows[d].feltCloserPct + '%'}`).join(' '));
});
//# sourceMappingURL=aggregateOutcomes.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"aggregateOutcomes.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.ts"],"names":[],"mappings":"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"}

View File

@ -1,69 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const aggregateOutcomes_1 = require("./aggregateOutcomes");
/** Builds N couples that completed day_30 with the given connection delta. */
function couplesWithConnectionDelta(n, connectionDelta) {
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 = (0, aggregateOutcomes_1.aggregate)(couplesWithConnectionDelta(aggregateOutcomes_1.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(aggregateOutcomes_1.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 = (0, aggregateOutcomes_1.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 = (0, aggregateOutcomes_1.aggregate)(couplesWithConnectionDelta(aggregateOutcomes_1.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', () => {
var _a, _b, _c, _d;
const couples = Array.from({ length: aggregateOutcomes_1.MIN_COHORT }, (_, i) => ({
deltas: {
day_30: {
connection: 1,
communication: i < aggregateOutcomes_1.MIN_COHORT / 2 ? 1 : 0, // exactly half improved
intimacy: 0,
happiness: -1,
},
},
}));
const stats = (0, aggregateOutcomes_1.aggregate)(couples);
expect((_a = stats.windows.day_30.improvedPctByMetric) === null || _a === void 0 ? void 0 : _a.connection).toBe(100);
expect((_b = stats.windows.day_30.improvedPctByMetric) === null || _b === void 0 ? void 0 : _b.communication).toBe(50);
expect((_c = stats.windows.day_30.improvedPctByMetric) === null || _c === void 0 ? void 0 : _c.intimacy).toBe(0);
expect((_d = stats.windows.day_30.improvedPctByMetric) === null || _d === void 0 ? void 0 : _d.happiness).toBe(0);
});
it('emits no couple-identifying data — only counts and percentages', () => {
const stats = (0, aggregateOutcomes_1.aggregate)(couplesWithConnectionDelta(aggregateOutcomes_1.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 = (0, aggregateOutcomes_1.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.day_0).toBeUndefined();
expect(out.deltas.day_30).toEqual({ connection: 2, communication: 1 });
expect(out.deltas.random).toBeUndefined();
});
});
//# sourceMappingURL=aggregateOutcomes.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"aggregateOutcomes.test.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,190 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInviteCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/**
* HTTPS callable that creates a secure invite code.
*
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
* invites directly. 6-character document IDs are enumerable, so a direct client
* write would expose pending invites to scanning.
*
* Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
* the server generates the code.
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
* code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
*
* Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
* all required. There is no plaintext-couple path.
*
* Response: { code: string, expiresAt: Timestamp }
*
* Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired.
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
* 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
* 4. Write the invite document with a 24-hour TTL.
*/
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 6;
const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const RATE_LIMIT_MAX = 5;
function generateCode() {
let code = '';
const randomValues = Buffer.alloc(CODE_LENGTH);
// crypto.randomBytes is synchronous and suitable for Cloud Functions.
require('crypto').randomFillSync(randomValues);
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_CHARS[randomValues[i] % CODE_CHARS.length];
}
return code;
}
exports.createInviteCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const data = request.data;
const db = admin.firestore();
// Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get();
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {
throw new https_1.HttpsError('failed-precondition', 'Caller is already paired.');
}
const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName;
// Rate limit: count invites created by this user in the last hour.
const now = admin.firestore.Timestamp.now();
const windowStart = admin.firestore.Timestamp.fromMillis(now.toMillis() - RATE_LIMIT_WINDOW_MS);
const recentInvitesQuery = db
.collection('invites')
.where('inviterUserId', '==', callerId)
.where('createdAt', '>=', windowStart)
.orderBy('createdAt', 'desc')
.limit(RATE_LIMIT_MAX + 1);
const recentInvites = await recentInvitesQuery.get();
if (recentInvites.size >= RATE_LIMIT_MAX) {
throw new https_1.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
}
const clientCode = data === null || data === void 0 ? void 0 : data.code;
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
if (!clientCode) {
throw new https_1.HttpsError('invalid-argument', 'code is required.');
}
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
// codes and anything that could be abused as the document id.
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
throw new https_1.HttpsError('invalid-argument', 'code must be 6 valid characters.');
}
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new https_1.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.');
}
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
// Android supplies its own code (used as the KDF input for phrase encryption, so the server
// must use it as-is). iOS omits the code; the server generates one in that case.
// Either way, validate uniqueness via transaction and return an error on collision so the
// client can retry with a fresh code.
let inviteRef = null;
let code = null;
const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode);
for (const candidate of candidates) {
const candidateRef = db.collection('invites').doc(candidate);
// eslint-disable-next-line no-await-in-loop
const created = await db.runTransaction(async (tx) => {
const snap = await tx.get(candidateRef);
if (snap.exists)
return false;
tx.set(candidateRef, {
code: candidate,
inviterUserId: callerId,
inviterDisplayName: callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null,
status: 'pending',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt,
usedAt: null,
usedByUserId: null,
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
});
return true;
});
if (created) {
code = candidate;
inviteRef = candidateRef;
break;
}
}
if (!code || !inviteRef) {
// Client-supplied code collided; the Android client will retry with a new code.
throw new https_1.HttpsError('already-exists', 'Invite code is already taken. Please try again.');
}
// Write a server-side audit log entry for the inviter. This is not read by
// clients and supports the rate-limit count as well as future abuse review.
try {
// Do not store the raw code — it is the KDF seed for the couple's recovery phrase.
await db.collection('users').doc(callerId).collection('notification_queue').add({
type: 'invite_created',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
read: true,
});
}
catch (err) {
// Audit write is best-effort; do not fail the invite if it errors.
log_1.logger.warn(`[createInviteCallable] audit log failed for ${callerId}`, { error: String(err) });
}
log_1.logger.log(`[createInviteCallable] ${callerId} created an invite; expires ${expiresAt.toDate().toISOString()}`);
return { code, expiresAt };
});
//# sourceMappingURL=createInviteCallable.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}

View File

@ -1,125 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.leaveCoupleCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/**
* HTTPS callable that atomically unlinks a couple.
*
* The client cannot do this directly because Firestore rules prevent a user
* from writing the partner's user document. The Admin SDK bypasses those rules.
*
* Steps:
* 1. Verify the caller is a member of their current couple.
* 2. Clear coupleId on both user docs (batch atomic).
* 3. Recursively delete the couple doc and all its subcollections.
*
* The existing onCoupleLeave Firestore trigger fires after step 2 and handles
* partner notification, so we don't duplicate that here.
*/
exports.leaveCoupleCallable = (0, https_1.onCall)(async (request) => {
var _a, _b;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const db = admin.firestore();
const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) {
// Already unpaired — idempotent success.
return { success: true };
}
const coupleRef = db.collection('couples').doc(coupleId);
// Security review Batch 2: do the membership check, member-clearing, and couple-doc
// delete in one transaction so two partners leaving concurrently can't clobber state.
// Critically, only clear a member's coupleId if it STILL points at this couple — a
// stale concurrent call must never wipe a coupleId set by a fresh re-pair.
let result;
try {
result = await db.runTransaction(async (tx) => {
var _a, _b, _c;
const coupleSnap = await tx.get(coupleRef);
if (!coupleSnap.exists) {
const callerRef = db.collection('users').doc(callerId);
const callerSnap = await tx.get(callerRef);
if (((_a = callerSnap.data()) === null || _a === void 0 ? void 0 : _a.coupleId) === coupleId) {
tx.update(callerRef, { coupleId: null });
}
return { membership: true };
}
const userIds = ((_c = (_b = coupleSnap.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
if (!userIds.includes(callerId)) {
return { membership: false };
}
// Reads must precede writes in a transaction: snapshot every member first.
const memberSnaps = await Promise.all(userIds.map((uid) => tx.get(db.collection('users').doc(uid))));
memberSnaps.forEach((snap, i) => {
var _a;
if (((_a = snap.data()) === null || _a === void 0 ? void 0 : _a.coupleId) === coupleId) {
tx.update(db.collection('users').doc(userIds[i]), { coupleId: null });
}
});
tx.delete(coupleRef);
return { membership: true };
});
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[leaveCoupleCallable] leave transaction failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to leave couple. Please try again.');
}
if (!result.membership) {
throw new https_1.HttpsError('permission-denied', 'Not a member of this couple.');
}
// Couple doc is deleted in the transaction; sweep any subcollections left behind.
// Best-effort + idempotent — the leave already succeeded, so a failed sweep must not
// surface as an error to the caller (orphaned subcollections can be reaped later).
try {
await db.recursiveDelete(coupleRef);
}
catch (e) {
log_1.logger.warn('[leaveCoupleCallable] subcollection sweep failed (ignored)', { error: String(e) });
}
log_1.logger.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`);
return { success: true };
});
//# sourceMappingURL=leaveCoupleCallable.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":"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"}

View File

@ -1,110 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onCoupleKeyRotated = void 0;
exports.isKeyGenerationIncrease = isKeyGenerationIncrease;
exports.isPhrasePublished = isPhrasePublished;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const queueAndPush_1 = require("../notifications/queueAndPush");
const log_1 = require("../log");
/**
* Fires when a couple's key generation increases one member rotated the couple key.
*
* Two jobs in one push, sent to BOTH members (every device):
* 1. Security signal ("was this you?" philosophy, same class as the restore self-alerts bypasses
* quiet hours, no preference toggle): a key rotation is worth knowing about even when benign.
* 2. Functional pickup nudge: the partner's closed app still holds only the old key and cannot
* decrypt anything written under the new one until it next loads Home the tap gets them there,
* where the existing adoption path unwraps the rotated keyset. No key material is read or logged.
*/
/** Pure edge guard: fire only when keyGeneration strictly increases (ignores every other update). */
function isKeyGenerationIncrease(before, after) {
const b = typeof before === 'number' ? before : 0;
const a = typeof after === 'number' ? after : 0;
return a > b;
}
/**
* Pure edge guard for a newly PUBLISHED recovery phrase (phase 1 of the change handshake).
*
* Deliberately keyed on `phraseGeneration`, not `phraseWrapGeneration`: the publish is the moment the
* partner needs to act on, and their ack is what lets the change finish at all. Same shape as
* isKeyGenerationIncrease so the two read alike.
*/
function isPhrasePublished(before, after) {
const b = typeof before === 'number' ? before : 0;
const a = typeof after === 'number' ? after : 0;
return a > b;
}
exports.onCoupleKeyRotated = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}', async (event) => {
var _a, _b, _c;
const before = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data();
const after = (_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data();
const rotated = isKeyGenerationIncrease(before === null || before === void 0 ? void 0 : before.keyGeneration, after === null || after === void 0 ? void 0 : after.keyGeneration);
const phrasePublished = isPhrasePublished(before === null || before === void 0 ? void 0 : before.phraseGeneration, after === null || after === void 0 ? void 0 : after.phraseGeneration);
if (!rotated && !phrasePublished)
return;
const { coupleId } = event.params;
const db = admin.firestore();
const userIds = ((_c = after === null || after === void 0 ? void 0 : after.userIds) !== null && _c !== void 0 ? _c : []);
if (userIds.length === 0)
return;
// A rotation and a phrase publish are separate write shapes (the rules forbid combining them), so
// exactly one of these is true per event.
const alert = rotated
? {
type: 'couple_key_rotated',
title: '🔑 Security update',
body: 'Your shared key was rotated. Open the app and everything continues as normal.',
}
: {
// Not merely informational: the change CANNOT complete until the partner's app opens and
// confirms it can read the new phrase. This push is what gets them there. And if they didn't
// expect a phrase change, this is how they find out — the was-this-you philosophy again.
type: 'recovery_phrase_changed',
title: '🔑 Security update',
body: 'Your recovery phrase was changed. Open the app to finish saving the new one.',
};
log_1.logger.log(`[onCoupleKeyRotated] couple=${coupleId} ` +
`${rotated ? `generation=${after === null || after === void 0 ? void 0 : after.keyGeneration}` : `phraseGeneration=${after === null || after === void 0 ? void 0 : after.phraseGeneration}`}`);
// Per-member isolation: one failed send must not cost the other member their alert.
const results = await Promise.allSettled(userIds.map((uid) => (0, queueAndPush_1.queueAndPush)(db, uid, Object.assign(Object.assign({}, alert), { coupleId, bypassQuietHours: true }))));
results.forEach((r, i) => {
if (r.status === 'rejected') {
log_1.logger.warn('[onCoupleKeyRotated] alert failed', { uid: userIds[i], error: String(r.reason) });
}
});
});
//# sourceMappingURL=onCoupleKeyRotated.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onCoupleKeyRotated.js","sourceRoot":"","sources":["../../src/couples/onCoupleKeyRotated.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiBA,0DAIC;AASD,8CAIC;AAlCD,sDAAuC;AACvC,+DAAmE;AACnE,gEAA4D;AAC5D,gCAA+B;AAE/B;;;;;;;;;GASG;AAEH,qGAAqG;AACrG,SAAgB,uBAAuB,CAAC,MAAe,EAAE,KAAc;IACrE,MAAM,CAAC,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;IACjD,MAAM,CAAC,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/C,OAAO,CAAC,GAAG,CAAC,CAAA;AACd,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,MAAe,EAAE,KAAc;IAC/D,MAAM,CAAC,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;IACjD,MAAM,CAAC,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/C,OAAO,CAAC,GAAG,CAAC,CAAA;AACd,CAAC;AAEY,QAAA,kBAAkB,GAAG,IAAA,6BAAiB,EAAC,oBAAoB,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;;IACxF,MAAM,MAAM,GAAG,MAAA,KAAK,CAAC,IAAI,0CAAE,MAAM,CAAC,IAAI,EAAE,CAAA;IACxC,MAAM,KAAK,GAAG,MAAA,KAAK,CAAC,IAAI,0CAAE,KAAK,CAAC,IAAI,EAAE,CAAA;IAEtC,MAAM,OAAO,GAAG,uBAAuB,CAAC,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,aAAa,EAAE,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,aAAa,CAAC,CAAA;IACpF,MAAM,eAAe,GAAG,iBAAiB,CAAC,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,gBAAgB,EAAE,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,gBAAgB,CAAC,CAAA;IAC5F,IAAI,CAAC,OAAO,IAAI,CAAC,eAAe;QAAE,OAAM;IAExC,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACjC,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,OAAO,GAAG,CAAC,MAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAClD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAEhC,kGAAkG;IAClG,0CAA0C;IAC1C,MAAM,KAAK,GAAG,OAAO;QACnB,CAAC,CAAC;YACE,IAAI,EAAE,oBAAoB;YAC1B,KAAK,EAAE,oBAAoB;YAC3B,IAAI,EAAE,+EAA+E;SACtF;QACH,CAAC,CAAC;YACE,yFAAyF;YACzF,6FAA6F;YAC7F,yFAAyF;YACzF,IAAI,EAAE,yBAAyB;YAC/B,KAAK,EAAE,oBAAoB;YAC3B,IAAI,EAAE,8EAA8E;SACrF,CAAA;IAEL,YAAM,CAAC,GAAG,CACR,+BAA+B,QAAQ,GAAG;QACxC,GAAG,OAAO,CAAC,CAAC,CAAC,cAAc,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,aAAa,EAAE,CAAC,CAAC,CAAC,oBAAoB,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,gBAAgB,EAAE,EAAE,CACtG,CAAA;IAED,oFAAoF;IACpF,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAClB,IAAA,2BAAY,EAAC,EAAE,EAAE,GAAG,kCACf,KAAK,KACR,QAAQ,EACR,gBAAgB,EAAE,IAAI,IACtB,CACH,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAC5B,YAAM,CAAC,IAAI,CAAC,mCAAmC,EAAE,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAChG,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -1,41 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const onCoupleKeyRotated_1 = require("./onCoupleKeyRotated");
describe('isKeyGenerationIncrease', () => {
it('fires on a genuine rotation (0 → 1, and every later bump)', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(3, 4)).toBe(true);
});
it('ignores every non-rotation update to the couple doc', () => {
// The trigger watches the whole couple doc — streaks, rhythm, recovery re-wraps all pass through here.
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, undefined)).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(2, 2)).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, undefined)).toBe(false);
});
it('never fires on a downgrade — rules forbid it, but a redelivered stale event must not alert either', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(5, 4)).toBe(false);
});
it('treats junk as zero rather than alerting on garbage', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)('x', 'y')).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(null, 'high')).toBe(false);
});
});
describe('isPhrasePublished', () => {
it('fires when a new recovery phrase is published (phase 1)', () => {
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(undefined, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(0, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(2, 3)).toBe(true);
});
it('ignores the acks and the completion — only the publish needs the partner in the app', () => {
// Phase 2 (acks) and phase 3 (the re-wrap) leave phraseGeneration alone, so they must not alert:
// the partner is already acting, and re-alerting them would be noise on a security channel.
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(1, 1)).toBe(false);
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(undefined, undefined)).toBe(false);
});
it('never fires on a downgrade or a redelivered stale event', () => {
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(5, 4)).toBe(false);
expect((0, onCoupleKeyRotated_1.isPhrasePublished)(1, undefined)).toBe(false);
});
});
//# sourceMappingURL=onCoupleKeyRotated.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onCoupleKeyRotated.test.js","sourceRoot":"","sources":["../../src/couples/onCoupleKeyRotated.test.ts"],"names":[],"mappings":";;AAAA,6DAAiF;AAEjF,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,IAAA,4CAAuB,EAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACxD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,uGAAuG;QACvG,MAAM,CAAC,IAAA,4CAAuB,EAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjE,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC3D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mGAAmG,EAAE,GAAG,EAAE;QAC3G,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACnD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,CAAC,IAAA,4CAAuB,EAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACrD,MAAM,CAAC,IAAA,4CAAuB,EAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC3D,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IACjC,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,IAAA,sCAAiB,EAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClD,MAAM,CAAC,IAAA,sCAAiB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC1C,MAAM,CAAC,IAAA,sCAAiB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC5C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qFAAqF,EAAE,GAAG,EAAE;QAC7F,iGAAiG;QACjG,4FAA4F;QAC5F,MAAM,CAAC,IAAA,sCAAiB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC3C,MAAM,CAAC,IAAA,sCAAiB,EAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC7D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,IAAA,sCAAiB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC3C,MAAM,CAAC,IAAA,sCAAiB,EAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACrD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -1,118 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onCoupleLeave = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Firestore trigger that notifies the remaining partner when a user's coupleId
* field is cleared (i.e. the user left the couple or was removed).
*
* Path: users/{userId}
* Condition: previous coupleId was non-empty and new coupleId is null/missing.
*/
exports.onCoupleLeave = (0, firestore_1.onDocumentUpdated)('users/{userId}', async (event) => {
var _a, _b, _c, _d, _e, _f;
const { userId } = event.params;
const previousData = (_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {};
const currentData = (_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {};
const previousCoupleId = previousData.coupleId;
const currentCoupleId = currentData.coupleId;
// Only act when coupleId transitions from a real value to null/empty.
if (!previousCoupleId || typeof previousCoupleId !== 'string') {
return;
}
if (currentCoupleId) {
return;
}
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(previousCoupleId).get();
if (!coupleDoc.exists) {
log_1.logger.warn(`[onCoupleLeave] couple ${previousCoupleId} not found`);
return;
}
const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []);
const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) {
log_1.logger.warn(`[onCoupleLeave] no partner found for couple ${previousCoupleId}`);
return;
}
// Make sure the partner is still paired in this couple.
// If both users are leaving simultaneously, avoid duplicate/phantom notifications.
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
const partnerCoupleId = partnerData === null || partnerData === void 0 ? void 0 : partnerData.coupleId;
if (partnerCoupleId !== previousCoupleId) {
log_1.logger.log(`[onCoupleLeave] partner ${partnerId} is no longer in couple ${previousCoupleId}; skipping notification`);
return;
}
// Dedupe redelivery of this leave (at-least-once) so the partner isn't double-notified and the
// in-app record isn't duplicated.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, previousCoupleId, `leave-${userId}`)))) {
log_1.logger.log(`[onCoupleLeave] already notified partner of ${userId} leaving; skipping`);
return;
}
const notificationPayload = {
type: 'partner_left',
title: 'Your partner has left',
body: 'You are no longer paired. Tap to create a new invite.',
};
// Write an in-app notification record for the partner.
// This is read-only denied for clients; the Cloud Function writes it.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add(Object.assign(Object.assign({}, notificationPayload), { read: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: {
title: notificationPayload.title,
body: notificationPayload.body,
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: notificationPayload.type,
},
}, partnerData);
if (res.sent === 0 && res.failed === 0) {
log_1.logger.log(`[onCoupleLeave] no FCM tokens for partner ${partnerId}`);
return;
}
log_1.logger.log(`[onCoupleLeave] notified partner ${partnerId} that user ${userId} left couple ${previousCoupleId}`);
});
//# sourceMappingURL=onCoupleLeave.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"}

View File

@ -1,123 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.scheduledOutcomesReminder = void 0;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const time_1 = require("../notifications/time");
const log_1 = require("../log");
const DAY_MS = 24 * 60 * 60 * 1000;
const REMINDER_DAYS = [30, 60, 90];
const DAY_KEY_MAP = { 30: 'day_30', 60: 'day_60', 90: 'day_90' };
exports.scheduledOutcomesReminder = (0, scheduler_1.onSchedule)({ schedule: 'every 24 hours', timeoutSeconds: 180 }, async () => {
var _a, _b;
const db = admin.firestore();
const messaging = admin.messaging();
const now = Date.now();
const couplesSnap = await db.collection('couples').limit(200).get();
const notifications = [];
for (const coupleDoc of couplesSnap.docs) {
const coupleId = coupleDoc.id;
const data = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {};
const createdAt = (0, time_1.toMillis)(data.createdAt);
if (createdAt <= 0)
continue;
const ageDays = Math.floor((now - createdAt) / DAY_MS);
const dueDays = REMINDER_DAYS.filter((day) => ageDays >= day && ageDays <= day + 2);
if (dueDays.length === 0)
continue;
const userIds = ((_b = data.userIds) !== null && _b !== void 0 ? _b : []);
if (userIds.length === 0)
continue;
// Check each due checkpoint; only remind for the first one without an outcome.
let remindedDay = null;
for (const day of dueDays) {
const dayKey = DAY_KEY_MAP[day];
const outcomeSnap = await coupleDoc.ref.collection('outcomes').doc(dayKey).get();
if (!outcomeSnap.exists) {
remindedDay = day;
break;
}
}
if (remindedDay == null)
continue;
const dayLabel = remindedDay;
const title = 'How are you feeling together?';
const body = `Youve been connected for ${dayLabel} days. Take a quick check-in to see how things have changed.`;
for (const userId of userIds) {
notifications.push({ userId, coupleId, day: dayLabel, title, body });
}
}
// Per-recipient failures are isolated so one bad send can't abort the batch.
await Promise.allSettled(notifications.map((notification) => sendOutcomeReminder(db, messaging, notification)));
log_1.logger.log(`[scheduledOutcomesReminder] scanned ${couplesSnap.size}; notified ${notifications.length}`);
});
async function sendOutcomeReminder(db, messaging, notification) {
const userDoc = await db.collection('users').doc(notification.userId).get();
const userData = userDoc.data();
// Honor the recipient's quiet hours (outcome check-ins are genuine, so no promotional gate).
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
log_1.logger.log(`[sendOutcomeReminder] skip ${notification.userId} — quiet hours`);
return;
}
await db
.collection('users')
.doc(notification.userId)
.collection('notification_queue')
.add({
type: 'outcome_reminder',
title: notification.title,
body: notification.body,
coupleId: notification.coupleId,
day: notification.day,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
notification: {
title: notification.title,
body: notification.body,
},
android: { notification: { channelId: 'reminders' } }, // E-OBS
data: {
type: 'outcome_reminder',
coupleId: notification.coupleId,
day: String(notification.day),
},
}, userData);
}
//# sourceMappingURL=scheduledOutcomesReminder.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"}

View File

@ -1,155 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.submitOutcomeCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
const DAY_KEYS = ['day_0', 'day_30', 'day_60', 'day_90'];
const SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness'];
const MIN_SCORE = 1;
const MAX_SCORE = 10;
function isValidDayKey(value) {
return typeof value === 'string' && DAY_KEYS.includes(value);
}
function isValidScoreMap(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false;
const map = value;
for (const key of SCORE_KEYS) {
const num = map[key];
if (typeof num !== 'number' || Number.isNaN(num))
return false;
if (num < MIN_SCORE || num > MAX_SCORE)
return false;
}
return true;
}
exports.submitOutcomeCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const data = request.data;
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
if (typeof coupleId !== 'string' || coupleId.length === 0) {
throw new https_1.HttpsError('invalid-argument', 'coupleId is required.');
}
const dayKey = data === null || data === void 0 ? void 0 : data.dayKey;
if (!isValidDayKey(dayKey)) {
throw new https_1.HttpsError('invalid-argument', `dayKey must be one of: ${DAY_KEYS.join(', ')}.`);
}
const scores = data === null || data === void 0 ? void 0 : data.scores;
if (!isValidScoreMap(scores)) {
throw new https_1.HttpsError('invalid-argument', `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`);
}
const db = admin.firestore();
const coupleRef = db.collection('couples').doc(coupleId);
// Caller must be a member of the couple.
const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists) {
throw new https_1.HttpsError('not-found', 'Couple not found.');
}
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
if (!userIds.includes(callerId)) {
throw new https_1.HttpsError('permission-denied', 'Caller is not a member of this couple.');
}
const now = admin.firestore.Timestamp.now();
const outcomeRef = coupleRef.collection('outcomes').doc(dayKey);
let result;
try {
result = await db.runTransaction(async (tx) => {
var _a, _b, _c, _d, _e, _f, _g, _h;
const existing = await tx.get(outcomeRef);
const existingData = existing.exists ? ((_a = existing.data()) !== null && _a !== void 0 ? _a : {}) : {};
// If this is a follow-up (non-baseline), a baseline must exist to compute delta.
if (dayKey !== 'day_0') {
const baselineRef = coupleRef.collection('outcomes').doc('day_0');
const baselineSnap = await tx.get(baselineRef);
if (!baselineSnap.exists) {
throw new https_1.HttpsError('failed-precondition', 'Baseline (day_0) outcome must be submitted before follow-up outcomes.');
}
}
const answeredBy = ((_b = existingData.answeredBy) !== null && _b !== void 0 ? _b : []);
if (!answeredBy.includes(callerId)) {
answeredBy.push(callerId);
}
const payload = {
dayKey,
answeredBy,
updatedAt: now,
createdAt: (_c = existingData.createdAt) !== null && _c !== void 0 ? _c : now,
};
if (dayKey === 'day_0') {
payload.baseline = scores;
}
else {
payload.scores = scores;
}
// Delta is recalculated every time so repeated submissions stay consistent.
if (dayKey !== 'day_0') {
const baselineRef = coupleRef.collection('outcomes').doc('day_0');
const baselineSnap = await tx.get(baselineRef);
if (baselineSnap.exists) {
const baseline = ((_e = (_d = baselineSnap.data()) === null || _d === void 0 ? void 0 : _d.baseline) !== null && _e !== void 0 ? _e : {});
const delta = {};
for (const key of SCORE_KEYS) {
delta[key] = ((_f = scores[key]) !== null && _f !== void 0 ? _f : 0) - ((_g = baseline[key]) !== null && _g !== void 0 ? _g : 0);
}
payload.delta = delta;
}
}
tx.set(outcomeRef, payload, { merge: true });
// Per-user mirror for cross-relationship stats.
const userOutcomeRef = db.collection('users').doc(callerId).collection('outcomes').doc(dayKey);
tx.set(userOutcomeRef, Object.assign(Object.assign({ dayKey,
coupleId }, (dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta })), { answeredBy: [callerId], createdAt: (_h = existingData.createdAt) !== null && _h !== void 0 ? _h : now, updatedAt: now }), { merge: true });
return { dayKey, answeredBy };
});
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[submitOutcomeCallable] outcome transaction failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to submit outcome. Please try again.');
}
log_1.logger.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`);
return Object.assign({ success: true }, result);
});
//# sourceMappingURL=submitOutcomeCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,98 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.notifyOnDateMatch = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const push_1 = require("../notifications/push");
/**
* Fires the "It's a match!" notification when a date match is created.
*
* Trigger: couples/{coupleId}/date_matches/{dateIdeaId} (onCreate)
*
* Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
* Mutual-match detection now happens client-side (whichever partner records the
* second LOVE writes the match marker, validated by Firestore rules). This trigger
* only sends the push to both partners it never reads swipe content.
*
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
* (or a redelivered event) never double-send. The match doc id is the date idea id, so
* the marker itself is already de-duplicated by the client transaction + rules.
*/
exports.notifyOnDateMatch = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_matches/{dateIdeaId}', async (event) => {
var _a, _b;
const snap = event.data;
if (!snap || !snap.exists)
return;
const { coupleId, dateIdeaId } = event.params;
const db = admin.firestore();
const matchRef = snap.ref;
// Atomically claim the FCM send so concurrent invocations don't double-send.
const shouldSend = await db.runTransaction(async (tx) => {
var _a;
const doc = await tx.get(matchRef);
if (!doc.exists || ((_a = doc.data()) === null || _a === void 0 ? void 0 : _a.fcmNotified) === true)
return false;
tx.update(matchRef, { fcmNotified: true });
return true;
});
if (!shouldSend)
return;
const coupleDoc = await db.collection('couples').doc(coupleId).get();
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
await Promise.allSettled(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)));
});
async function notifyDateMatch(db, userId, coupleId, dateIdeaId) {
await db.collection('users').doc(userId).collection('notification_queue').add({
type: 'date_match',
title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.",
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, admin.messaging(), userId, {
notification: {
title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.",
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: 'date_match',
couple_id: coupleId,
date_idea_id: dateIdeaId,
},
});
}
//# sourceMappingURL=createDateMatch.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,gDAAsD;AAEtD;;;;;;;;;;;;;GAaG;AACU,QAAA,iBAAiB,GAAG,IAAA,6BAAiB,EAChD,8CAA8C,EAC9C,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACvB,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,OAAM;IAEjC,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IAE7C,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAA;IAEzB,6EAA6E;IAC7E,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;;QACtD,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QAClC,IAAI,CAAC,GAAG,CAAC,MAAM,IAAI,CAAA,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,WAAW,MAAK,IAAI;YAAE,OAAO,KAAK,CAAA;QACjE,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAA;QAC1C,OAAO,IAAI,CAAA;IACb,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,UAAU;QAAE,OAAM;IAEvB,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,eAAe,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC,CAAC,CAAA;AAChG,CAAC,CACF,CAAA;AAED,KAAK,UAAU,eAAe,CAC5B,EAA6B,EAC7B,MAAc,EACd,QAAgB,EAChB,UAAkB;IAElB,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC5E,IAAI,EAAE,YAAY;QAClB,KAAK,EAAE,eAAe;QACtB,IAAI,EAAE,2DAA2D;QACjE,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEF,MAAM,IAAA,qBAAc,EAAC,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE;QAClD,YAAY,EAAE;YACZ,KAAK,EAAE,eAAe;YACtB,IAAI,EAAE,2DAA2D;SAClE;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;QACtE,IAAI,EAAE;YACJ,IAAI,EAAE,YAAY;YAClB,SAAS,EAAE,QAAQ;YACnB,YAAY,EAAE,UAAU;SACzB;KACF,CAAC,CAAA;AACJ,CAAC"}

View File

@ -1,85 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateHistoryCreated = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Fires when a date is logged as completed (`couples/{coupleId}/date_history/{dateId}` created). Nudges
* the OTHER partner (not the one who logged it) to add their reflection so the reflectreveal loop
* starts even if the logger doesn't reflect right away. Gated on `notifPartnerAnswered` + quiet hours.
*/
exports.onDateHistoryCreated = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_history/{dateId}', async (event) => {
var _a, _b, _c;
const { coupleId, dateId } = event.params;
const snap = event.data;
if (!snap)
return;
const db = admin.firestore();
const addedBy = (_a = snap.data()) === null || _a === void 0 ? void 0 : _a.addedBy;
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists)
return;
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
const partnerId = userIds.find((u) => u !== addedBy);
if (!partnerId)
return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false)
return;
// Dedupe redelivery (at-least-once) before writing the in-app record or sending.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-logged-${dateId}`)))) {
log_1.logger.log(`[onDateHistoryCreated] already notified for date ${dateId}; skipping`);
return;
}
const title = 'You went on a date 💜';
const body = 'Reflect on it together while its fresh.';
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type: 'date_logged', title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
if ((0, quietHours_1.recipientInQuietHours)(partnerData))
return;
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { title, body },
data: { type: 'date_logged', couple_id: coupleId, date_id: dateId },
android: { notification: { channelId: 'partner_activity' } },
}, partnerData);
});
//# sourceMappingURL=onDateHistoryCreated.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onDateHistoryCreated.js","sourceRoot":"","sources":["../../src/dates/onDateHistoryCreated.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,4DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;GAIG;AACU,QAAA,oBAAoB,GAAG,IAAA,6BAAiB,EACnD,0CAA0C,EAC1C,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACzC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACvB,IAAI,CAAC,IAAI;QAAE,OAAM;IACjB,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,EAAE,0CAAE,OAA6B,CAAA;IAC1D,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,OAAO,CAAC,CAAA;IACpD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,MAAM,WAAW,GAAG,cAAc,CAAC,IAAI,EAAE,CAAA;IACzC,IAAI,CAAA,WAAW,aAAX,WAAW,uBAAX,WAAW,CAAE,oBAAoB,MAAK,KAAK;QAAE,OAAM;IAEvD,iFAAiF;IACjF,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,QAAQ,EAAE,eAAe,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzE,YAAM,CAAC,GAAG,CAAC,oDAAoD,MAAM,YAAY,CAAC,CAAA;QAClF,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,uBAAuB,CAAA;IACrC,MAAM,IAAI,GAAG,0CAA0C,CAAA;IAEvD,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACvG,CAAC,CAAA;IAEF,IAAI,IAAA,kCAAqB,EAAC,WAAW,CAAC;QAAE,OAAM;IAE9C,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE;QACnE,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE;KAC7D,EACD,WAAW,CACZ,CAAA;AACH,CAAC,CACF,CAAA"}

View File

@ -1,99 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateReflectionRevealed = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Fires when a partner OPENS (reveals) the shared date reflections their own reflection metadata doc
* flips `isRevealed` false true (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`).
* Notifies the OTHER partner that their reflection was read, mirroring daily's `onAnswerRevealed`:
* generic copy (no decrypted content, no name the app renders the real name in-app), gated on
* `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/
exports.onDateReflectionRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => {
var _a, _b, _c, _d, _e, _f, _g;
const { coupleId, dateId, userId } = event.params;
const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {});
const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {});
// Only on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true)
return;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists)
return;
const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []);
if (!userIds.includes(userId))
return;
const partnerId = userIds.find((u) => u !== userId);
if (!partnerId)
return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
log_1.logger.log(`[onDateReflectionRevealed] ${partnerId} has partner notifications off`);
return;
}
// Dedupe redelivery (at-least-once) before writing the in-app record or sending.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-reveal-${dateId}-${userId}`)))) {
log_1.logger.log(`[onDateReflectionRevealed] already notified for ${userId} on ${dateId}; skipping`);
return;
}
const title = 'Your partner opened your reflection ✨';
const body = 'Open to see what you each wrote.';
const type = 'date_reflection_opened';
// In-app record (→ Together feed) — written regardless of quiet hours.
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[onDateReflectionRevealed] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return;
}
const senderAvatar = (_g = (await db.collection('users').doc(userId).get()).data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { title, body },
data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {})),
android: { notification: { channelId: 'partner_activity' } },
}, partnerData);
});
//# sourceMappingURL=onDateReflectionRevealed.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onDateReflectionRevealed.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionRevealed.ts"],"names":[],"mappings":"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"}

View File

@ -1,103 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDateReflectionWritten = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Fires when a partner writes their post-date reflection
* (`couples/{coupleId}/date_reflections/{dateId}/answers/{userId}`). Notifies the OTHER partner "your
* turn to reflect" if they haven't yet, or "ready to reveal together" if both now have. Without this the
* second partner never learns to reflect and the mutual reveal stalls. Mirrors `onAnswerWritten`:
* generic copy (no decrypted content, no name the app renders the real name in-app), gated on
* `notifPartnerAnswered` + quiet hours (push suppressed in quiet hours, but the in-app record is kept).
*/
exports.onDateReflectionWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/date_reflections/{dateId}/answers/{userId}', async (event) => {
var _a, _b, _c;
const { coupleId, dateId, userId } = event.params;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists)
return;
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
if (!userIds.includes(userId))
return;
const partnerId = userIds.find((u) => u !== userId);
if (!partnerId)
return;
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Partner-activity opt-out (default on).
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
log_1.logger.log(`[onDateReflectionWritten] ${partnerId} has partner notifications off`);
return;
}
// Dedupe redelivery (at-least-once) before writing the in-app record or sending.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `date-reflect-${dateId}-${userId}`)))) {
log_1.logger.log(`[onDateReflectionWritten] already notified for ${userId} on ${dateId}; skipping`);
return;
}
// Did this complete the pair? If the recipient already reflected, both are done → reveal-ready.
const partnerReflection = await db
.collection('couples').doc(coupleId)
.collection('date_reflections').doc(dateId)
.collection('answers').doc(partnerId).get();
const bothReflected = partnerReflection.exists;
const title = bothReflected ? 'Your date reflections are ready ✨' : 'Your partner reflected on your date 💭';
const body = bothReflected ? 'Open to reveal them together.' : 'Add yours to reveal together.';
const type = bothReflected ? 'date_reflection_ready' : 'date_reflection_partner';
// In-app record (→ Together feed) — written regardless of quiet hours.
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
// Quiet hours: keep the in-app record, suppress the disruptive push.
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[onDateReflectionWritten] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return;
}
const senderAvatar = (_c = (await db.collection('users').doc(userId).get()).data()) === null || _c === void 0 ? void 0 : _c.photoUrl;
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { title, body },
data: Object.assign({ type, couple_id: coupleId, date_id: dateId }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {})),
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
}, partnerData);
});
//# sourceMappingURL=onDateReflectionWritten.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAAmE;AACnE,4DAAmE;AACnE,gDAAsD;AACtD,8DAAmE;AACnE,gCAA+B;AAE/B;;;;;;;GAOG;AACU,QAAA,uBAAuB,GAAG,IAAA,6BAAiB,EACtD,+DAA+D,EAC/D,KAAK,EAAE,KAAK,EAAE,EAAE;;IACd,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACjD,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM;QAAE,OAAM;IAC7B,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAM;IACrC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,CAAA;IACnD,IAAI,CAAC,SAAS;QAAE,OAAM;IAEtB,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACxE,MAAM,WAAW,GAAG,cAAc,CAAC,IAAI,EAAE,CAAA;IACzC,yCAAyC;IACzC,IAAI,CAAA,WAAW,aAAX,WAAW,uBAAX,WAAW,CAAE,oBAAoB,MAAK,KAAK,EAAE,CAAC;QAChD,YAAM,CAAC,GAAG,CAAC,6BAA6B,SAAS,gCAAgC,CAAC,CAAA;QAClF,OAAM;IACR,CAAC;IAED,iFAAiF;IACjF,IAAI,CAAC,CAAC,MAAM,IAAA,uBAAS,EAAC,IAAA,uBAAS,EAAC,EAAE,EAAE,QAAQ,EAAE,gBAAgB,MAAM,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACpF,YAAM,CAAC,GAAG,CAAC,kDAAkD,MAAM,OAAO,MAAM,YAAY,CAAC,CAAA;QAC7F,OAAM;IACR,CAAC;IAED,gGAAgG;IAChG,MAAM,iBAAiB,GAAG,MAAM,EAAE;SAC/B,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;SACnC,UAAU,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;SAC1C,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IAC7C,MAAM,aAAa,GAAG,iBAAiB,CAAC,MAAM,CAAA;IAE9C,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAC,CAAC,wCAAwC,CAAA;IAC5G,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,+BAA+B,CAAC,CAAC,CAAC,+BAA+B,CAAA;IAC9F,MAAM,IAAI,GAAG,aAAa,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,yBAAyB,CAAA;IAEhF,uEAAuE;IACvE,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,qEAAqE;IACrE,IAAI,IAAA,kCAAqB,EAAC,WAAW,CAAC,EAAE,CAAC;QACvC,YAAM,CAAC,GAAG,CAAC,6BAA6B,SAAS,iDAAiD,CAAC,CAAA;QACnG,OAAM;IACR,CAAC;IAED,MAAM,YAAY,GAAG,MAAA,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAEtF,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,SAAS,EACT;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,kBACF,IAAI,EACJ,SAAS,EAAE,QAAQ,EACnB,OAAO,EAAE,MAAM,IACZ,CAAC,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC;YAC7D,CAAC,CAAC,EAAE,iBAAiB,EAAE,YAAY,EAAE;YACrC,CAAC,CAAC,EAAE,CAAC,CACR;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;KACvE,EACD,WAAW,CACZ,CAAA;AACH,CAAC,CACF,CAAA"}

View File

@ -1,303 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const log_1 = require("../log");
/**
* Firestore trigger that notifies partners when a game session is created or completed.
*
* Path: couples/{coupleId}/sessions/{sessionId}
* Condition: onWrite (create, update, delete)
*/
exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/sessions/{sessionId}', async (event) => {
var _a, _b, _c, _d, _e, _f, _g, _h;
const { coupleId, sessionId } = event.params;
// The per-couple active-session lock lives at sessions/_active — it is a pointer, not a
// game session, so it must never produce a partner notification.
if (sessionId === '_active')
return;
const change = event.data;
if (!change)
return;
if (!change.after.exists)
return; // deletion — nothing to notify (skip the four reads below)
const db = admin.firestore();
const messaging = admin.messaging();
// Get the session document
const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get();
const session = sessionDoc.data();
if (!session) {
log_1.logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`);
return;
}
// Get couple info
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
log_1.logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`);
return;
}
const coupleData = (_a = coupleDoc.data()) !== null && _a !== void 0 ? _a : {};
const userIds = ((_b = coupleData.userIds) !== null && _b !== void 0 ? _b : []);
if (userIds.length !== 2) {
log_1.logger.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`);
return;
}
const partnerA = userIds[0];
const partnerB = userIds[1];
const userA = await db.collection('users').doc(partnerA).get();
const userB = await db.collection('users').doc(partnerB).get();
// displayName is E2EE in users/{uid}, so the OS-rendered push uses a generic label; the app shows
// the real name in-app (resolved locally). Avatar (photoUrl) stays plaintext and is still sent.
const partnerAName = 'Your partner';
const partnerBName = 'Your partner';
const avatarA = (_c = userA.data()) === null || _c === void 0 ? void 0 : _c.photoUrl;
const avatarB = (_d = userB.data()) === null || _d === void 0 ? void 0 : _d.photoUrl;
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
const dataFor = (uid) => (uid === partnerA ? userA.data() : userB.data());
const currentData = (_e = change.after.data()) !== null && _e !== void 0 ? _e : {};
const status = currentData.status;
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
// Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the
// change.before→change.after status diff. The atomic session start (F-RACE-001) writes the
// session doc AND the sessions/_active pointer in a single transaction; transactional writes
// can be delivered to onWrite with change.before === change.after (the "Snapshot has no
// readTime" path), which made the inactive→active edge unreliable and intermittently dropped
// the partner_started_game push. Claiming a flag inside a transaction makes each notification
// fire exactly once no matter how the event is delivered (and prevents double-sends).
// ── New session started ──────────────────────────────────────────────
if (status === 'active' && !currentData.startNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d || d.status !== 'active' || d.startNotifiedAt)
return false;
tx.update(sessionRef, { startNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (claimed) {
const startedBy = currentData.startedByUserId;
const gameType = (_f = currentData.gameType) !== null && _f !== void 0 ? _f : 'wheel';
const recipientId = startedBy === partnerA ? partnerB : partnerA;
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
if ((0, quietHours_1.recipientInQuietHours)(dataFor(recipientId))) {
log_1.logger.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`);
}
else {
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId);
}
}
return;
}
// ── Partner joined an active session ─────────────────────────────────
// The non-starter opening the session writes their uid into `joinedByUsers` (client, rule-gated).
// Notify the STARTER once, with the joiner's name + avatar. One-time via `joinNotifiedAt`
// (server-only flag, claimed in a transaction — same pattern as start/finishNotifiedAt).
if (status === 'active' && !currentData.joinNotifiedAt && Array.isArray(currentData.joinedByUsers)) {
const startedBy = currentData.startedByUserId;
const joiner = currentData.joinedByUsers.find((u) => u && u !== startedBy);
if (joiner) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d || d.status !== 'active' || d.joinNotifiedAt)
return false;
const j = Array.isArray(d.joinedByUsers) ? d.joinedByUsers : [];
if (!j.some((u) => u && u !== d.startedByUserId))
return false;
tx.update(sessionRef, { joinNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (claimed) {
const gameType = (_g = currentData.gameType) !== null && _g !== void 0 ? _g : 'wheel';
const joinerName = joiner === partnerA ? partnerAName : partnerBName;
const joinerAvatar = joiner === partnerA ? avatarA : avatarB;
if ((0, quietHours_1.recipientInQuietHours)(dataFor(startedBy))) {
log_1.logger.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`);
}
else {
await notifyPartner(db, messaging, startedBy, joinerName, gameType, 'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId, joinerAvatar, sessionId);
}
}
}
return;
}
// ── Session completed (reveal ready for both) ────────────────────────
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : [];
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt)
return false;
tx.update(sessionRef, { finishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (claimed) {
const gt = (_h = currentData.gameType) !== null && _h !== void 0 ? _h : 'wheel';
// Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerA))) {
log_1.logger.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`);
}
else {
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId);
}
if ((0, quietHours_1.recipientInQuietHours)(dataFor(partnerB))) {
log_1.logger.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`);
}
else {
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId);
}
}
return;
}
});
/**
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
*
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
* Between first-finish and both-finish the waiting partner got NOTHING they never learned it was
* their turn (the symptom: "X finished a game but the partner was never notified"). The
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
*
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
*
* Deployed as four narrow, explicitly-pathed triggers (one per async-game collection) sharing the
* handler below, rather than one broad `{gameType}` wildcard the wildcard fired a (no-op)
* invocation on every write to ANY couple subcollection (messages, reactions, ); the narrow paths
* only fire for the four game collections.
*/
async function handleGamePartFinished(coupleId, gameType, sessionId, change) {
var _a, _b, _c, _d, _e;
if (!(change === null || change === void 0 ? void 0 : change.after.exists))
return;
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {});
const answerUids = Object.keys(answers);
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
// partner_finished_game instead.
if (answerUids.length !== 1)
return;
const finisherUid = answerUids[0];
const db = admin.firestore();
const messaging = admin.messaging();
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d)
return false;
if (d.status === 'completed' || d.partFinishNotifiedAt)
return false;
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (!claimed)
return;
const coupleDoc = await db.collection('couples').doc(coupleId).get();
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const recipient = userIds.find((u) => u !== finisherUid);
if (!recipient)
return;
const finisher = await db.collection('users').doc(finisherUid).get();
const finisherName = 'Your partner'; // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
}
exports.onThisOrThatPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/this_or_that/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'this_or_that', event.params.sessionId, event.data));
exports.onWheelPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/wheel/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'wheel', event.params.sessionId, event.data));
exports.onHowWellPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/how_well/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'how_well', event.params.sessionId, event.data));
exports.onDesireSyncPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/desire_sync/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'desire_sync', event.params.sessionId, event.data));
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType) {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers';
case 'desire_sync': return 'Your turn — only mutual yeses ever show';
case 'this_or_that': return 'Your turn — see where you line up';
default: return 'Your turn — reveal how you line up';
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/
async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl, sessionId) {
const title = notificationType === 'partner_finished_game'
? `${partnerName} finished the game`
: notificationType === 'partner_completed_part'
? `${partnerName} finished their part`
: notificationType === 'partner_joined_game'
? `${partnerName} joined your game`
: `${partnerName} is playing`;
// Write an in-app notification record for the partner
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: notificationType,
title,
body,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, partnerId, {
notification: { title, body },
// Put backgrounded notifications on the Games channel instead of the FCM fallback channel,
// so importance/sound and the per-category toggle apply. E-OBS.
android: { notification: { channelId: 'game_activity' } },
data: Object.assign(Object.assign({ type: notificationType, couple_id: coupleId, game_type: gameType,
// The acting partner's display name (public; also in the title) so the in-app foreground
// banner can name them instead of a generic "Your partner".
sender_name: partnerName }, (sessionId ? { game_session_id: sessionId } : {})), (senderAvatarUrl && senderAvatarUrl.length > 0
? { sender_avatar_url: senderAvatarUrl }
: {})),
});
}
//# sourceMappingURL=onGameSessionUpdate.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,125 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleKeyRotated = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.cleanupExpiredRestoreRequests = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined.
require("./options");
const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a
// single idempotent init here is sufficient and avoids "already exists" errors.
if (admin.apps.length === 0) {
admin.initializeApp();
}
// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
// must exist before any deploy. Seed it with:
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");
Object.defineProperty(exports, "onEntitlementChanged", { enumerable: true, get: function () { return onEntitlementChanged_1.onEntitlementChanged; } });
var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable");
Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } });
var sendThinkingOfYouCallable_1 = require("./notifications/sendThinkingOfYouCallable");
Object.defineProperty(exports, "sendThinkingOfYouCallable", { enumerable: true, get: function () { return sendThinkingOfYouCallable_1.sendThinkingOfYouCallable; } });
var gameRetention_1 = require("./notifications/gameRetention");
Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, get: function () { return gameRetention_1.sendChallengeDayReminders; } });
Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } });
var dailyQuestionReminder_1 = require("./notifications/dailyQuestionReminder");
Object.defineProperty(exports, "sendDailyQuestionProactiveReminder", { enumerable: true, get: function () { return dailyQuestionReminder_1.sendDailyQuestionProactiveReminder; } });
var streakReminder_1 = require("./notifications/streakReminder");
Object.defineProperty(exports, "sendStreakReminder", { enumerable: true, get: function () { return streakReminder_1.sendStreakReminder; } });
var reengagement_1 = require("./notifications/reengagement");
Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, get: function () { return reengagement_1.sendReengagementReminder; } });
var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
var createDateMatch_1 = require("./dates/createDateMatch");
Object.defineProperty(exports, "notifyOnDateMatch", { enumerable: true, get: function () { return createDateMatch_1.notifyOnDateMatch; } });
var onDateReflectionWritten_1 = require("./dates/onDateReflectionWritten");
Object.defineProperty(exports, "onDateReflectionWritten", { enumerable: true, get: function () { return onDateReflectionWritten_1.onDateReflectionWritten; } });
var onDateReflectionRevealed_1 = require("./dates/onDateReflectionRevealed");
Object.defineProperty(exports, "onDateReflectionRevealed", { enumerable: true, get: function () { return onDateReflectionRevealed_1.onDateReflectionRevealed; } });
var onDateHistoryCreated_1 = require("./dates/onDateHistoryCreated");
Object.defineProperty(exports, "onDateHistoryCreated", { enumerable: true, get: function () { return onDateHistoryCreated_1.onDateHistoryCreated; } });
var onRestoreRequested_1 = require("./backup/onRestoreRequested");
Object.defineProperty(exports, "onRestoreRequested", { enumerable: true, get: function () { return onRestoreRequested_1.onRestoreRequested; } });
Object.defineProperty(exports, "onRestoreFulfilled", { enumerable: true, get: function () { return onRestoreRequested_1.onRestoreFulfilled; } });
var cleanupRestoreRequests_1 = require("./backup/cleanupRestoreRequests");
Object.defineProperty(exports, "cleanupExpiredRestoreRequests", { enumerable: true, get: function () { return cleanupRestoreRequests_1.cleanupExpiredRestoreRequests; } });
var assignDailyQuestion_1 = require("./questions/assignDailyQuestion");
Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } });
Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });
var onAnswerWritten_1 = require("./questions/onAnswerWritten");
Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.onAnswerWritten; } });
var onAnswerRevealed_1 = require("./questions/onAnswerRevealed");
Object.defineProperty(exports, "onAnswerRevealed", { enumerable: true, get: function () { return onAnswerRevealed_1.onAnswerRevealed; } });
var onMessageWritten_1 = require("./questions/onMessageWritten");
Object.defineProperty(exports, "onMessageWritten", { enumerable: true, get: function () { return onMessageWritten_1.onMessageWritten; } });
var onCoupleLeave_1 = require("./couples/onCoupleLeave");
Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } });
var onCoupleKeyRotated_1 = require("./couples/onCoupleKeyRotated");
Object.defineProperty(exports, "onCoupleKeyRotated", { enumerable: true, get: function () { return onCoupleKeyRotated_1.onCoupleKeyRotated; } });
var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable");
Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } });
var acceptInviteCallable_1 = require("./couples/acceptInviteCallable");
Object.defineProperty(exports, "acceptInviteCallable", { enumerable: true, get: function () { return acceptInviteCallable_1.acceptInviteCallable; } });
var createInviteCallable_1 = require("./couples/createInviteCallable");
Object.defineProperty(exports, "createInviteCallable", { enumerable: true, get: function () { return createInviteCallable_1.createInviteCallable; } });
var submitOutcomeCallable_1 = require("./couples/submitOutcomeCallable");
Object.defineProperty(exports, "submitOutcomeCallable", { enumerable: true, get: function () { return submitOutcomeCallable_1.submitOutcomeCallable; } });
var aggregateOutcomes_1 = require("./couples/aggregateOutcomes");
Object.defineProperty(exports, "aggregateOutcomeStats", { enumerable: true, get: function () { return aggregateOutcomes_1.aggregateOutcomeStats; } });
var scheduledOutcomesReminder_1 = require("./couples/scheduledOutcomesReminder");
Object.defineProperty(exports, "scheduledOutcomesReminder", { enumerable: true, get: function () { return scheduledOutcomesReminder_1.scheduledOutcomesReminder; } });
var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } });
Object.defineProperty(exports, "onThisOrThatPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onThisOrThatPartFinished; } });
Object.defineProperty(exports, "onWheelPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onWheelPartFinished; } });
Object.defineProperty(exports, "onHowWellPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onHowWellPartFinished; } });
Object.defineProperty(exports, "onDesireSyncPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onDesireSyncPartFinished; } });
var wrapReleaseKeyCallable_1 = require("./releaseKey/wrapReleaseKeyCallable");
Object.defineProperty(exports, "wrapReleaseKeyCallable", { enumerable: true, get: function () { return wrapReleaseKeyCallable_1.wrapReleaseKeyCallable; } });
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
// was removed to shrink attack surface. Deployment can be verified via
// `firebase functions:list`. If an uptime probe is ever needed, re-add it behind
// auth / a shared secret rather than as an open endpoint.
//# sourceMappingURL=index.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,iGAAiG;AACjG,8CAA8C;AAC9C,6DAA6D;AAC7D,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,0EAA+E;AAAtE,uIAAA,6BAA6B,OAAA;AACtC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,mEAAiE;AAAxD,wHAAA,kBAAkB,OAAA;AAC3B,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

49
functions/dist/log.js vendored
View File

@ -1,49 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.logger = void 0;
exports.redactToken = redactToken;
const logger = __importStar(require("firebase-functions/logger"));
exports.logger = logger;
/**
* FCM registration tokens are device secrets never log them in full. Use this to reduce a token
* to a short, non-identifying suffix for correlation in logs.
*/
function redactToken(token) {
if (!token)
return '(none)';
return token.length <= 6 ? '***' : `${token.slice(-6)}`;
}
//# sourceMappingURL=log.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAaA,kCAGC;AAhBD,kEAAmD;AAO1C,wBAAM;AAEf;;;GAGG;AACH,SAAgB,WAAW,CAAC,KAAgC;IAC1D,IAAI,CAAC,KAAK;QAAE,OAAO,QAAQ,CAAA;IAC3B,OAAO,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;AAC1D,CAAC"}

View File

@ -1,166 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendDailyQuestionProactiveReminder = void 0;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
const log_1 = require("../log");
/**
* Proactive daily question reminder.
*
* Schedule: 4:00 PM America/Chicago (2 hours before the question expires at 6 PM).
*
* Logic:
* 1. Query all `daily_question` docs (collection group) with expiresAt in the
* next 3 hours these are the ones expiring today.
* 2. For each, count the answers subcollection. If 0 answers (neither partner
* has responded), the couple needs a nudge.
* 3. Skip couples where a gentle_reminder or automated_daily_reminder was
* already sent today (avoid over-notifying couples where a partner already
* manually nudged the other).
* 4. Send FCM to all users in the couple + write to notification_queue.
* 5. Record automated_daily_reminder/{docId} so this function is idempotent
* on re-runs.
*/
exports.sendDailyQuestionProactiveReminder = (0, scheduler_1.onSchedule)({ schedule: '0 16 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
const db = admin.firestore();
const messaging = admin.messaging();
const now = Date.now();
const threeHoursMs = 3 * 60 * 60 * 1000;
// Docs expiring within the next 3 hours — the active window for today's question.
const expiringSnap = await db
.collectionGroup('daily_question')
.where('expiresAt', '>', admin.firestore.Timestamp.fromMillis(now))
.where('expiresAt', '<', admin.firestore.Timestamp.fromMillis(now + threeHoursMs))
.get();
let notified = 0;
let skipped = 0;
// Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(expiringSnap.docs.map(async (questionDoc) => {
var _a, _b;
const coupleRef = questionDoc.ref.parent.parent;
if (!coupleRef)
return;
const reminderId = `auto_${questionDoc.id}`;
const reminderRef = coupleRef.collection('daily_reminders').doc(reminderId);
// Idempotency: skip if we already sent this reminder.
const alreadySent = await reminderRef.get();
if (alreadySent.exists) {
skipped++;
return;
}
// Skip if a manual gentle_reminder was sent today (same date key).
const dateKey = questionDoc.id; // daily_question docs are keyed by date (YYYY-MM-DD)
const gentleRef = coupleRef.collection('gentle_reminders').doc(dateKey);
const gentleSent = await gentleRef.get();
if (gentleSent.exists) {
skipped++;
return;
}
// Check answer count — only remind if nobody has answered yet.
const answersSnap = await questionDoc.ref.collection('answers').limit(1).get();
if (!answersSnap.empty) {
skipped++;
return;
}
// Fetch couple members.
const coupleDoc = await coupleRef.get();
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
if (userIds.length === 0) {
skipped++;
return;
}
// Claim the reminder slot atomically.
try {
await db.runTransaction(async (tx) => {
const fresh = await tx.get(reminderRef);
if (fresh.exists)
throw new Error('already_sent');
tx.set(reminderRef, {
sentAt: admin.firestore.FieldValue.serverTimestamp(),
questionDate: dateKey,
});
});
}
catch (_c) {
skipped++;
return;
}
// Send FCM + notification_queue entry to every user in the couple.
await Promise.allSettled(userIds.map((userId) => sendReminder(db, messaging, userId, coupleRef.id, dateKey)));
notified += userIds.length;
}));
log_1.logger.log(`[sendDailyQuestionProactiveReminder] scanned ${expiringSnap.size} docs; notified ${notified} users; skipped ${skipped}`);
});
async function sendReminder(db, messaging, userId, coupleId, questionDate) {
const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data();
// Respect the user's Daily Reminder toggle (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifDailyReminder) === false) {
log_1.logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — daily reminder off`);
return;
}
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
log_1.logger.log(`[sendDailyQuestionProactiveReminder] skip ${userId} — quiet hours`);
return;
}
// In-app notification record.
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add({
type: 'daily_question_reminder',
title: "Tonight's question is waiting.",
body: 'Answer together before it expires.',
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, userId, {
notification: {
title: "Tonight's question is waiting.",
body: 'Answer together before it expires.',
},
android: { notification: { channelId: 'reminders' } }, // E-OBS
data: {
type: 'daily_question_reminder',
couple_id: coupleId,
question_date: questionDate,
},
}, userData);
}
//# sourceMappingURL=dailyQuestionReminder.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"dailyQuestionReminder.js","sourceRoot":"","sources":["../../src/notifications/dailyQuestionReminder.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,+DAA4D;AAC5D,6CAAoD;AACpD,iCAAuC;AACvC,gCAA+B;AAE/B;;;;;;;;;;;;;;;;GAgBG;AACU,QAAA,kCAAkC,GAAG,IAAA,sBAAU,EAC1D,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,iBAAiB,EAAE,cAAc,EAAE,GAAG,EAAE,EAC5E,KAAK,IAAI,EAAE;IACT,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IACnC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IACtB,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IAEvC,kFAAkF;IAClF,MAAM,YAAY,GAAG,MAAM,EAAE;SAC1B,eAAe,CAAC,gBAAgB,CAAC;SACjC,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;SAClE,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,GAAG,YAAY,CAAC,CAAC;SACjF,GAAG,EAAE,CAAA;IAER,IAAI,QAAQ,GAAG,CAAC,CAAA;IAChB,IAAI,OAAO,GAAG,CAAC,CAAA;IAEf,gFAAgF;IAChF,MAAM,OAAO,CAAC,UAAU,CACtB,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE;;QAC1C,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAA;QAC/C,IAAI,CAAC,SAAS;YAAE,OAAM;QAEtB,MAAM,UAAU,GAAG,QAAQ,WAAW,CAAC,EAAE,EAAE,CAAA;QAC3C,MAAM,WAAW,GAAG,SAAS,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;QAE3E,sDAAsD;QACtD,MAAM,WAAW,GAAG,MAAM,WAAW,CAAC,GAAG,EAAE,CAAA;QAC3C,IAAI,WAAW,CAAC,MAAM,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAE7C,mEAAmE;QACnE,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAA,CAAC,qDAAqD;QACpF,MAAM,SAAS,GAAG,SAAS,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;QACvE,MAAM,UAAU,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;QACxC,IAAI,UAAU,CAAC,MAAM,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAE5C,+DAA+D;QAC/D,MAAM,WAAW,GAAG,MAAM,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAA;QAC9E,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAE7C,wBAAwB;QACxB,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;QACvC,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;QAC7D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAAC,OAAO,EAAE,CAAC;YAAC,OAAM;QAAC,CAAC;QAE/C,sCAAsC;QACtC,IAAI,CAAC;YACH,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;gBACnC,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;gBACvC,IAAI,KAAK,CAAC,MAAM;oBAAE,MAAM,IAAI,KAAK,CAAC,cAAc,CAAC,CAAA;gBACjD,EAAE,CAAC,GAAG,CAAC,WAAW,EAAE;oBAClB,MAAM,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;oBACpD,YAAY,EAAE,OAAO;iBACtB,CAAC,CAAA;YACJ,CAAC,CAAC,CAAA;QACJ,CAAC;QAAC,WAAM,CAAC;YACP,OAAO,EAAE,CAAA;YACT,OAAM;QACR,CAAC;QAED,mEAAmE;QACnE,MAAM,OAAO,CAAC,UAAU,CACtB,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CACrB,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,EAAE,OAAO,CAAC,CAC3D,CACF,CAAA;QACD,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAA;IAC5B,CAAC,CAAC,CACH,CAAA;IAED,YAAM,CAAC,GAAG,CACR,gDAAgD,YAAY,CAAC,IAAI,mBAAmB,QAAQ,mBAAmB,OAAO,EAAE,CACzH,CAAA;AACH,CAAC,CACF,CAAA;AAED,KAAK,UAAU,YAAY,CACzB,EAA6B,EAC7B,SAAoC,EACpC,MAAc,EACd,QAAgB,EAChB,YAAoB;IAEpB,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAA;IAC9D,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,EAAE,CAAA;IAE/B,yEAAyE;IACzE,IAAI,CAAA,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAE,kBAAkB,MAAK,KAAK,EAAE,CAAC;QAC3C,YAAM,CAAC,GAAG,CAAC,6CAA6C,MAAM,uBAAuB,CAAC,CAAA;QACtF,OAAM;IACR,CAAC;IACD,IAAI,IAAA,kCAAqB,EAAC,QAAQ,CAAC,EAAE,CAAC;QACpC,YAAM,CAAC,GAAG,CAAC,6CAA6C,MAAM,gBAAgB,CAAC,CAAA;QAC/E,OAAM;IACR,CAAC;IAED,8BAA8B;IAC9B,MAAM,EAAE;SACL,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,MAAM,CAAC;SACX,UAAU,CAAC,oBAAoB,CAAC;SAChC,GAAG,CAAC;QACH,IAAI,EAAE,yBAAyB;QAC/B,KAAK,EAAE,gCAAgC;QACvC,IAAI,EAAE,oCAAoC;QAC1C,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEJ,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,SAAS,EACT,MAAM,EACN;QACE,YAAY,EAAE;YACZ,KAAK,EAAE,gCAAgC;YACvC,IAAI,EAAE,oCAAoC;SAC3C;QACD,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE,EAAE,QAAQ;QAC/D,IAAI,EAAE;YACJ,IAAI,EAAE,yBAAyB;YAC/B,SAAS,EAAE,QAAQ;YACnB,aAAa,EAAE,YAAY;SAC5B;KACF,EACD,QAAQ,CACT,CAAA;AACH,CAAC"}

View File

@ -1,213 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
const log_1 = require("../log");
const DAY_MS = 24 * 60 * 60 * 1000;
const CHALLENGE_TITLES = {
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 },
quality_time: { title: 'Quality Time', durationDays: 7 },
deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
};
exports.unlockDueMemoryCapsules = (0, scheduler_1.onSchedule)('every 1 hours', async () => {
const db = admin.firestore();
const messaging = admin.messaging();
const now = Date.now();
const snapshot = await db
.collectionGroup('capsules')
.where('status', '==', 'sealed')
.where('unlockAt', '<=', now)
.limit(100)
.get();
const notifications = [];
for (const capsuleDoc of snapshot.docs) {
const capsuleRef = capsuleDoc.ref;
const coupleRef = capsuleRef.parent.parent;
if (!coupleRef)
continue;
const capsuleNotifications = await db.runTransaction(async (tx) => {
var _a, _b, _c, _d;
const freshCapsule = await tx.get(capsuleRef);
const capsule = (_a = freshCapsule.data()) !== null && _a !== void 0 ? _a : {};
if (capsule.status !== 'sealed' || Number((_b = capsule.unlockAt) !== null && _b !== void 0 ? _b : 0) > now) {
return [];
}
const coupleDoc = await tx.get(coupleRef);
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
if (userIds.length === 0)
return [];
tx.update(capsuleRef, {
status: 'unlocked',
unlockedAt: now,
unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(),
});
const capsuleId = capsuleRef.id;
const coupleId = coupleRef.id;
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
? capsule.title.trim()
: 'A memory capsule';
return userIds.map((userId) => ({
userId,
type: 'memory_capsule_unlocked',
title: 'Your memory capsule opened',
body: `${title} is ready to read together.`,
data: { couple_id: coupleId, capsule_id: capsuleId },
}));
});
notifications.push(...capsuleNotifications);
}
// Per-recipient failures are isolated so one bad send can't abort the batch.
await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)));
log_1.logger.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`);
});
exports.sendChallengeDayReminders = (0, scheduler_1.onSchedule)('every 24 hours', async () => {
const db = admin.firestore();
const messaging = admin.messaging();
const now = Date.now();
const snapshot = await db
.collectionGroup('challenges')
.where('status', '==', 'active')
.limit(100)
.get();
const notifications = [];
for (const challengeDoc of snapshot.docs) {
const challengeRef = challengeDoc.ref;
const coupleRef = challengeRef.parent.parent;
if (!coupleRef)
continue;
const challengeNotifications = await db.runTransaction(async (tx) => {
var _a, _b, _c, _d, _e, _f, _g;
const freshChallenge = await tx.get(challengeRef);
const challenge = (_a = freshChallenge.data()) !== null && _a !== void 0 ? _a : {};
if (challenge.status !== 'active')
return [];
const startedAt = Number((_b = challenge.startedAt) !== null && _b !== void 0 ? _b : 0);
if (startedAt <= 0 || startedAt > now)
return [];
const challengeId = typeof challenge.challengeId === 'string'
? challenge.challengeId
: challengeRef.id;
const catalogEntry = (_c = CHALLENGE_TITLES[challengeId]) !== null && _c !== void 0 ? _c : {
title: 'Connection Challenge',
durationDays: 7,
};
const day = Math.floor((now - startedAt) / DAY_MS) + 1;
if (day < 1 || day > catalogEntry.durationDays)
return [];
const coupleDoc = await tx.get(coupleRef);
const userIds = ((_e = (_d = coupleDoc.data()) === null || _d === void 0 ? void 0 : _d.userIds) !== null && _e !== void 0 ? _e : []);
if (userIds.length === 0)
return [];
const completions = ((_f = challenge.completions) !== null && _f !== void 0 ? _f : {});
const reminderSent = ((_g = challenge.challengeReminderSent) !== null && _g !== void 0 ? _g : {});
const dueUserIds = userIds.filter((userId) => {
var _a;
const completedDays = (_a = completions[userId]) !== null && _a !== void 0 ? _a : [];
const alreadyCompleted = completedDays.map(Number).includes(day);
const alreadySent = reminderSent[reminderKey(userId, day)] === true;
return !alreadyCompleted && !alreadySent;
});
if (dueUserIds.length === 0)
return [];
const updates = {
lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(),
};
dueUserIds.forEach((userId) => {
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true;
});
tx.update(challengeRef, updates);
const coupleId = coupleRef.id;
return dueUserIds.map((userId) => ({
userId,
type: 'challenge_day_ready',
title: `Day ${day} is ready`,
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
data: { couple_id: coupleId, challenge_id: challengeId, day: String(day) },
}));
});
notifications.push(...challengeNotifications);
}
// Per-recipient failures are isolated so one bad send can't abort the batch.
await Promise.allSettled(notifications.map((notification) => sendNotification(db, messaging, notification)));
log_1.logger.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`);
});
function reminderKey(userId, day) {
return `${userId.replace(/[^\w-]/g, '_')}_${day}`;
}
async function sendNotification(db, messaging, notification) {
const userDoc = await db.collection('users').doc(notification.userId).get();
const userData = userDoc.data();
// Challenge-day reminders are retention nudges → respect the promotional opt-out (default on).
// (Memory-capsule unlocks are a genuine couple event, so they are not promotional-gated.)
if (notification.type === 'challenge_day_ready' && (userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) {
log_1.logger.log(`[sendNotification] skip ${notification.userId} — promotional off`);
return;
}
// Honor the recipient's quiet hours for every scheduled push.
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
log_1.logger.log(`[sendNotification] skip ${notification.userId} — quiet hours`);
return;
}
await db
.collection('users')
.doc(notification.userId)
.collection('notification_queue')
.add({
type: notification.type,
title: notification.title,
body: notification.body,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, notification.userId, {
notification: {
title: notification.title,
body: notification.body,
},
// E-OBS: challenge reminders → Reminders channel; capsule-unlocked → partner-activity channel.
android: {
notification: {
channelId: notification.type === 'challenge_day_ready' ? 'reminders' : 'partner_activity',
},
},
data: Object.assign({ type: notification.type }, notification.data),
}, userData);
}
//# sourceMappingURL=gameRetention.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,75 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.claimOnce = claimOnce;
exports.notifMark = notifMark;
const admin = __importStar(require("firebase-admin"));
const log_1 = require("../log");
/** gRPC status for a create() that hit an existing document. */
const ALREADY_EXISTS = 6;
/**
* Best-effort one-time claim for a notification event, keyed by a deterministic marker doc.
*
* Background triggers are delivered AT LEAST ONCE, so a redelivery of the same event would
* otherwise double-send a push. `create()` is an atomic create-if-absent: the first delivery
* writes the marker and returns true; a redelivery finds it present and returns false, so the
* caller skips. Claim right before sending a suppressed (quiet-hours / opted-out) notification
* should not burn a claim.
*
* Fail-OPEN: if create() fails for any reason OTHER than "already claimed", return true and let
* the notification proceed. A rare duplicate is better UX than a silently dropped ping, and it
* keeps an infra blip on the marker write from swallowing every notification. The trade this makes
* explicit: at-least-once at-most-once (a send failure AFTER the claim drops that one push).
*/
async function claimOnce(markRef) {
try {
await markRef.create({ claimedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
}
catch (e) {
if (e.code === ALREADY_EXISTS)
return false;
log_1.logger.warn('[claimOnce] marker create failed; proceeding without dedupe', {
path: markRef.path,
error: String(e),
});
return true;
}
}
/** Standard per-couple marker ref for a notification event (`couples/{id}/notif_marks/{markId}`). */
function notifMark(db, coupleId, markId) {
return db.collection('couples').doc(coupleId).collection('notif_marks').doc(markId);
}
//# sourceMappingURL=idempotency.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../../src/notifications/idempotency.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoBA,8BAYC;AAGD,8BAMC;AAzCD,sDAAuC;AACvC,gCAA+B;AAE/B,gEAAgE;AAChE,MAAM,cAAc,GAAG,CAAC,CAAA;AAExB;;;;;;;;;;;;;GAaG;AACI,KAAK,UAAU,SAAS,CAAC,OAA0C;IACxE,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE,EAAE,CAAC,CAAA;QACjF,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAK,CAAuB,CAAC,IAAI,KAAK,cAAc;YAAE,OAAO,KAAK,CAAA;QAClE,YAAM,CAAC,IAAI,CAAC,6DAA6D,EAAE;YACzE,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;SACjB,CAAC,CAAA;QACF,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED,qGAAqG;AACrG,SAAgB,SAAS,CACvB,EAA6B,EAC7B,QAAgB,EAChB,MAAc;IAEd,OAAO,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;AACrF,CAAC"}

View File

@ -1,19 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const idempotency_1 = require("./idempotency");
function markRef(create) {
return { path: 'couples/c1/notif_marks/m1', create };
}
describe('claimOnce', () => {
it('claims (true) when the marker did not exist', async () => {
expect(await (0, idempotency_1.claimOnce)(markRef(async () => undefined))).toBe(true);
});
it('does not claim (false) when the marker already exists (ALREADY_EXISTS = 6)', async () => {
expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject({ code: 6 })))).toBe(false);
});
it('fails open (true) on any other create error so a ping is not silently dropped', async () => {
expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject({ code: 14 })))).toBe(true);
expect(await (0, idempotency_1.claimOnce)(markRef(async () => Promise.reject(new Error('boom'))))).toBe(true);
});
});
//# sourceMappingURL=idempotency.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"idempotency.test.js","sourceRoot":"","sources":["../../src/notifications/idempotency.test.ts"],"names":[],"mappings":";;AAAA,+CAAyC;AAEzC,SAAS,OAAO,CAAC,MAA8B;IAC7C,OAAO,EAAE,IAAI,EAAE,2BAA2B,EAAE,MAAM,EAAS,CAAA;AAC7D,CAAC;AAED,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IACzB,EAAE,CAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;QAC3D,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4EAA4E,EAAE,KAAK,IAAI,EAAE;QAC1F,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACvF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+EAA+E,EAAE,KAAK,IAAI,EAAE;QAC7F,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACrF,MAAM,CAAC,MAAM,IAAA,uBAAS,EAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC5F,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -1,125 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.isDeadTokenError = isDeadTokenError;
exports.selectDeadTokens = selectDeadTokens;
exports.pruneDeadTokens = pruneDeadTokens;
const admin = __importStar(require("firebase-admin"));
const log_1 = require("../log");
/**
* FCM error codes that mean a token is *permanently* dead and safe to delete.
*
* Deliberately narrow. We only prune on errors that unambiguously blame the TOKEN:
* - `registration-token-not-registered` the app was uninstalled / data-cleared / token rotated.
* - `invalid-registration-token` the token string itself is malformed.
* We intentionally do NOT include `messaging/invalid-argument` or any transient/server error
* (`unavailable`, `internal`, `quota-exceeded`, auth issues): those can be caused by a bad *payload*
* or a temporary outage, and treating them as dead would let one bug wipe every user's tokens.
*/
const DEAD_TOKEN_CODES = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
]);
/** Extract the `messaging/*` code from a firebase-admin messaging rejection, wherever it lives. */
function messagingErrorCode(reason) {
var _a, _b;
const r = reason;
const code = (_b = (_a = r === null || r === void 0 ? void 0 : r.errorInfo) === null || _a === void 0 ? void 0 : _a.code) !== null && _b !== void 0 ? _b : r === null || r === void 0 ? void 0 : r.code;
return typeof code === 'string' ? code : undefined;
}
/** True only when an FCM send rejection means the token is permanently invalid (safe to delete). */
function isDeadTokenError(reason) {
const code = messagingErrorCode(reason);
return code !== undefined && DEAD_TOKEN_CODES.has(code);
}
/**
* Pure: given the same `tokens` array and `Promise.allSettled` results a caller already has, return the
* distinct token strings that FCM reported as permanently dead. Index `i` in results maps to `tokens[i]`.
*/
function selectDeadTokens(tokens, results) {
const dead = new Set();
results.forEach((r, i) => {
const token = tokens[i];
if (r.status === 'rejected' && token && isDeadTokenError(r.reason))
dead.add(token);
});
return [...dead];
}
/**
* Best-effort removal of tokens FCM reported as permanently dead. Pass the `tokens` array and the
* `Promise.allSettled` results from the send you just performed; any dead token is deleted from the
* user's `fcmTokens` subcollection and cleared from the legacy `fcmToken` field if it matches.
*
* Never throws pruning is housekeeping and must never fail the notification path it runs after.
* Returns the number of token records removed.
*/
async function pruneDeadTokens(db, uid, tokens, results) {
var _a;
const dead = new Set(selectDeadTokens(tokens, results));
if (dead.size === 0)
return 0;
try {
const userRef = db.collection('users').doc(uid);
const [tokenSnap, userDoc] = await Promise.all([
userRef.collection('fcmTokens').get(),
userRef.get(),
]);
const batch = db.batch();
let removed = 0;
tokenSnap.docs.forEach((d) => {
var _a;
const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && dead.has(t)) {
batch.delete(d.ref);
removed++;
}
});
const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
if (typeof legacy === 'string' && dead.has(legacy)) {
batch.update(userRef, { fcmToken: admin.firestore.FieldValue.delete() });
removed++;
}
if (removed > 0) {
await batch.commit();
log_1.logger.log(`[pruneDeadTokens] uid=${uid} removed ${removed} dead token(s)`);
}
return removed;
}
catch (e) {
log_1.logger.warn(`[pruneDeadTokens] uid=${uid} prune failed (ignored)`, { error: String(e) });
return 0;
}
}
//# sourceMappingURL=pruneTokens.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"pruneTokens.js","sourceRoot":"","sources":["../../src/notifications/pruneTokens.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,4CAGC;AAMD,4CAUC;AAUD,0CAqCC;AA5FD,sDAAuC;AACvC,gCAA+B;AAE/B;;;;;;;;;GASG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAS;IACvC,6CAA6C;IAC7C,sCAAsC;CACvC,CAAC,CAAA;AAEF,mGAAmG;AACnG,SAAS,kBAAkB,CAAC,MAAe;;IACzC,MAAM,CAAC,GAAG,MAA+E,CAAA;IACzF,MAAM,IAAI,GAAG,MAAA,MAAA,CAAC,aAAD,CAAC,uBAAD,CAAC,CAAE,SAAS,0CAAE,IAAI,mCAAI,CAAC,aAAD,CAAC,uBAAD,CAAC,CAAE,IAAI,CAAA;IAC1C,OAAO,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACpD,CAAC;AAED,oGAAoG;AACpG,SAAgB,gBAAgB,CAAC,MAAe;IAC9C,MAAM,IAAI,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAA;IACvC,OAAO,IAAI,KAAK,SAAS,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AACzD,CAAC;AAED;;;GAGG;AACH,SAAgB,gBAAgB,CAC9B,MAAgB,EAChB,OAAwC;IAExC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAC9B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU,IAAI,KAAK,IAAI,gBAAgB,CAAC,CAAC,CAAC,MAAM,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IACrF,CAAC,CAAC,CAAA;IACF,OAAO,CAAC,GAAG,IAAI,CAAC,CAAA;AAClB,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,eAAe,CACnC,EAA6B,EAC7B,GAAW,EACX,MAAgB,EAChB,OAAwC;;IAExC,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACvD,IAAI,IAAI,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,CAAC,CAAA;IAC7B,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAC/C,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAC7C,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE;YACrC,OAAO,CAAC,GAAG,EAAE;SACd,CAAC,CAAA;QACF,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,EAAE,CAAA;QACxB,IAAI,OAAO,GAAG,CAAC,CAAA;QACf,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;YAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;YACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;gBACzC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;gBACnB,OAAO,EAAE,CAAA;YACX,CAAC;QACH,CAAC,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;QACvC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YACnD,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;YACxE,OAAO,EAAE,CAAA;QACX,CAAC;QACD,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;YAChB,MAAM,KAAK,CAAC,MAAM,EAAE,CAAA;YACpB,YAAM,CAAC,GAAG,CAAC,yBAAyB,GAAG,YAAY,OAAO,gBAAgB,CAAC,CAAA;QAC7E,CAAC;QACD,OAAO,OAAO,CAAA;IAChB,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,YAAM,CAAC,IAAI,CAAC,yBAAyB,GAAG,yBAAyB,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;QACxF,OAAO,CAAC,CAAA;IACV,CAAC;AACH,CAAC"}

View File

@ -1,55 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const pruneTokens_1 = require("./pruneTokens");
// Shapes mirror what firebase-admin's FirebaseMessagingError actually throws (see errorInfo.code),
// plus the `code` getter form. Both must be recognised; transient/server errors must NOT be.
const deadNotRegistered = { errorInfo: { code: 'messaging/registration-token-not-registered' }, codePrefix: 'messaging' };
const deadInvalidToken = { code: 'messaging/invalid-registration-token' };
const transientUnavailable = { errorInfo: { code: 'messaging/server-unavailable' } };
const transientInternal = { code: 'messaging/internal-error' };
const badPayload = { errorInfo: { code: 'messaging/invalid-argument' } }; // payload bug — must NOT prune
const rej = (reason) => ({ status: 'rejected', reason });
const ful = () => ({ status: 'fulfilled', value: 'id' });
describe('isDeadTokenError', () => {
it('is true for permanently-dead token codes (both errorInfo.code and code shapes)', () => {
expect((0, pruneTokens_1.isDeadTokenError)(deadNotRegistered)).toBe(true);
expect((0, pruneTokens_1.isDeadTokenError)(deadInvalidToken)).toBe(true);
});
it('is false for transient/server errors — never prune on these', () => {
expect((0, pruneTokens_1.isDeadTokenError)(transientUnavailable)).toBe(false);
expect((0, pruneTokens_1.isDeadTokenError)(transientInternal)).toBe(false);
});
it('is false for invalid-argument — that blames the payload, not the token', () => {
expect((0, pruneTokens_1.isDeadTokenError)(badPayload)).toBe(false);
});
it('is false for missing/garbage reasons (fail safe)', () => {
expect((0, pruneTokens_1.isDeadTokenError)(undefined)).toBe(false);
expect((0, pruneTokens_1.isDeadTokenError)(null)).toBe(false);
expect((0, pruneTokens_1.isDeadTokenError)({})).toBe(false);
expect((0, pruneTokens_1.isDeadTokenError)('boom')).toBe(false);
expect((0, pruneTokens_1.isDeadTokenError)({ code: 42 })).toBe(false);
});
});
describe('selectDeadTokens', () => {
it('maps rejected-dead results to their token by index', () => {
const tokens = ['A', 'B', 'C'];
const results = [ful(), rej(deadNotRegistered), rej(transientUnavailable)];
expect((0, pruneTokens_1.selectDeadTokens)(tokens, results)).toEqual(['B']);
});
it('ignores fulfilled sends and transient failures', () => {
const tokens = ['A', 'B'];
expect((0, pruneTokens_1.selectDeadTokens)(tokens, [ful(), rej(transientInternal)])).toEqual([]);
});
it('dedupes when the same dead token appears twice', () => {
const tokens = ['A', 'A'];
expect((0, pruneTokens_1.selectDeadTokens)(tokens, [rej(deadNotRegistered), rej(deadInvalidToken)])).toEqual(['A']);
});
it('returns empty when there are no failures', () => {
expect((0, pruneTokens_1.selectDeadTokens)(['A', 'B'], [ful(), ful()])).toEqual([]);
});
it('never selects a token whose index is missing', () => {
// more results than tokens (defensive) — no crash, no phantom token
expect((0, pruneTokens_1.selectDeadTokens)(['A'], [rej(deadNotRegistered), rej(deadNotRegistered)])).toEqual(['A']);
});
});
//# sourceMappingURL=pruneTokens.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"pruneTokens.test.js","sourceRoot":"","sources":["../../src/notifications/pruneTokens.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,82 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getUserTokens = getUserTokens;
exports.batchResponseToResults = batchResponseToResults;
exports.sendPushToUser = sendPushToUser;
const pruneTokens_1 = require("./pruneTokens");
const log_1 = require("../log");
/**
* One canonical FCM-token reader and one canonical sender for the whole codebase.
*
* Before this module, ~10 files re-implemented "read the user's tokens" and ~19 re-implemented
* "send to each token, log failures, prune the dead ones". This collapses both into a single,
* unit-tested unit. Callers keep what is genuinely theirs gating (quiet hours / per-notif
* toggles) and any in-app `notification_queue` write; this module only touches FCM.
*/
/**
* Merge a user's FCM tokens from the legacy `fcmToken` field and the `fcmTokens` subcollection,
* de-duplicated, order-stable (legacy first). Pass `userData` if the user doc is already loaded to
* avoid a redundant read.
*/
async function getUserTokens(db, uid, userData) {
const tokens = [];
const data = userData !== null && userData !== void 0 ? userData : (await db.collection('users').doc(uid).get()).data();
const legacy = data === null || data === void 0 ? void 0 : data.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const snap = await db.collection('users').doc(uid).collection('fcmTokens').get();
snap.docs.forEach((d) => {
var _a;
const t = (_a = d.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t))
tokens.push(t);
});
return tokens;
}
/**
* Adapt a multicast `BatchResponse` to the `PromiseSettledResult[]` shape `pruneDeadTokens`
* consumes (index `i` still maps to `tokens[i]`). Exported for unit testing the dead-token path.
*/
function batchResponseToResults(batch) {
return batch.responses.map((r) => r.success
? { status: 'fulfilled', value: r.messageId }
: { status: 'rejected', reason: r.error });
}
/**
* Send one push to every FCM token a user has, in a single batched `sendEachForMulticast`
* round-trip, then prune any tokens FCM reports as permanently dead. Best-effort: never throws;
* returns a per-user summary. A whole-batch failure (auth/quota, not a per-token death) is logged
* and does not prune anything.
*
* `excludeTokens` skips specific devices e.g. the restore self-alert must not go to the very
* device that requested the restore ("was this you?" to the asker is noise; their partner's and
* their other devices' copies are the signal). Excluding everything yields a clean zero no-op.
*/
async function sendPushToUser(db, messaging, uid, content, userData, excludeTokens) {
let tokens = await getUserTokens(db, uid, userData);
if (excludeTokens && excludeTokens.length > 0) {
tokens = tokens.filter((t) => !excludeTokens.includes(t));
}
if (tokens.length === 0)
return { sent: 0, failed: 0, pruned: 0 };
const message = Object.assign(Object.assign({ tokens, notification: content.notification }, (content.data ? { data: content.data } : {})), (content.android ? { android: content.android } : {}));
let batch;
try {
batch = await messaging.sendEachForMulticast(message);
}
catch (e) {
log_1.logger.warn(`[push] sendEachForMulticast failed for uid=${uid}`, { error: String(e) });
return { sent: 0, failed: tokens.length, pruned: 0 };
}
batch.responses.forEach((r, i) => {
var _a;
if (!r.success) {
log_1.logger.warn(`[push] FCM send failed uid=${uid} token=${(0, log_1.redactToken)(tokens[i])}`, {
code: (_a = r.error) === null || _a === void 0 ? void 0 : _a.code,
});
}
});
const pruned = await (0, pruneTokens_1.pruneDeadTokens)(db, uid, tokens, batchResponseToResults(batch));
return { sent: batch.successCount, failed: batch.failureCount, pruned };
}
//# sourceMappingURL=push.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"push.js","sourceRoot":"","sources":["../../src/notifications/push.ts"],"names":[],"mappings":"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"}

View File

@ -1,166 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const push_1 = require("./push");
const pruneTokens_1 = require("./pruneTokens");
// ── Lightweight Firestore/Messaging fakes ────────────────────────────────────
// Only the surface push.ts + pruneTokens.ts actually touch.
const deadError = { code: 'messaging/registration-token-not-registered' };
const transientError = { code: 'messaging/server-unavailable' };
function makeFakeDb(userData, subTokens) {
const deleted = [];
const commits = { count: 0 };
const fcmDocs = subTokens.map((t, i) => ({
ref: { __token: t, __id: `tok${i}` },
data: () => ({ token: t }),
}));
const userRef = {
get: async () => ({ exists: true, data: () => userData }),
collection: (name) => {
if (name !== 'fcmTokens')
throw new Error(`unexpected subcollection ${name}`);
return { get: async () => ({ docs: fcmDocs }) };
},
};
const db = {
collection: (name) => {
if (name !== 'users')
throw new Error(`unexpected collection ${name}`);
return { doc: () => userRef };
},
batch: () => ({
delete: (ref) => deleted.push(ref.__token),
update: () => deleted.push('__legacy__'),
commit: async () => {
commits.count++;
},
}),
};
return { db, deleted, commits };
}
function batch(responses) {
return {
responses,
successCount: responses.filter((r) => r.success).length,
failureCount: responses.filter((r) => !r.success).length,
};
}
function makeFakeMessaging(response) {
return { sendEachForMulticast: async () => response };
}
describe('getUserTokens', () => {
it('merges the legacy field with the subcollection, deduped and order-stable', async () => {
const { db } = makeFakeDb({ fcmToken: 'A' }, ['A', 'B']);
expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual(['A', 'B']);
});
it('returns only subcollection tokens when there is no legacy field', async () => {
const { db } = makeFakeDb({}, ['B', 'C']);
expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual(['B', 'C']);
});
it('returns empty when the user has no tokens', async () => {
const { db } = makeFakeDb({}, []);
expect(await (0, push_1.getUserTokens)(db, 'u1')).toEqual([]);
});
it('uses provided userData without re-reading the user doc', async () => {
const { db } = makeFakeDb({}, ['B']);
// Make the user-doc read blow up; passing userData must avoid it entirely.
db.collection = (name) => {
if (name !== 'users')
throw new Error('unexpected');
return {
doc: () => ({
get: async () => {
throw new Error('should not read user doc');
},
collection: () => ({ get: async () => ({ docs: [{ data: () => ({ token: 'B' }) }] }) }),
}),
};
};
expect(await (0, push_1.getUserTokens)(db, 'u1', { fcmToken: 'A' })).toEqual(['A', 'B']);
});
});
describe('batchResponseToResults', () => {
it('maps a multicast BatchResponse to settled results that pick out dead tokens by index', () => {
const results = (0, push_1.batchResponseToResults)(batch([
{ success: true, messageId: 'm1' },
{ success: false, error: deadError },
{ success: false, error: transientError },
]));
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'rejected']);
// End-to-end: only the permanently-dead token (index 1) is selected for pruning.
expect((0, pruneTokens_1.selectDeadTokens)(['A', 'B', 'C'], results)).toEqual(['B']);
});
});
describe('sendPushToUser', () => {
it('skips excluded tokens — the requesting device must not get its own "was this you?"', async () => {
const { db, deleted } = makeFakeDb({}, ['A', 'B', 'C']);
const sent = [];
const messaging = {
sendEachForMulticast: async (msg) => {
sent.push(msg.tokens);
return batch(msg.tokens.map((_) => ({ success: true, messageId: 'm' })));
},
};
const result = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }, undefined, ['B']);
expect(sent).toEqual([['A', 'C']]);
expect(result).toEqual({ sent: 2, failed: 0, pruned: 0 });
expect(deleted).toEqual([]);
});
it('excluding every token is a clean zero no-op, never a throw', async () => {
const { db } = makeFakeDb({}, ['A']);
const messaging = {
sendEachForMulticast: async () => {
throw new Error('must not be called with zero tokens');
},
};
const result = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } }, undefined, ['A']);
expect(result).toEqual({ sent: 0, failed: 0, pruned: 0 });
});
it('an absent exclude list changes nothing', async () => {
const { db } = makeFakeDb({}, ['A', 'B']);
const sent = [];
const messaging = {
sendEachForMulticast: async (msg) => {
sent.push(msg.tokens);
return batch(msg.tokens.map(() => ({ success: true, messageId: 'm' })));
},
};
await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } });
expect(sent).toEqual([['A', 'B']]);
});
it('sends to every token and prunes exactly the permanently-dead ones', async () => {
const { db, deleted, commits } = makeFakeDb({ fcmToken: 'LEG' }, ['LEG', 'DEAD', 'GOOD']);
const messaging = makeFakeMessaging(batch([
{ success: true, messageId: 'm1' }, // LEG
{ success: false, error: deadError }, // DEAD
{ success: true, messageId: 'm2' }, // GOOD
]));
const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } });
expect(res).toEqual({ sent: 2, failed: 1, pruned: 1 });
expect(deleted).toEqual(['DEAD']);
expect(commits.count).toBe(1);
});
it('does not prune on transient failures', async () => {
const { db, deleted } = makeFakeDb({}, ['T1']);
const messaging = makeFakeMessaging(batch([{ success: false, error: transientError }]));
const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } });
expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 });
expect(deleted).toEqual([]);
});
it('no-ops cleanly when the user has no tokens', async () => {
const { db } = makeFakeDb({}, []);
const messaging = makeFakeMessaging(batch([]));
expect(await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } })).toEqual({
sent: 0,
failed: 0,
pruned: 0,
});
});
it('treats a whole-batch send failure as non-fatal (no prune)', async () => {
const { db, deleted } = makeFakeDb({}, ['X']);
const messaging = { sendEachForMulticast: async () => Promise.reject(new Error('quota')) };
const res = await (0, push_1.sendPushToUser)(db, messaging, 'u1', { notification: { title: 't', body: 'b' } });
expect(res).toEqual({ sent: 0, failed: 1, pruned: 0 });
expect(deleted).toEqual([]);
});
});
//# sourceMappingURL=push.test.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,65 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.queueAndPush = queueAndPush;
const admin = __importStar(require("firebase-admin"));
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
/**
* Write the durable in-app queue entry (always) and unless quiet hours suppress it push to every
* device. `bypassQuietHours` is for security signals (the "was this you?" self-alert class) that must
* not be silenced.
*
* Extracted from `backup/onRestoreRequested.ts` when the restore-request cleanup sweep needed the same
* behavior one implementation, so queue-entry/quiet-hours/push semantics can't drift between callers.
*/
async function queueAndPush(db, uid, opts) {
const { type, title, body, coupleId, bypassQuietHours, channelId = 'partner_activity', excludeTokens } = opts;
await db.collection('users').doc(uid).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
const userDoc = await db.collection('users').doc(uid).get();
if (!userDoc.exists)
return;
const userData = userDoc.data();
if (!bypassQuietHours && (0, quietHours_1.recipientInQuietHours)(userData))
return;
await (0, push_1.sendPushToUser)(db, admin.messaging(), uid, {
notification: { title, body },
data: { type, couple_id: coupleId },
android: { notification: { channelId } },
}, userData, excludeTokens);
}
//# sourceMappingURL=queueAndPush.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"queueAndPush.js","sourceRoot":"","sources":["../../src/notifications/queueAndPush.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAYA,oCAoCC;AAhDD,sDAAuC;AACvC,6CAAoD;AACpD,iCAAuC;AAEvC;;;;;;;GAOG;AACI,KAAK,UAAU,YAAY,CAChC,EAA6B,EAC7B,GAAW,EACX,IASC;IAED,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,gBAAgB,EAAE,SAAS,GAAG,kBAAkB,EAAE,aAAa,EAAE,GAAG,IAAI,CAAA;IAC7G,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QACzE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxF,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3D,IAAI,CAAC,OAAO,CAAC,MAAM;QAAE,OAAM;IAC3B,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,EAAE,CAAA;IAC/B,IAAI,CAAC,gBAAgB,IAAI,IAAA,kCAAqB,EAAC,QAAQ,CAAC;QAAE,OAAM;IAEhE,MAAM,IAAA,qBAAc,EAClB,EAAE,EACF,KAAK,CAAC,SAAS,EAAE,EACjB,GAAG,EACH;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;QAC7B,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE;QACnC,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,EAAE;KACzC,EACD,QAAQ,EACR,aAAa,CACd,CAAA;AACH,CAAC"}

View File

@ -1,55 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.recipientInQuietHours = recipientInQuietHours;
/**
* Quiet-hours suppression for partner-action pushes.
*
* The Settings UI promises "10 PM 8 AM, no notifications". The client stores the window in local
* DataStore AND mirrors it to the recipient's `users/{uid}` doc (quietHoursEnabled / *StartMinutes /
* *EndMinutes / timezone). Because a partner push carries a `notification` block, the OS shows it
* directly when the recipient app is backgrounded/killed so the only place the promise can be kept
* is server-side, here, before the push is sent (M-001).
*
* FAIL-OPEN by design: if quiet hours is not explicitly enabled, or any field (window/timezone) is
* missing or malformed, we return `false` (do NOT suppress). A bug here can therefore only ever fall
* back to today's behavior (notification delivered) it can never wrongly drop a notification, and
* existing installs keep delivering exactly as before until the client backfills the fields.
*/
function recipientInQuietHours(userData, now = new Date()) {
var _a, _b;
if (!userData || userData.quietHoursEnabled !== true)
return false;
const start = userData.quietHoursStartMinutes;
const end = userData.quietHoursEndMinutes;
const tz = userData.timezone;
if (typeof start !== 'number' || typeof end !== 'number' || typeof tz !== 'string' || !tz) {
return false;
}
let nowMinutes;
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(now);
const hour = Number((_a = parts.find((p) => p.type === 'hour')) === null || _a === void 0 ? void 0 : _a.value) % 24;
const minute = Number((_b = parts.find((p) => p.type === 'minute')) === null || _b === void 0 ? void 0 : _b.value);
if (Number.isNaN(hour) || Number.isNaN(minute))
return false;
nowMinutes = hour * 60 + minute;
}
catch (_c) {
// Unknown/invalid timezone id → fail open.
return false;
}
// Start == end means "no window" (nothing suppressed) — kept in lockstep with the client's
// QuietHoursManager.isInQuietHours so both decide identically.
if (start === end)
return false;
// Window may cross midnight (e.g. 22:00 → 08:00).
return start < end
? nowMinutes >= start && nowMinutes <= end
: nowMinutes >= start || nowMinutes <= end;
}
//# sourceMappingURL=quietHours.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"quietHours.js","sourceRoot":"","sources":["../../src/notifications/quietHours.ts"],"names":[],"mappings":";;AAcA,sDAsCC;AApDD;;;;;;;;;;;;;GAaG;AACH,SAAgB,qBAAqB,CACnC,QAAoD,EACpD,MAAY,IAAI,IAAI,EAAE;;IAEtB,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,iBAAiB,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAElE,MAAM,KAAK,GAAG,QAAQ,CAAC,sBAAsB,CAAA;IAC7C,MAAM,GAAG,GAAG,QAAQ,CAAC,oBAAoB,CAAA;IACzC,MAAM,EAAE,GAAG,QAAQ,CAAC,QAAQ,CAAA;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,CAAC,EAAE,EAAE,CAAC;QAC1F,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,UAAkB,CAAA;IACtB,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;YAC7C,QAAQ,EAAE,EAAE;YACZ,IAAI,EAAE,SAAS;YACf,MAAM,EAAE,SAAS;YACjB,MAAM,EAAE,KAAK;SACd,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;QACrB,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,0CAAE,KAAK,CAAC,GAAG,EAAE,CAAA;QACrE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAA,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,0CAAE,KAAK,CAAC,CAAA;QACpE,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,OAAO,KAAK,CAAA;QAC5D,UAAU,GAAG,IAAI,GAAG,EAAE,GAAG,MAAM,CAAA;IACjC,CAAC;IAAC,WAAM,CAAC;QACP,2CAA2C;QAC3C,OAAO,KAAK,CAAA;IACd,CAAC;IAED,2FAA2F;IAC3F,+DAA+D;IAC/D,IAAI,KAAK,KAAK,GAAG;QAAE,OAAO,KAAK,CAAA;IAE/B,kDAAkD;IAClD,OAAO,KAAK,GAAG,GAAG;QAChB,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG;QAC1C,CAAC,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,IAAI,GAAG,CAAA;AAC9C,CAAC"}

View File

@ -1,60 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const quietHours_1 = require("./quietHours");
// recipientInQuietHours is a pure function (Intl + plain userData), so no firebase-admin mock is needed.
// All times below are expressed in UTC and the userData timezone is 'UTC' for determinism. Minutes:
// 22:00 = 1320, 08:00 = 480, 10:00 = 600, 12:00 = 720.
const at = (iso) => new Date(`2026-01-15T${iso}:00Z`);
describe('recipientInQuietHours', () => {
describe('fail-open guards', () => {
it('returns false when quiet hours is not explicitly enabled', () => {
expect((0, quietHours_1.recipientInQuietHours)(undefined, at('23:30'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)({}, at('23:30'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)({ quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' }, at('23:30'))).toBe(false);
});
it('returns false when window or timezone fields are missing/malformed', () => {
const base = { quietHoursEnabled: true, timezone: 'UTC' };
expect((0, quietHours_1.recipientInQuietHours)(Object.assign(Object.assign({}, base), { quietHoursEndMinutes: 480 }), at('23:30'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(Object.assign(Object.assign({}, base), { quietHoursStartMinutes: 1320 }), at('23:30'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)({ quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480 }, at('23:30'))).toBe(false);
});
it('returns false for an unknown timezone id', () => {
expect((0, quietHours_1.recipientInQuietHours)({ quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'Not/AZone' }, at('23:30'))).toBe(false);
});
});
describe('overnight window (22:00 → 08:00)', () => {
const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 480, timezone: 'UTC' };
it('suppresses inside the window, including both boundaries', () => {
expect((0, quietHours_1.recipientInQuietHours)(qh, at('23:30'))).toBe(true);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('03:00'))).toBe(true);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('22:00'))).toBe(true); // start inclusive
expect((0, quietHours_1.recipientInQuietHours)(qh, at('08:00'))).toBe(true); // end inclusive
});
it('does not suppress outside the window', () => {
expect((0, quietHours_1.recipientInQuietHours)(qh, at('08:01'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('21:59'))).toBe(false);
});
});
describe('same-day window (10:00 → 12:00)', () => {
const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 600, quietHoursEndMinutes: 720, timezone: 'UTC' };
it('suppresses inside the window, including both boundaries', () => {
expect((0, quietHours_1.recipientInQuietHours)(qh, at('10:30'))).toBe(true);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('10:00'))).toBe(true);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(true);
});
it('does not suppress outside the window', () => {
expect((0, quietHours_1.recipientInQuietHours)(qh, at('09:59'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:01'))).toBe(false);
});
});
describe('start == end is "no window"', () => {
it('never suppresses (matches the client QuietHoursManager)', () => {
const qh = { quietHoursEnabled: true, quietHoursStartMinutes: 1320, quietHoursEndMinutes: 1320, timezone: 'UTC' };
expect((0, quietHours_1.recipientInQuietHours)(qh, at('22:00'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('03:00'))).toBe(false);
expect((0, quietHours_1.recipientInQuietHours)(qh, at('12:00'))).toBe(false);
});
});
});
//# sourceMappingURL=quietHours.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"quietHours.test.js","sourceRoot":"","sources":["../../src/notifications/quietHours.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,138 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendReengagementReminder = void 0;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
const log_1 = require("../log");
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
const REENGAGEMENT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000;
/**
* Re-engagement nudge for couples who went quiet.
*
* Schedule: 12:00 PM America/Chicago daily.
*
* Targets couples whose lastAnsweredAt is between 3 and 10 days ago
* recently lapsed but not completely inactive. Respects a 3-day cooldown
* via reengagementSentAt to avoid spamming.
*
* Requires a Firestore composite index on couples: lastAnsweredAt ASC.
*/
exports.sendReengagementReminder = (0, scheduler_1.onSchedule)({ schedule: '0 12 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
const db = admin.firestore();
const messaging = admin.messaging();
const now = Date.now();
const threeDaysAgo = admin.firestore.Timestamp.fromMillis(now - THREE_DAYS_MS);
const tenDaysAgo = admin.firestore.Timestamp.fromMillis(now - TEN_DAYS_MS);
const snap = await db
.collection('couples')
.where('lastAnsweredAt', '>', tenDaysAgo)
.where('lastAnsweredAt', '<', threeDaysAgo)
.limit(200)
.get();
let notified = 0;
let skipped = 0;
// Per-couple failures are isolated so one bad couple can't abort the whole run.
await Promise.allSettled(snap.docs.map(async (coupleDoc) => {
var _a;
const data = coupleDoc.data();
const coupleId = coupleDoc.id;
const userIds = ((_a = data.userIds) !== null && _a !== void 0 ? _a : []);
if (userIds.length === 0) {
skipped++;
return;
}
// Respect cooldown — don't re-send within 3 days.
const sentAt = data.reengagementSentAt;
if (sentAt && now - sentAt.toMillis() < REENGAGEMENT_COOLDOWN_MS) {
skipped++;
return;
}
// Claim atomically so parallel runs don't double-send.
const claimed = await db.runTransaction(async (tx) => {
var _a;
const fresh = await tx.get(coupleDoc.ref);
const freshSentAt = (_a = fresh.data()) === null || _a === void 0 ? void 0 : _a.reengagementSentAt;
if (freshSentAt && now - freshSentAt.toMillis() < REENGAGEMENT_COOLDOWN_MS)
return false;
tx.update(coupleDoc.ref, {
reengagementSentAt: admin.firestore.FieldValue.serverTimestamp(),
});
return true;
});
if (!claimed) {
skipped++;
return;
}
await Promise.allSettled(userIds.map((uid) => sendNudge(db, messaging, uid, coupleId)));
notified += userIds.length;
}));
log_1.logger.log(`[sendReengagementReminder] scanned ${snap.size}; notified ${notified}; skipped ${skipped}`);
});
async function sendNudge(db, messaging, userId, coupleId) {
const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data();
// Re-engagement is a promotional nudge — respect the opt-out (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifPromotional) === false) {
log_1.logger.log(`[sendReengagementReminder] skip ${userId} — promotional off`);
return;
}
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
log_1.logger.log(`[sendReengagementReminder] skip ${userId} — quiet hours`);
return;
}
await db.collection('users').doc(userId).collection('notification_queue').add({
type: 'reengagement',
title: "It's been a while.",
body: "Tonight's question is a good reason to reconnect.",
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, userId, {
notification: {
title: "It's been a while.",
body: "Tonight's question is a good reason to reconnect.",
},
android: { notification: { channelId: 'reminders' } }, // E-OBS
data: {
type: 'reengagement',
couple_id: coupleId,
},
}, userData);
}
//# sourceMappingURL=reengagement.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"reengagement.js","sourceRoot":"","sources":["../../src/notifications/reengagement.ts"],"names":[],"mappings":"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"}

View File

@ -1,173 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendGentleReminderCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const push_1 = require("./push");
const log_1 = require("../log");
const GENTLE_REMINDER_MAX_PER_HOUR = 5;
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour
/**
* Sends a gentle nudge from one partner to the other when the caller has
* already answered today's question but the partner hasn't.
*
* Rate limits:
* - Per-user: max 5 gentle reminders per rolling hour. Guarded by a server-side
* transaction on `rate_limits/{uid}_gentle_reminder` so malicious clients
* cannot bypass it by calling the callable in a loop. The Android-side
* NotificationRateLimiter remains for UX but is not the authoritative guard.
* - Per-couple: one reminder per couple per calendar day (UTC). The lock is
* stored in couples/{coupleId}/gentle_reminders/{date} so it survives
* function restarts and is visible to both partners.
*
* The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display).
*/
exports.sendGentleReminderCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const db = admin.firestore();
// ── 1. Resolve couple + partner ──────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) {
throw new https_1.HttpsError('failed-precondition', 'Not in a couple.');
}
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
throw new https_1.HttpsError('not-found', 'Couple not found.');
}
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== callerId);
if (!partnerId) {
throw new https_1.HttpsError('failed-precondition', 'No partner found.');
}
try {
// ── 2. Server-side per-user throttle: 5 per hour (rolling window) ────────
const now = admin.firestore.Timestamp.now();
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`);
const throttleResult = await db.runTransaction(async (tx) => {
const snap = await tx.get(rateLimitRef);
const data = snap.data();
let windowStart;
let count;
if (!snap.exists || !data) {
windowStart = now;
count = 0;
}
else {
windowStart = data.windowStart;
count = (typeof data.count === 'number' ? data.count : 0);
const elapsedMs = now.toMillis() - windowStart.toMillis();
if (elapsedMs >= GENTLE_REMINDER_WINDOW_MS) {
// Rolling window has expired; start a fresh one.
windowStart = now;
count = 0;
}
}
if (count >= GENTLE_REMINDER_MAX_PER_HOUR) {
const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis());
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60000));
return { allowed: false, retryAfterMinutes };
}
tx.set(rateLimitRef, {
count: count + 1,
windowStart,
updatedAt: now,
}, { merge: true });
return { allowed: true };
});
if (!throttleResult.allowed) {
throw new https_1.HttpsError('resource-exhausted', `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`);
}
// ── 3. Rate limit: one per couple per day ────────────────────────────────
const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19"
const lockRef = db
.collection('couples')
.doc(coupleId)
.collection('gentle_reminders')
.doc(today);
const existingLock = await lockRef.get();
if (existingLock.exists) {
return { sent: false, reason: 'already_sent_today' };
}
// ── 4. Write in-app notification record ──────────────────────────────────
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'gentle_reminder',
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
// ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({
sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(),
});
// ── 6. Send FCM push ─────────────────────────────────────────────────────
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: {
title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.",
},
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: {
type: 'gentle_reminder',
couple_id: coupleId,
},
});
log_1.logger.log(`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
return { sent: true };
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[sendGentleReminderCallable] failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to send reminder. Please try again.');
}
});
//# sourceMappingURL=sendGentleReminderCallable.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":"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"}

View File

@ -1,137 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendThinkingOfYouCallable = void 0;
const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
const log_1 = require("../log");
const THINKING_OF_YOU_MAX_PER_DAY = 10;
const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h
/**
* "Thinking of you 💜" a one-tap affectionate nudge from the partner sheet.
*
* Partner-initiated (like the gentle reminder), so it is guarded by a rate limit + quiet hours rather
* than a per-type opt-out:
* - Per-user rolling 24h cap (transaction on `rate_limits/{uid}_thinking_of_you`) so it can't be looped.
* - Quiet hours: during the recipient's window the FCM push is suppressed, but the in-app record is
* still written so the love is waiting when they wake. (Improvement over the gentle reminder.)
*
* Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push.
*/
exports.sendThinkingOfYouCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const db = admin.firestore();
// ── Resolve couple + partner ─────────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) {
throw new https_1.HttpsError('failed-precondition', 'Not in a couple.');
}
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
throw new https_1.HttpsError('not-found', 'Couple not found.');
}
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== callerId);
if (!partnerId) {
throw new https_1.HttpsError('failed-precondition', 'No partner found.');
}
try {
// ── Per-user rolling rate limit (transactional) ──────────────────────────
const now = admin.firestore.Timestamp.now();
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`);
const throttle = await db.runTransaction(async (tx) => {
const snap = await tx.get(rateLimitRef);
const data = snap.data();
let windowStart = now;
let count = 0;
if (snap.exists && data) {
windowStart = data.windowStart;
count = typeof data.count === 'number' ? data.count : 0;
if (now.toMillis() - windowStart.toMillis() >= THINKING_OF_YOU_WINDOW_MS) {
windowStart = now;
count = 0;
}
}
if (count >= THINKING_OF_YOU_MAX_PER_DAY) {
return { allowed: false };
}
tx.set(rateLimitRef, { count: count + 1, windowStart, updatedAt: now }, { merge: true });
return { allowed: true };
});
if (!throttle.allowed) {
throw new https_1.HttpsError('resource-exhausted', "You've sent a few already — give it a moment.");
}
// ── In-app record (always — shows in-app + the Together feed) ────────────
await db.collection('users').doc(partnerId).collection('notification_queue').add({
type: 'thinking_of_you',
title: 'Your partner is thinking of you 💜',
body: 'Tap to send one back.',
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
// ── Quiet hours: keep the in-app record, suppress the disruptive push ─────
const partnerDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerDoc.data();
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return { sent: true };
}
// ── FCM push ─────────────────────────────────────────────────────────────
await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: { title: 'Your partner is thinking of you 💜', body: 'Tap to send one back.' },
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: { type: 'thinking_of_you', couple_id: coupleId },
}, partnerData);
log_1.logger.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
return { sent: true };
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[sendThinkingOfYouCallable] failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to send. Please try again.');
}
});
//# sourceMappingURL=sendThinkingOfYouCallable.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}

View File

@ -1,136 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendStreakReminder = void 0;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const quietHours_1 = require("./quietHours");
const push_1 = require("./push");
const time_1 = require("./time");
const log_1 = require("../log");
/**
* Streak reminder an evening "don't lose your streak" nudge.
*
* Schedule: 7 PM America/Chicago. For couples with an active streak (`streakCount > 0`) who have NOT
* recorded a shared action today, nudge each partner to do something together before the day ends.
*
* Gating: per-user `notifStreakReminder` toggle (default on) + quiet hours. Deduped per local day via a
* transactional `couples/{id}/streak_reminders/{dateKey}` marker (scheduled jobs can fire more than once).
*
* Known limitation: the "today" boundary uses America/Chicago (the cron's timezone), not each couple's
* local day true per-timezone firing is a future refinement; quiet-hours suppression keeps a mistimed
* fire from landing at a bad local hour. (Streak day-boundary note in the plan.)
*/
exports.sendStreakReminder = (0, scheduler_1.onSchedule)({ schedule: '0 19 * * *', timeZone: 'America/Chicago', timeoutSeconds: 180 }, async () => {
const db = admin.firestore();
const messaging = admin.messaging();
const todayKey = (0, time_1.chicagoDateKey)(new Date());
const coupleSnap = await db.collection('couples').where('streakCount', '>', 0).get();
let notified = 0;
let skipped = 0;
const results = await Promise.allSettled(coupleSnap.docs.map(async (coupleDoc) => {
var _a, _b;
const couple = coupleDoc.data();
const streak = ((_a = couple.streakCount) !== null && _a !== void 0 ? _a : 0);
if (streak <= 0) {
skipped++;
return;
}
// Already did a shared action today → the streak is safe, no nudge needed.
const lastMs = (0, time_1.toMillis)(couple.lastAnsweredAt);
if (lastMs > 0 && (0, time_1.chicagoDateKey)(new Date(lastMs)) === todayKey) {
skipped++;
return;
}
const userIds = ((_b = couple.userIds) !== null && _b !== void 0 ? _b : []);
if (userIds.length === 0) {
skipped++;
return;
}
// Per-day dedupe (transactional create-if-absent) — idempotent across re-runs.
const markerRef = coupleDoc.ref.collection('streak_reminders').doc(todayKey);
try {
await db.runTransaction(async (tx) => {
const fresh = await tx.get(markerRef);
if (fresh.exists)
throw new Error('already_sent');
tx.set(markerRef, { sentAt: admin.firestore.FieldValue.serverTimestamp(), streak });
});
}
catch (_c) {
skipped++;
return;
}
await Promise.allSettled(userIds.map((userId) => sendStreakNudge(db, messaging, userId, coupleDoc.id, streak, todayKey)));
notified += userIds.length;
}));
results.forEach((r) => {
if (r.status === 'rejected')
log_1.logger.warn('[sendStreakReminder] couple failed', { error: String(r.reason) });
});
log_1.logger.log(`[sendStreakReminder] scanned ${coupleSnap.size} streak couples; notified ${notified}; skipped ${skipped}`);
});
async function sendStreakNudge(db, messaging, userId, coupleId, streak, dateKey) {
const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data();
// Respect the user's Streak Reminder toggle (default on) and quiet hours.
if ((userData === null || userData === void 0 ? void 0 : userData.notifStreakReminder) === false) {
log_1.logger.log(`[sendStreakReminder] skip ${userId} — streak reminder off`);
return;
}
if ((0, quietHours_1.recipientInQuietHours)(userData)) {
log_1.logger.log(`[sendStreakReminder] skip ${userId} — quiet hours`);
return;
}
const title = `🔥 Keep your ${streak}-day streak`;
const body = "Answer tonight's question together before the day ends.";
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add({
type: 'streak',
title,
body,
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
await (0, push_1.sendPushToUser)(db, messaging, userId, {
notification: { title, body },
android: { notification: { channelId: 'reminders' } },
data: { type: 'streak', couple_id: coupleId, reminder_date: dateKey },
}, userData);
}
//# sourceMappingURL=streakReminder.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"streakReminder.js","sourceRoot":"","sources":["../../src/notifications/streakReminder.ts"],"names":[],"mappings":"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"}

View File

@ -1,65 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.chicagoDateKey = chicagoDateKey;
exports.toMillis = toMillis;
const admin = __importStar(require("firebase-admin"));
/**
* Shared date/time helpers for the scheduled jobs, consolidated from per-file copies
* (streakReminder's chicagoDateKey/toMillis, scheduledOutcomesReminder's millisFromFirestoreValue).
* The CST assignment helpers in questions/assignDailyQuestion.ts are a separate, unit-tested set and
* stay there.
*/
/** YYYY-MM-DD for the given instant in America/Chicago (DST-safe; en-CA gives ISO date order). */
function chicagoDateKey(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
/** Best-effort milliseconds from a Firestore Timestamp, a raw number, or any `{ toMillis() }` value. */
function toMillis(value) {
if (typeof value === 'number')
return value;
if (value instanceof admin.firestore.Timestamp)
return value.toMillis();
if (value != null && typeof value.toMillis === 'function') {
return value.toMillis();
}
return 0;
}
//# sourceMappingURL=time.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"time.js","sourceRoot":"","sources":["../../src/notifications/time.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,wCAOC;AAGD,4BAOC;AA3BD,sDAAuC;AAEvC;;;;;GAKG;AAEH,kGAAkG;AAClG,SAAgB,cAAc,CAAC,CAAO;IACpC,OAAO,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;QACtC,QAAQ,EAAE,iBAAiB;QAC3B,IAAI,EAAE,SAAS;QACf,KAAK,EAAE,SAAS;QAChB,GAAG,EAAE,SAAS;KACf,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;AACd,CAAC;AAED,wGAAwG;AACxG,SAAgB,QAAQ,CAAC,KAAc;IACrC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,IAAI,KAAK,YAAY,KAAK,CAAC,SAAS,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAA;IACvE,IAAI,KAAK,IAAI,IAAI,IAAI,OAAQ,KAAgC,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QACtF,OAAQ,KAAoC,CAAC,QAAQ,EAAE,CAAA;IACzD,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC"}

View File

@ -1,34 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.REGION = void 0;
const v2_1 = require("firebase-functions/v2");
/**
* Global 2nd-gen options for every v2 function in this codebase.
*
* region: pinned to us-central1 the Android client calls FirebaseFunctions.getInstance() with
* no region override (di/SecurityModule.kt, data/remote/FirestoreAnswerDataSource.kt), so the
* callables MUST live in us-central1 or every install breaks. Do not change without also
* pinning the client.
* maxInstances: a runaway-bill guardrail AND a Cloud Run quota constraint. Each 2nd-gen function
* is a Cloud Run service, and the project's regional "total CPU allocation" quota is charged as
* the SUM of (maxInstances x vCPU) across every function. With ~34 v2 functions and a new
* project's default quota (~560 vCPU in us-central1), this must stay low or deploys fail with
* "Quota exceeded for total allowable CPU per project per region". 5 x 34 = 170, well under.
* For production: request a Cloud Run CPU quota increase (console IAM & Admin Quotas) and
* raise this. ~80 concurrent requests per instance means 5 instances still serves ~400 in flight.
*
* cpu 'gcf_gen1' + concurrency 1: v2 defaults every instance to a FULL vCPU (required for the
* default concurrency of 80). On this project's default Cloud Run CPU quota that made deploys
* fail their container healthchecks ("Quota exceeded for total allowable CPU"). gcf_gen1 uses
* the gen1 fractional tiers (256MiB 1/6 vCPU, 512MiB 1/3) a 6x smaller CPU footprint,
* which is exactly how these workloads ran on gen1 until now. Concurrency must be 1 when
* cpu < 1; at dev scale that costs nothing. AT LAUNCH: request the Cloud Run CPU quota
* increase, then delete these two lines to restore 1 vCPU + concurrency 80.
*
* This module is imported FIRST in index.ts so these options are set before any v2 function is
* defined. It has no effect on the one v1 holdout (onUserDelete), which keeps its own defaults.
*/
(0, v2_1.setGlobalOptions)({ region: 'us-central1', maxInstances: 5, cpu: 'gcf_gen1', concurrency: 1 });
/** Region constant for any per-function override that ever needs to pin it explicitly. */
exports.REGION = 'us-central1';
//# sourceMappingURL=options.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"options.js","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":";;;AAAA,8CAAwD;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,IAAA,qBAAgB,EAAC,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC,CAAA;AAE7F,0FAA0F;AAC7E,QAAA,MAAM,GAAG,aAAa,CAAA"}

View File

@ -1,310 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.dayOfYearUtc = dayOfYearUtc;
exports.isWildcardDay = isWildcardDay;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/**
* Scheduled function that assigns one daily question per couple every day.
*
* Schedule: 6:00 PM CST (America/Chicago) == 23:00 UTC.
* Document path: couples/{coupleId}/daily_question/{date}
* - questionId: string
* - date: string (YYYY-MM-DD)
* - assignedAt: Timestamp
* - expiresAt: Timestamp (next day at 6 PM CST)
*
* Admin SDK bypasses Firestore rules; the `daily_question` doc is server-write
* only (`allow write: if false` in rules).
*/
exports.assignDailyQuestion = (0, scheduler_1.onSchedule)({ schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 }, async () => {
const db = admin.firestore();
const today = cstDateString();
const nextDay = nextCstDateString();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
log_1.logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
return;
}
const assignedAt = admin.firestore.FieldValue.serverTimestamp();
const expiresAt = timestampAt6PmCst(nextDay);
// Paginate the couple scan so we never load every couple into memory at once, and each write
// burst is bounded to a page (instead of an unbounded parallel fan-out). Ordering by __name__
// needs no custom index. create() is idempotent — ALREADY_EXISTS (code 6) is the no-op path.
const PAGE_SIZE = 300;
let scanned = 0;
let lastDoc;
for (;;) {
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE);
if (lastDoc)
query = query.startAfter(lastDoc);
const page = await query.get();
if (page.empty)
break;
scanned += page.size;
await Promise.allSettled(page.docs.map(async (coupleDoc) => {
var _a;
const docRef = db
.collection('couples')
.doc(coupleDoc.id)
.collection('daily_question')
.doc(today);
try {
await docRef.create({ questionId, date: today, assignedAt, expiresAt });
}
catch (err) {
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_a = err === null || err === void 0 ? void 0 : err.message) === null || _a === void 0 ? void 0 : _a.includes('ALREADY_EXISTS')))
return; // idempotent
log_1.logger.error(`[assignDailyQuestion] failed for ${coupleDoc.id}`, { error: String(err) });
}
}));
lastDoc = page.docs[page.docs.length - 1];
if (page.size < PAGE_SIZE)
break;
}
log_1.logger.log(`[assignDailyQuestion] assigned ${questionId} to ${scanned} couples for ${today}`);
});
/**
* Callable function for immediate daily question assignment.
*
* Useful when a couple is newly created and should not wait for the next
* scheduled run, or for manual admin/testing triggers.
*
* Body: { coupleId: string, date?: string }
*/
exports.assignDailyQuestionCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const data = request.data;
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
if (!coupleId || typeof coupleId !== 'string') {
throw new https_1.HttpsError('invalid-argument', 'coupleId is required.');
}
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
throw new https_1.HttpsError('not-found', 'Couple not found.');
}
// Caller must be a member of the couple.
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
if (!userIds.includes(callerId)) {
throw new https_1.HttpsError('permission-denied', 'Caller is not a couple member.');
}
// Security review Batch 2: constrain the client-supplied date. Only today's CST date
// may be assigned on demand — this blocks creating arbitrary past/future daily_question
// docs and, combined with create()'s ALREADY_EXISTS guard, caps it to one per day
// (effective rate limit; repeat calls return already-exists).
const today = cstDateString();
const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : today;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new https_1.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.');
}
if (date !== today) {
throw new https_1.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
}
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
throw new https_1.HttpsError('internal', 'No active questions available.');
}
const nextDay = nextCstDateString(date);
const docRef = db
.collection('couples')
.doc(coupleId)
.collection('daily_question')
.doc(date);
try {
await docRef.create({
questionId,
date,
assignedAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt: timestampAt6PmCst(nextDay),
});
}
catch (err) {
if (err instanceof https_1.HttpsError)
throw err;
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_d = err === null || err === void 0 ? void 0 : err.message) === null || _d === void 0 ? void 0 : _d.includes('ALREADY_EXISTS'))) {
throw new https_1.HttpsError('already-exists', `Daily question already assigned for ${date}.`);
}
throw new https_1.HttpsError('internal', 'Failed to assign daily question.');
}
return { success: true, coupleId, date, questionId };
});
/**
* Picks a random active free question from the Firestore `questions` pool.
*
* The pool is expected to be a top-level collection with documents:
* - id: string
* - active: boolean
* - isPremium: boolean
*
* If no Firestore pool exists yet, falls back to a deterministic placeholder
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest', // Saturday — Side Quest Saturday
};
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
// The client promotes ~10% of days to a "Wildcard" surprise theme instead of the weekday mode
// (DailyModeResolver.resolve(): dayOfYear % 10 === 3). FROZEN to mirror that tag + cadence.
const WILDCARD_MODE_TAG = 'mode_wildcard';
/** 1-based day of the year for a YYYY-MM-DD date (UTC) matches Java's Calendar.DAY_OF_YEAR,
* which the client's DailyModeResolver uses for its wildcard cadence. */
function dayOfYearUtc(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor((Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 1)) / 86400000) + 1;
}
/** Mirrors the client's DailyModeResolver ~10% wildcard override (dayOfYear % 10 === 3). */
function isWildcardDay(dateStr) {
return dayOfYearUtc(dateStr) % 10 === 3;
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* getting different questions (the client falls back to device-local weekday only when there is no
* server assignment). Returns null if the questions pool is unseeded so the caller can no-op rather
* than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr) {
const db = admin.firestore();
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get();
if (snapshot.empty)
return null;
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const inTag = (tag) => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId);
const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()];
// On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
// WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
let pool = isWildcardDay(dateStr) ? inTag(WILDCARD_MODE_TAG) : [];
if (pool.length === 0)
pool = inTag(weekdayTag);
if (pool.length === 0)
pool = snapshot.docs.slice().sort(byId); // last-resort: mode unseeded
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id;
}
/**
* Returns today's date as YYYY-MM-DD in America/Chicago time.
*/
function cstDateString() {
return formatCstDate(new Date());
}
/**
* Returns tomorrow's date as YYYY-MM-DD relative to America/Chicago time.
*/
function nextCstDateString(from) {
if (from) {
const d = parseCstDate(from);
d.setUTCDate(d.getUTCDate() + 1);
return formatCstDate(d);
}
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()));
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
return formatCstDate(todayChicago);
}
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs));
const get = (t) => { var _a; return Number((_a = parts.find((p) => p.type === t)) === null || _a === void 0 ? void 0 : _a.value); };
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'));
return Math.round((atUtcMs - localAsUtcMs) / 3600000);
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
function formatCstDate(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
function parseCstDate(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6);
const offset = chicagoOffsetHours(guessMs);
return new Date(Date.UTC(y, m - 1, day, offset));
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
function timestampAt6PmCst(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
const offset = chicagoOffsetHours(guessMs);
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
}
//# sourceMappingURL=assignDailyQuestion.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,66 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const assignDailyQuestion_1 = require("./assignDailyQuestion");
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07');
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08');
});
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07');
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08');
});
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z');
// Winter: midnight CST == 06:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z');
});
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z');
// Winter: 18:00 CST == 00:00Z next day
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z');
});
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect((0, assignDailyQuestion_1.formatCstDate)((0, assignDailyQuestion_1.parseCstDate)(day))).toBe(day);
}
});
});
// Wildcard cadence must mirror the client's DailyModeResolver (dayOfYear % 10 === 3), or the
// server would assign a weekday question on days the app themes "Wildcard" (and vice versa).
describe('wildcard-day cadence (mirrors client DailyModeResolver)', () => {
it('computes 1-based day-of-year like Calendar.DAY_OF_YEAR', () => {
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-01-01')).toBe(1);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-01-03')).toBe(3);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-02-01')).toBe(32);
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2026-12-31')).toBe(365); // 2026 is not a leap year
expect((0, assignDailyQuestion_1.dayOfYearUtc)('2028-12-31')).toBe(366); // leap year
});
it('fires wildcard exactly when day-of-year mod 10 === 3', () => {
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-03')).toBe(true); // doy 3
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-13')).toBe(true); // doy 13
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-02-01')).toBe(false); // doy 32
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-01')).toBe(false); // doy 1
expect((0, assignDailyQuestion_1.isWildcardDay)('2026-01-04')).toBe(false); // doy 4
});
it('flags ~10% of the year as wildcard days', () => {
let wild = 0;
for (let doy = 1; doy <= 365; doy++) {
const d = new Date(Date.UTC(2026, 0, doy));
const dateStr = d.toISOString().slice(0, 10);
if ((0, assignDailyQuestion_1.isWildcardDay)(dateStr))
wild++;
}
expect(wild).toBe(37); // doy ∈ {3,13,…,363} → 37 days (~10.1%)
});
});
//# sourceMappingURL=assignDailyQuestion.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":"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"}

View File

@ -1,113 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerRevealed = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Firestore trigger: when one partner OPENS (reveals) the shared answers i.e. their own
* daily answer doc flips isRevealed false true notify the other partner that they've
* looked, so the pair stays in sync ("[Name] just opened your answers").
*
* Path: couples/{coupleId}/daily_question/{date}/answers/{userId}
*/
exports.onAnswerRevealed = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => {
var _a, _b, _c, _d, _e, _f, _g;
const { coupleId, date, userId } = event.params;
const before = ((_b = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data()) !== null && _b !== void 0 ? _b : {});
const after = ((_d = (_c = event.data) === null || _c === void 0 ? void 0 : _c.after.data()) !== null && _d !== void 0 ? _d : {});
// Only fire on the false → true reveal transition.
if (before.isRevealed === true || after.isRevealed !== true)
return;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
log_1.logger.warn(`[onAnswerRevealed] couple ${coupleId} not found`);
return;
}
const userIds = ((_f = (_e = coupleDoc.data()) === null || _e === void 0 ? void 0 : _e.userIds) !== null && _f !== void 0 ? _f : []);
if (!userIds.includes(userId)) {
log_1.logger.warn(`[onAnswerRevealed] revealer ${userId} not a member of ${coupleId}`);
return;
}
const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) {
log_1.logger.warn(`[onAnswerRevealed] no partner for couple ${coupleId}`);
return;
}
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Respect the same partner-activity opt-out as the answered ping.
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
log_1.logger.log(`[onAnswerRevealed] partner ${partnerId} has partner-activity notifications off`);
return;
}
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[onAnswerRevealed] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
// Dedupe redelivery of this reveal (at-least-once). The false→true guard above already stops
// the marker write from re-triggering this handler; the claim stops a redelivered event.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `reveal-${date}-${userId}`)))) {
log_1.logger.log(`[onAnswerRevealed] already notified for ${userId}'s reveal on ${date}; skipping`);
return;
}
const questionId = typeof after.questionId === 'string' ? after.questionId : '';
// displayName is E2EE in users/{uid} → generic title; the app shows the real name in-app. Avatar
// (photoUrl) stays plaintext, so it's still sent.
const revealerDoc = await db.collection('users').doc(userId).get();
const revealerAvatar = (_g = revealerDoc.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: {
title: 'Your partner opened your answers',
body: 'Open to see what you each said.',
},
data: Object.assign({ type: 'partner_opened_answer', couple_id: coupleId, question_id: questionId, date }, (typeof revealerAvatar === 'string' && revealerAvatar.length > 0
? { sender_avatar_url: revealerAvatar }
: {})),
android: { notification: { channelId: 'partner_activity' } },
}, partnerData);
if (res.sent === 0 && res.failed === 0) {
log_1.logger.log(`[onAnswerRevealed] no FCM tokens for partner ${partnerId}`);
return;
}
log_1.logger.log(`[onAnswerRevealed] notified ${partnerId} that ${userId} opened couple ${coupleId}`);
});
//# sourceMappingURL=onAnswerRevealed.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":"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"}

View File

@ -1,132 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onAnswerWritten = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Firestore trigger that sends an FCM notification to the other partner when
* one partner writes an answer under
* couples/{coupleId}/daily_question/{date}/answers/{userId}.
*
* The notification payload is a data message so the client can route directly to
* the answer reveal screen. It also contains a `notification` block for
* system-tray display when the app is in the background.
*/
exports.onAnswerWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/daily_question/{date}/answers/{userId}', async (event) => {
var _a, _b, _c;
const { coupleId, date, userId } = event.params;
const snap = event.data;
if (!snap)
return;
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
log_1.logger.warn(`[onAnswerWritten] couple ${coupleId} not found`);
return;
}
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
// Security review Batch 2: re-verify the writer actually belongs to this couple
// before sending a cross-user notification. Firestore rules already enforce this,
// but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
if (!userIds.includes(userId)) {
log_1.logger.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`);
return;
}
const partnerId = userIds.find((uid) => uid !== userId);
if (!partnerId) {
log_1.logger.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`);
return;
}
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Respect the partner's notification preference (opt-out; default is enabled).
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifPartnerAnswered) === false) {
log_1.logger.log(`[onAnswerWritten] partner ${partnerId} has partner-answered notifications off`);
return;
}
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[onAnswerWritten] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
// Dedupe: at-least-once delivery can redeliver this create; claim once so a redelivery
// doesn't double-ping the partner (at-most-once: a post-claim send failure drops this one).
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `answer-${date}-${userId}`)))) {
log_1.logger.log(`[onAnswerWritten] already notified for ${userId}'s answer on ${date}; skipping`);
return;
}
const answerData = snap.data();
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
// Sender (the partner who just answered) avatar — used as the notification large icon.
const senderDoc = await db.collection('users').doc(userId).get();
const senderAvatar = (_c = senderDoc.data()) === null || _c === void 0 ? void 0 : _c.photoUrl;
// Did this answer COMPLETE the pair? If the recipient (partner) has already answered, the
// reveal just unlocked for both — tell them it's ready to open, instead of "go answer".
const partnerAnswerSnap = await db
.collection('couples').doc(coupleId)
.collection('daily_question').doc(date)
.collection('answers').doc(partnerId).get();
const bothAnswered = partnerAnswerSnap.exists;
const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: bothAnswered
? {
title: 'Your answers are unlocked ✨',
body: "You've both answered — open to see what you each said.",
}
: {
title: 'Your partner just answered!',
body: "See what they shared for tonight's prompt.",
},
data: Object.assign({ type: 'partner_answered', couple_id: coupleId, question_id: questionId, date }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
? { sender_avatar_url: senderAvatar }
: {})),
android: { notification: { channelId: 'partner_activity' } }, // E-OBS
}, partnerData);
if (res.sent === 0 && res.failed === 0) {
log_1.logger.log(`[onAnswerWritten] no FCM tokens for partner ${partnerId}`);
return;
}
// Track last activity time on the couple doc for re-engagement targeting.
await db.collection('couples').doc(coupleId).update({
lastAnsweredAt: admin.firestore.FieldValue.serverTimestamp(),
}).catch((e) => log_1.logger.warn('[onAnswerWritten] lastAnsweredAt update failed', { error: String(e) }));
log_1.logger.log(`[onAnswerWritten] notified partner ${partnerId} for couple ${coupleId} question ${questionId}`);
});
//# sourceMappingURL=onAnswerWritten.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}

View File

@ -1,111 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onMessageWritten = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
const push_1 = require("../notifications/push");
const idempotency_1 = require("../notifications/idempotency");
const log_1 = require("../log");
/**
* Firestore trigger that notifies the other partner when a chat message is
* sent in a conversation (the couple chat or a per-question discussion).
*
* Path: couples/{coupleId}/conversations/{conversationId}/messages/{messageId}
*
* Respects the recipient's `notifChatMessage` preference (default: enabled).
*/
exports.onMessageWritten = (0, firestore_1.onDocumentCreated)('couples/{coupleId}/conversations/{conversationId}/messages/{messageId}', async (event) => {
var _a, _b, _c, _d;
const { coupleId, conversationId, messageId } = event.params;
const snap = event.data;
if (!snap)
return;
const db = admin.firestore();
const messageData = snap.data();
const authorId = typeof messageData.authorUserId === 'string' ? messageData.authorUserId : null;
if (!authorId) {
log_1.logger.warn(`[onMessageWritten] no authorUserId on message ${messageId}`);
return;
}
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
log_1.logger.warn(`[onMessageWritten] couple ${coupleId} not found`);
return;
}
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
const partnerId = userIds.find((uid) => uid !== authorId);
if (!partnerId) {
log_1.logger.warn(`[onMessageWritten] no partner found for couple ${coupleId}`);
return;
}
const partnerUserDoc = await db.collection('users').doc(partnerId).get();
const partnerData = partnerUserDoc.data();
// Respect the partner's notification preference (opt-out; default is enabled).
if ((partnerData === null || partnerData === void 0 ? void 0 : partnerData.notifChatMessage) === false) {
log_1.logger.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
return;
}
// M-001: honor the recipient's quiet-hours window ("no notifications" promise). Fail-open.
if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[onMessageWritten] partner ${partnerId} is in quiet hours — suppressing`);
return;
}
// Dedupe redelivery of this message create (at-least-once). messageId is unique per message.
if (!(await (0, idempotency_1.claimOnce)((0, idempotency_1.notifMark)(db, coupleId, `msg-${messageId}`)))) {
log_1.logger.log(`[onMessageWritten] already notified for message ${messageId}; skipping`);
return;
}
// displayName is E2EE in users/{uid}, so the server can't read it for the OS-rendered push (the
// app shows the real name in-app). photoUrl stays plaintext, so the avatar is still sent.
const authorDoc = await db.collection('users').doc(authorId).get();
const authorPhotoUrl = (_d = (_c = authorDoc.data()) === null || _c === void 0 ? void 0 : _c.photoUrl) !== null && _d !== void 0 ? _d : '';
const res = await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
notification: {
title: 'Your partner sent a message',
body: 'Tap to read and reply.',
},
data: Object.assign({ type: 'chat_message', couple_id: coupleId, conversation_id: conversationId }, (authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {})),
// E-OBS: backgrounded delivery on the Chat/partner channel, not the FCM fallback channel.
android: { notification: { channelId: 'partner_activity' } },
}, partnerData);
if (res.sent === 0 && res.failed === 0) {
log_1.logger.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`);
return;
}
log_1.logger.log(`[onMessageWritten] notified partner ${partnerId} for conversation ${conversationId} in couple ${coupleId}`);
});
//# sourceMappingURL=onMessageWritten.js.map

Some files were not shown because too many files have changed in this diff Show More