← 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 };