mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
Added verse-submission infrastructure
This commit is contained in:
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user