Added verse-submission infrastructure

This commit is contained in:
George Powell
2026-07-07 15:41:10 -04:00
parent 99552c57ad
commit be0d7ad297
17 changed files with 1293 additions and 26 deletions
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { bibleBooks } from '$lib/server/bible';
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
// ~1189 chapter entries total; safe to cache indefinitely.
let cached: { bookId: string; chapters: number[] }[] | null = null;
function build(): { bookId: string; chapters: number[] }[] {
if (cached) return cached;
const out = bibleBooks.map((book) => {
const chapterCount = getChapterCount(book.order);
const chapters: number[] = new Array(chapterCount);
for (let c = 1; c <= chapterCount; c++) {
chapters[c - 1] = getVerseCount(book.order, c);
}
return { bookId: book.id, chapters };
});
cached = out;
return out;
}
export const GET: RequestHandler = async () => {
return json(build());
};
@@ -0,0 +1,189 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import { bibleBooks } from '$lib/types/bible';
import {
getChapterCount,
getVerseCount,
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
const MAX_SCAN_RETRIES = 5;
// Dev-only: insert a verse_submissions row (from a random existing account) +
// its corresponding daily_verses row, scheduled on the earliest valid future
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
// verify the admin view without going through the (Step 5+) submit API.
export const POST: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
// Pick a random existing user.
const users = await db.select().from(user);
if (users.length === 0) {
return json(
{ error: 'No users exist yet. Sign up at least one account first.' },
{ status: 400 }
);
}
const submitter = users[Math.floor(Math.random() * users.length)];
// Pick a random valid (book, chapter, verse).
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
const chapterCount = getChapterCount(book.order);
const chapter = Math.floor(Math.random() * chapterCount) + 1;
const verseCount = getVerseCount(book.order, chapter);
const verse = Math.floor(Math.random() * verseCount) + 1;
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
return json({ error: 'Failed to compose verse window' }, { status: 500 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
const verseText = window.verses.join(' ');
const todayUtc = new Date().toISOString().slice(0, 10);
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
function addDays(ms: number, n: number): string {
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
}
// Fetch all future daily_verses into memory (date → bookId, date → reference).
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
const byDate = new Map<string, { bookId: string; reference: string }>();
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
// For the 60-day repeat rule, also need a bounded historical lookup per
// candidate date. Build a set of all existing references globally to do a
// cheap pre-check, then a precise range query when we settle on a date.
const allRows = await db.select().from(dailyVerses);
const refDates = new Map<string, string[]>(); // reference → list of dates
for (const r of allRows) {
const arr = refDates.get(r.reference) ?? [];
arr.push(r.date);
refDates.set(r.reference, arr);
}
let scheduledDate: string | null = null;
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
// Walk day-by-day from tomorrow.
for (let offset = 0; offset < 10000; offset++) {
const D = addDays(tomorrowMs, offset);
// Rule 1: empty (no committed daily_verses row).
if (byDate.has(D)) continue;
// Rule 2: no back-to-back with prior neighbor.
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
if (prev && prev.bookId === book.id) continue;
// Rule 3: no back-to-back with next committed neighbor.
const next = byDate.get(addDays(tomorrowMs, offset + 1));
if (next && next.bookId === book.id) continue;
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
const sameRefDates = refDates.get(reference) ?? [];
const dMs = new Date(D + 'T00:00:00Z').getTime();
const tooClose = sameRefDates.some((d) => {
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
return diff <= 60 * 86400000;
});
if (tooClose) continue;
scheduledDate = D;
break;
}
if (!scheduledDate) break; // exhausted retries
}
if (!scheduledDate) {
return json({ error: 'Could not find a valid future date' }, { status: 500 });
}
// Insert both rows in a transaction. On a unique conflict (concurrent
// submission grabbed the same date), bubble up so the caller can retry.
const now = Date.now();
const verseId = Bun.randomUUIDv7();
const submissionId = Bun.randomUUIDv7();
try {
db.transaction((tx) => {
tx.insert(dailyVerses)
.values({
id: verseId,
date: scheduledDate!,
bookId: book.id,
verseText,
reference,
createdAt: sql`${Math.floor(now / 1000)}`
})
.run();
tx.insert(verseSubmissions)
.values({
id: submissionId,
userId: submitter.id,
scheduledDate: scheduledDate!,
selectedBookId: book.id,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt: now
})
.run();
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return json(
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
{ status: 409 }
);
}
throw err;
}
return json({
ok: true,
submitter: { id: submitter.id, email: submitter.email },
selected: {
bookId: book.id,
bookName: book.name,
chapter,
verse
},
scheduledDate,
reference,
verseText
});
};
// Convenience: GET returns the current count of submissions + future scheduled
// verses, useful as a quick dev sanity check without mutating state.
export const GET: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
const subs = await db.select().from(verseSubmissions);
const future = await db
.select()
.from(dailyVerses)
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
return json({
totalSubmissions: subs.length,
futureScheduledVerses: future.length
});
};
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookById } from '$lib/server/bible';
import {
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
// Live 3-verse preview for the submission selector. Computes the fall-forward
// window anchored on the selected verse (spec: "Verse Windowing").
export const GET: RequestHandler = async ({ url }) => {
const bookId = url.searchParams.get('bookId');
const chapterParam = url.searchParams.get('chapter');
const verseParam = url.searchParams.get('verse');
if (!bookId || !chapterParam || !verseParam) {
return json({ error: 'bookId, chapter, and verse are required' }, { status: 400 });
}
const book = getBookById(bookId);
if (!book) {
return json({ error: 'Unknown bookId' }, { status: 400 });
}
const chapter = Number(chapterParam);
const verse = Number(verseParam);
if (!Number.isInteger(chapter) || !Number.isInteger(verse) || chapter < 1 || verse < 1) {
return json({ error: 'chapter and verse must be positive integers' }, { status: 400 });
}
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
// chapter/verse out of range — caught by composeVerseWindow's bounds checks
return json({ error: 'chapter or verse out of range for this book' }, { status: 400 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
return json({
windowVerses: window.verses,
reference,
bookId: window.bookId,
selectedVerse: window.selectedVerse
});
};