← back to Professional Directory

agents/api-agent/routes/reviews.js

212 lines

/**
 * /reviews — patient + community reviews of professionals + organizations.
 *
 *   GET    /reviews?target_org=X|target_pro=X     — public list (honors suppression + hidden + 24h delay)
 *   POST   /reviews                                — submit (auth required)
 *   POST   /reviews/:id/respond                    — doctor's reply (claimed-doctor only)
 *   POST   /reviews/:id/flag                       — community moderation
 *   POST   /reviews/:id/suppress                   — claimed target hides it from their profile
 *
 * Admin moderation lives in /admin/reviews/* (separate router).
 *
 * Anti-brigading: user-source reviews go live 24h after creation (go_live_at).
 * Seeded reviews go live immediately.
 */
const express = require('express');
const { query } = require('../../shared/db');
const { requireRole } = require('../auth');

const router = express.Router();

// PUBLIC: list reviews for a target. Filters out suppressed/hidden/not-yet-live.
router.get('/', async (req, res, next) => {
  try {
    const proId = req.query.target_pro ? Number(req.query.target_pro) : null;
    const orgId = req.query.target_org ? Number(req.query.target_org) : null;
    if (!proId && !orgId) return res.status(400).json({ error: 'target_pro or target_org required' });

    const limit  = Math.min(Number(req.query.limit) || 25, 100);
    const offset = Number(req.query.offset) || 0;

    const where = [
      'r.suppressed_by_target = false',
      'r.hidden_by_admin = false',
      "(r.go_live_at IS NULL OR r.go_live_at <= now())",
    ];
    const params = [];
    if (proId) { params.push(proId); where.push(`r.target_professional_id = $${params.length}`); }
    if (orgId) { params.push(orgId); where.push(`r.target_organization_id = $${params.length}`); }

    const r = await query(`
      SELECT r.id, r.target_professional_id, r.target_organization_id,
             r.service_score, r.price_score, r.quality_score, r.overall_score,
             r.title, r.body, r.source, r.source_url, r.source_posted_at,
             r.created_at,
             COALESCE(r.reviewer_display, u.display_name, 'Patient') AS author_name,
             rr.id AS response_id, rr.body AS response_body, rr.created_at AS response_at
        FROM reviews r
   LEFT JOIN users u ON u.id = r.reviewer_user_id
   LEFT JOIN review_responses rr ON rr.review_id = r.id
       WHERE ${where.join(' AND ')}
       ORDER BY r.created_at DESC
       LIMIT ${limit} OFFSET ${offset}
    `, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e) { next(e); }
});

// AGGREGATE — per-target denormalized scores, fast lookup.
router.get('/summary', async (req, res, next) => {
  try {
    const proId = req.query.target_pro ? Number(req.query.target_pro) : null;
    const orgId = req.query.target_org ? Number(req.query.target_org) : null;
    if (!proId && !orgId) return res.status(400).json({ error: 'target_pro or target_org required' });
    const r = await query(`
      SELECT * FROM aggregated_ratings
       WHERE ($1::bigint IS NOT NULL AND target_professional_id = $1)
          OR ($2::bigint IS NOT NULL AND target_organization_id = $2)
       LIMIT 1`,
      [proId, orgId]);
    res.json(r.rows[0] || { n_reviews: 0, overall_avg: null });
  } catch (e) { next(e); }
});

// AUTHED routes below.
router.use((req, res, next) => {
  if (!req.user) return res.status(401).json({ error: 'auth required' });
  next();
});

// POST /reviews — submit a new user review.
router.post('/', async (req, res, next) => {
  try {
    const b = req.body || {};
    const proId = b.target_professional_id ? Number(b.target_professional_id) : null;
    const orgId = b.target_organization_id ? Number(b.target_organization_id) : null;
    if (Boolean(proId) === Boolean(orgId)) {
      return res.status(400).json({ error: 'must provide exactly one of target_professional_id or target_organization_id' });
    }

    // Block reviewing your own claimed entity.
    if ((proId && req.user.claimed_professional_id === proId) ||
        (orgId && req.user.claimed_organization_id === orgId)) {
      return res.status(403).json({ error: 'cannot review your own claimed profile' });
    }

    const body = String(b.body || '').trim();
    if (body.length < 10 || body.length > 5000) return res.status(400).json({ error: 'body 10-5000 chars' });
    const title = b.title ? String(b.title).slice(0, 200) : null;
    const sv = b.service_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.service_score)))) : null;
    const pr = b.price_score   != null ? Math.max(1, Math.min(5, Math.round(Number(b.price_score))))   : null;
    const ql = b.quality_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.quality_score)))) : null;
    const ov = (sv != null && pr != null && ql != null) ? Math.round((sv + pr + ql) / 3) : (b.overall_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.overall_score)))) : null);

    const r = await query(`
      INSERT INTO reviews (target_professional_id, target_organization_id, reviewer_user_id,
                           service_score, price_score, quality_score, overall_score,
                           title, body, source, go_live_at)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'user', now() + interval '24 hours')
      RETURNING id, go_live_at
    `, [proId, orgId, req.user.id, sv, pr, ql, ov, title, body]);
    res.json({ ok: true, review_id: r.rows[0].id, go_live_at: r.rows[0].go_live_at, note: 'visible publicly in 24 hours (anti-brigading delay)' });
  } catch (e) {
    if (e.code === '23505') return res.status(409).json({ error: 'you already reviewed this target' });
    next(e);
  }
});

// POST /reviews/:id/respond — doctor's official reply on a review of THEIR claimed target.
router.post('/:id/respond', async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    const body = String(req.body?.body || '').trim();
    if (body.length < 10 || body.length > 3000) return res.status(400).json({ error: 'body 10-3000 chars' });
    const r = (await query(`SELECT target_professional_id, target_organization_id FROM reviews WHERE id=$1`, [id])).rows[0];
    if (!r) return res.status(404).json({ error: 'review not found' });
    const claimedPro = req.user.claimed_professional_id;
    const claimedOrg = req.user.claimed_organization_id;
    const ownsTarget = (r.target_professional_id && r.target_professional_id == claimedPro) ||
                       (r.target_organization_id && r.target_organization_id == claimedOrg);
    if (!ownsTarget && req.user.role !== 'admin') {
      return res.status(403).json({ error: 'only the claimed practitioner/practice or an admin may respond' });
    }
    if (req.user.role !== 'admin' && req.user.tier !== 'paid') {
      return res.status(402).json({ error: 'doctor responses require paid tier', upgrade: '/subscriptions/checkout' });
    }
    const ins = await query(`
      INSERT INTO review_responses (review_id, author_user_id, body)
      VALUES ($1, $2, $3)
      ON CONFLICT (review_id) DO UPDATE SET body = EXCLUDED.body, updated_at = now()
      RETURNING id, created_at, updated_at`, [id, req.user.id, body]);
    res.json({ ok: true, response: ins.rows[0] });
  } catch (e) { next(e); }
});

// POST /reviews/:id/flag — any authed user
router.post('/:id/flag', async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    const reason = String(req.body?.reason || '').toLowerCase();
    const allowed = ['spam','fake','off_topic','medical_advice','personal_attack','other'];
    if (!allowed.includes(reason)) return res.status(400).json({ error: 'reason must be one of: ' + allowed.join(', ') });
    const notes = String(req.body?.notes || '').slice(0, 500);
    await query(`INSERT INTO review_flags (review_id, flagger_user_id, reason, notes) VALUES ($1, $2, $3, $4)`,
      [id, req.user.id, reason, notes]);
    res.json({ ok: true });
  } catch (e) { next(e); }
});

// POST /reviews/:id/suppress — only the claimed-target user
// (suppress = hide on MY profile; review still exists, admin can see).
router.post('/:id/suppress', async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    const r = (await query(`SELECT target_professional_id, target_organization_id FROM reviews WHERE id=$1`, [id])).rows[0];
    if (!r) return res.status(404).json({ error: 'not found' });
    const claimedPro = req.user.claimed_professional_id;
    const claimedOrg = req.user.claimed_organization_id;
    const ownsTarget = (r.target_professional_id && r.target_professional_id == claimedPro) ||
                       (r.target_organization_id && r.target_organization_id == claimedOrg);
    if (!ownsTarget && req.user.role !== 'admin') {
      return res.status(403).json({ error: 'only the claimed target may suppress reviews on their own profile' });
    }
    const flag = req.body?.suppress !== false;
    await query(`UPDATE reviews SET suppressed_by_target=$2, updated_at=now() WHERE id=$1`, [id, flag]);
    res.json({ ok: true, suppressed: flag });
  } catch (e) { next(e); }
});

// ─── /admin/reviews moderation queue ──────────────────────────────────────
const adminRouter = express.Router();
adminRouter.use(requireRole('admin'));

adminRouter.get('/queue', async (req, res, next) => {
  try {
    const r = await query(`
      SELECT r.*, COUNT(rf.id) AS flag_count,
             ARRAY_AGG(DISTINCT rf.reason) FILTER (WHERE rf.reason IS NOT NULL) AS flag_reasons
        FROM reviews r
   LEFT JOIN review_flags rf ON rf.review_id = r.id
       WHERE r.hidden_by_admin = false
         AND (rf.id IS NOT NULL OR r.go_live_at > now())
       GROUP BY r.id
       ORDER BY COUNT(rf.id) DESC, r.created_at DESC
       LIMIT 200`);
    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 reason = String(req.body?.reason || '').slice(0, 500);
    if (!['hide','restore'].includes(decision)) return res.status(400).json({ error: "decision must be 'hide' or 'restore'" });
    const flag = decision === 'hide';
    await query(`UPDATE reviews SET hidden_by_admin=$2, hidden_reason=$3, updated_at=now() WHERE id=$1`, [id, flag, reason || null]);
    res.json({ ok: true, hidden: flag });
  } catch (e) { next(e); }
});

module.exports = { router, adminRouter };