310 lines
14 KiB
JavaScript
310 lines
14 KiB
JavaScript
"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
|