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