← back to Patterndesignlab

server.js

467 lines

// patterndesignlab.com — modern seamless-pattern licensing marketplace (a nicer Patternbank).
// OWNED content only. LOCAL + Basic-Auth until Steve flips it public. Stripe stays TEST-mode.
// The patternbank-archive scrape is compliance-LOCKED and is NEVER read/served/referenced here.
const express = require('express');
const path = require('path');
const fs = require('fs');
const { Pool } = require('pg');
try { require('dotenv').config(); } catch {}

const app = express();
const PORT = process.env.PORT || 9781;
const USER = process.env.PDL_USER || 'admin';
const PASS = process.env.PDL_PASS || 'DW2024!';
const PUBLIC = (process.env.PUBLIC || '0') === '1'; // Steve's switch — never flipped here

const pool = new Pool({
  host: process.env.PGHOST || '127.0.0.1',
  user: process.env.PGUSER || 'dw_admin',
  database: process.env.PGDATABASE || 'patterndesignlab',
  password: process.env.PGPASSWORD || undefined,
});

// ---- designer accounts: bcrypt login + signed JWT cookie + image upload ----
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const cookieLib = require('cookie');
const JWT_DEV_SECRET = 'pdl-designer-dev-secret-change-me';
const JWT_SECRET = process.env.PDL_JWT_SECRET || JWT_DEV_SECRET;
// Fail-closed: the dev-default secret is publicly known — anyone could forge a
// designer cookie (account takeover). Refuse to boot in PUBLIC mode without a
// real PDL_JWT_SECRET. Local/basic-auth dev (PUBLIC=0) is unaffected.
if (PUBLIC && JWT_SECRET === JWT_DEV_SECRET) {
  console.error('FATAL: PUBLIC=1 but PDL_JWT_SECRET is unset/default — refusing to boot (cookie-forgery risk).');
  process.exit(1);
}
const DCOOKIE = 'pdl_designer';
const UP_DIR = path.join(__dirname, 'public', 'assets', 'designers', 'uploads');
fs.mkdirSync(UP_DIR, { recursive: true });
const uploadKind = (req) => (req.query.type === 'logo' ? 'logo' : 'avatar');
// Raster-only allowlist. SVG is deliberately excluded: uploads are served from
// this origin, and an SVG can carry inline <script> → stored XSS. Extension is
// derived from the allowed mimetype, never trusted from the client filename.
const ALLOWED_IMG = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
const upload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => cb(null, UP_DIR),
    filename: (req, file, cb) => {
      const ext = ALLOWED_IMG[file.mimetype] || '.png';
      const slug = (req.designer && req.designer.slug) ? req.designer.slug.replace(/[^a-z0-9._-]/gi, '') : 'd';
      cb(null, `${slug}-${uploadKind(req)}-${Date.now()}${ext}`);
    },
  }),
  limits: { fileSize: 6 * 1024 * 1024 },
  fileFilter: (req, file, cb) => cb(null, Object.prototype.hasOwnProperty.call(ALLOWED_IMG, file.mimetype)),
});
function readDesignerCookie(req) {
  try { const t = cookieLib.parse(req.headers.cookie || '')[DCOOKIE]; return t ? jwt.verify(t, JWT_SECRET) : null; }
  catch { return null; }
}
async function requireDesigner(req, res, next) {
  const tok = readDesignerCookie(req);
  if (!tok || !tok.id) return res.status(401).json({ error: 'sign in required' });
  const r = await pool.query('SELECT * FROM designers WHERE id=$1', [tok.id]);
  if (!r.rowCount) return res.status(401).json({ error: 'account not found' });
  req.designer = r.rows[0]; next();
}
const publicDesigner = (row) => { const d = { ...row }; delete d.password_hash; return d; };

// ---- Basic Auth until PUBLIC=1 (healthz always open for probes) ----
app.use((req, res, next) => {
  if (req.path === '/api/healthz') return next();
  if (PUBLIC) return next();
  const h = req.headers.authorization || '';
  const [u, p] = Buffer.from(h.split(' ')[1] || '', 'base64').toString().split(':');
  if (u === USER && p === PASS) return next();
  res.set('WWW-Authenticate', 'Basic realm="patterndesignlab"').status(401).send('auth required');
});
app.use(express.json());

// Admin endpoints must require auth even under PUBLIC=1 — the global basic-auth above
// steps aside in public mode, so re-check the credentials explicitly here.
// Accepts EITHER Basic Auth (curl/scripts) OR the pdl_admin JWT cookie set by
// /admin-login (browser flow on the public site, where no basic-auth prompt exists).
const ACOOKIE = 'pdl_admin';
function isAdmin(req) {
  const h = req.headers.authorization || '';
  const [u, p] = Buffer.from(h.split(' ')[1] || '', 'base64').toString().split(':');
  if (u === USER && p === PASS) return true;
  try {
    const t = cookieLib.parse(req.headers.cookie || '')[ACOOKIE];
    return !!(t && jwt.verify(t, JWT_SECRET).admin);
  } catch { return false; }
}
function requireAdmin(req, res, next) {
  if (isAdmin(req)) return next();
  res.status(401).json({ error: 'admin auth required' });
}

app.get('/favicon.ico', (req, res) => res.status(204).end());
app.use(express.static(path.join(__dirname, 'public')));

// ---- Stripe TEST-mode only; sk_live_ is REFUSED (money is Steve-gated) [lifted verbatim] ----
let stripe = null;
const SK = process.env.STRIPE_SECRET_KEY || '';
if (SK.startsWith('sk_test_')) { try { stripe = require('stripe')(SK); } catch {} }
else if (SK.startsWith('sk_live_')) console.error('REFUSING sk_live_ key — live charges are Steve-gated. Money routes disabled.');

app.get('/api/config', (req, res) => res.json({
  checkoutReady: !!stripe, mode: stripe ? 'test' : 'none', public: PUBLIC,
  note: stripe ? 'TEST MODE — no real charges' : 'test keys not yet installed — inquiries only',
}));

// ---- served-visibility gate ----
// The settlement post-gen VISION pass flags designs whose local master image fails the
// settlement gate. 'vision_blocked' (full Part A + Part B violation) and 'vision_review'
// (fail-closed / ambiguous) are EXCLUDED from everything served to the storefront, but stay
// in the DB for audit. NULL (unscreened / hand-curated) and 'vision_ok' are served.
const servedClause = (alias) => `COALESCE(${alias}.settlement_status,'') NOT IN ('vision_blocked','vision_review')`;

// ---- search + faceted browse ----
const SORTS = {
  newest:   'x.created_at DESC',
  color:    'x.dominant_hex ASC, x.title ASC',
  style:    'x.style ASC, x.title ASC',
  sku_az:   'x.id ASC',
  title_az: 'x.title ASC',
  price_up: 'x.price_min ASC NULLS LAST, x.title ASC',
  price_dn: 'x.price_min DESC NULLS LAST, x.title ASC',
};
function buildWhere(q, params) {
  const w = [servedClause('x')]; // storefront never serves settlement-blocked/review designs
  if (q.q) { params.push(q.q); w.push(`x.search_tsv @@ plainto_tsquery('english', $${params.length})`); }
  for (const [key, col] of [['style','style'],['colorway','colorway'],['motif','motif'],['technique','technique']]) {
    if (q[key]) { params.push(q[key]); w.push(`x.${col} = $${params.length}`); }
  }
  if (q.seamless === 'yes') w.push('x.seamless = true');
  if (q.seamless === 'no')  w.push('x.seamless = false');
  // Collection segment — buyer-facing "Design-forward" (credible) / "Playful" (novelty); internal class column.
  if (q.class === 'credible') w.push(`x.content_class = 'credible'`);
  if (q.class === 'novelty')  w.push(`x.content_class = 'novelty'`);
  if (q.pmin) { params.push(Number(q.pmin)); w.push(`x.price_min >= $${params.length}`); }
  if (q.pmax) { params.push(Number(q.pmax)); w.push(`x.price_min <= $${params.length}`); }
  if (q.designer) { params.push(q.designer); w.push(`d.slug = $${params.length}`); }
  return w.length ? 'WHERE ' + w.join(' AND ') : '';
}

app.get('/api/designs', async (req, res) => {
  try {
    const params = [];
    const where = buildWhere(req.query, params);
    // Default landing = credible-first: on the pristine grid (default/newest sort, no search, no
    // facet/class filter) lead with credible-class designs so page 1 shows the marketplace at its
    // best. Any explicit sort, search, or filter reverts to the exact prior behavior.
    const sortKey = req.query.sort && SORTS[req.query.sort] ? req.query.sort : 'newest';
    const pristine = (!req.query.sort || req.query.sort === 'newest') &&
      !req.query.q && !req.query.style && !req.query.colorway && !req.query.motif &&
      !req.query.technique && !req.query.seamless && !req.query.pmin && !req.query.pmax &&
      !req.query.designer && !req.query.class;
    const order = pristine
      ? `(x.content_class='credible') DESC, x.created_at DESC`
      : SORTS[sortKey];
    // ---- pagination (?page=N&limit=48) ----
    const page  = Math.max(1, parseInt(req.query.page, 10) || 1);
    const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 48));
    const offset = (page - 1) * limit;

    // total matching (same WHERE + join so a designer filter still counts right)
    const totalSql = `SELECT count(*)::int AS total
      FROM designs x JOIN designers d ON d.id=x.designer_id ${where}`;
    const total = (await pool.query(totalSql, params)).rows[0].total;

    const pageParams = params.slice();
    pageParams.push(limit); const limIdx = pageParams.length;
    pageParams.push(offset); const offIdx = pageParams.length;
    const sql = `SELECT x.id,x.slug,x.title,x.style,x.motif,x.colorway,x.technique,x.seamless,
        x.dominant_hex,x.tags,x.img,x.price_min,x.price_max,x.kind,x.content_class,x.created_at,
        d.name AS designer_name, d.slug AS designer_slug
      FROM designs x JOIN designers d ON d.id=x.designer_id
      ${where} ORDER BY ${order} LIMIT $${limIdx} OFFSET $${offIdx}`;
    const r = await pool.query(sql, pageParams);
    res.json({
      designs: r.rows,
      count: r.rowCount,          // rows in THIS page
      total,                      // total matching rows
      page, limit,
      hasMore: offset + r.rowCount < total,
    });
  } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
});

app.get('/api/facets', async (req, res) => {
  try {
    const facet = async (col) => (await pool.query(
      `SELECT ${col} AS value, count(*)::int AS n FROM designs WHERE ${col} IS NOT NULL AND ${servedClause('designs')} GROUP BY ${col} ORDER BY n DESC, value ASC`)).rows;
    const price = (await pool.query(`SELECT min(price_min)::float lo, max(price_min)::float hi FROM designs WHERE ${servedClause('designs')}`)).rows[0];
    // Collection facet counts — buyer-friendly labels applied in the UI, internal class values here.
    const cc = (await pool.query(
      `SELECT content_class AS value, count(*)::int AS n FROM designs WHERE content_class IS NOT NULL AND ${servedClause('designs')} GROUP BY content_class`)).rows;
    const collection = {
      credible: (cc.find(r => r.value === 'credible') || {}).n || 0,
      novelty:  (cc.find(r => r.value === 'novelty')  || {}).n || 0,
    };
    collection.all = collection.credible + collection.novelty;
    res.json({
      style: await facet('style'), colorway: await facet('colorway'),
      motif: await facet('motif'), technique: await facet('technique'), price, collection,
    });
  } catch (e) { console.error(e); res.status(500).json({ error: 'facets failed' }); }
});

app.get('/api/designs/:id', async (req, res) => {
  try {
    const r = await pool.query(
      `SELECT x.*, d.name AS designer_name, d.slug AS designer_slug, d.country AS designer_country
       FROM designs x JOIN designers d ON d.id=x.designer_id
       WHERE (x.id=$1 OR x.slug=$1) AND ${servedClause('x')}`, [req.params.id]);
    if (!r.rowCount) return res.status(404).json({ error: 'not found' });
    res.json(r.rows[0]);
  } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
});

app.get('/api/designers/:slug', async (req, res) => {
  try {
    const d = await pool.query('SELECT * FROM designers WHERE slug=$1', [req.params.slug]);
    if (!d.rowCount) return res.status(404).json({ error: 'not found' });
    const items = await pool.query(
      `SELECT x.id,x.slug,x.title,x.style,x.colorway,x.dominant_hex,x.img,x.price_min,x.seamless,x.created_at
       FROM designs x WHERE x.designer_id=$1 AND ${servedClause('x')} ORDER BY x.created_at DESC`, [d.rows[0].id]);
    const { password_hash, email, ...pub } = d.rows[0]; // public profile: never expose hash or raw email
    res.json({ designer: pub, designs: items.rows, count: items.rowCount });
  } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
});

// ================= Designer sign-in + self-service profile editing =================
app.post('/api/designer/login', async (req, res) => {
  try {
    const email = String((req.body || {}).email || '').trim().toLowerCase();
    const password = String((req.body || {}).password || '');
    if (!email || !password) return res.status(400).json({ error: 'email and password required' });
    const r = await pool.query('SELECT * FROM designers WHERE lower(email)=$1', [email]);
    if (!r.rowCount || !r.rows[0].password_hash) return res.status(401).json({ error: 'invalid credentials' });
    if (!(await bcrypt.compare(password, r.rows[0].password_hash))) return res.status(401).json({ error: 'invalid credentials' });
    const token = jwt.sign({ id: r.rows[0].id, slug: r.rows[0].slug }, JWT_SECRET, { expiresIn: '30d' });
    res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, token, { httpOnly: true, secure: PUBLIC, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 }));
    res.json({ ok: true, slug: r.rows[0].slug });
  } catch (e) { console.error('login', e.message); res.status(500).json({ error: 'login failed' }); }
});
app.post('/api/designer/logout', (req, res) => {
  res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, '', { httpOnly: true, path: '/', maxAge: 0 }));
  res.json({ ok: true });
});
app.get('/api/designer/me', requireDesigner, (req, res) => res.json({ designer: publicDesigner(req.designer) }));

const EDITABLE = ['name', 'founder_name', 'tagline', 'bio', 'city', 'state_region', 'country', 'accent_hex',
  'avatar', 'logo', 'website', 'instagram', 'pinterest', 'tiktok', 'facebook', 'twitter', 'youtube', 'linkedin'];
app.patch('/api/designer/me', requireDesigner, async (req, res) => {
  try {
    const b = req.body || {}; const sets = [], vals = [];
    for (const k of EDITABLE) if (k in b) { vals.push(b[k] === '' ? null : String(b[k]).slice(0, 4000)); sets.push(`${k}=$${vals.length}`); }
    if (!sets.length) return res.json({ designer: publicDesigner(req.designer) });
    vals.push(req.designer.id);
    const r = await pool.query(`UPDATE designers SET ${sets.join(',')}, updated_at=now() WHERE id=$${vals.length} RETURNING *`, vals);
    res.json({ designer: publicDesigner(r.rows[0]) });
  } catch (e) { console.error('patch me', e.message); res.status(500).json({ error: 'update failed' }); }
});
// image upload — 'file' field, ?type=avatar|logo. (URL mode = PATCH avatar/logo directly; generated fallback = seeded portrait.)
app.post('/api/designer/upload', requireDesigner, (req, res, next) => {
  // Wrap multer so its errors (size limit / rejected type) return clean JSON
  // instead of falling through to Express's default HTML stack-trace handler.
  upload.single('file')(req, res, (err) => {
    if (err) return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'image too large (max 6 MB)' : 'upload rejected' });
    next();
  });
}, async (req, res) => {
  try {
    if (!req.file) return res.status(400).json({ error: 'no image uploaded (png/jpg/webp/gif only)' });
    const kind = uploadKind(req);
    const url = `/assets/designers/uploads/${req.file.filename}`;
    await pool.query(`UPDATE designers SET ${kind}=$1, updated_at=now() WHERE id=$2`, [url, req.designer.id]);
    res.json({ ok: true, type: kind, url });
  } catch (e) { console.error('upload', e.message); res.status(500).json({ error: 'upload failed' }); }
});

// ---- license checkout — Stripe TEST-mode only; without keys it records an inquiry ----
app.post('/api/license/checkout', async (req, res) => {
  const { designId, tier, email } = req.body || {};
  try {
    const r = await pool.query('SELECT title, license_tiers FROM designs WHERE id=$1 OR slug=$1', [String(designId)]);
    if (!r.rowCount) return res.status(404).json({ error: 'unknown design' });
    const design = r.rows[0];
    const t = (design.license_tiers || []).find(x => x.tier === tier);
    if (!t) return res.status(400).json({ error: 'unknown tier' });
    await pool.query('INSERT INTO license_events(design_id,tier,email,kind) VALUES($1,$2,$3,$4)',
      [String(designId), tier, (email || '').slice(0, 254), stripe ? 'checkout_start' : 'inquiry']);
    if (!stripe) return res.json({ ok: true, mode: 'inquiry', note: 'recorded — checkout opens when Stripe test keys land' });
    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      line_items: [{ price_data: { currency: 'usd', unit_amount: Math.round((t.priceUsd || 149) * 100),
        product_data: { name: `${design.title} — ${t.label || tier} license` } }, quantity: 1 }],
      success_url: `http://127.0.0.1:${PORT}/?licensed=${designId}`, cancel_url: `http://127.0.0.1:${PORT}/`,
      metadata: { designId: String(designId), tier },
    });
    res.json({ ok: true, mode: 'test', url: session.url });
  } catch (e) { console.error('checkout error', e.message); res.status(502).json({ error: 'checkout unavailable', detail: e.message }); }
});

// ================= Admin session login (for the public site, where basic-auth
// never prompts). Same credentials as basic auth; sets a signed httpOnly cookie. =================
app.post('/api/admin/login', (req, res) => {
  const { user, pass } = req.body || {};
  if (String(user || '') !== USER || String(pass || '') !== PASS) {
    return res.status(401).json({ error: 'invalid credentials' });
  }
  const token = jwt.sign({ admin: true }, JWT_SECRET, { expiresIn: '12h' });
  res.setHeader('Set-Cookie', cookieLib.serialize(ACOOKIE, token,
    { httpOnly: true, secure: PUBLIC, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 12 }));
  res.json({ ok: true });
});
app.post('/api/admin/logout', (req, res) => {
  res.setHeader('Set-Cookie', cookieLib.serialize(ACOOKIE, '', { httpOnly: true, path: '/', maxAge: 0 }));
  res.json({ ok: true });
});
// The design page probes this to decide whether to show the admin bar.
app.get('/api/admin/me', (req, res) => res.json({ admin: isAdmin(req) }));

// ================= Add a design to the DW Shopify store as a DIG- item =================
// Convention (matches the 900+ existing DIG- items): vendor "DW Bespoke Studio",
// product_type "Wallcovering", SKU DIG-<design number>. Created as DRAFT by default —
// the admin flips it ACTIVE in Shopify (or picks Active in the UI) so nothing goes
// customer-facing without an explicit human choice.
const SHOP = process.env.SHOPIFY_SHOP || 'designer-laboratory-sandbox.myshopify.com';
const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
const SHOP_API = `https://${SHOP}/admin/api/2024-10`;
app.post('/api/admin/designs/:id/shopify', requireAdmin, async (req, res) => {
  try {
    if (!SHOP_TOKEN) return res.status(503).json({ error: 'SHOPIFY_ADMIN_TOKEN not configured on this server' });
    const b = req.body || {};
    const r = await pool.query(
      `SELECT x.*, d.name AS designer_name FROM designs x JOIN designers d ON d.id=x.designer_id
       WHERE (x.id=$1 OR x.slug=$1)`, [req.params.id]);
    if (!r.rowCount) return res.status(404).json({ error: 'design not found' });
    const design = r.rows[0];
    // The settlement gate applies to commerce too — blocked/review designs never ship.
    if (['vision_blocked', 'vision_review'].includes(design.settlement_status || '')) {
      return res.status(403).json({ error: `design is settlement-${design.settlement_status} — cannot list` });
    }
    if (design.shopify_product_id && !b.force) {
      return res.status(409).json({ error: 'already in Shopify', productId: design.shopify_product_id, sku: design.shopify_sku });
    }
    const sku = String(b.sku || `DIG-${design.id}`).trim().slice(0, 100);
    const price = Number(b.price) > 0 ? Number(b.price).toFixed(2) : Number(design.price_min || 149).toFixed(2);
    const status = b.status === 'active' ? 'active' : 'draft';
    const tags = Array.from(new Set([
      'Pattern Design Lab', 'DW Bespoke Studio', 'Wallcovering', 'Pattern',
      design.style, design.motif, design.colorway, design.technique,
      ...(design.tags || []),
    ].filter(t => t && !/etsy/i.test(t)))).join(', '); // internal source tags never ship to Shopify
    const body_html = [
      design.style_line ? `<p>${design.style_line}</p>` : '',
      `<p>${design.title} — an original seamless pattern by ${design.designer_name} from the Pattern Design Lab collection. Digitally printed to order as a custom wallcovering by DW Bespoke Studio.</p>`,
    ].join('');
    const payload = { product: {
      title: design.title, body_html, vendor: 'DW Bespoke Studio', product_type: 'Wallcovering',
      status, tags,
      variants: [{
        sku, price, taxable: true, requires_shipping: true, inventory_management: null,
        weight: 1, weight_unit: 'lb', // DW checkout shipping is weight-based — never leave 0
      }],
    } };
    // Attach the master pattern image from local disk (base64) when it exists.
    try {
      const imgPath = path.join(__dirname, 'public', String(design.img || '').replace(/^\//, ''));
      if (design.img && imgPath.startsWith(path.join(__dirname, 'public') + path.sep) && fs.existsSync(imgPath)) {
        payload.product.images = [{ attachment: fs.readFileSync(imgPath).toString('base64'), filename: path.basename(imgPath) }];
      }
    } catch (e) { console.error('image attach skipped:', e.message); }
    if (b.dryRun) return res.json({ ok: true, dryRun: true, wouldCreate: { ...payload.product, images: payload.product.images ? ['<attached>'] : [] } });

    const resp = await fetch(`${SHOP_API}/products.json`, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    const j = await resp.json().catch(() => ({}));
    if (!resp.ok || !j.product) {
      console.error('shopify create failed', resp.status, JSON.stringify(j).slice(0, 500));
      return res.status(502).json({ error: 'Shopify create failed', status: resp.status, detail: j.errors || j });
    }
    await pool.query(
      'UPDATE designs SET shopify_product_id=$1, shopify_sku=$2, shopify_pushed_at=now() WHERE id=$3',
      [String(j.product.id), sku, design.id]);
    const storeHandle = SHOP.replace('.myshopify.com', '');
    res.json({ ok: true, productId: String(j.product.id), sku, status: j.product.status,
      adminUrl: `https://admin.shopify.com/store/${storeHandle}/products/${j.product.id}` });
  } catch (e) { console.error('shopify push', e); res.status(500).json({ error: 'push failed', detail: e.message }); }
});

// ---- admin design list (requireAdmin: enforced even under PUBLIC=1) ----
app.get('/api/admin/designs', requireAdmin, async (req, res) => {
  try {
    const r = await pool.query(
      `SELECT x.id,x.title,x.style,x.colorway,x.motif,x.technique,x.seamless,x.dominant_hex,
         x.img,x.price_min,x.price_max,x.pricing_status,x.created_at,d.name AS designer_name,
         x.settlement_status,x.settlement_checked_at
       FROM designs x JOIN designers d ON d.id=x.designer_id ORDER BY x.created_at DESC`);
    res.json({ count: r.rowCount, designs: r.rows });
  } catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
});

// ---- Trend Boards — editorial market boards over OWNED designs (data/trend-board.json).
// Never 500s: a missing/unparseable file returns an explicit empty shape the page renders. ----
app.get('/api/trends', (req, res) => {
  let board = null;
  try { board = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'trend-board.json'), 'utf8')); } catch {}
  if (!board || typeof board !== 'object' || !Array.isArray(board.trends)) {
    return res.json({ trends: [], scannedAt: null, market: null, empty: true });
  }
  res.json({ trends: board.trends, scannedAt: board.scannedAt || null, market: board.market || null, empty: board.trends.length === 0 });
});

// ---- Designer onboarding — capture applications only. No outbound email (send-to-list is gated). ----
const APPLICATIONS = path.join(__dirname, 'data', 'designer-applications.jsonl');
app.post('/api/designer/apply', (req, res) => {
  const b = req.body || {};
  const name = String(b.name || '').trim().slice(0, 160);
  const email = String(b.email || '').trim().slice(0, 254);
  const portfolio = String(b.portfolio || '').trim().slice(0, 500);
  const samples = String(b.samples || '').trim().slice(0, 2000);
  const about = String(b.about || '').trim().slice(0, 2000);
  if (!name || !email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    return res.status(400).json({ error: 'name and a valid email are required' });
  }
  const rec = { id: 'app_' + Date.now().toString(36), name, email, portfolio, samples, about,
    created_at: new Date().toISOString(), ip: (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').toString().split(',')[0] };
  try { fs.appendFileSync(APPLICATIONS, JSON.stringify(rec) + '\n'); }
  catch (e) { console.error('apply write failed', e.message); return res.status(500).json({ error: 'could not record application' }); }
  res.json({ ok: true, id: rec.id, note: 'Application received — our curation team reviews new designers weekly.' });
});

app.get('/api/admin/applications', requireAdmin, (req, res) => {
  let rows = [];
  try {
    const raw = fs.readFileSync(APPLICATIONS, 'utf8');
    rows = raw.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
  } catch { /* file may not exist yet — empty is fine */ }
  rows.reverse(); // newest first
  res.json({ count: rows.length, applications: rows });
});

// ---- pretty routes ----
app.get('/design/:id', (req, res) => res.sendFile(path.join(__dirname, 'public', 'design.html')));
app.get('/designer/:slug', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer.html')));
app.get('/trends', (req, res) => res.sendFile(path.join(__dirname, 'public', 'trends.html')));
app.get('/designer-signup', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-signup.html')));
app.get('/designer-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-login.html')));
app.get('/designer-dashboard', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-dashboard.html')));
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.get('/admin-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin-login.html')));

app.get('/api/healthz', async (req, res) => {
  try { const r = await pool.query('SELECT count(*)::int n FROM designs'); res.json({ ok: true, service: 'patterndesignlab', designs: r.rows[0].n, public: PUBLIC, stripe: stripe ? 'test' : 'none' }); }
  catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});

app.listen(PORT, () => console.log(`patterndesignlab on :${PORT} · public=${PUBLIC} · stripe=${stripe ? 'test' : 'none'}`));