Teaching: hide-notes-until share, session length check, slides export
- A study share link can hide the owner's notes until a chosen time: the server sends only passages and discussion questions until then, with a banner in the shared view (new share_reveal column, migrated on start). - Outline: a session length with "N of M min planned, X min over" and a marker on the point where the plan runs past it. - Export > Slides: printable 16:9 slides (title, passage, outline points with their verse, discussion questions) for one session or all; light or dark. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67645cf1df
commit
2c7c7145c4
14 files changed
+303
-33
No files matched your search
+14
-12
@@ -140,7 +140,7 @@ export function initDb() {
|
||||
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']) {
|
||||
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', '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');
|
||||
@@ -780,7 +780,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, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = 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 ----
|
||||
@@ -800,14 +800,14 @@ export function clearOutlineShareToken(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: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
|
||||
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
|
||||
|
||||
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
|
||||
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
|
||||
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;
|
||||
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
||||
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
|
||||
}
|
||||
|
||||
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
||||
@@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) {
|
||||
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) {
|
||||
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
|
||||
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, 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;
|
||||
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
||||
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt 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; }
|
||||
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js';
|
||||
import { buildOutlinePayload } from '../outlineShare.js';
|
||||
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
||||
|
||||
@@ -16,7 +16,7 @@ export function registerSharingRoutes(app) {
|
||||
try {
|
||||
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
|
||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
@@ -37,8 +37,10 @@ export function registerSharingRoutes(app) {
|
||||
// a blank passcode field means "keep it"; null clears; a string sets
|
||||
const current = getSharePassHash(req.params.id, userId, outline);
|
||||
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
||||
// only a study link can hide its notes until a time; a past or empty time turns hiding off
|
||||
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
|
||||
} catch (err) {
|
||||
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
@@ -123,14 +125,14 @@ export function registerSharingRoutes(app) {
|
||||
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||
return null;
|
||||
}
|
||||
return found.project;
|
||||
return found;
|
||||
}
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||
app.get('/api/share-outline/:token', async (req, res) => {
|
||||
try {
|
||||
const project = await openSharedLink(req, res, true);
|
||||
if (project) res.json(buildOutlinePayload(project));
|
||||
const found = await openSharedLink(req, res, true);
|
||||
if (found) res.json(buildOutlinePayload(found.project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
@@ -140,8 +142,8 @@ export function registerSharingRoutes(app) {
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||
app.get('/api/share/:token', async (req, res) => {
|
||||
try {
|
||||
const project = await openSharedLink(req, res, false);
|
||||
if (project) res.json(project);
|
||||
const found = await openSharedLink(req, res, false);
|
||||
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
|
||||
@@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now =
|
||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
|
||||
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
|
||||
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
|
||||
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
|
||||
|
||||
export function hideUntilReveal(project, revealAt, now = Date.now()) {
|
||||
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
|
||||
return {
|
||||
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
|
||||
hiddenUntil: revealAt,
|
||||
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
|
||||
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
|
||||
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
|
||||
export function revealFromInput(value, now = Date.now()) {
|
||||
const n = Number(value);
|
||||
if (!value || !Number.isFinite(n) || n <= now) return null;
|
||||
return Math.min(n, now + 365 * 86_400_000);
|
||||
}
|
||||
@@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked
|
||||
const passHash = await hashPasscode('x');
|
||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||
});
|
||||
|
||||
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
|
||||
|
||||
const study = () => ({
|
||||
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
|
||||
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
|
||||
});
|
||||
|
||||
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
|
||||
const now = 1_000;
|
||||
const out = hideUntilReveal(study(), now + 5_000, now);
|
||||
expect(out.hiddenUntil).toBe(now + 5_000);
|
||||
expect(out.outline).toEqual([]);
|
||||
expect(out.chapters[0].verses).toHaveLength(1);
|
||||
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
|
||||
expect(JSON.stringify(out)).not.toContain('secret');
|
||||
});
|
||||
|
||||
test('after the reveal time, or with none set, the whole study is sent', () => {
|
||||
const full = study();
|
||||
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
|
||||
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
|
||||
});
|
||||
|
||||
test('a reveal time must be in the future, and no more than a year away', () => {
|
||||
const now = 1_000_000;
|
||||
expect(revealFromInput(null, now)).toBeNull();
|
||||
expect(revealFromInput(now - 1, now)).toBeNull();
|
||||
expect(revealFromInput('nope', now)).toBeNull();
|
||||
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
|
||||
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
|
||||
});
|
||||
Reference in new issue
Block a user