/** * 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}`); }