← back to Lawyer Directory Builder
src/server/community.ts
653 lines
/**
* Community + Ratings routes.
*
* POST /api/ratings — submit a rating (auth required)
* GET /api/firms/:id/ratings — public, list ratings + aggregate
* GET /api/attorneys/:id/ratings — public, list ratings + aggregate
* PATCH /api/me/comments-enabled — toggle (auth required)
*
* GET /community — server-rendered feed (client_public)
* POST /api/threads — start a thread / DM
* GET /api/threads — list (filtered by channel + my participation)
* GET /api/threads/:id/messages — list messages
* POST /api/threads/:id/messages — post a message
*
* POST /api/lawyer/claim — start lawyer-claim by bar#
*
* Tier gating (resolved via existing auth.ts session):
* guest → can read public ratings/threads
* client → can post ratings, post in client_public, DM other clients
* lawyer → above + post in lawyer_public, DM clients/peers, edit own firm
* admin → everything + moderation
*/
import express from 'express';
import { query } from '../db/pool.ts';
const router = express.Router();
// Skip JSON parsing for /webhooks/* so Stripe-style raw-body handlers (mounted in
// other routers) still see the original Buffer instead of a parsed object.
router.use((req, res, next) => {
if (req.path.startsWith('/webhooks/')) return next();
return express.json()(req, res, next);
});
// ─── tier resolver ─────────────────────────────────────────────────────────
type TierUser = { id: number; tier: 'guest'|'client'|'lawyer'|'admin'; comments_enabled: boolean; claimed_professional_id: number | null };
async function resolveTier(req: express.Request): Promise<TierUser> {
if (!req.user) return { id: 0, tier: 'guest', comments_enabled: true, claimed_professional_id: null };
const r = await query<{ tier: string; comments_enabled: boolean; claimed_professional_id: number | null }>(
`SELECT tier, comments_enabled, claimed_professional_id FROM app_users WHERE id = $1`, [req.user.id]);
const row = r.rows[0];
if (!row) return { id: req.user.id, tier: 'client', comments_enabled: true, claimed_professional_id: null };
// Role admin always wins — even if the legacy migration left tier='client' on a
// role='admin' account, treat them as admin tier so they can use admin-tier UI.
const tier = req.user.role === 'admin' ? 'admin' : ((row.tier as any) || 'client');
return {
id: req.user.id,
tier,
comments_enabled: !!row.comments_enabled,
claimed_professional_id: row.claimed_professional_id,
};
}
function requireTier(...tiers: Array<'client'|'lawyer'|'admin'>) {
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const u = await resolveTier(req);
if (!tiers.includes(u.tier as any)) return res.status(403).json({ error: 'tier_required', need: tiers, have: u.tier });
(req as any).tierUser = u;
next();
};
}
// ─── ratings ────────────────────────────────────────────────────────────────
router.post('/api/ratings', requireTier('client', 'lawyer', 'admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const b = req.body && typeof req.body === 'object' ? req.body : {};
const targetKind = b.target_kind === 'professional' ? 'professional' : 'organization';
const targetId = parseInt(b.target_id, 10);
const stars = Number(b.stars);
if (!Number.isFinite(targetId)) return res.status(400).json({ error: 'bad target_id' });
if (!(stars >= 0 && stars <= 5)) return res.status(400).json({ error: 'stars must be 0-5' });
const service = b.service_score == null ? null : Number(b.service_score);
const price = b.price_score == null ? null : Number(b.price_score);
const quality = b.quality_score == null ? null : Number(b.quality_score);
const comment = String(b.comment || '').slice(0, 5000) || null;
const r = await query<{ id: number }>(`
INSERT INTO ratings (
${targetKind === 'professional' ? 'professional_id' : 'organization_id'},
reviewer_user_id, source, stars, service_score, price_score, quality_score, comment
) VALUES ($1, $2, 'user', $3, $4, $5, $6, $7)
RETURNING id
`, [targetId, u.id, stars, service, price, quality, comment]);
await refreshAggregate(targetKind, targetId);
res.json({ id: r.rows[0].id, ok: true });
});
export async function refreshAggregate(kind: 'professional' | 'organization', id: number) {
const fk = kind === 'professional' ? 'professional_id' : 'organization_id';
await query(`
INSERT INTO rating_aggregates (target_kind, target_id, total_reviews, user_review_count,
avg_stars, avg_service, avg_price, avg_quality, overall_score, by_source, updated_at)
SELECT
$1, $2,
COUNT(*)::int,
COUNT(*) FILTER (WHERE source='user')::int,
AVG(stars)::numeric(3,2),
AVG(service_score)::numeric(3,2),
AVG(price_score)::numeric(3,2),
AVG(quality_score)::numeric(3,2),
(
0.5 * COALESCE(AVG(stars), 0)
+ 0.2 * COALESCE(AVG(service_score), 0)
+ 0.2 * COALESCE(AVG(quality_score), 0)
+ 0.1 * COALESCE(AVG(price_score), 0)
)::numeric(4,2),
(
SELECT jsonb_object_agg(source, jsonb_build_object('count', cnt, 'avg', avg_s))
FROM (
SELECT source, COUNT(*) AS cnt, AVG(stars)::numeric(3,2) AS avg_s
FROM ratings WHERE ${fk} = $2 AND hidden_at IS NULL GROUP BY source
) s
),
NOW()
FROM ratings WHERE ${fk} = $2 AND hidden_at IS NULL
ON CONFLICT (target_kind, target_id) DO UPDATE SET
total_reviews = EXCLUDED.total_reviews,
user_review_count = EXCLUDED.user_review_count,
avg_stars = EXCLUDED.avg_stars,
avg_service = EXCLUDED.avg_service,
avg_price = EXCLUDED.avg_price,
avg_quality = EXCLUDED.avg_quality,
overall_score = EXCLUDED.overall_score,
by_source = EXCLUDED.by_source,
updated_at = NOW();
`, [kind, id]);
}
router.get('/api/firms/:id/ratings', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='organization' AND target_id=$1`, [id]);
const list = await query(`
SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score,
r.comment, r.posted_at, r.reviewer_name,
u.full_name AS reviewer_user_name, u.comments_enabled
FROM ratings r
LEFT JOIN app_users u ON u.id = r.reviewer_user_id
WHERE r.organization_id = $1 AND r.hidden_at IS NULL
ORDER BY r.posted_at DESC LIMIT 100
`, [id]);
res.json({ aggregate: agg.rows[0] || null, ratings: list.rows });
});
router.get('/api/attorneys/:id/ratings', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='professional' AND target_id=$1`, [id]);
const list = await query(`
SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score,
r.comment, r.posted_at, r.reviewer_name,
u.full_name AS reviewer_user_name, u.comments_enabled
FROM ratings r
LEFT JOIN app_users u ON u.id = r.reviewer_user_id
WHERE r.professional_id = $1 AND r.hidden_at IS NULL
ORDER BY r.posted_at DESC LIMIT 100
`, [id]);
res.json({ aggregate: agg.rows[0] || null, ratings: list.rows });
});
// Lawyer-tier (paid) can post one reply per rating on their claimed firm
router.post('/api/ratings/:id/reply', requireTier('lawyer','admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const ratingId = parseInt(req.params.id, 10);
const body = String((req.body && req.body.body) || '').trim().slice(0, 5000);
if (!body) return res.status(400).json({ error: 'body required' });
// Verify the rating targets the lawyer's claimed firm/professional
const r = await query<{ organization_id: number | null; professional_id: number | null }>(
`SELECT organization_id, professional_id FROM ratings WHERE id = $1`, [ratingId]);
if (!r.rowCount) return res.status(404).json({ error: 'rating_not_found' });
if (u.tier === 'lawyer') {
if (!u.claimed_professional_id) return res.status(403).json({ error: 'must_claim_first' });
const owns = await query<{ ok: boolean }>(`
SELECT TRUE AS ok FROM professionals p
LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
WHERE p.id = $1 AND ($2::bigint IS NULL OR pl.organization_id = $2)
AND ($3::bigint IS NULL OR p.id = $3)
`, [u.claimed_professional_id, r.rows[0].organization_id, r.rows[0].professional_id]);
if (!owns.rowCount) return res.status(403).json({ error: 'not_your_firm' });
}
const ins = await query<{ id: number }>(`
INSERT INTO rating_replies (rating_id, responder_user_id, body)
VALUES ($1, $2, $3)
ON CONFLICT (rating_id, responder_user_id) DO UPDATE SET body=$3, edited_at=NOW()
RETURNING id
`, [ratingId, u.id, body]);
res.json({ id: ins.rows[0].id, ok: true });
});
router.patch('/api/me/comments-enabled', requireTier('client','lawyer','admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const enabled = !!(req.body && req.body.enabled);
await query(`UPDATE app_users SET comments_enabled = $2 WHERE id = $1`, [u.id, enabled]);
res.json({ ok: true, comments_enabled: enabled });
});
// ─── community threads + messages ──────────────────────────────────────────
const ALLOWED_CHANNELS_BY_TIER: Record<string, string[]> = {
client: ['client_public', 'dm_client_lawyer', 'dm_client_client'],
lawyer: ['client_public', 'lawyer_public', 'dm_client_lawyer', 'dm_lawyer_lawyer'],
admin: ['client_public', 'lawyer_public', 'dm_client_lawyer', 'dm_client_client', 'dm_lawyer_lawyer'],
};
router.post('/api/threads', requireTier('client','lawyer','admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const b = req.body || {};
const channel = String(b.channel || '');
const allowed = ALLOWED_CHANNELS_BY_TIER[u.tier] || [];
if (!allowed.includes(channel)) return res.status(403).json({ error: 'channel_not_allowed', allowed });
const topic = String(b.topic || '').slice(0, 280) || null;
const orgId = b.organization_id ? parseInt(b.organization_id, 10) : null;
const proId = b.professional_id ? parseInt(b.professional_id, 10) : null;
const participants = Array.isArray(b.participant_user_ids)
? Array.from(new Set([...b.participant_user_ids.map(Number), u.id])).filter(Number.isFinite)
: [u.id];
const r = await query<{ id: number }>(`
INSERT INTO threads (channel, topic, organization_id, professional_id, participant_user_ids, created_by)
VALUES ($1, $2, $3, $4, $5::bigint[], $6) RETURNING id
`, [channel, topic, orgId, proId, participants, u.id]);
res.json({ id: r.rows[0].id });
});
router.get('/api/threads', async (req, res) => {
const u = await resolveTier(req);
const channel = String(req.query.channel || 'client_public');
const orgId = req.query.organization_id ? parseInt(req.query.organization_id as string, 10) : null;
const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 200);
// Public channels readable by all; DM channels need participation
const isPublic = channel === 'client_public' || channel === 'lawyer_public';
const where: string[] = ['t.channel = $1'];
const params: any[] = [channel];
if (orgId) { params.push(orgId); where.push(`t.organization_id = $${params.length}`); }
if (!isPublic) {
if (u.tier === 'guest') return res.status(403).json({ error: 'tier_required' });
params.push(u.id);
where.push(`$${params.length} = ANY(t.participant_user_ids)`);
}
params.push(limit);
const r = await query(`
SELECT t.id, t.channel, t.topic, t.organization_id, t.professional_id,
t.participant_user_ids, t.created_by, t.created_at, t.last_message_at, t.message_count
FROM threads t
WHERE ${where.join(' AND ')}
ORDER BY t.last_message_at DESC
LIMIT $${params.length}
`, params);
res.json({ count: r.rowCount, rows: r.rows });
});
router.get('/api/threads/:id/messages', async (req, res) => {
const u = await resolveTier(req);
const tid = parseInt(req.params.id, 10);
const t = await query<{ channel: string; participant_user_ids: number[] }>(
`SELECT channel, participant_user_ids FROM threads WHERE id = $1`, [tid]);
if (!t.rowCount) return res.status(404).json({ error: 'not found' });
const isPublic = t.rows[0].channel === 'client_public' || t.rows[0].channel === 'lawyer_public';
if (!isPublic) {
if (u.tier === 'guest' || !(t.rows[0].participant_user_ids || []).includes(u.id)) {
return res.status(403).json({ error: 'not_participant' });
}
}
const r = await query(`
SELECT m.id, m.user_id, m.body, m.posted_at, m.edited_at,
u.full_name AS author_name, u.tier AS author_tier
FROM messages m
LEFT JOIN app_users u ON u.id = m.user_id
WHERE m.thread_id = $1 AND m.hidden_at IS NULL
ORDER BY m.posted_at ASC
LIMIT 500
`, [tid]);
res.json({ count: r.rowCount, rows: r.rows });
});
router.post('/api/threads/:id/messages', requireTier('client','lawyer','admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const tid = parseInt(req.params.id, 10);
const body = String((req.body && req.body.body) || '').trim().slice(0, 5000);
if (!body) return res.status(400).json({ error: 'body required' });
const t = await query<{ channel: string; participant_user_ids: number[] }>(
`SELECT channel, participant_user_ids FROM threads WHERE id = $1`, [tid]);
if (!t.rowCount) return res.status(404).json({ error: 'not found' });
const channel = t.rows[0].channel;
const allowed = ALLOWED_CHANNELS_BY_TIER[u.tier] || [];
if (!allowed.includes(channel)) return res.status(403).json({ error: 'channel_not_allowed' });
const isPublic = channel === 'client_public' || channel === 'lawyer_public';
if (!isPublic && !(t.rows[0].participant_user_ids || []).includes(u.id)) {
return res.status(403).json({ error: 'not_participant' });
}
const r = await query<{ id: number; posted_at: string }>(`
INSERT INTO messages (thread_id, user_id, body) VALUES ($1, $2, $3)
RETURNING id, posted_at
`, [tid, u.id, body]);
res.json({ id: r.rows[0].id, posted_at: r.rows[0].posted_at });
});
// ─── lawyer-claim ──────────────────────────────────────────────────────────
router.post('/api/lawyer/claim', requireTier('client','lawyer','admin'), async (req, res) => {
const u: TierUser = (req as any).tierUser;
const bar = String((req.body && req.body.bar_number) || '').trim();
if (!/^\d{3,7}$/.test(bar)) return res.status(400).json({ error: 'bad bar_number' });
const p = await query<{ id: number; full_name: string }>(
`SELECT id, full_name FROM professionals WHERE bar_number = $1 LIMIT 1`, [bar]);
if (!p.rowCount) return res.status(404).json({ error: 'bar_number_not_found' });
// Provisional claim (still needs verification email — Slice B)
await query(`
UPDATE app_users SET claimed_professional_id = $1, bar_number = $2, tier = CASE WHEN tier='client' THEN 'lawyer' ELSE tier END
WHERE id = $3
`, [p.rows[0].id, bar, u.id]);
res.json({
ok: true,
claimed_professional_id: p.rows[0].id,
full_name: p.rows[0].full_name,
note: 'provisional — bar-verification email is Slice B (not yet wired to PurelyMail send)',
});
});
// ─── server-rendered /firms/:id — public firm page w/ rating widget ──────
router.get('/firms/:id', async (req, res) => {
const u = await resolveTier(req);
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).send('bad id');
const r = await query<any>(`
SELECT o.id, o.name, o.address, o.city, o.zip, o.phone, o.website, o.firm_size_band, o.attorney_count, o.lat, o.lng,
(SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
(SELECT screenshot_path FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS shot,
(SELECT primary_color FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS color
FROM organizations o WHERE o.id = $1
`, [id]);
if (!r.rowCount) return res.status(404).send('not found');
const f = r.rows[0];
const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='organization' AND target_id=$1`, [id]);
const reviews = await query<any>(`
SELECT r.id, r.source, r.stars, r.service_score, r.price_score, r.quality_score, r.comment, r.posted_at, r.reviewer_name,
u.full_name AS reviewer_user_name, u.comments_enabled
FROM ratings r LEFT JOIN app_users u ON u.id = r.reviewer_user_id
WHERE r.organization_id = $1 AND r.hidden_at IS NULL
ORDER BY r.posted_at DESC LIMIT 50
`, [id]);
const attorneys = await query<any>(`
SELECT p.id, p.full_name, p.bar_number, p.license_status
FROM professional_locations pl JOIN professionals p ON p.id = pl.professional_id
WHERE pl.organization_id = $1 ORDER BY p.full_name LIMIT 50
`, [id]);
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const a = agg.rows[0];
const score = a?.overall_score == null ? null : Number(a.overall_score);
const stars = a?.avg_stars == null ? null : Number(a.avg_stars);
const starHtml = (n: number | null) => {
if (n == null) return '<span style="color:#444">—</span>';
const full = Math.round(n);
return '★'.repeat(full) + '☆'.repeat(5 - full) + ` <span style="color:#8b857a;font-size:12px">(${n.toFixed(1)})</span>`;
};
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(f.name)} — Lawyer Directory</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0c;--panel:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--good:#10b981;--warn:#facc15;--bad:#ef4444}
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:300 15px/1.55 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1100px;margin:0 auto;padding:32px 28px 80px}
.crumb{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:18px}
.crumb a{color:var(--metal);text-decoration:none}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:46px;line-height:1.1;margin:0 0 8px}
.meta{color:var(--ink-mute);font-size:13px;margin-bottom:24px}
.meta a{color:var(--metal);text-decoration:none}
.score-row{display:flex;gap:32px;flex-wrap:wrap;padding:20px 24px;background:var(--panel);border:1px solid var(--rule);border-radius:6px;margin-bottom:32px;align-items:center}
.score-row .item{display:flex;flex-direction:column;gap:2px}
.score-row .item .lbl{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
.score-row .item .val{font-family:Cormorant Garamond,Georgia,serif;font-size:26px;color:var(--metal)}
.score-row .item .stars{font-size:18px;color:var(--metal)}
.grid{display:grid;grid-template-columns:1.6fr 1fr;gap:32px}
@media (max-width:760px){.grid{grid-template-columns:1fr}h1{font-size:34px}}
section{margin-bottom:32px}
section h2{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:24px;color:var(--metal);margin:0 0 12px;border-bottom:1px solid var(--rule);padding-bottom:8px}
.review{padding:14px 0;border-bottom:1px solid var(--rule)}
.review .head{display:flex;gap:10px;align-items:center;font-size:12px;color:var(--ink-mute)}
.review .head .src{padding:1px 6px;border-radius:3px;background:#1c2237;color:var(--metal);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
.review .body{margin-top:6px;color:var(--ink-soft)}
.atty{padding:8px 0;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;font-size:13px}
.atty a{color:var(--ink);text-decoration:none}
.atty a:hover{color:var(--metal)}
.atty .bar{color:var(--ink-mute);font-size:12px}
.rate-form{padding:18px 20px;background:var(--panel);border:1px solid var(--metal);border-radius:6px;margin-top:16px}
.rate-form h3{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:18px;margin:0 0 10px;color:var(--metal)}
.rate-form .row{display:grid;grid-template-columns:90px 1fr 36px;align-items:center;gap:8px;margin:6px 0}
.rate-form .row label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-mute)}
.rate-form .row input[type=range]{accent-color:var(--metal)}
.rate-form .row .v{font-variant-numeric:tabular-nums;color:var(--metal);text-align:right}
.rate-form textarea{width:100%;background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:10px;border-radius:3px;font:inherit;margin-top:8px;min-height:80px}
.rate-form button{margin-top:10px;padding:10px 22px;background:var(--metal);color:var(--bg);border:0;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:11px;border-radius:3px;cursor:pointer}
.cta{margin-top:18px;padding:14px;background:var(--panel);border:1px solid var(--rule);border-radius:6px;text-align:center;color:var(--ink-mute);font-size:13px}
.cta a{color:var(--metal)}
.shot{width:100%;border:1px solid var(--rule);border-radius:6px;display:block;background:#000;margin-bottom:16px}
</style></head><body>
<div class="wrap">
<div class="crumb"><a href="/dashboard.html">← Dashboard</a> · <span>Firm</span></div>
<h1>${esc(f.name)}</h1>
<div class="meta">
${f.address ? esc(f.address) + ' · ' : ''}${esc([f.city, f.zip].filter(Boolean).join(', '))}
${f.phone ? ` · <a href="tel:${esc(f.phone.replace(/[^+0-9]/g,''))}">${esc(f.phone)}</a>` : ''}
${f.website ? ` · <a href="${esc(f.website)}" target="_blank" rel="noopener">${esc(f.website.replace(/^https?:\/\//,''))}</a>` : ''}
</div>
<div class="score-row">
<div class="item">
<div class="lbl">Overall</div>
<div class="val">${score == null ? '—' : score.toFixed(2)}</div>
</div>
<div class="item">
<div class="lbl">Stars</div>
<div class="stars">${starHtml(stars)}</div>
</div>
<div class="item">
<div class="lbl">Reviews</div>
<div class="val">${a?.total_reviews || 0}</div>
</div>
${f.audit_score != null ? `<div class="item"><div class="lbl">Site audit</div><div class="val">${f.audit_score}/100</div></div>` : ''}
${f.firm_size_band ? `<div class="item"><div class="lbl">Size</div><div class="val" style="font-size:18px;text-transform:capitalize">${esc(f.firm_size_band)}</div></div>` : ''}
${attorneys.rowCount ? `<div class="item"><div class="lbl">Attorneys</div><div class="val">${attorneys.rowCount}</div></div>` : ''}
</div>
<div class="grid">
<div>
<section>
<h2>Reviews</h2>
${reviews.rowCount ? reviews.rows.map(r => `
<div class="review">
<div class="head">
<span class="src">${esc(r.source)}</span>
<span style="color:${Number(r.stars) >= 4 ? 'var(--good)' : Number(r.stars) >= 3 ? 'var(--warn)' : 'var(--bad)'}">★ ${Number(r.stars).toFixed(1)}</span>
<span>${esc(r.reviewer_user_name || r.reviewer_name || 'anonymous')}</span>
<span style="margin-left:auto">${esc(String(r.posted_at).slice(0,10))}</span>
</div>
${r.comment && (r.comments_enabled !== false) ? `<div class="body">${esc(r.comment)}</div>` : (r.comments_enabled === false ? '<div class="body" style="color:var(--ink-mute);font-style:italic">(reviewer disabled comment display)</div>' : '')}
</div>`).join('') : '<p style="color:var(--ink-mute)">No reviews yet — be first.</p>'}
${u.tier === 'guest' ? `
<div class="cta">Want to leave a review? <a href="/login">Log in</a> or <a href="/signup">sign up</a> as a client.</div>
` : `
<form class="rate-form" id="rate-form">
<h3>Leave a review</h3>
<div class="row"><label>Stars</label><input type="range" min="0" max="5" step="0.5" name="stars" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
<div class="row"><label>Service</label><input type="range" min="0" max="5" step="0.5" name="service_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
<div class="row"><label>Price</label><input type="range" min="0" max="5" step="0.5" name="price_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
<div class="row"><label>Quality</label><input type="range" min="0" max="5" step="0.5" name="quality_score" value="5" oninput="this.parentNode.querySelector('.v').textContent=this.value"><span class="v">5</span></div>
<textarea name="comment" placeholder="What was it like working with this firm? (optional)"></textarea>
<button type="submit">Post review</button>
</form>
<script>
document.getElementById('rate-form').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const r = await fetch('/api/ratings', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
target_kind: 'organization', target_id: ${id},
stars: f.stars.value, service_score: f.service_score.value,
price_score: f.price_score.value, quality_score: f.quality_score.value,
comment: f.comment.value
})}).then(r=>r.json());
if (r.ok) location.reload(); else alert(JSON.stringify(r));
});
</script>
`}
</section>
</div>
<div>
${f.shot ? `<a href="${esc(f.website||'#')}" target="_blank"><img class="shot" src="${esc(f.shot)}" alt="current site"></a>` : ''}
<section>
<h2>Attorneys (${attorneys.rowCount})</h2>
${attorneys.rows.map(a => `
<div class="atty">
<a href="/attorneys/${a.id}">${esc(a.full_name)}</a>
<span class="bar">${a.bar_number ? '#'+esc(a.bar_number) : ''} ${a.license_status === 'Active' ? '<span style="color:var(--good)">●</span>' : '<span style="color:var(--ink-mute)">○</span>'}</span>
</div>`).join('') || '<p style="color:var(--ink-mute)">No attorneys linked yet.</p>'}
</section>
<div style="margin-top:24px">
<a href="/p/${id}" style="display:block;text-align:center;padding:14px;background:var(--metal);color:var(--bg);text-decoration:none;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:12px;border-radius:3px">View redesign concept →</a>
</div>
</div>
</div>
</div>
</body></html>`);
});
// ─── server-rendered /attorneys/:id ───────────────────────────────────────
router.get('/attorneys/:id', async (req, res) => {
const u = await resolveTier(req);
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).send('bad id');
const r = await query<any>(`
SELECT p.id, p.full_name, p.bar_number, p.license_status, p.admission_date, p.law_school, p.primary_practice_area,
pl.organization_id AS firm_id, o.name AS firm_name, o.city, o.phone AS firm_phone, o.website AS firm_website
FROM professionals p
LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
LEFT JOIN organizations o ON o.id = pl.organization_id
WHERE p.id = $1
`, [id]);
if (!r.rowCount) return res.status(404).send('not found');
const p = r.rows[0];
const agg = await query(`SELECT * FROM rating_aggregates WHERE target_kind='professional' AND target_id=$1`, [id]);
const reviews = await query<any>(`
SELECT r.id, r.source, r.stars, r.comment, r.posted_at, r.reviewer_name,
u.full_name AS reviewer_user_name, u.comments_enabled
FROM ratings r LEFT JOIN app_users u ON u.id = r.reviewer_user_id
WHERE r.professional_id = $1 AND r.hidden_at IS NULL
ORDER BY r.posted_at DESC LIMIT 50
`, [id]);
const a = agg.rows[0];
const stars = a?.avg_stars == null ? null : Number(a.avg_stars);
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(p.full_name)} — Lawyer Directory</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0c;--panel:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--good:#10b981;--warn:#facc15;--bad:#ef4444}
body{margin:0;background:var(--bg);color:var(--ink);font:300 15px/1.55 -apple-system,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:760px;margin:0 auto;padding:32px 28px 80px}
.crumb{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:18px}
.crumb a{color:var(--metal);text-decoration:none}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:42px;line-height:1.1;margin:0 0 8px}
.meta{color:var(--ink-mute);font-size:13px;margin-bottom:24px}
.meta a{color:var(--metal);text-decoration:none}
.score{display:inline-block;padding:14px 22px;background:#131316;border:1px solid var(--rule);border-radius:6px;margin-bottom:24px}
.score .stars{font-size:24px;color:var(--metal)}
.score .lbl{font-size:11px;color:var(--ink-mute);text-transform:uppercase;letter-spacing:.18em}
.review{padding:14px 0;border-bottom:1px solid var(--rule)}
.review .head{display:flex;gap:10px;align-items:center;font-size:12px;color:var(--ink-mute)}
.review .head .src{padding:1px 6px;border-radius:3px;background:#1c2237;color:var(--metal);font-size:10px;text-transform:uppercase}
.review .body{margin-top:6px;color:var(--ink-soft)}
.claim{margin-top:32px;padding:18px;border:1px solid var(--metal);background:var(--panel);border-radius:6px}
.claim h3{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:20px;margin:0 0 8px;color:var(--metal)}
.claim button{margin-top:10px;padding:10px 22px;background:var(--metal);color:var(--bg);border:0;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:11px;border-radius:3px;cursor:pointer}
</style></head><body>
<div class="wrap">
<div class="crumb"><a href="/dashboard.html">← Dashboard</a>${p.firm_id ? ` · <a href="/firms/${p.firm_id}">${esc(p.firm_name || '')}</a>` : ''}</div>
<h1>${esc(p.full_name)}</h1>
<div class="meta">
${p.bar_number ? `Bar #${esc(p.bar_number)}` : ''}
${p.license_status ? ` · <span style="color:${p.license_status==='Active'?'var(--good)':'var(--ink-mute)'}">${esc(p.license_status)}</span>` : ''}
${p.law_school ? ` · ${esc(p.law_school)}` : ''}
${p.admission_date ? ` · admitted ${esc(String(p.admission_date).slice(0,10))}` : ''}
</div>
${stars != null ? `<div class="score"><div class="lbl">Star rating</div><div class="stars">${'★'.repeat(Math.round(stars))}${'☆'.repeat(5-Math.round(stars))} ${stars.toFixed(1)} (${a?.total_reviews||0})</div></div>` : ''}
<h2 style="font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:22px;color:var(--metal);border-bottom:1px solid var(--rule);padding-bottom:6px">Reviews</h2>
${reviews.rowCount ? reviews.rows.map(r => `
<div class="review">
<div class="head">
<span class="src">${esc(r.source)}</span>
<span style="color:${Number(r.stars) >= 4 ? 'var(--good)' : Number(r.stars) >= 3 ? 'var(--warn)' : 'var(--bad)'}">★ ${Number(r.stars).toFixed(1)}</span>
<span>${esc(r.reviewer_user_name || r.reviewer_name || 'anonymous')}</span>
<span style="margin-left:auto">${esc(String(r.posted_at).slice(0,10))}</span>
</div>
${r.comment && (r.comments_enabled !== false) ? `<div class="body">${esc(r.comment)}</div>` : ''}
</div>`).join('') : '<p style="color:var(--ink-mute)">No reviews yet.</p>'}
${u.tier === 'client' && p.bar_number ? `
<div class="claim">
<h3>Are you ${esc(p.full_name)}?</h3>
<p style="color:var(--ink-mute);font-size:13px;margin:6px 0">Claim this profile to respond to reviews and update your information.</p>
<button onclick="claim()">Claim with Bar #${esc(p.bar_number)}</button>
<script>
async function claim() {
const r = await fetch('/api/lawyer/claim', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ bar_number: '${esc(p.bar_number)}' })}).then(r=>r.json());
if (r.ok) { alert('Provisional claim submitted. Verification email is Slice B.'); location.reload(); }
else alert(JSON.stringify(r));
}
</script>
</div>` : ''}
</div>
</body></html>`);
});
// ─── server-rendered /community feed ───────────────────────────────────────
router.get('/community', async (req, res) => {
const u = await resolveTier(req);
// channel is interpolated into HTML <title> and a JS string literal below —
// constrain to the public allowlist so a forged ?channel=…');alert(1);//
// can't escape into either context.
const requested = String(req.query.channel || 'client_public');
const channel = (requested === 'client_public' || requested === 'lawyer_public') ? requested : 'client_public';
const r = await query<any>(`
SELECT t.id, t.topic, t.organization_id, t.last_message_at, t.message_count,
o.name AS firm_name
FROM threads t
LEFT JOIN organizations o ON o.id = t.organization_id
WHERE t.channel = $1
ORDER BY t.last_message_at DESC
LIMIT 50
`, [channel]);
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const rows = r.rows.map(t => `
<div class="t">
<a class="title" href="/community/thread/${t.id}">${esc(t.topic || '(no topic)')}</a>
<div class="meta">${esc(t.firm_name || '')} · ${t.message_count} replies · ${esc(String(t.last_message_at).slice(0,16))}</div>
</div>`).join('');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>Community · ${channel}</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;margin:0;padding:32px;max-width:760px;margin-left:auto;margin-right:auto}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400}
.tabs{display:flex;gap:14px;margin:14px 0 24px;border-bottom:1px solid #2a2724;padding-bottom:8px}
.tabs a{color:#8b857a;text-decoration:none;font-size:12px;letter-spacing:.1em;text-transform:uppercase}
.tabs a.active{color:#b89968}
.t{padding:14px 0;border-bottom:1px solid #1a1815}
.t .title{color:#f4f1ea;font-size:16px;font-weight:500;text-decoration:none}
.t .meta{color:#8b857a;font-size:12px;margin-top:4px}
.compose{margin-top:32px;padding:18px;border:1px solid #2a2724;background:#131316;border-radius:6px}
.compose input,.compose textarea{width:100%;background:#0a0a0c;border:1px solid #2a2724;color:#f4f1ea;padding:10px;border-radius:3px;font:inherit;margin-bottom:8px}
.compose button{background:#b89968;color:#0a0a0c;border:0;padding:9px 18px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:12px;border-radius:3px;cursor:pointer}
.tier{font-size:11px;color:#8b857a;margin-left:8px}
</style></head><body>
<h1>Community <span class="tier">[${u.tier}]</span></h1>
<div class="tabs">
<a class="${channel==='client_public'?'active':''}" href="?channel=client_public">Clients</a>
<a class="${channel==='lawyer_public'?'active':''}" href="?channel=lawyer_public">Lawyers</a>
${u.tier!=='guest'?'<a href="/community/dm">DMs</a>':''}
</div>
${rows || '<p style="color:#8b857a">No threads yet — be the first.</p>'}
${(u.tier==='client' || u.tier==='lawyer' || u.tier==='admin') && (channel === 'client_public' || (channel === 'lawyer_public' && (u.tier === 'lawyer' || u.tier === 'admin'))) ? `
<div class="compose">
<form id="newt">
<input name="topic" placeholder="What's on your mind?" required>
<textarea name="body" placeholder="Say more…" rows="3" required></textarea>
<button type="submit">Post</button>
</form>
<script>
document.getElementById('newt').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const t = await fetch('/api/threads', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ channel: '${channel}', topic: f.topic.value })}).then(r=>r.json());
if (!t.id) { alert(JSON.stringify(t)); return; }
await fetch('/api/threads/'+t.id+'/messages', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ body: f.body.value })});
location.reload();
});
</script>
</div>` : `<p style="color:#8b857a;font-size:13px;margin-top:24px">${u.tier==='guest' ? '<a href="/login" style="color:#b89968">Log in</a> to post.' : 'Lawyer-tier required to post in Lawyers channel.'}</p>`}
</body></html>`);
});
export default router;