212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
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)
|
|
}
|