Files
study-bible-project/src/syncService.js
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

321 lines
13 KiB
JavaScript

/**
* syncService.js
*
* Thin wrapper around the /api/projects endpoints.
* All functions are fire-and-forget friendly: they never throw — they
* return { ok: true, data } or { ok: false, error }.
*
* The caller decides whether to surface the error to the user.
*/
const BASE = '/api';
async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
try {
const opts = {
method,
headers: { 'Content-Type': 'application/json', ...headers },
credentials: 'include',
};
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
}
return { ok: true, data };
} catch (err) {
return { ok: false, status: null, error: err?.message ?? 'Network error' };
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* List all remote project summaries.
* Returns { ok, data: Array<{ id, title, lastEdited, chapterSummary }> }
*/
export async function listRemoteProjects() {
return request('GET', '/projects');
}
/**
* Fetch a single full project by id.
* Returns { ok, data: project }
*/
export async function loadRemoteProject(id) {
return request('GET', `/projects/${id}`);
}
/**
* Save (create or update) a project on the server.
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
* Returns { ok, data: summary }
*/
export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
});
}
/**
* Delete a project from the server.
* Returns { ok }
*/
export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
}
// ---------------------------------------------------------------------------
// Reading plan progress
// ---------------------------------------------------------------------------
/** Returns the signed-in user's whole reading-plan progress list. */
export async function getRemoteReadingProgress() {
return request('GET', '/reading-plans');
}
/** Overwrites the signed-in user's whole reading-plan progress list. */
export async function saveRemoteReadingProgress(progress) {
return request('PUT', '/reading-plans', progress);
}
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
/** Returns { shareToken } — the project's current share token, or null if sharing is off. */
export async function getShareStatus(id) {
return request('GET', `/projects/${id}/share`);
}
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
export async function enableSharing(id, options = {}) {
return request('POST', `/projects/${id}/share`, options);
}
/** Revokes a project's share link. */
export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`);
}
// ---- earlier versions of a study ----
export async function listStudyVersions(id) { return request('GET', `/projects/${id}/versions`); }
export async function getStudyVersion(id, versionId) { return request('GET', `/projects/${id}/versions/${versionId}`); }
/** Puts an earlier version back; returns the study as it now is. */
export async function restoreStudyVersion(id, versionId) { return request('POST', `/projects/${id}/versions/${versionId}/restore`); }
// ---- co-teachers: other accounts that may edit a study ----
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
export async function createEditInvite(id) { return request('POST', `/projects/${id}/collaborators/invite`); }
export async function removeCollaborator(id, userId) { return request('DELETE', `/projects/${id}/collaborators/${userId}`); }
/** Uses an invite link: -> { projectId, title } */
export async function joinStudy(token) { return request('POST', `/join/${encodeURIComponent(token)}`); }
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
export async function getOutlineShareStatus(id) {
return request('GET', `/projects/${id}/outline-share`);
}
export async function enableOutlineSharing(id, options = {}) {
return request('POST', `/projects/${id}/outline-share`, options);
}
export async function disableOutlineSharing(id) {
return request('DELETE', `/projects/${id}/outline-share`);
}
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
export async function getSharedOutline(token, passcode) {
return request('GET', `/share-outline/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
}
/** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token, passcode) {
return request('GET', `/share/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
}
/**
* Check whether the server is reachable.
* Returns true / false.
*/
export async function isServerReachable() {
try {
const res = await fetch(`${BASE}/health`, { method: 'GET' });
return res.ok;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
/**
* Returns the signed-in user, or null if not signed in.
* Returns { ok: false } (with status: null) only on a genuine network failure,
* so callers can distinguish "not logged in" from "server unreachable".
*/
export async function getCurrentUser() {
const result = await request('GET', '/auth/me');
if (result.ok) return { ok: true, user: result.data };
if (result.status === 401) return { ok: true, user: null };
return result;
}
export async function registerUser(email, password, invite) {
return request('POST', '/auth/register', { email, password, invite });
}
/** Who may sign up here: { mode: 'open' | 'invite' | 'closed' }. */
export async function getSignupMode() {
return request('GET', '/auth/signup-mode');
}
export async function loginUser(email, password) {
return request('POST', '/auth/login', { email, password });
}
// Passkeys and signed-in devices
export const passkeySignInOptions = () => request('POST', '/auth/passkey/options');
export const passkeySignInVerify = (response) => request('POST', '/auth/passkey/verify', response);
export const listPasskeys = () => request('GET', '/passkeys');
export const passkeyAddOptions = () => request('POST', '/passkeys/options');
export const passkeyAdd = (response, name) => request('POST', '/passkeys', { response, name });
export const passkeyRename = (id, name) => request('PATCH', `/passkeys/${encodeURIComponent(id)}`, { name });
export const passkeyRemove = (id) => request('DELETE', `/passkeys/${encodeURIComponent(id)}`);
export const listDevices = () => request('GET', '/sessions');
export const signOutDevice = (id) => request('DELETE', `/sessions/${id}`);
export const signOutOtherDevices = () => request('POST', '/sessions/sign-out-others');
export const setLoginAlerts = (enabled) => request('PUT', '/auth/login-alerts', { enabled });
/** Asks for a password-reset code to be sent to the account's notification topic. */
export async function requestPasswordReset(email) {
return request('POST', '/auth/forgot', { email });
}
/** Sets a new password using the code that was sent. `token` is the authenticator code, when 2FA is on. */
export async function resetPassword({ email, code, newPassword, token }) {
return request('POST', '/auth/reset', { email, code, newPassword, token });
}
export async function logoutUser() {
return request('POST', '/auth/logout');
}
/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */
export async function verifyMfaLogin({ token, backupCode }) {
return request('POST', '/auth/mfa/verify', { token, backupCode });
}
/** Updates account-level profile settings (currently just the podcast/show name). */
export async function updateProfile({ podcastName }) {
return request('PATCH', '/auth/profile', { podcastName });
}
/** Self-service password change. Requires the current password; signs out every other session. */
export async function changePassword(currentPassword, newPassword) {
return request('POST', '/auth/change-password', { currentPassword, newPassword });
}
/** The signed-in user's synced display preferences: { prefs: { key: { v, at } } }. */
export async function getRemotePrefs() {
return request('GET', '/prefs');
}
/** Merges changed preferences into the account ({ key: { v, at } }); newest change per key wins. */
export async function saveRemotePrefs(prefs) {
return request('PUT', '/prefs', { prefs });
}
/** The signed-in user's bookmarks + highlights: { bookmarks, highlights, updatedAt }. */
export async function getRemoteMarks() {
return request('GET', '/reader/marks');
}
/** Replaces the user's bookmarks + highlights ({ bookmarks, highlights }); returns { updatedAt }. */
export async function saveRemoteMarks(marks) {
return request('PUT', '/reader/marks', marks);
}
/** The prayer journal: { prayers: [...] }. */
export async function getRemotePrayers() { return request('GET', '/prayers'); }
/** Merges this device's prayers into the account's (the newer version of each wins); returns the combined list. */
export async function saveRemotePrayers(prayers) { return request('PUT', '/prayers', { prayers }); }
/** What the person has read: { chapters, days }. */
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
/** Merges this device's reading history into the account's; returns the combined history. */
export async function saveRemoteHistory(history) { return request('PUT', '/reader/history', history); }
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
export async function sendReminderTest(settings) {
return request('POST', '/auth/reminder-test', settings);
}
/** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */
export async function updateReminderSettings(settings) {
return request('PATCH', '/auth/reminder-settings', settings);
}
// ---------------------------------------------------------------------------
// Two-factor auth setup (Account Settings page)
// ---------------------------------------------------------------------------
/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */
export async function startMfaSetup() {
return request('POST', '/auth/mfa/setup');
}
/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */
export async function confirmMfaSetup(token) {
return request('POST', '/auth/mfa/enable', { token });
}
/** Turns 2FA off. Requires the current password as a safety check. */
export async function disableMfa(password) {
return request('POST', '/auth/mfa/disable', { password });
}
// ---------------------------------------------------------------------------
// Admin (restricted server-side to the designated admin account)
// ---------------------------------------------------------------------------
export async function adminListUsers() {
return request('GET', '/admin/users');
}
export const adminGetHealth = () => request('GET', '/admin/health');
export const adminBackupNow = () => request('POST', '/admin/backups');
export const adminRestoreSnapshot = (name) => request('POST', `/admin/backups/${encodeURIComponent(name)}/restore`, { confirm: name });
export const adminSnapshotUrl = (name) => `${BASE}/admin/backups/${encodeURIComponent(name)}`;
export async function adminDeleteUser(id) {
return request('DELETE', `/admin/users/${id}`);
}
/** Sets a random temporary password for a user and signs them out everywhere. Returns { temporaryPassword }. */
export async function adminResetPassword(id) {
return request('POST', `/admin/users/${id}/reset-password`);
}
/** The administrator's sign-up setting and invite codes. */
export async function adminGetSignup() { return request('GET', '/admin/signup'); }
export async function adminSetSignupMode(mode) { return request('PUT', '/admin/signup', { mode }); }
export async function adminCreateInvite({ note, days } = {}) { return request('POST', '/admin/invites', { note, days }); }
export async function adminDeleteInvite(code) { return request('DELETE', `/admin/invites/${encodeURIComponent(code)}`); }
export async function adminListProjects() {
return request('GET', '/admin/projects');
}
export async function adminGetProject(id) {
return request('GET', `/admin/projects/${id}`);
}
export async function adminDeleteProject(id) {
return request('DELETE', `/admin/projects/${id}`);
}