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