Closer/functions/dist/questions/assignDailyQuestion.js

310 lines
14 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;
exports.dayOfYearUtc = dayOfYearUtc;
exports.isWildcardDay = isWildcardDay;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler");
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/**
* 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 = (0, scheduler_1.onSchedule)({ schedule: '0 23 * * *', timeZone: 'America/Chicago', memory: '512MiB', timeoutSeconds: 300 }, async () => {
const db = admin.firestore();
const today = cstDateString();
const nextDay = nextCstDateString();
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
log_1.logger.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
return;
}
const assignedAt = admin.firestore.FieldValue.serverTimestamp();
const expiresAt = timestampAt6PmCst(nextDay);
// Paginate the couple scan so we never load every couple into memory at once, and each write
// burst is bounded to a page (instead of an unbounded parallel fan-out). Ordering by __name__
// needs no custom index. create() is idempotent — ALREADY_EXISTS (code 6) is the no-op path.
const PAGE_SIZE = 300;
let scanned = 0;
let lastDoc;
for (;;) {
let query = db.collection('couples').orderBy('__name__').limit(PAGE_SIZE);
if (lastDoc)
query = query.startAfter(lastDoc);
const page = await query.get();
if (page.empty)
break;
scanned += page.size;
await Promise.allSettled(page.docs.map(async (coupleDoc) => {
var _a;
const docRef = db
.collection('couples')
.doc(coupleDoc.id)
.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')))
return; // idempotent
log_1.logger.error(`[assignDailyQuestion] failed for ${coupleDoc.id}`, { error: String(err) });
}
}));
lastDoc = page.docs[page.docs.length - 1];
if (page.size < PAGE_SIZE)
break;
}
log_1.logger.log(`[assignDailyQuestion] assigned ${questionId} to ${scanned} 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 = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d;
const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
if (!request.app) {
throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
}
const data = request.data;
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
if (!coupleId || typeof coupleId !== 'string') {
throw new https_1.HttpsError('invalid-argument', 'coupleId is required.');
}
const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) {
throw new https_1.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 https_1.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 https_1.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.');
}
if (date !== today) {
throw new https_1.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
}
const questionId = await pickDailyQuestionId(today);
if (!questionId) {
throw new https_1.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 instanceof https_1.HttpsError)
throw 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 https_1.HttpsError('already-exists', `Daily question already assigned for ${date}.`);
}
throw new https_1.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.
*/
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest', // Saturday — Side Quest Saturday
};
/** Days since epoch for a YYYY-MM-DD Chicago date — mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
// The client promotes ~10% of days to a "Wildcard" surprise theme instead of the weekday mode
// (DailyModeResolver.resolve(): dayOfYear % 10 === 3). FROZEN to mirror that tag + cadence.
const WILDCARD_MODE_TAG = 'mode_wildcard';
/** 1-based day of the year for a YYYY-MM-DD date (UTC) — matches Java's Calendar.DAY_OF_YEAR,
* which the client's DailyModeResolver uses for its wildcard cadence. */
function dayOfYearUtc(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor((Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 1)) / 86400000) + 1;
}
/** Mirrors the client's DailyModeResolver ~10% wildcard override (dayOfYear % 10 === 3). */
function isWildcardDay(dateStr) {
return dayOfYearUtc(dateStr) % 10 === 3;
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's mode (weekday, or wildcard on ~10% of days), ordered by id, indexed by epochDay %
* poolSize. Making this SERVER-side and authoritative also fixes partners in different time zones
* getting different questions (the client falls back to device-local weekday only when there is no
* server assignment). Returns null if the questions pool is unseeded so the caller can no-op rather
* than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr) {
const db = admin.firestore();
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get();
if (snapshot.empty)
return null;
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const inTag = (tag) => snapshot.docs.filter((d) => d.get('modeTag') === tag).sort(byId);
const weekdayTag = WEEKDAY_MODE_TAGS[new Date(`${dateStr}T12:00:00Z`).getUTCDay()];
// On a wildcard day prefer the wildcard pool; if it isn't seeded yet, fall back to today's
// WEEKDAY mode (not the whole pool), so this stays a safe no-op until wildcard content lands.
let pool = isWildcardDay(dateStr) ? inTag(WILDCARD_MODE_TAG) : [];
if (pool.length === 0)
pool = inTag(weekdayTag);
if (pool.length === 0)
pool = snapshot.docs.slice().sort(byId); // last-resort: mode unseeded
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].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);
}
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()));
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
return formatCstDate(todayChicago);
}
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs));
const get = (t) => { var _a; return Number((_a = parts.find((p) => p.type === t)) === null || _a === void 0 ? void 0 : _a.value); };
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'));
return Math.round((atUtcMs - localAsUtcMs) / 3600000);
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
function formatCstDate(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
function parseCstDate(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6);
const offset = chicagoOffsetHours(guessMs);
return new Date(Date.UTC(y, m - 1, day, offset));
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
function timestampAt6PmCst(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
const offset = chicagoOffsetHours(guessMs);
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
}
//# sourceMappingURL=assignDailyQuestion.js.map