import * as functions from 'firebase-functions' import * as admin from '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). */ export const 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) => { 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: any) { if (err?.code === 6 || err?.message?.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 } */ export const assignDailyQuestionCallable = functions.https.onCall(async (data: any, context) => { const callerId = context.auth?.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?.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 = (coupleDoc.data()?.userIds ?? []) as string[] 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?.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: any) { if (err?.code === 6 || err?.message?.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(): Promise { 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(): string { return formatCstDate(new Date()) } /** * Returns tomorrow's date as YYYY-MM-DD relative to America/Chicago time. */ function nextCstDateString(from?: string): string { 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: Date): Date { const utcMs = d.getTime() const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000 return new Date(cstMs) } function formatCstDate(d: Date): string { 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: string): Date { 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: string): admin.firestore.Timestamp { 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) }