Files
study-bible-project/server/shareLinks.js
T
nmemmertandClaude Sonnet 5.5 2c7c7145c4 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>
2026-10-14 13:08:21 -04:00

52 lines
2.8 KiB
JavaScript

// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
// so they can be tested on their own.
import bcrypt from 'bcryptjs';
export const MAX_PASSCODE = 100;
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
export function expiryFromDays(days, now = Date.now()) {
const n = Number(days);
if (!days || !Number.isFinite(n) || n <= 0) return null;
return now + Math.min(365, Math.round(n)) * 86_400_000;
}
export async function hashPasscode(passcode) {
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
}
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
if (!passHash) return { ok: true };
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
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);
}