import { db as defaultDb } from '$lib/server/db'; import { dailyVerses, verseSubmissions } from '$lib/server/db/schema'; import { eq, desc, sql } from 'drizzle-orm'; import { getBookById } from '$lib/server/bible'; // Drizzle instance type alias (the production db or the test db). export type Db = typeof defaultDb; import { composeVerseWindow, formatWindowReference, getChapterCount, getVerseCount } from '$lib/server/xml-bible'; // Server-side constants for the community-verse-submission feature // (spec: "Rate Limiting & Cooldown", "Scheduling Algorithm"). const DAY_MS = 1000 * 60 * 60 * 24; export const COOLDOWN_MS = 7 * DAY_MS; export const REPEAT_WINDOW_DAYS = 60; const MAX_SCHEDULE_RETRIES = 5; // Safety cap on the day-by-day forward scan. 10k days (~27 years) is well // beyond any realistic calendar density; prevents an accidental infinite loop. const MAX_SCAN_DAYS = 10_000; export interface SubmissionInput { bookId: string; chapter: number; verse: number; } export interface ScheduleResult { scheduledDate: string; reference: string; windowText: string; bookId: string; } /** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */ export function addDays(dateStr: string, n: number): string { const d = new Date(dateStr + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } /** Whole-day difference (b - a) between two YYYY-MM-DD strings, UTC. */ export function dayDiff(a: string, b: string): number { const ta = new Date(a + 'T00:00:00Z').getTime(); const tb = new Date(b + 'T00:00:00Z').getTime(); return Math.round((tb - ta) / DAY_MS); } /** Current server UTC date as YYYY-MM-DD. */ export function todayUtcStr(): string { return new Date().toISOString().slice(0, 10); } /** * The canonical identity of a 3-verse window, used by the 60-day repeat rule. * Combines bookId + formatted reference so identical windows collide regardless * of how the underlying row was produced (submission vs. random gap-fill). */ function windowRef(bookId: string, reference: string): string { return `${bookId}|${reference}`; } /** Is this a SQLite unique-constraint violation (used for retry-on-conflict)? */ export function isUniqueConstraintError(err: unknown): boolean { const e = err as { code?: string; message?: string } | null; return !!e && ( e.code === 'SQLITE_CONSTRAINT_UNIQUE' || e.code === 'SQLITE_CONSTRAINT' || !!(e.message && /UNIQUE/i.test(e.message)) ); } export interface CooldownState { /** True if the user submitted within the last 7 days. */ onCooldown: boolean; /** Server UTC millis when the cooldown expires, or null. */ cooldownEndsAt: number | null; } /** Compute the rolling 7-day cooldown state for a user (server UTC). */ export async function getCooldownState( db: Db, userId: string, now: number = Date.now() ): Promise { const [last] = await db .select({ submittedAt: verseSubmissions.submittedAt }) .from(verseSubmissions) .where(eq(verseSubmissions.userId, userId)) .orderBy(desc(verseSubmissions.submittedAt)) .limit(1); if (!last) { return { onCooldown: false, cooldownEndsAt: null }; } const cooldownEndsAt = last.submittedAt + COOLDOWN_MS; if (now < cooldownEndsAt) { return { onCooldown: true, cooldownEndsAt }; } return { onCooldown: false, cooldownEndsAt: null }; } /** Structural validation of a user-supplied (bookId, chapter, verse) selection. */ export function validateSelection(input: SubmissionInput): string | null { const book = getBookById(input.bookId); if (!book) return 'Unknown bookId'; if (!Number.isInteger(input.chapter) || input.chapter < 1) { return 'chapter must be a positive integer'; } const chapterCount = getChapterCount(book.order); if (input.chapter > chapterCount) { return `chapter out of range (1-${chapterCount})`; } if (!Number.isInteger(input.verse) || input.verse < 1) { return 'verse must be a positive integer'; } const verseCount = getVerseCount(book.order, input.chapter); if (input.verse > verseCount) { return `verse out of range (1-${verseCount})`; } return null; } interface LoadedCalendar { byDate: Map; datesByWindowRef: Map; } /** Load every committed daily_verses row into in-memory indexes for the scan. */ async function loadCalendar(db: Db): Promise { const rows = await db .select({ date: dailyVerses.date, bookId: dailyVerses.bookId, reference: dailyVerses.reference }) .from(dailyVerses); const byDate = new Map(); const datesByWindowRef = new Map(); for (const r of rows) { const wr = windowRef(r.bookId, r.reference); byDate.set(r.date, { bookId: r.bookId, windowRef: wr }); const arr = datesByWindowRef.get(wr); if (arr) arr.push(r.date); else datesByWindowRef.set(wr, [r.date]); } return { byDate, datesByWindowRef }; } /** * Find the earliest valid candidate date `D` (scanning forward from tomorrow, * server UTC) for a submission with the given book + window identity. * * Validity (spec "Scheduling Algorithm"): * 1. Empty: no daily_verses row at D. * 2. No back-to-back: D-1 (if committed) has a different book. * 3. No back-to-back: D+1 (if committed) has a different book. * 4. 60-day repeat: no identical window within [D-60, D+60] inclusive. */ async function findCandidateDate( db: Db, bookId: string, submissionWindowRef: string, opts?: { startFrom?: string; today?: string } ): Promise { const { byDate, datesByWindowRef } = await loadCalendar(db); let cursor = opts?.startFrom ?? addDays(opts?.today ?? todayUtcStr(), 1); for (let i = 0; i < MAX_SCAN_DAYS; i++) { // Rule 1: must be empty. if (!byDate.has(cursor)) { // Rules 2 & 3: no back-to-back with committed neighbors. const prev = byDate.get(addDays(cursor, -1)); const next = byDate.get(addDays(cursor, 1)); const backToBack = (!!prev && prev.bookId === bookId) || (!!next && next.bookId === bookId); if (!backToBack) { // Rule 4: 60-day repeat distance for the identical window. const sameWindowDates = datesByWindowRef.get(submissionWindowRef) ?? []; const tooClose = sameWindowDates.some( (d) => Math.abs(dayDiff(d, cursor)) <= REPEAT_WINDOW_DAYS ); if (!tooClose) { return cursor; } } } cursor = addDays(cursor, 1); } return null; } /** * Run the scheduling scan and write the daily_verses + verse_submissions rows * in a transaction. On a unique-constraint conflict (concurrent submission * picked the same date), re-run the scan from the next day; retry up to a * small bound. */ export async function scheduleSubmission( db: Db, input: SubmissionInput, userId: string, now: number = Date.now(), opts?: { today?: string } ): Promise { const book = getBookById(input.bookId); if (!book) throw new Error('Invalid bookId'); const window = composeVerseWindow(book.order, input.chapter, input.verse); if (!window) throw new Error('Invalid chapter/verse for this book'); const reference = formatWindowReference( window.bookName, window.startChapter, window.startVerse, window.endChapter, window.endVerse ); const windowText = window.verses.join(' '); const submissionWindowRef = windowRef(book.id, reference); let lastCandidate = ''; for (let attempt = 0; attempt < MAX_SCHEDULE_RETRIES; attempt++) { // Re-compute the candidate each attempt — a concurrent submission may // have claimed the previous candidate between scan and insert. After a // conflict, re-scan from the day *after* the contested candidate. const candidate = await findCandidateDate( db, book.id, submissionWindowRef, attempt === 0 ? { today: opts?.today } : { startFrom: addDays(lastCandidate, 1) } ); if (!candidate) { throw new Error('No valid candidate date found within the scan window'); } lastCandidate = candidate; try { db.transaction((tx) => { tx.insert(dailyVerses) .values({ id: Bun.randomUUIDv7(), date: candidate, bookId: book.id, verseText: windowText, reference, createdAt: sql`${Math.floor(now / 1000)}` }) .run(); tx.insert(verseSubmissions) .values({ id: Bun.randomUUIDv7(), userId, scheduledDate: candidate, selectedBookId: input.bookId, selectedChapter: input.chapter, selectedVerse: input.verse, submittedAt: now }) .run(); }); return { scheduledDate: candidate, reference, windowText, bookId: book.id }; } catch (err) { if (isUniqueConstraintError(err)) { continue; // retry — re-scan from tomorrow } throw err; } } throw new Error('Failed to schedule submission after retries'); }