Closer/functions/dist/questions/assignDailyQuestion.js

226 lines
8.9 KiB
JavaScript

"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.');
}
const date = (data === null || data === void 0 ? void 0 : 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) {
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