"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