"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; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); const CST_OFFSET_HOURS = -6; /** * 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 = functions.pubsub .schedule('0 23 * * *') .timeZone('America/Chicago') .onRun(async () => { const db = admin.firestore(); const today = cstDateString(); const nextDay = nextCstDateString(); const questionId = await pickRandomQuestionId(); if (!questionId) { console.error('[assignDailyQuestion] no active questions available'); return; } const couplesSnap = await db.collection('couples').get(); const assignedAt = admin.firestore.FieldValue.serverTimestamp(); const expiresAt = timestampAt6PmCst(nextDay); const writes = couplesSnap.docs.map(async (coupleDoc) => { var _a; const coupleId = coupleDoc.id; const docRef = db .collection('couples') .doc(coupleId) .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'))) { // Already assigned for today — idempotent. return; } console.error(`[assignDailyQuestion] failed for ${coupleId}:`, err); } }); await Promise.all(writes); console.log(`[assignDailyQuestion] assigned ${questionId} to ${couplesSnap.size} 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 = functions.https.onCall(async (data, context) => { var _a, _b, _c, _d; const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; if (!callerId) { throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.'); } if (!context.app) { throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); } const coupleId = data === null || data === void 0 ? void 0 : data.coupleId; if (!coupleId || typeof coupleId !== 'string') { throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.'); } const db = admin.firestore(); const coupleDoc = await db.collection('couples').doc(coupleId).get(); if (!coupleDoc.exists) { throw new functions.https.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 functions.https.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 functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.'); } if (date !== today) { throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.'); } const questionId = await pickRandomQuestionId(); if (!questionId) { throw new functions.https.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 === 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 functions.https.HttpsError('already-exists', `Daily question already assigned for ${date}.`); } throw new functions.https.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. */ async function pickRandomQuestionId() { const db = admin.firestore(); const snapshot = await db .collection('questions') .where('active', '==', true) .where('isPremium', '==', false) .get(); if (snapshot.empty) { // Rollout fallback: keep the feature working until the questions pool is seeded. return 'q_default_daily'; } const docs = snapshot.docs; const chosen = docs[Math.floor(Math.random() * docs.length)]; return chosen.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); } const d = new Date(); const cst = applyCstOffset(d); cst.setUTCDate(cst.getUTCDate() + 1); return formatCstDate(cst); } function applyCstOffset(d) { const utcMs = d.getTime(); const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000; return new Date(cstMs); } function formatCstDate(d) { const cst = applyCstOffset(d); const y = cst.getUTCFullYear(); const m = String(cst.getUTCMonth() + 1).padStart(2, '0'); const day = String(cst.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function parseCstDate(dateStr) { const [y, m, day] = dateStr.split('-').map(Number); // Build a UTC timestamp that represents midnight CST, then reverse the offset. const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0); return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000); } function timestampAt6PmCst(dateStr) { const d = parseCstDate(dateStr); const utcMs = d.getTime(); // 6:00 PM CST == 18:00 CST == 00:00 UTC next day. // We already have midnight CST in UTC form; add 18 hours. const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000; return admin.firestore.Timestamp.fromMillis(at6pmCstMs); } //# sourceMappingURL=assignDailyQuestion.js.map