Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s

- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+231 -13
View File
@@ -81,6 +81,15 @@ export function initDb() {
`);
}
// Optional daily verse of the day, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'votd_enabled')) {
db.exec(`
ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN votd_time TEXT;
ALTER TABLE users ADD COLUMN last_votd_date TEXT;
`);
}
// Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
@@ -93,9 +102,56 @@ export function initDb() {
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
}
// Share links can expire and be protected by a passcode.
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only.
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
note TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT,
used_at INTEGER
);
`);
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
db.exec(`
CREATE TABLE IF NOT EXISTS collaborators (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
CREATE TABLE IF NOT EXISTS edit_invites (
token TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT
);
`);
// What the person has read in the Reader (chapters and days), merged from every device.
db.exec(`
CREATE TABLE IF NOT EXISTS reader_history (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
@@ -182,6 +238,7 @@ function parseUserRow(row) {
...row,
totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
@@ -191,7 +248,8 @@ const USER_SELECT = `
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate
FROM users
`;
@@ -234,6 +292,25 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime,
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
}
/** Turns the daily verse of the day on or off, and says when to send it. */
export function setVotdSettings(userId, { votdEnabled, votdTime }) {
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
}
/** Every account with the verse of the day turned on and a topic to send it to. */
export function getUsersWithVotdEnabled() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime,
last_votd_date AS lastVotdDate, reminder_tz AS reminderTz
FROM users
WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL
`).all();
}
export function setLastVotdDate(userId, dateString) {
db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId);
}
/** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() {
return db.prepare(`
@@ -249,6 +326,41 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
// ---------------------------------------------------------------------------
// Settings and sign-up invites
// ---------------------------------------------------------------------------
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(key, value) {
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
}
export function createInvite(code, note, expiresAt) {
db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt);
}
export function listInvites() {
return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all();
}
export function deleteInvite(code) {
db.prepare('DELETE FROM invites WHERE code = ?').run(code);
}
/** An invite that exists, hasn't expired and hasn't been used. */
export function findOpenInvite(code) {
return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null;
}
/** Marks an invite used by a new account; false if someone else got there first. */
export function useInvite(code, userId) {
return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0;
}
// ---------------------------------------------------------------------------
// Password reset codes
// ---------------------------------------------------------------------------
@@ -325,6 +437,18 @@ export function setReaderMarks(userId, { bookmarks, highlights }) {
return updatedAt;
}
export function getReaderHistory(userId) {
const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId);
try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; }
}
export function setReaderHistory(userId, history) {
db.prepare(`
INSERT INTO reader_history (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(history), Date.now());
}
/** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(`
@@ -376,20 +500,33 @@ export function claimOrphanProjects(userId) {
* Does NOT return full project data to keep the response small.
*/
export function getAllProjects(userId) {
// the studies you own, plus the ones another account has invited you to edit
const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
FROM projects
WHERE user_id = ?
ORDER BY last_edited DESC
`).all(userId);
return rows;
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
FROM projects p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
ORDER BY p.last_edited DESC
`).all(userId, userId, userId);
return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary }));
}
/** True when the account owns the study or has been invited to edit it. */
export function canEditProject(id, userId) {
return !!db.prepare(`
SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?))
`).get(id, userId, userId);
}
export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
/**
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/
export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
if (!row) return null;
try {
return JSON.parse(row.data);
@@ -400,7 +537,8 @@ export function getProject(id, userId) {
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
export function getProjectEdited(id, userId) {
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id);
return row ? row.lastEdited : null;
}
@@ -410,7 +548,7 @@ export function getProjectEdited(id, userId) {
*/
export function upsertProject(project, userId) {
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
if (existing && existing.userId !== userId) {
if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
return null;
}
@@ -437,7 +575,54 @@ export function upsertProject(project, userId) {
* Delete a project by id, scoped to userId. No-op if not found/not owned.
*/
export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
db.transaction(() => {
if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) {
// not the owner: "deleting" a study you were invited to just removes you from it
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId);
return;
}
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
})();
}
// ---------------------------------------------------------------------------
// Co-teachers
// ---------------------------------------------------------------------------
export function createEditInvite(token, projectId, expiresAt) {
db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt);
}
export function listCollaborators(projectId) {
return db.prepare(`
SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id
WHERE c.project_id = ? ORDER BY c.added_at
`).all(projectId);
}
export function countOpenEditInvites(projectId) {
return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n;
}
export function removeCollaborator(projectId, userId) {
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId);
}
/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */
export function acceptEditInvite(token, userId) {
return db.transaction(() => {
const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token);
if (!inv) return { error: 'This invite link is not valid.' };
if (inv.usedBy) return { error: 'This invite link has already been used.' };
if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' };
const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId);
if (!project) return { error: 'That study no longer exists.' };
if (project.ownerId === userId) return { error: 'That is your own study.' };
db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now());
db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token);
return { projectId: inv.projectId, title: project.title };
})();
}
// ---------------------------------------------------------------------------
@@ -458,7 +643,7 @@ export function setShareToken(id, userId, token) {
/** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
@@ -473,7 +658,39 @@ export function setOutlineShareToken(id, userId, token) {
}
export function clearOutlineShareToken(id, userId) {
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
const shareCols = (outline) => (outline
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
export function getShareSettings(id, userId, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
}
/** The stored passcode hash of a link (kept when only the expiry is changed). */
export function getSharePassHash(id, userId, outline = false) {
const c = shareCols(outline);
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
}
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
const c = shareCols(outline);
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
}
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
export function getSharedByToken(token, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
if (!row) return null;
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
}
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
@@ -513,6 +730,7 @@ export function adminGetAllUsers() {
export function adminDeleteUser(userId) {
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
})();