← back to Patterndesignlab

scripts/seed-designers.js

156 lines

#!/usr/bin/env node
/**
 * Seed the 15 shared designer personas into patterndesignlab and split the
 * existing catalog across them by style lane. Idempotent.
 *   - upserts 15 designers (name, bio, socials, generated portrait as avatar)
 *   - copies portraits → public/assets/designers/<slug>.png
 *   - reassigns EVERY design.designer_id to the best-matching persona (keyword
 *     score on style/motif/technique/title/tags; zero-match → round-robin)
 *   - removes stale designers (the 3 seed placeholders)
 *   - generates ONE shared credential per persona (works on both platforms)
 */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { Pool } = require('pg');

const SHARED = path.join(process.env.HOME, 'Projects/_shared/designer-personas');
const personas = JSON.parse(fs.readFileSync(path.join(SHARED, 'personas.json'), 'utf8')).designers;
const ASSETS = path.join(__dirname, '..', 'public', 'assets', 'designers');
fs.mkdirSync(ASSETS, { recursive: true });

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,
});

// ---- shared credentials (generate once, reuse across platforms) ----
function loadOrMakeCreds() {
  const p = path.join(SHARED, 'credentials.json');
  let creds = {};
  if (fs.existsSync(p)) creds = JSON.parse(fs.readFileSync(p, 'utf8'));
  let changed = false;
  for (const d of personas) {
    if (!creds[d.slug]) {
      const pw = crypto.randomBytes(9).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 12) + '2x';
      creds[d.slug] = { email: `${d.slug}@designerstudios.com`, password: pw };
      changed = true;
    }
  }
  if (changed) fs.writeFileSync(p, JSON.stringify(creds, null, 2));
  return creds;
}

function copyPortrait(slug) {
  const src = path.join(SHARED, 'portraits', `${slug}.png`);
  const dst = path.join(ASSETS, `${slug}.png`);
  if (fs.existsSync(src)) { fs.copyFileSync(src, dst); return `/assets/designers/${slug}.png`; }
  return null;
}

function scoreDesign(design, kw) {
  const hay = ' ' + [design.style, design.motif, design.technique, design.title, (design.tags || []).join(' ')]
    .filter(Boolean).join(' ').toLowerCase() + ' ';
  const words = new Set(hay.split(/[^a-z]+/).filter(Boolean)); // whole-word tokens
  let s = 0;
  for (const k of kw) {
    if (k.includes(' ')) { if (hay.includes(' ' + k + ' ') || hay.includes(k)) s++; } // phrase
    else if (words.has(k)) s++;                                                        // whole word only
  }
  return s;
}

(async () => {
  const creds = loadOrMakeCreds();
  const slugs = personas.map((d) => d.slug);

  // 1. upsert 15 designers
  for (const d of personas) {
    const avatar = copyPortrait(d.slug);
    const hash = await bcrypt.hash(creds[d.slug].password, 10);
    const s = d.socials || {};
    await pool.query(
      `INSERT INTO designers
        (slug,name,founder_name,tagline,bio,country,city,state_region,avatar,accent_hex,email,password_hash,
         website,instagram,pinterest,tiktok,facebook,twitter,youtube,linkedin,updated_at)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now())
       ON CONFLICT (slug) DO UPDATE SET
         name=EXCLUDED.name, founder_name=EXCLUDED.founder_name, tagline=EXCLUDED.tagline,
         bio=EXCLUDED.bio, country=EXCLUDED.country, city=EXCLUDED.city, state_region=EXCLUDED.state_region,
         avatar=COALESCE(EXCLUDED.avatar, designers.avatar), accent_hex=EXCLUDED.accent_hex,
         email=EXCLUDED.email, password_hash=COALESCE(designers.password_hash, EXCLUDED.password_hash),
         website=EXCLUDED.website, instagram=EXCLUDED.instagram, pinterest=EXCLUDED.pinterest,
         tiktok=EXCLUDED.tiktok, facebook=EXCLUDED.facebook, twitter=EXCLUDED.twitter,
         youtube=EXCLUDED.youtube, linkedin=EXCLUDED.linkedin, updated_at=now()`,
      [d.slug, d.studio_name, d.founder_name, d.tagline, d.bio, d.location.country,
       d.location.city, d.location.state_region || null, avatar, d.accent_hex,
       creds[d.slug].email, hash, s.website || null, s.instagram || null, s.pinterest || null,
       s.tiktok || null, s.facebook || null, s.twitter || null, s.youtube || null, s.linkedin || null]);
  }

  // 2. resolve persona slug -> id
  const idBySlug = {};
  for (const row of (await pool.query('SELECT id,slug FROM designers WHERE slug = ANY($1)', [slugs])).rows) {
    idBySlug[row.slug] = row.id;
  }

  // 3. CLUSTER the catalog into 15 coherent, balanced chunks, then assign the
  //    best-fitting persona to each chunk. Sorting by style→motif→colorway→hex
  //    makes each contiguous slice internally consistent (one signature style),
  //    and popular styles (chinoiserie/stripe) naturally span several studios —
  //    like a real marketplace.
  const designs = (await pool.query(
    `SELECT id,style,motif,colorway,dominant_hex,title,tags FROM designs
     ORDER BY style NULLS LAST, motif NULLS LAST, colorway NULLS LAST, dominant_hex NULLS LAST, id`)).rows;
  const buckets = {}; slugs.forEach((s) => (buckets[s] = []));
  const K = slugs.length;
  const chunkSize = Math.ceil(designs.length / K);
  const chunks = [];
  for (let i = 0; i < K; i++) chunks.push(designs.slice(i * chunkSize, (i + 1) * chunkSize));

  const dominantStyle = (chunk) => {
    const m = {}; chunk.forEach((d) => { const s = d.style || 'Mixed'; m[s] = (m[s] || 0) + 1; });
    return Object.entries(m).sort((a, b) => b[1] - a[1])[0][0];
  };
  const personaChunkScore = (p, chunk) => {
    let s = 0; for (const d of chunk.slice(0, 60)) s += scoreDesign(d, p.style_lane.keywords); return s;
  };
  // greedy best-fit persona↔chunk assignment
  const pairs = [];
  chunks.forEach((c, ci) => personas.forEach((p) => pairs.push({ ci, slug: p.slug, sc: personaChunkScore(p, c) })));
  pairs.sort((a, b) => b.sc - a.sc);
  const assign = {}; const chunkTaken = new Set(); const usedPersona = new Set();
  for (const pr of pairs) {
    if (chunkTaken.has(pr.ci) || usedPersona.has(pr.slug) || pr.sc === 0) continue;
    assign[pr.ci] = pr.slug; chunkTaken.add(pr.ci); usedPersona.add(pr.slug);
  }
  const leftover = slugs.filter((s) => !usedPersona.has(s));
  chunks.forEach((c, ci) => { if (!assign[ci]) assign[ci] = leftover.shift(); });
  chunks.forEach((c, ci) => c.forEach((d) => buckets[assign[ci]].push(d.id)));
  // stash dominant style per studio for the report
  const domBySlug = {}; chunks.forEach((c, ci) => (domBySlug[assign[ci]] = dominantStyle(c)));

  // 4. apply reassignment
  for (const slug of slugs) {
    if (buckets[slug].length) {
      await pool.query('UPDATE designs SET designer_id=$1 WHERE id = ANY($2)', [idBySlug[slug], buckets[slug]]);
    }
  }

  // 5. remove stale designers (now unreferenced)
  const del = await pool.query('DELETE FROM designers WHERE slug <> ALL($1) RETURNING slug', [slugs]);

  // 6. report
  const dist = (await pool.query(
    `SELECT d.slug, count(x.id)::int n FROM designers d LEFT JOIN designs x ON x.designer_id=d.id
     GROUP BY d.slug ORDER BY n DESC`)).rows;
  console.log('Catalog split across 15 studios (signature style):');
  dist.forEach((r) => console.log(`  ${r.slug.padEnd(20)} ${String(r.n).padStart(4)}   ${domBySlug[r.slug] || ''}`));
  console.log(`Removed ${del.rowCount} stale designer(s): ${del.rows.map((r) => r.slug).join(', ') || '(none)'}`);
  console.log(`\nCredentials written → ${path.join(SHARED, 'credentials.json')}`);
  await pool.end();
})().catch((e) => { console.error(e); process.exit(1); });