pediatric-ai-scribe-v3/src/utils/registrationInvites.js
Daniel 7b084c7edf
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
fix: an invitation can only be deleted once it has been used
The delete button was offered on every invitation regardless of state, and the
query behind it deleted any row it was given. Deleting an unused code takes it
off the list without taking it out of anybody's inbox: the person still holds
something that looks like a valid invitation, it silently stops working, and
there is no longer a record of who it went to or why. Revoke is what stops a
live code — it leaves the row behind, marked.

So the delete is now for spent codes only, in three places rather than one: the
query carries AND used_at IS NOT NULL, the route answers 409 with the reason
instead of pretending the row is missing, and the button is rendered only on a
used row.

A "Clear N used" control alongside, since the complaint was clutter and clearing
them one at a time is not much of an answer. Same rule — nothing unused or
revoked is touched — and it confirms first, because it is still a delete.

The bulk route is declared before /invites/:id, or Express reads "used" as an id.

Verified against the live database: deleting an unused invitation is refused and
the row survives, deleting a used one works, the bulk clear removes only used
ones, and the unused probe row was still there afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:30:06 +02:00

159 lines
5.5 KiB
JavaScript

// ============================================================
// REGISTRATION INVITES
// registration_enabled is open-or-closed. This is the middle setting: open to
// people you invited. One module owns the rules so the register route and the
// admin routes cannot disagree about what "valid" means.
//
// A code is single-use, expires, and can be revoked. It is stored hashed,
// because an invite grants account creation and a database dump should not
// hand someone a working one.
// ============================================================
var crypto = require('crypto');
// Required lazily. The code-generation and hashing helpers are pure, and
// requiring the database at module load made simply importing this file open a
// connection — which is why a test that only checked those helpers hung.
function db() {
return require('../db/database');
}
var DEFAULT_TTL_DAYS = 7;
var MAX_TTL_DAYS = 90;
// Crockford-style: no I, L, O or U, so a code read aloud or copied off a screen
// is hard to mistype and cannot spell anything unfortunate.
var ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
function generateCode() {
var bytes = crypto.randomBytes(16);
var out = '';
for (var i = 0; i < 16; i++) {
out += ALPHABET[bytes[i] % ALPHABET.length];
if (i % 4 === 3 && i !== 15) out += '-';
}
return out; // XXXX-XXXX-XXXX-XXXX
}
function normalize(code) {
return String(code == null ? '' : code).toUpperCase().replace(/[^0-9A-Z]/g, '');
}
function hash(code) {
return crypto.createHash('sha256').update(normalize(code)).digest('hex');
}
function ttlDays(requested) {
var days = Number(requested);
if (!Number.isFinite(days) || days < 1) return DEFAULT_TTL_DAYS;
return Math.min(Math.floor(days), MAX_TTL_DAYS);
}
// Returns the code once. It is never recoverable afterwards — only its hash and
// the last four characters are kept.
async function create(adminUserId, options) {
var opts = options || {};
var code = generateCode();
var days = ttlDays(opts.days);
var note = String(opts.note || '').trim().slice(0, 200);
await db().run(
"INSERT INTO registration_invites (code_hash, code_hint, note, created_by, expires_at) " +
"VALUES ($1, $2, $3, $4, NOW() + ($5 || ' days')::interval)",
[hash(code), normalize(code).slice(-4), note, adminUserId, String(days)]
);
return { code: code, days: days, note: note };
}
async function list() {
return db().all(
"SELECT i.id, i.code_hint, i.note, i.created_at, i.expires_at, i.used_at, i.revoked_at, " +
" c.email AS created_by_email, u.email AS used_by_email, " +
" CASE WHEN i.revoked_at IS NOT NULL THEN 'revoked' " +
" WHEN i.used_at IS NOT NULL THEN 'used' " +
" WHEN i.expires_at <= NOW() THEN 'expired' " +
" ELSE 'active' END AS status " +
"FROM registration_invites i " +
"LEFT JOIN users c ON c.id = i.created_by " +
"LEFT JOIN users u ON u.id = i.used_by " +
"ORDER BY i.created_at DESC LIMIT 200", []
);
}
async function revoke(id, adminUserId) {
// Revoking an already-used code would rewrite history, so only a live one.
var result = await db().run(
'UPDATE registration_invites SET revoked_at = NOW(), revoked_by = $1 ' +
'WHERE id = $2 AND revoked_at IS NULL AND used_at IS NULL',
[adminUserId, id]
);
return result.changes > 0;
}
/**
* Delete a spent invitation.
*
* Used codes only. A code that has not been used yet is one somebody may still
* be holding: deleting it takes it out of the list without taking it out of
* their inbox, and there is then no record of who it went to or why it stopped
* working. Revoke does that job — it leaves the row, marked. This is only for
* clearing away codes whose whole story is already told.
*
* Returns false for a code that is not spent, which the caller reports rather
* than treating as a missing row.
*/
async function remove(id) {
var result = await db().run(
'DELETE FROM registration_invites WHERE id = $1 AND used_at IS NOT NULL', [id]);
return result.changes > 0;
}
// Every spent invitation at once, which is what "they clutter the list" asks
// for. Same rule: nothing unused is touched.
async function removeUsed() {
var result = await db().run('DELETE FROM registration_invites WHERE used_at IS NOT NULL');
return result.changes || 0;
}
/**
* Claim a code for a registration, atomically.
*
* The UPDATE carries every condition, so two registrations racing the same code
* cannot both succeed: the second matches no row. Checking first and updating
* after would leave exactly that gap.
*
* Returns the invite id, or null if the code is unusable for any reason —
* unknown, expired, revoked or already used. The caller must not say which:
* distinguishing them tells someone probing codes which guesses were closer.
*/
async function claim(code, userId) {
var normalized = normalize(code);
if (!normalized) return null;
var rows = await db().all(
'UPDATE registration_invites SET used_at = NOW(), used_by = $1 ' +
'WHERE code_hash = $2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() ' +
'RETURNING id',
[userId, hash(normalized)]
);
return rows && rows.length ? rows[0].id : null;
}
// True when a code must be supplied to register at all.
async function inviteOnly() {
return String(await db().getSetting('registration_invite_only') || 'false') === 'true';
}
module.exports = {
DEFAULT_TTL_DAYS,
MAX_TTL_DAYS,
generateCode,
normalize,
hash,
ttlDays,
create,
list,
revoke,
remove,
removeUsed,
claim,
inviteOnly
};