← back to Stars of Design
lib/feed.js
136 lines
'use strict';
// Phone-shaped social feed for the StarsOfDesign /clients tier. One designer
// per card, vertical scroll, anonymous Like (cookie-tracked) + Comment thread.
//
// Anon ID — a random opaque cookie set on first visit. Used to:
// - dedupe Likes per visitor (one like per visitor per designer)
// - tag comments so a visitor can see/delete their own
// Anonymous comments allow Steve to capture commenter name + (optional) email
// for follow-up without standing up a full auth system.
const crypto = require('crypto');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
function ensureAnonId(req, res) {
let a = req.cookies && req.cookies.sod_anon;
if (!a || !/^[a-f0-9]{16,32}$/.test(a)) {
a = crypto.randomBytes(12).toString('hex');
res.cookie('sod_anon', a, {
maxAge: 1000 * 60 * 60 * 24 * 365 * 2, // 2 years
httpOnly: false, // need JS to fetch likes-state
sameSite: 'lax',
secure: false,
});
}
return a;
}
function hashIp(req) {
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || '';
return crypto.createHash('sha256').update(String(ip)).digest('hex').slice(0, 24);
}
// Page of designers for the feed, with like-count + has-liked state per
// caller. Ordered by recency (created_at DESC) so latest profiles bubble up.
async function feedPage({ anonId, limit = 20, offset = 0 }) {
const r = await pool.query(`
SELECT d.id, d.slug, d.full_name, d.role,
COALESCE(f.city, d.city) AS city,
COALESCE(f.state_or_region, d.state_or_region) AS state,
COALESCE(d.areas_served, f.areas_served, '{}'::text[]) AS areas,
f.name AS firm_name, f.slug AS firm_slug,
d.headshot_source, d.headshot_url,
d.created_at,
(SELECT count(*) FROM sod_likes l WHERE l.designer_id = d.id) AS like_count,
EXISTS (SELECT 1 FROM sod_likes l WHERE l.designer_id = d.id AND l.anon_id = $3) AS i_liked,
(SELECT count(*) FROM sod_comments c WHERE c.designer_id = d.id AND c.is_hidden = false) AS comment_count,
(SELECT json_agg(json_build_object('url', l.url, 'kind', l.kind))
FROM sod_links l WHERE l.designer_id = d.id OR l.firm_id = f.id) AS links
FROM sod_designers d
LEFT JOIN sod_designer_firm df ON df.designer_id = d.id AND df.is_current = true
LEFT JOIN sod_firms f ON f.id = df.firm_id
WHERE (d.role IS NULL OR d.role <> 'Manufacturer Rep')
ORDER BY d.created_at DESC, d.id DESC
LIMIT $1 OFFSET $2`, [limit, offset, anonId]);
return r.rows.map(row => ({
id: row.id,
slug: row.slug,
full_name: row.full_name,
role: row.role,
city: row.city,
state: row.state,
areas: row.areas || [],
firm_name: row.firm_name,
firm_slug: row.firm_slug,
headshot: (row.headshot_source === 'made-with-ai' || !row.headshot_url) ? '/img/made-with-ai.svg' : row.headshot_url,
like_count: parseInt(row.like_count, 10),
i_liked: row.i_liked,
comment_count: parseInt(row.comment_count, 10),
created_at: row.created_at,
links: (row.links || []).reduce((acc, l) => { (acc[l.kind] = acc[l.kind] || []).push(l.url); return acc; }, {}),
}));
}
async function commentsFor(designerId, limit = 40) {
const r = await pool.query(`
SELECT id, parent_id, author_name, body, created_at
FROM sod_comments
WHERE designer_id = $1 AND is_hidden = false
ORDER BY created_at ASC
LIMIT $2`, [designerId, limit]);
return r.rows;
}
async function toggleLike({ designerId, anonId }) {
const existing = await pool.query(
`SELECT id FROM sod_likes WHERE designer_id=$1 AND anon_id=$2`, [designerId, anonId]
);
if (existing.rows[0]) {
await pool.query(`DELETE FROM sod_likes WHERE id=$1`, [existing.rows[0].id]);
} else {
await pool.query(
`INSERT INTO sod_likes (designer_id, anon_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`,
[designerId, anonId]
);
}
const c = await pool.query(`SELECT count(*)::int AS n FROM sod_likes WHERE designer_id=$1`, [designerId]);
return { liked: !existing.rows[0], like_count: c.rows[0].n };
}
const SPAM_RE = /\b(viagra|cialis|crypto|bitcoin|seo services|loan|casino|porn|nude)\b/i;
function isSpamy(body) {
if (!body) return true;
if (body.length < 2 || body.length > 1500) return true;
// Way too many URLs
if ((body.match(/https?:\/\//g) || []).length > 2) return true;
if (SPAM_RE.test(body)) return true;
return false;
}
async function postComment({ designerId, anonId, ipHash, author_name, author_email, body }) {
author_name = String(author_name || '').trim().slice(0, 80);
author_email = author_email ? String(author_email).trim().slice(0, 160) : null;
body = String(body || '').trim();
if (!author_name || author_name.length < 2) throw new Error('name-required');
if (isSpamy(body)) throw new Error('spam-or-empty');
// Burst limit — at most 5 comments per anonId per hour
const burst = await pool.query(
`SELECT count(*) AS n FROM sod_comments WHERE anon_id=$1 AND created_at > NOW() - INTERVAL '1 hour'`,
[anonId]
);
if (parseInt(burst.rows[0].n, 10) >= 5) throw new Error('rate-limit');
const r = await pool.query(`
INSERT INTO sod_comments (designer_id, author_name, author_email, body, anon_id, ip_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, author_name, body, created_at`,
[designerId, author_name, author_email, body, anonId, ipHash]);
return r.rows[0];
}
module.exports = { ensureAnonId, hashIp, feedPage, commentsFor, toggleLike, postComment };