mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
294 lines
8.7 KiB
TypeScript
294 lines
8.7 KiB
TypeScript
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<CooldownState> {
|
|
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<string, { bookId: string; windowRef: string }>;
|
|
datesByWindowRef: Map<string, string[]>;
|
|
}
|
|
|
|
/** Load every committed daily_verses row into in-memory indexes for the scan. */
|
|
async function loadCalendar(db: Db): Promise<LoadedCalendar> {
|
|
const rows = await db
|
|
.select({
|
|
date: dailyVerses.date,
|
|
bookId: dailyVerses.bookId,
|
|
reference: dailyVerses.reference
|
|
})
|
|
.from(dailyVerses);
|
|
|
|
const byDate = new Map<string, { bookId: string; windowRef: string }>();
|
|
const datesByWindowRef = new Map<string, string[]>();
|
|
|
|
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<string | null> {
|
|
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<ScheduleResult> {
|
|
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');
|
|
}
|