← back to Professional Directory
agents/api-agent/routes/claims.js
257 lines
/**
* /doctor-claims — submit a claim, verify via email-link, admin moderate.
*
* POST /doctor-claims — submit (auth required)
* GET /doctor-claims/:token — email-link verifier
* GET /doctor-claims/mine — my claims (auth required)
* GET /admin/doctor-claims — admin moderation queue
* POST /admin/doctor-claims/:id/decide — approve | reject (admin only)
*
* Auto-approval rules:
* - For target_professional_id: NPI matches AND email_verified_at set AND
* submitted email domain matches a known org-website domain for that pro.
* - For target_organization_id: email_verified_at set AND submitted email
* domain matches the org.website hostname.
* Otherwise queued for admin manual review.
*/
const express = require('express');
const crypto = require('node:crypto');
const { fetch } = require('undici');
const { query } = require('../../shared/db');
const { requireRole } = require('../auth');
const router = express.Router();
const GEORGE_BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
function token(n = 32) {
return crypto.randomBytes(n).toString('hex');
}
function emailDomain(e) {
const at = String(e || '').lastIndexOf('@');
return at >= 0 ? String(e).slice(at + 1).toLowerCase() : '';
}
function urlDomain(u) {
try { return new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u).hostname.toLowerCase().replace(/^www\./, ''); }
catch { return ''; }
}
async function sendVerifyEmail(to, link, name) {
if (!process.env.GEORGE_DISABLE) {
try {
await fetch(`${GEORGE_BASE}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to,
subject: `Verify your claim on ${name || 'a healthcare profile'}`,
body: `Click to verify the claim:\n\n${link}\n\nIf you did not request this, ignore this email. The link expires in 7 days.\n\n— Agent Abrams`,
}),
signal: AbortSignal.timeout(10_000),
});
} catch (_) { /* George down — admin can re-send */ }
}
}
router.use((req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'auth required' });
next();
});
// Submit a claim.
router.post('/', async (req, res, next) => {
try {
const b = req.body || {};
const targetProId = b.target_professional_id ? Number(b.target_professional_id) : null;
const targetOrgId = b.target_organization_id ? Number(b.target_organization_id) : null;
const npi = b.npi ? String(b.npi).slice(0, 10) : null;
const license = b.license_number ? String(b.license_number).slice(0, 64) : null;
if (Boolean(targetProId) === Boolean(targetOrgId)) {
return res.status(400).json({ error: 'must provide exactly one of target_professional_id or target_organization_id' });
}
// Pull verification context.
let row = null;
if (targetProId) {
row = (await query('SELECT id, full_name, npi_number, license_number FROM professionals WHERE id = $1 AND opted_out = false', [targetProId])).rows[0];
} else {
row = (await query('SELECT id, name, website FROM organizations WHERE id = $1 AND opted_out = false', [targetOrgId])).rows[0];
}
if (!row) return res.status(404).json({ error: 'target not found' });
// Block second pending claim for same target.
const existing = await query(
`SELECT id FROM doctor_claims
WHERE status = 'pending' AND
((target_professional_id = $1 AND $1 IS NOT NULL) OR
(target_organization_id = $2 AND $2 IS NOT NULL))
LIMIT 1`,
[targetProId, targetOrgId]
);
if (existing.rowCount > 0) return res.status(409).json({ error: 'a pending claim already exists for this target' });
const verifyToken = token();
const r = await query(`
INSERT INTO doctor_claims (user_id, target_professional_id, target_organization_id,
npi_provided, license_provided, email_verification_token, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
RETURNING id, email_verification_token, created_at
`, [req.user.id, targetProId, targetOrgId, npi, license, verifyToken]);
const link = `${req.protocol}://${req.get('host')}/doctor-claims/${verifyToken}`;
const name = row.full_name || row.name || '';
await sendVerifyEmail(req.user.email, link, name);
res.json({ ok: true, claim_id: r.rows[0].id, verify_link_sent_to: req.user.email });
} catch (e) { next(e); }
});
// My claims. Defined BEFORE /:token so 'mine' isn't captured as a token.
router.get('/mine', async (req, res, next) => {
try {
const r = await query(`
SELECT id, target_professional_id, target_organization_id,
status, status_reason, email_verified_at, created_at, decided_at
FROM doctor_claims
WHERE user_id = $1
ORDER BY id DESC LIMIT 50
`, [req.user.id]);
res.json({ count: r.rowCount, rows: r.rows });
} catch (e) { next(e); }
});
// Email-link verify. Open in browser; sets email_verified_at and runs auto-approval.
router.get('/:token', async (req, res, next) => {
try {
const t = String(req.params.token || '');
if (!/^[a-f0-9]{32,128}$/i.test(t)) return res.status(400).type('text').send('invalid token');
const claim = (await query(
`SELECT * FROM doctor_claims WHERE email_verification_token = $1 LIMIT 1`,
[t]
)).rows[0];
if (!claim) return res.status(404).type('text').send('claim not found');
if (claim.status !== 'pending') return res.status(409).type('text').send(`claim already ${claim.status}`);
if (claim.user_id !== req.user.id) return res.status(403).type('text').send('this verify link is for a different user');
await query(`UPDATE doctor_claims SET email_verified_at = now() WHERE id = $1`, [claim.id]);
// Auto-approval check.
let autoApprove = false;
let reason = '';
const userDomain = emailDomain(req.user.email);
if (claim.target_professional_id) {
const pro = (await query('SELECT npi_number FROM professionals WHERE id=$1', [claim.target_professional_id])).rows[0];
if (pro && pro.npi_number && claim.npi_provided && pro.npi_number === claim.npi_provided) {
// Look up the org website domain via professional_locations.
const orgWeb = (await query(`
SELECT DISTINCT o.website
FROM professional_locations pl
JOIN organizations o ON o.id = pl.organization_id
WHERE pl.professional_id = $1 AND o.website IS NOT NULL`,
[claim.target_professional_id])).rows.map(r => urlDomain(r.website)).filter(Boolean);
if (orgWeb.includes(userDomain)) {
autoApprove = true;
reason = 'NPI match + email domain matches practice website';
}
}
} else if (claim.target_organization_id) {
const org = (await query('SELECT website FROM organizations WHERE id=$1', [claim.target_organization_id])).rows[0];
if (org && org.website) {
const ow = urlDomain(org.website);
// SECURITY: codex flagged that string-suffix matching lets attacker@example.com
// claim *.example.com practices. Require strict equality, OR exact subdomain
// (the user's domain ends with `.<orgdomain>`) — not the reverse.
// Also: queue for admin review when there's any ambiguity.
if (ow && ow === userDomain) {
autoApprove = true;
reason = 'email domain exactly matches org website';
}
}
}
if (autoApprove) {
await query(`UPDATE doctor_claims SET status='approved', status_reason=$2, decided_at=now() WHERE id=$1`, [claim.id, reason]);
// Promote user role + claim link.
const newRole = claim.target_professional_id ? 'doctor' : 'doctor';
await query(`
UPDATE users
SET role = $1,
claimed_professional_id = COALESCE($2, claimed_professional_id),
claimed_organization_id = COALESCE($3, claimed_organization_id)
WHERE id = $4
`, [newRole, claim.target_professional_id, claim.target_organization_id, claim.user_id]);
// Flip target's claim_status.
if (claim.target_professional_id) {
await query(`UPDATE professionals SET claim_status='verified' WHERE id=$1`, [claim.target_professional_id]);
} else {
await query(`UPDATE organizations SET claim_status='verified' WHERE id=$1`, [claim.target_organization_id]);
}
return res.type('html').send(`<h2>Claim approved</h2><p>${reason}.</p><p>You're now signed in as a <strong>doctor</strong>. <a href="/">Continue</a>.</p>`);
}
res.type('html').send(`<h2>Email verified</h2><p>Your claim is now in admin review. We'll email you when it's decided.</p><p><a href="/">Back</a></p>`);
} catch (e) { next(e); }
});
// ─── /admin/doctor-claims ─────────────────────────────────────────────────
const adminRouter = express.Router();
adminRouter.use(requireRole('admin'));
adminRouter.get('/', async (req, res, next) => {
try {
const status = req.query.status || 'pending';
const r = await query(`
SELECT c.*, u.email AS user_email,
COALESCE(p.full_name, o.name) AS target_name,
p.npi_number AS target_npi,
o.website AS target_website
FROM doctor_claims c
JOIN users u ON u.id = c.user_id
LEFT JOIN professionals p ON p.id = c.target_professional_id
LEFT JOIN organizations o ON o.id = c.target_organization_id
WHERE c.status = $1
ORDER BY c.id DESC LIMIT 200
`, [status]);
res.json({ count: r.rowCount, rows: r.rows });
} catch (e) { next(e); }
});
adminRouter.post('/:id/decide', async (req, res, next) => {
try {
const id = Number(req.params.id);
const decision = String(req.body?.decision || '').toLowerCase();
const note = String(req.body?.note || '').slice(0, 500);
if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: "decision must be 'approve' or 'reject'" });
const claim = (await query('SELECT * FROM doctor_claims WHERE id = $1 FOR UPDATE', [id])).rows[0];
if (!claim) return res.status(404).json({ error: 'not found' });
if (claim.status !== 'pending') return res.status(409).json({ error: `already ${claim.status}` });
if (decision === 'approve') {
await query(`UPDATE doctor_claims SET status='approved', status_reason=$2, decided_at=now(), decided_by_user_id=$3 WHERE id=$1`,
[id, note || 'admin approved', req.user.id]);
await query(`UPDATE users SET role='doctor',
claimed_professional_id = COALESCE($2, claimed_professional_id),
claimed_organization_id = COALESCE($3, claimed_organization_id)
WHERE id = $1`,
[claim.user_id, claim.target_professional_id, claim.target_organization_id]);
if (claim.target_professional_id) {
await query(`UPDATE professionals SET claim_status='verified' WHERE id=$1`, [claim.target_professional_id]);
} else {
await query(`UPDATE organizations SET claim_status='verified' WHERE id=$1`, [claim.target_organization_id]);
}
} else {
await query(`UPDATE doctor_claims SET status='rejected', status_reason=$2, decided_at=now(), decided_by_user_id=$3 WHERE id=$1`,
[id, note || 'admin rejected', req.user.id]);
}
res.json({ ok: true, claim_id: id, decision });
} catch (e) { next(e); }
});
module.exports = { router, adminRouter };