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:
parent
fc5d709174
commit
884cf9f614
|
|
@ -110,3 +110,8 @@ __pycache__/
|
||||||
|
|
||||||
# Asset-db backups must never live under assets/ — everything there ships in the APK
|
# Asset-db backups must never live under assets/ — everything there ships in the APK
|
||||||
app/src/main/assets/database/*.bak*
|
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/
|
||||||
|
|
|
||||||
|
|
@ -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')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1171,7 +1171,20 @@ npm run serve # local emulator
|
||||||
firebase deploy --only functions
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 you’re 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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"cleanupRestoreRequests.js","sourceRoot":"","sources":["../../src/backup/cleanupRestoreRequests.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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: 'They’re 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 wasn’t 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 REQUESTED→READY 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 wasn’t 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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onRestoreRequested.js","sourceRoot":"","sources":["../../src/backup/onRestoreRequested.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"entitlementLogic.js","sourceRoot":"","sources":["../../src/billing/entitlementLogic.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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 inactive→active 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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onEntitlementChanged.js","sourceRoot":"","sources":["../../src/billing/onEntitlementChanged.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qFAAqF;AACrF,+DAA+D;AAC/D,IAAI,CAAC,IAAI,CAAC,6BAA6B,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAC3E,IAAI,CAAC,IAAI,CAAC,2BAA2B,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AACtF,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAC/F,IAAI,CAAC,IAAI,CAAC,oBAAoB,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAE9E,+CAAiC;AACjC,2DAAwE;AAExE,MAAM,MAAM,GAAG,0BAA0B,CAAC;AAC1C,MAAM,GAAG,GAAG,aAAiB,CAAC,CAAC,oBAAoB;AACnD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;AACtC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;AAE1F,gGAAgG;AAChG,SAAS,IAAI,CAAC,IAAqB,EAAE,KAAa,EAAE,MAAM,GAAG,MAAM;IACjE,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACrE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACvE,MAAM,EAAE,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7E,OAAO,KAAK,KAAK,OAAO,EAAE,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,MAAM,CAAC,YAAmE,EAAE;IACnF,OAAO,IAAA,0CAAsB,kBAC3B,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAC1B,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAC1B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,GAAG,IACP,SAAS,EACZ,CAAC;AACL,CAAC;AAED,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,2BAA2B,EAAE,GAAG,EAAE;QACnC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;IAC9E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAChF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAC1F,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;QAClC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAChC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACrE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACnF,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,cAAc,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACjF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"aggregateOutcomes.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDA,8BAuBC;AAGD,oDAWC;AA3FD,sDAAuC;AACvC,+DAA4D;AAC5D,gCAA+B;AAsBlB,QAAA,UAAU,GAAe,CAAC,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,CAAC,CAAA;AACjF,QAAA,aAAa,GAAqB,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAA;AAChE,QAAA,UAAU,GAAG,EAAE,CAAA;AAqB5B,SAAS,GAAG,CAAC,CAAS,EAAE,KAAa;IACnC,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAA;AACxD,CAAC;AAED;;GAEG;AACH,SAAgB,SAAS,CAAC,OAAwB,EAAE,YAAoB,kBAAU;IAChF,MAAM,OAAO,GAAG,EAAwC,CAAA;IACxD,KAAK,MAAM,GAAG,IAAI,qBAAa,EAAE,CAAC;QAChC,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAA;QAC7D,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAA;QAC9B,IAAI,MAAM,GAAG,SAAS,EAAE,CAAC;YACvB,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,CAAA;YAC3C,SAAQ;QACV,CAAC;QACD,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,eAAC,OAAA,CAAC,MAAA,MAAA,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,0CAAE,UAAU,mCAAI,CAAC,CAAC,GAAG,CAAC,CAAA,EAAA,CAAC,CAAC,MAAM,CAAA;QACtF,MAAM,QAAQ,GAAG,EAA8B,CAAA;QAC/C,KAAK,MAAM,MAAM,IAAI,kBAAU,EAAE,CAAC;YAChC,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,eAAC,OAAA,CAAC,MAAA,MAAA,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,0CAAG,MAAM,CAAC,mCAAI,CAAC,CAAC,GAAG,CAAC,CAAA,EAAA,CAAC,CAAC,MAAM,CAAA;YAClF,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;QAC1C,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,GAAG;YACb,MAAM;YACN,UAAU,EAAE,KAAK;YACjB,aAAa,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC;YACtC,mBAAmB,EAAE,QAAQ;SAC9B,CAAA;IACH,CAAC;IACD,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAA;AAC/B,CAAC;AAED,iFAAiF;AACjF,SAAgB,oBAAoB,CAClC,WAA4D;IAE5D,MAAM,MAAM,GAA4B,EAAE,CAAA;IAC1C,KAAK,MAAM,GAAG,IAAI,WAAW,EAAE,CAAC;QAC9B,IAAK,qBAA0B,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACjD,MAAM,KAAK,GAAG,GAAG,CAAC,IAAI,CAAC,KAAsD,CAAA;YAC7E,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;gBAAE,MAAM,CAAC,GAAG,CAAC,EAAoB,CAAC,GAAG,KAAK,CAAA;QAClF,CAAC;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,CAAA;AACnB,CAAC;AAEY,QAAA,qBAAqB,GAAG,IAAA,sBAAU,EAC7C,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,EAAE,GAAG,EAAE,EACrE,KAAK,IAAI,EAAE;IACT,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,6FAA6F;IAC7F,+FAA+F;IAC/F,4EAA4E;IAC5E,MAAM,OAAO,GAAoB,EAAE,CAAA;IACnC,MAAM,SAAS,GAAG,GAAG,CAAA;IACrB,IAAI,OAA4D,CAAA;IAChE,SAAS,CAAC;QACR,IAAI,KAAK,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;QACzE,IAAI,OAAO;YAAE,KAAK,GAAG,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;QAC9C,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,CAAA;QAC9B,IAAI,IAAI,CAAC,KAAK;YAAE,MAAK;QACrB,MAAM,YAAY,GAAG,MAAM,OAAO,CAAC,GAAG,CACpC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YAC7B,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,CAAA;YAClE,OAAO,oBAAoB,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,CAAA;QAC3F,CAAC,CAAC,CACH,CAAA;QACD,OAAO,CAAC,IAAI,CAAC,GAAG,YAAY,CAAC,CAAA;QAC7B,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;QACzC,IAAI,IAAI,CAAC,IAAI,GAAG,SAAS;YAAE,MAAK;IAClC,CAAC;IAED,MAAM,KAAK,GAAG,SAAS,CAAC,OAAO,CAAC,CAAA;IAChC,MAAM,EAAE,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,GAAG,iCACrD,KAAK,KACR,YAAY,EAAE,OAAO,CAAC,MAAM,EAC5B,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,IAC5C,CAAA;IAEF,YAAM,CAAC,GAAG,CACR,2BAA2B,OAAO,CAAC,MAAM,aAAa;QACpD,qBAAa,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,aAAa,GAAG,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAClI,CAAA;AACH,CAAC,CACF,CAAA"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"aggregateOutcomes.test.js","sourceRoot":"","sources":["../../src/couples/aggregateOutcomes.test.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onCoupleKeyRotated.js","sourceRoot":"","sources":["../../src/couples/onCoupleKeyRotated.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onCoupleLeave.js","sourceRoot":"","sources":["../../src/couples/onCoupleLeave.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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 = `You’ve 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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"scheduledOutcomesReminder.js","sourceRoot":"","sources":["../../src/couples/scheduledOutcomesReminder.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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 reflect→reveal 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 it’s 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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onDateReflectionRevealed.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionRevealed.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onDateReflectionWritten.js","sourceRoot":"","sources":["../../src/dates/onDateReflectionWritten.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"dailyQuestionReminder.js","sourceRoot":"","sources":["../../src/notifications/dailyQuestionReminder.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"pruneTokens.js","sourceRoot":"","sources":["../../src/notifications/pruneTokens.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"pruneTokens.test.js","sourceRoot":"","sources":["../../src/notifications/pruneTokens.test.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"push.js","sourceRoot":"","sources":["../../src/notifications/push.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"quietHours.test.js","sourceRoot":"","sources":["../../src/notifications/quietHours.test.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"reengagement.js","sourceRoot":"","sources":["../../src/notifications/reengagement.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"sendGentleReminderCallable.js","sourceRoot":"","sources":["../../src/notifications/sendGentleReminderCallable.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"streakReminder.js","sourceRoot":"","sources":["../../src/notifications/streakReminder.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":";;AAAA,+DAAmH;AAEnH,iFAAiF;AACjF,uEAAuE;AACvE,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E,gFAAgF;QAChF,uEAAuE;QACvE,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,oFAAoF;QACpF,gFAAgF;QAChF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,4CAA4C;QAC5C,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QACjF,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACnF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,8BAA8B;QAC9B,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QAC/F,uCAAuC;QACvC,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,KAAK,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YACzF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAA,kCAAY,EAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACpD,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,6FAA6F;AAC7F,6FAA6F;AAC7F,QAAQ,CAAC,yDAAyD,EAAE,GAAG,EAAE;IACvE,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC1C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC1C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QAC3C,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA,CAAC,0BAA0B;QACvE,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA,CAAC,YAAY;IAC3D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC9D,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA,CAAC,QAAQ;QACvD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA,CAAC,SAAS;QACxD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,SAAS;QACzD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,QAAQ;QACxD,MAAM,CAAC,IAAA,mCAAa,EAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA,CAAC,QAAQ;IAC1D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,IAAI,IAAI,GAAG,CAAC,CAAA;QACZ,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC;YACpC,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC,CAAA;YAC1C,MAAM,OAAO,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;YAC5C,IAAI,IAAA,mCAAa,EAAC,OAAO,CAAC;gBAAE,IAAI,EAAE,CAAA;QACpC,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA,CAAC,wCAAwC;IAChE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onAnswerRevealed.js","sourceRoot":"","sources":["../../src/questions/onAnswerRevealed.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue