Closer/functions/src/questions/assignDailyQuestion.ts

212 lines
6.6 KiB
TypeScript
Raw Normal View History

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.')
}
const date = data?.date && typeof data.date === 'string' ? data.date : cstDateString()
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<string | null> {
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)
}