← back to Whatsmystyle
server.js
2415 lines
/**
* WhatsMyStyle — AI personal stylist
*
* The optometrist loop in code:
* GET /api/duel → returns two catalog items + the question
* POST /api/duel → user picks A or B; we tune their taste vector
* GET /api/recommend → top-N items ranked by current taste vector
*
* The closet:
* POST /api/closet/photo → upload, llava extracts garments
* GET /api/closet → list user's garments
*
* Receipts / Gmail / Photos / Social — all gated behind /api/connect/*
*/
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const PORT = Number(process.env.PORT || 9777);
const { tierFor } = require('./scripts/sustainability');
const DATA_DIR = path.join(__dirname, 'data');
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, 'whatsmystyle.db'));
db.pragma('journal_mode = WAL');
// ---------- schema ---------------------------------------------------------
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
display_name TEXT,
email TEXT UNIQUE,
gender_expr TEXT,
age_band TEXT,
height_cm INTEGER,
body_shape TEXT,
hair TEXT,
undertone TEXT,
city TEXT,
lat REAL, lng REAL,
budget_band TEXT,
sustainability TEXT,
taste_vec TEXT, -- JSON 32-d float vector
onboarded_at TEXT
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL, -- 'realreal' | 'poshmark' | 'seed'
external_id TEXT,
title TEXT NOT NULL,
brand TEXT,
category TEXT, -- 'top' | 'bottom' | 'dress' | 'outerwear' | 'shoes' | 'bag' | 'accessory'
color TEXT,
pattern TEXT,
material TEXT,
price_cents INTEGER,
currency TEXT DEFAULT 'USD',
image_url TEXT,
product_url TEXT,
tags TEXT, -- JSON array
embedding TEXT, -- JSON 32-d vector
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(source, external_id)
);
CREATE INDEX IF NOT EXISTS items_cat_idx ON items(category);
CREATE INDEX IF NOT EXISTS items_brand_idx ON items(brand);
CREATE TABLE IF NOT EXISTS duels (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
item_a INTEGER NOT NULL,
item_b INTEGER NOT NULL,
question TEXT NOT NULL, -- 'better silhouette?' | 'better color?' | etc.
picked TEXT, -- 'A' | 'B' | 'skip'
created_at TEXT DEFAULT (datetime('now')),
answered_at TEXT
);
CREATE TABLE IF NOT EXISTS closet (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
photo_path TEXT,
category TEXT, color TEXT, pattern TEXT, material_guess TEXT, vendor_guess TEXT,
bbox TEXT, -- JSON {x,y,w,h}
acquired_via TEXT, -- 'receipt' | 'gmail' | 'manual' | 'photo'
source_meta TEXT, -- JSON
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
kind TEXT NOT NULL, -- 'stripe_fc' | 'gmail' | 'camera_roll' | 'social' | 'location'
state TEXT NOT NULL, -- 'pending' | 'connected' | 'revoked' | 'declined'
meta TEXT,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, kind)
);
CREATE TABLE IF NOT EXISTS user_avatars (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
source_photos TEXT, -- JSON array of paths
canonical_path TEXT, -- path to the canonical full-body render
pose_json TEXT, -- detected skeleton (open-pose-ish)
body_measurements TEXT, -- JSON {shoulder_w, torso_h, leg_h, ...}
status TEXT DEFAULT 'pending', -- pending | building | ready | failed
created_at TEXT DEFAULT (datetime('now')),
ready_at TEXT
);
CREATE TABLE IF NOT EXISTS tryon_jobs (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
avatar_id INTEGER, -- which avatar to render onto
item_id INTEGER, -- catalog item (mutually exclusive with closet_id)
closet_id INTEGER, -- user's own closet item — the "wish I'd worn" path
mode TEXT NOT NULL, -- 'avatar' | 'photo_swap' | 'closet_to_oldphoto'
source_photo_path TEXT, -- old photo for photo_swap / closet_to_oldphoto
occasion TEXT, -- 'wedding' | 'graduation' | freeform user tag
result_path TEXT,
provider TEXT, -- 'dopple' | 'gemini-2.5-flash-image' | 'idm-vton' | 'ootdiffusion' | 'stub'
status TEXT DEFAULT 'queued', -- queued | running | done | failed
error TEXT,
created_at TEXT DEFAULT (datetime('now')),
done_at TEXT
);
CREATE INDEX IF NOT EXISTS tryon_user_idx ON tryon_jobs(user_id, status);
`);
// idempotent column adds (SQLite has no ADD COLUMN IF NOT EXISTS)
function safeAddColumn(table, ddl) {
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); }
catch (e) { if (!/duplicate column/i.test(e.message)) throw e; }
}
safeAddColumn('tryon_jobs', 'closet_id INTEGER');
safeAddColumn('tryon_jobs', 'occasion TEXT');
db.exec(`
CREATE TABLE IF NOT EXISTS outfit_picks (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
anchor_kind TEXT NOT NULL, -- 'closet' | 'catalog'
anchor_id INTEGER NOT NULL, -- closet.id or items.id
outfits TEXT NOT NULL, -- JSON: [{slots:{top,bottom,shoes,bag,outerwear}, score}, ...]
picked_index INTEGER, -- which of the 3 the user chose
created_at TEXT DEFAULT (datetime('now')),
picked_at TEXT
);
CREATE INDEX IF NOT EXISTS outfit_user_idx ON outfit_picks(user_id);
CREATE TABLE IF NOT EXISTS waitlist (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE,
source TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Track every paid-API call: provider, kind, cost_cents, meta JSON.
-- We use it to render the budget HUD + a "real cost at end" tally.
CREATE TABLE IF NOT EXISTS provider_costs (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (datetime('now')),
provider TEXT NOT NULL, -- 'gemini-2.5-flash-image' | 'dopple' | ...
kind TEXT NOT NULL, -- 'tryon' | 'avatar' | 'photo_swap'
cost_cents REAL NOT NULL, -- USD cents
user_id INTEGER,
ref_job_id INTEGER, -- tryon_jobs.id when applicable
meta TEXT -- JSON
);
CREATE INDEX IF NOT EXISTS provider_costs_ts_idx ON provider_costs(ts DESC);
CREATE TABLE IF NOT EXISTS taste_history (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
duels_answered INTEGER,
taste_vec TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS taste_history_user_idx ON taste_history(user_id, id);
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS debates (
id INTEGER PRIMARY KEY,
topic TEXT NOT NULL,
context TEXT,
verdict TEXT,
panel TEXT, -- JSON array of {role, model, output}
created_at TEXT DEFAULT (datetime('now'))
);
-- Tick 13: couples / shared-closets scaffold per debate id 8 verdict (HOLD - pilot).
-- DB-only this tick; gated behind app_config.couples_pilot (default false).
CREATE TABLE IF NOT EXISTS couples (
id INTEGER PRIMARY KEY,
partner_a_user_id INTEGER NOT NULL,
partner_b_user_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | exited
opt_in_at TEXT, -- both partners confirmed
exit_at TEXT, -- breakup flow; on set, cross-twin training is purged
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(partner_a_user_id, partner_b_user_id)
);
CREATE INDEX IF NOT EXISTS couples_a_idx ON couples(partner_a_user_id);
CREATE INDEX IF NOT EXISTS couples_b_idx ON couples(partner_b_user_id);
-- Tick 13: catalog re-embed drift ledger. One row per (item, run) where
-- new vs stored similarity < 0.80. checked_at is second-precision so the
-- UNIQUE constraint blocks dupes within the same drift sweep second.
CREATE TABLE IF NOT EXISTS embedding_drifts (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL,
old_vec TEXT NOT NULL,
new_vec TEXT NOT NULL,
similarity REAL NOT NULL,
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(item_id, checked_at)
);
CREATE INDEX IF NOT EXISTS embedding_drifts_item_idx ON embedding_drifts(item_id, id);
`);
// Tick 13: closet.privacy column (default 'shared'). Idempotent.
safeAddColumn('closet', "privacy TEXT DEFAULT 'shared'");
// Tick 15.5: three user layers — admin / public / set_decorator.
// `user_role` is the user's actual tier; `view_as` is admin-only role
// simulation so the admin can preview the retail/general-user view.
// `effective_role` (computed) = view_as if admin & view_as set, else user_role.
safeAddColumn('users', "user_role TEXT DEFAULT 'public'");
safeAddColumn('users', "view_as TEXT");
// Tick 24: favorites — per-user star list across catalog + closet items.
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
item_kind TEXT NOT NULL, -- 'catalog' | 'closet'
item_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, item_kind, item_id)
);
CREATE INDEX IF NOT EXISTS favorites_user_idx ON favorites(user_id, created_at DESC);
`);
// Tick 25: per-user share_id for public favorites list. Stored on users.
safeAddColumn('users', 'favorites_share_id TEXT');
// Tick 31: shareable recs snapshots. Stores the OWNER's filter+sort state, not
// the items themselves — public viewers see fresh stock re-evaluated against
// the owner's current taste vector at view time.
db.exec(`
CREATE TABLE IF NOT EXISTS recs_shares (
share_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
state_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS recs_shares_user_idx ON recs_shares(user_id, created_at DESC);
`);
// Tick 15.5: productions — film/TV/commercial projects a set_decorator user
// is buying for. Lets set-decorator-mode anchor closet + try-on rows to a
// specific show + episode, mirroring shotonwhat.com's credit structure.
db.exec(`
CREATE TABLE IF NOT EXISTS productions (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
kind TEXT, -- 'film' | 'tv-episode' | 'tv-season' | 'commercial' | 'theater'
role TEXT, -- 'Set Decorator' | 'Set Decoration Buyer' | 'Production Designer' | freeform
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS productions_user_idx ON productions(user_id, id);
`);
safeAddColumn('closet', 'production_id INTEGER');
safeAddColumn('tryon_jobs', 'production_id INTEGER');
// Tick 16: pro_grade flag on catalog items — set decorators need pieces that
// are durable / replaceable / commercial-grade (multiple in stock at the
// vendor, not one-off resale). 0/1 int; null = unknown. Heuristic seed pass
// below tags items whose brand+category combo is typically multi-stocked.
safeAddColumn('items', 'pro_grade INTEGER');
try {
// Idempotent seed pass — tag items as pro_grade where brand is a known
// multi-stock retailer (mass + DTC contemporary). Resale-only brands stay
// untagged (single-piece inventory). Run once-per-boot; quick.
// PRO_BRANDS — multi-stocked, restockable, set-decorator-friendly. Tick 17:
// broadened to cover seed-catalog brands the original list missed
// (Reformation, Saint Laurent line-extension basics, Burberry trench, etc.)
// and added a normalize() pass so 'L.L. Bean' matches 'L.L.Bean'.
const PRO_BRANDS = new Set([
'Everlane', 'COS', 'Uniqlo', 'Madewell', 'J.Crew', 'Banana Republic',
'Theory', 'AGOLDE', 'Mother', 'Frame', "Levi's",
'Vince', 'rag & bone', 'Marine Layer', 'L.L.Bean', 'Carhartt',
'Citizens of Humanity', 'Saint James', 'Tradlands', 'Mott & Bow',
'Patagonia', 'Allbirds', 'Veja', 'Naadam', 'Boody', 'Pact',
'Aritzia', 'Anthropologie', 'Free People', 'Express', 'Gap', 'Old Navy',
'Nike', 'Adidas',
// tick 17 additions — restockable basics that landed in seed catalog
'Reformation', 'Outerknown', 'Cuyana', 'Kotn', 'Mate the Label',
'Christy Dawn', 'Boyish', 'Boyish Jeans', 'For Days', 'Knickey',
'Sezane', 'Mara Hoffman', 'Frank And Oak', 'DL1961', 'Toteme',
'Burberry', // classic trench coats are multi-stocked
'G.H. Bass', // classic loafers — Weejuns are stock
'L.L. Bean', // alias for L.L.Bean
'Common Projects', // staple white sneaker
]);
const RESALE_SOURCES = new Set(['realreal', 'poshmark', 'vestiaire', 'grailed', 'depop']);
// Normalize for fuzzy matching: lowercase, collapse whitespace, drop dots.
const norm = s => (s || '').toLowerCase().replace(/[.\s]+/g, '').trim();
const PRO_BRANDS_NORM = new Set(Array.from(PRO_BRANDS).map(norm));
const all = db.prepare("SELECT id, source, brand, pro_grade FROM items").all();
const upd = db.prepare('UPDATE items SET pro_grade = ? WHERE id = ?');
let tagged = 0;
for (const r of all) {
if (r.pro_grade !== null && r.pro_grade !== 0) continue; // keep manual 1, re-run 0/null
const isResale = RESALE_SOURCES.has((r.source || '').toLowerCase());
const isProBrand = PRO_BRANDS_NORM.has(norm(r.brand));
const grade = isResale ? 0 : (isProBrand ? 1 : (r.pro_grade ?? 0));
if (grade !== r.pro_grade) { upd.run(grade, r.id); tagged++; }
}
if (tagged > 0) console.log(`[pro_grade seed] tagged ${tagged} items`);
} catch (e) { console.error('[pro_grade seed] error', e.message); }
// ---------- app ------------------------------------------------------------
const app = express();
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET || 'whatsmystyle-dev',
resave: false,
saveUninitialized: true,
cookie: { httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 * 90 }
}));
// Snapshot-file 404-guard: refuse to serve *.bak / *.pre-* / *.orig / *.rej
// from static root regardless of what's on disk. Defense-in-depth against an
// accidental rsync of an editor backup leaking server-side state to the web.
app.use((req, res, next) => {
if (/\.(bak|orig|rej)(\.|$)|\.pre-/.test(req.path)) return res.status(404).end();
next();
});
app.use(express.static(path.join(__dirname, 'public')));
const upload = multer({ dest: UPLOAD_DIR, limits: { fileSize: 25 * 1024 * 1024 } });
// guarantee a user row exists for each session
function currentUser(req) {
if (!req.session.uid) {
const { lastInsertRowid } = db.prepare(
'INSERT INTO users (taste_vec) VALUES (?)'
).run(JSON.stringify(Array(32).fill(0)));
req.session.uid = lastInsertRowid;
}
return db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.uid);
}
// ---------- routes ---------------------------------------------------------
// Public client-safe config — only fields safe to expose in browser.
// PICKER NEEDS: GOOGLE_PICKER_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_DRIVE_APP_ID
// — see ~/Projects/secrets-manager. Client secret is NEVER exposed here.
app.get('/api/public-config', (req, res) => {
res.json({
google_picker: {
api_key: process.env.GOOGLE_PICKER_API_KEY || null,
client_id: process.env.GOOGLE_CLIENT_ID || process.env.GOOGLE_OAUTH_CLIENT_ID || null,
app_id: process.env.GOOGLE_DRIVE_APP_ID || null,
},
});
});
// Tick 15.5: role helpers. Admin tier is granted by hostname OR ?admin=1,
// the same gate that already protects /api/admin/*. We also persist
// `users.user_role = 'admin'` on first hit when the gate matches so the
// role survives an admin loading the app from 127.0.0.1.
function resolveAdmin(req) {
return req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.admin === '1';
}
function effectiveRoleFor(u, req) {
const isAdmin = u.user_role === 'admin' || resolveAdmin(req);
if (isAdmin && u.view_as && ['public', 'set_decorator'].includes(u.view_as)) return u.view_as;
return u.user_role || 'public';
}
function maybePromoteAdmin(u, req) {
if (resolveAdmin(req) && u.user_role !== 'admin') {
db.prepare("UPDATE users SET user_role='admin' WHERE id=?").run(u.id);
u.user_role = 'admin';
}
return u;
}
app.get('/api/me', (req, res) => {
let u = currentUser(req);
u = maybePromoteAdmin(u, req);
const effective = effectiveRoleFor(u, req);
res.json({
user: u,
onboarded: !!u.onboarded_at,
role: u.user_role || 'public',
view_as: u.view_as || null,
effective_role: effective,
is_admin: u.user_role === 'admin' || resolveAdmin(req),
});
});
// Admin-only — write user_role on a specific user (default self).
// Use case: bootstrapping a set-decorator user without rolling auth.
app.put('/api/admin/set-role', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const role = req.body?.role;
const userId = Number(req.body?.user_id || currentUser(req).id);
if (!['admin', 'public', 'set_decorator'].includes(role)) return res.status(400).json({ error: 'role must be admin|public|set_decorator' });
db.prepare('UPDATE users SET user_role=? WHERE id=?').run(role, userId);
res.json({ ok: true, user_id: userId, role });
});
// Admin-only — flip the admin's own `view_as` so they can preview the
// public/set_decorator UI without leaving their session. Pass null/''
// to exit preview mode.
app.put('/api/admin/view-as', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const u = currentUser(req);
let viewAs = req.body?.view_as;
if (viewAs === '' || viewAs === null) viewAs = null;
else if (!['public', 'set_decorator'].includes(viewAs)) return res.status(400).json({ error: 'view_as must be public|set_decorator|null' });
db.prepare('UPDATE users SET view_as=? WHERE id=?').run(viewAs, u.id);
res.json({ ok: true, view_as: viewAs });
});
// Productions — list for self (any logged-in user; gated UI for set_decorator role).
app.get('/api/productions', (req, res) => {
const u = currentUser(req);
const rows = db.prepare('SELECT * FROM productions WHERE user_id=? ORDER BY year DESC, id DESC').all(u.id);
// Annotate each with the count of closet rows and tryon_jobs anchored on it.
const counts = rows.map(r => {
const closetCount = db.prepare('SELECT COUNT(*) c FROM closet WHERE production_id=?').get(r.id).c;
const tryonCount = db.prepare('SELECT COUNT(*) c FROM tryon_jobs WHERE production_id=?').get(r.id).c;
return { ...r, closet_count: closetCount, tryon_count: tryonCount, total_credits: closetCount + tryonCount };
});
res.json({ productions: counts });
});
app.post('/api/productions', (req, res) => {
const u = currentUser(req);
const { title, year, kind, role } = req.body || {};
if (!title || String(title).trim().length < 2) return res.status(400).json({ error: 'title required' });
const { lastInsertRowid } = db.prepare(
'INSERT INTO productions (user_id, title, year, kind, role) VALUES (?, ?, ?, ?, ?)'
).run(u.id, String(title).trim(), year ? Number(year) : null, kind || 'tv-episode', role || 'Set Decoration Buyer');
res.json({ ok: true, id: lastInsertRowid });
});
// Tick 15.6: per-production résumé view — every closet item + try-on row
// tagged to one production, ordered date desc. Mirrors /api/item-credits
// shape so the same /credits screen renderer can consume both endpoints.
app.get('/api/production-credits/:id', (req, res) => {
const u = currentUser(req);
const id = Number(req.params.id);
const prod = db.prepare('SELECT * FROM productions WHERE id=? AND user_id=?').get(id, u.id);
if (!prod) return res.status(404).json({ error: 'production not found' });
const closetRows = db.prepare(
`SELECT id, vendor_guess AS brand, category, color, pattern, created_at
FROM closet WHERE production_id=? AND user_id=? ORDER BY id DESC`
).all(id, u.id);
const tryonRows = db.prepare(
`SELECT id, mode, occasion, provider, status, item_id, closet_id, created_at, done_at
FROM tryon_jobs WHERE production_id=? AND user_id=? ORDER BY id DESC`
).all(id, u.id);
const credits = [];
for (const c of closetRows) {
credits.push({
role: 'Set piece',
occasion: null,
date: c.created_at,
type: 'Closet',
with: [`${c.color || ''} ${c.pattern || ''} ${c.category || ''}`.trim() || 'piece'],
brand: c.brand,
});
}
for (const t of tryonRows) {
const role = ({
avatar: 'Avatar render',
photo_swap: 'Photo-swap render',
closet_to_oldphoto: 'Time-Travel render',
})[t.mode] || 'Try-on';
credits.push({
role,
occasion: t.occasion || null,
date: t.done_at || t.created_at,
type: t.mode === 'closet_to_oldphoto' ? 'Time-Travel' : (t.mode === 'photo_swap' ? 'Photo Swap' : 'Try-On'),
provider: t.provider,
status: t.status,
with: [],
});
}
credits.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
res.json({
production: prod,
item: { kind: 'production', id: prod.id, title: prod.title, brand: prod.role, category: prod.kind, since: prod.created_at },
total: credits.length,
credits,
});
});
// Tick 15.6: tag closet item or try-on with a production_id. Pass null/0 to clear.
function clampProductionId(v) {
if (v === null || v === undefined || v === 0 || v === '') return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
}
app.put('/api/closet/:id/production', (req, res) => {
const u = currentUser(req);
const pid = clampProductionId(req.body?.production_id);
if (pid !== null) {
const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(pid, u.id);
if (!owned) return res.status(400).json({ error: 'production not yours' });
}
const result = db.prepare('UPDATE closet SET production_id=? WHERE id=? AND user_id=?').run(pid, req.params.id, u.id);
if (result.changes === 0) return res.status(404).json({ error: 'closet item not found' });
res.json({ ok: true, production_id: pid });
});
app.put('/api/tryon/:id/production', (req, res) => {
const u = currentUser(req);
const pid = clampProductionId(req.body?.production_id);
if (pid !== null) {
const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(pid, u.id);
if (!owned) return res.status(400).json({ error: 'production not yours' });
}
const result = db.prepare('UPDATE tryon_jobs SET production_id=? WHERE id=? AND user_id=?').run(pid, req.params.id, u.id);
if (result.changes === 0) return res.status(404).json({ error: 'tryon not found' });
res.json({ ok: true, production_id: pid });
});
app.post('/api/onboard', (req, res) => {
const u = currentUser(req);
const f = req.body || {};
db.prepare(`UPDATE users SET
display_name = COALESCE(?, display_name),
gender_expr = COALESCE(?, gender_expr),
age_band = COALESCE(?, age_band),
height_cm = COALESCE(?, height_cm),
body_shape = COALESCE(?, body_shape),
hair = COALESCE(?, hair),
undertone = COALESCE(?, undertone),
city = COALESCE(?, city),
lat = COALESCE(?, lat), lng = COALESCE(?, lng),
budget_band = COALESCE(?, budget_band),
sustainability = COALESCE(?, sustainability),
onboarded_at = COALESCE(onboarded_at, datetime('now'))
WHERE id = ?`).run(
f.display_name, f.gender_expr, f.age_band, f.height_cm, f.body_shape,
f.hair, f.undertone, f.city, f.lat, f.lng, f.budget_band, f.sustainability,
u.id);
res.json({ ok: true });
});
// ---- the optometrist loop -------------------------------------------------
// Cold-start hybrid duel — per debate #6 verdict.
// First COLD_START_CUTOFF picks (default 3): anchor to mandatory onboarding
// traits (budget_band, body_shape, city) with OUTLIER_INJECTION_RATE chance
// of swapping one item for a random outlier. After cutoff: pure random.
function configValue(key, fallback) {
try {
const r = db.prepare('SELECT value FROM app_config WHERE key=?').get(key);
if (r) return JSON.parse(r.value);
} catch {}
return fallback;
}
function priceCentsForBudget(band) {
return ({ u20:[0,2000], '20-50':[2000,5000], '50-150':[5000,15000],
'150-500':[15000,50000], '500-1500':[50000,150000],
'1500p':[150000,9999999], nocap:[0,9999999] })[band] || [0, 9999999];
}
app.get('/api/duel', (req, res) => {
const u = currentUser(req);
const cat = req.query.category || null;
const cutoff = configValue('cold_start_cutoff', 3);
const outlierRate = configValue('outlier_injection_rate', 0.2);
const dueled = db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B','skip')").get(u.id).c;
// Tick 26: hard guard — never serve an item with a null/empty image_url.
// Broken thumbnails poison the demo and the duel UI just shows a gray box.
const IMG_OK = "image_url IS NOT NULL AND image_url != ''";
let pair;
if (dueled < cutoff && u.budget_band) {
// Anchored: filter by user's budget band first, optionally body-shape via tags
const [lo, hi] = priceCentsForBudget(u.budget_band);
const anchorSql = cat
? `SELECT id, title, brand, category, color, image_url, product_url, price_cents
FROM items
WHERE ${IMG_OK} AND category = ? AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
ORDER BY RANDOM() LIMIT 4`
: `SELECT id, title, brand, category, color, image_url, product_url, price_cents
FROM items
WHERE ${IMG_OK} AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
ORDER BY RANDOM() LIMIT 4`;
const pool = cat ? db.prepare(anchorSql).all(cat, lo, hi) : db.prepare(anchorSql).all(lo, hi);
pair = pool.slice(0, 2);
// Inject an outlier with probability outlierRate (swap pair[1] for a fully-random item)
if (pair.length === 2 && Math.random() < outlierRate) {
const outlier = db.prepare(`SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 1`).get(pair[0].id, pair[1].id);
if (outlier) pair[1] = outlier;
}
} else {
const sql = cat
? `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND category = ? ORDER BY RANDOM() LIMIT 2`
: `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} ORDER BY RANDOM() LIMIT 2`;
pair = cat ? db.prepare(sql).all(cat) : db.prepare(sql).all();
}
pair.forEach(p => p.sustain = tierFor(p.brand));
if (pair.length < 2) return res.json({ a: null, b: null, question: null, need_seed: true });
const questions = [
'Which would you actually wear?',
'Better silhouette?',
'Better color on you?',
'Better for a weekday?',
'Better for an evening?',
"Which feels more 'you'?",
];
const question = questions[Math.floor(Math.random() * questions.length)];
const { lastInsertRowid } = db.prepare(
'INSERT INTO duels (user_id, item_a, item_b, question) VALUES (?, ?, ?, ?)'
).run(u.id, pair[0].id, pair[1].id, question);
// Tick 27: prefetch the next pair so the client can preload images and
// taps feel instant. Cheap second random pick from the same category
// (skipping the items just served). No duels row inserted for the
// prefetch — that happens on the next /api/duel call.
let nextPair = null;
try {
const skipIds = [pair[0].id, pair[1].id];
const nextSql = cat
? `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND category = ? AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`
: `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`;
const prefetch = cat ? db.prepare(nextSql).all(cat, ...skipIds) : db.prepare(nextSql).all(...skipIds);
if (prefetch.length === 2) {
prefetch.forEach(p => p.sustain = tierFor(p.brand));
nextPair = { a: prefetch[0], b: prefetch[1] };
}
} catch {}
res.json({ duel_id: lastInsertRowid, a: pair[0], b: pair[1], question, next_pair: nextPair });
});
app.post('/api/duel', (req, res) => {
const u = currentUser(req);
const { duel_id, picked } = req.body || {};
if (!['A', 'B', 'skip'].includes(picked)) return res.status(400).json({ error: 'picked must be A/B/skip' });
const d = db.prepare('SELECT * FROM duels WHERE id = ? AND user_id = ?').get(duel_id, u.id);
if (!d) return res.status(404).json({ error: 'duel not found' });
db.prepare("UPDATE duels SET picked = ?, answered_at = datetime('now') WHERE id = ?").run(picked, duel_id);
// taste-vector update — light additive learning
// Tick 24: compute per-dim deltas and surface the top-3 movers so the /taste
// screen can render a "+0.03 fitted, +0.02 evening" toast after each pick.
let topDeltas = [];
if (picked !== 'skip') {
const winnerId = picked === 'A' ? d.item_a : d.item_b;
const loserId = picked === 'A' ? d.item_b : d.item_a;
const w = db.prepare('SELECT embedding FROM items WHERE id = ?').get(winnerId);
const l = db.prepare('SELECT embedding FROM items WHERE id = ?').get(loserId);
let taste = JSON.parse(u.taste_vec || '[]');
if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
const we = w?.embedding ? JSON.parse(w.embedding) : Array(32).fill(0);
const le = l?.embedding ? JSON.parse(l.embedding) : Array(32).fill(0);
const prev = taste.slice();
for (let i = 0; i < 32; i++) {
taste[i] = taste[i] * 0.95 + (we[i] - le[i]) * 0.05;
}
db.prepare('UPDATE users SET taste_vec = ? WHERE id = ?').run(JSON.stringify(taste), u.id);
// Snapshot every 5th picked duel so /taste can render the progression
const answered = db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B')").get(u.id).c;
if (answered > 0 && answered % 5 === 0) {
db.prepare('INSERT INTO taste_history (user_id, duels_answered, taste_vec) VALUES (?, ?, ?)')
.run(u.id, answered, JSON.stringify(taste));
}
// Tick 24: top movers — name comes from the BASIS list in embed-items.js
try {
const { BASIS } = require('./scripts/embed-items');
topDeltas = taste.map((v, i) => ({ dim: BASIS[i]?.key || `dim${i}`, delta: v - prev[i] }))
.filter(d => Math.abs(d.delta) > 0.001)
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
.slice(0, 3)
.map(d => ({ ...d, delta: Number(d.delta.toFixed(3)) }));
} catch {}
}
res.json({ ok: true, deltas: topDeltas });
});
// ---- recommendations ------------------------------------------------------
app.get('/api/recommend', (req, res) => {
const u = currentUser(req);
let taste = JSON.parse(u.taste_vec || '[]');
if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
const limit = Math.min(Number(req.query.limit) || 24, 100);
const cat = req.query.category || null;
const proOnly = req.query.pro_only === '1' || req.query.pro_only === 'true';
// pro_only restricts to set-grade pieces (pro_grade = 1). Tick 16:
// surfaced from the /recs UI for set decorators so the grid biases toward
// multi-stocked vendor pieces instead of one-off resale.
// Tick 26: image-isnull guard — never recommend an item with a broken thumb.
const where = ['embedding IS NOT NULL', "image_url IS NOT NULL AND image_url != ''"];
const params = [];
if (cat) { where.push('category = ?'); params.push(cat); }
if (proOnly) { where.push('pro_grade = 1'); }
// Tick 31: brand filter — case-insensitive exact match. Drives the /brands → main app deep link.
const brandFilter = (req.query.brand || '').trim();
if (brandFilter) { where.push('LOWER(brand) = LOWER(?)'); params.push(brandFilter); }
// Tick 23: color + material facets. Comma-separated query strings; case-
// insensitive LIKE-match on the seed column values so 'red' matches 'red',
// 'Red', or 'true red'. Empty filter list = no filter.
const colors = (req.query.colors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const materials = (req.query.materials || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
if (colors.length) {
where.push(`(${colors.map(() => 'LOWER(color) LIKE ?').join(' OR ')})`);
params.push(...colors.map(c => `%${c}%`));
}
if (materials.length) {
where.push(`(${materials.map(() => 'LOWER(material) LIKE ?').join(' OR ')})`);
params.push(...materials.map(m => `%${m}%`));
}
const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(' AND ')}`).all(...params);
const sustainBoost = u.sustainability === 'top' ? 0.15 : u.sustainability === 'nice' ? 0.05 : 0;
// Tick 19: production credit count per catalog item for the current user.
// Aggregates over tryon_jobs where production_id is set + outfit_picks JSON
// where this item appeared in a slot. Pre-computed once per request, then
// attached to each item via a Map lookup so we don't N+1 the DB.
const tryonCounts = new Map();
db.prepare(`SELECT item_id, COUNT(DISTINCT production_id) c
FROM tryon_jobs WHERE user_id=? AND production_id IS NOT NULL AND item_id IS NOT NULL
GROUP BY item_id`).all(u.id).forEach(r => tryonCounts.set(r.item_id, r.c));
const allScored = rows.map(r => {
const e = JSON.parse(r.embedding);
let dot = 0;
for (let i = 0; i < 32; i++) dot += taste[i] * e[i];
const tier = tierFor(r.brand);
const sustainAdj = tier ? ((tier - 3) / 5) * sustainBoost : 0;
return { ...r, score: dot + sustainAdj, sustain: tier, production_credits: tryonCounts.get(r.id) || 0 };
});
// Tick 30: server-side sort. Default "best" = cosine score (existing behavior).
// Other modes operate on the full scored pool BEFORE the limit slice so the
// user sees true Newest / Price ↑↓ / Sustain rankings across the whole match
// set, not just the top-N-by-cosine.
const sort = String(req.query.sort || 'best').toLowerCase();
let ordered = allScored;
if (sort === 'sustain') ordered = allScored.slice().sort((a, b) => (b.sustain || 0) - (a.sustain || 0) || b.score - a.score);
else if (sort === 'price_asc') ordered = allScored.slice().sort((a, b) => (a.price_cents ?? Number.POSITIVE_INFINITY) - (b.price_cents ?? Number.POSITIVE_INFINITY));
else if (sort === 'price_desc') ordered = allScored.slice().sort((a, b) => (b.price_cents ?? -1) - (a.price_cents ?? -1));
else if (sort === 'newest') ordered = allScored.slice().sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
else ordered = allScored.slice().sort((a, b) => b.score - a.score); // 'best' default
const scored = ordered.slice(0, limit);
// Tick 23: facet counts over the SCORED slice (what user sees), top 6 colors + top 4 materials.
function topFacets(field, n) {
const counts = new Map();
for (const r of scored) {
const v = (r[field] || '').trim().toLowerCase();
if (v) counts.set(v, (counts.get(v) || 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
}
res.json({
items: scored,
sustainability_weight: sustainBoost,
facets: { colors: topFacets('color', 6), materials: topFacets('material', 4) },
applied: { colors, materials },
});
});
// ---- brand page (tick 24) -------------------------------------------------
// GET /api/brand/:name → { brand, sustain_tier, bio, items[] }
// Server-side filter on items.brand (case-insensitive match). Sustainability
// tier pulled from scripts/sustainability.js. Bio sourced from brands.json
// (clothing-apis registry) for any registered brand.
// Tick 27: brand index — all brands present in the catalog with item count +
// sustain tier. Public, no auth. Sort desc by item count so the heavy hitters
// surface first.
app.get('/api/brands', (req, res) => {
const rows = db.prepare(
`SELECT brand, COUNT(*) c FROM items
WHERE brand IS NOT NULL AND brand != ''
AND image_url IS NOT NULL AND image_url != ''
GROUP BY brand
ORDER BY c DESC
LIMIT 200`
).all();
let tierFor; try { ({ tierFor } = require('./scripts/sustainability')); } catch { tierFor = () => null; }
const items = rows.map(r => ({
brand: r.brand,
item_count: r.c,
sustain_tier: tierFor(r.brand),
}));
res.json({ brands: items });
});
app.get('/brands', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'brands.html'));
});
app.get('/api/brand/:name', (req, res) => {
const u = currentUser(req);
const name = decodeURIComponent(req.params.name || '').trim();
if (!name) return res.status(400).json({ error: 'brand required' });
const items = db.prepare(
`SELECT id, title, brand, category, color, pattern, material, price_cents, image_url, product_url, pro_grade
FROM items WHERE LOWER(brand) = LOWER(?) ORDER BY id DESC LIMIT 120`
).all(name);
// Sustainability tier
const tier = (() => {
try { return require('./scripts/sustainability').tierFor(name); }
catch { return null; }
})();
// Bio from brands.json (sustain_tier + domain hint; LLM enrichment later)
let bio = null;
try {
const BRANDS = require('./scripts/clothing-apis/brands.json');
const all = [...(BRANDS.shopify_dtc || []), ...(BRANDS.headless_spa || [])];
const entry = all.find(b => b.name?.toLowerCase() === name.toLowerCase());
if (entry) {
const tierText = entry.sustain_tier ? `Sustainability tier ${entry.sustain_tier}/5.` : '';
const proText = entry.pro_grade ? 'Multi-stocked — set-decorator friendly.' : '';
bio = [tierText, proText, entry.disabled_reason ? `(${entry.disabled_reason})` : ''].filter(Boolean).join(' ');
}
} catch {}
res.json({
brand: name,
sustain_tier: tier,
bio,
item_count: items.length,
items,
});
});
// ---- favorites (tick 24) -------------------------------------------------
// GET /api/favorites → list with hydrated item data
// POST /api/favorites → { kind, id } (idempotent — INSERT OR IGNORE)
// DELETE /api/favorites/:kind/:id → unstar
app.get('/api/favorites', (req, res) => {
const u = currentUser(req);
const rows = db.prepare(
`SELECT f.id AS fav_id, f.item_kind, f.item_id, f.created_at FROM favorites f
WHERE f.user_id=? ORDER BY f.created_at DESC`
).all(u.id);
// Hydrate from items / closet
const hydrated = rows.map(r => {
if (r.item_kind === 'catalog') {
const it = db.prepare('SELECT id, title, brand, category, color, price_cents, image_url, product_url FROM items WHERE id=?').get(r.item_id);
return it ? { ...r, ...it, kind: 'catalog' } : { ...r, kind: 'catalog', _missing: true };
} else {
const it = db.prepare('SELECT id, vendor_guess AS brand, category, color, photo_path FROM closet WHERE id=? AND user_id=?').get(r.item_id, u.id);
return it ? { ...r, ...it, kind: 'closet', image_url: `/api/closet/photo/${it.id}` } : { ...r, kind: 'closet', _missing: true };
}
}).filter(x => !x._missing);
res.json({ items: hydrated });
});
app.post('/api/favorites', express.json({ limit: '4kb' }), (req, res) => {
const u = currentUser(req);
const { kind, id } = req.body || {};
if (!['catalog', 'closet'].includes(kind)) return res.status(400).json({ error: 'kind must be catalog|closet' });
const itemId = Number(id);
if (!Number.isFinite(itemId)) return res.status(400).json({ error: 'id required' });
db.prepare(`INSERT OR IGNORE INTO favorites (user_id, item_kind, item_id) VALUES (?, ?, ?)`).run(u.id, kind, itemId);
res.json({ ok: true });
});
app.delete('/api/favorites/:kind/:id', (req, res) => {
const u = currentUser(req);
const { kind } = req.params;
const itemId = Number(req.params.id);
if (!['catalog', 'closet'].includes(kind) || !Number.isFinite(itemId)) return res.status(400).json({ error: 'bad params' });
const r = db.prepare(`DELETE FROM favorites WHERE user_id=? AND item_kind=? AND item_id=?`).run(u.id, kind, itemId);
res.json({ ok: true, deleted: r.changes });
});
// Tick 25: share-favorites. Generates a stable share_id on first call; the
// same id round-trips so the share URL is durable. POST to rotate (invalidates
// the previous link). Public GET endpoint exposes ONLY catalog items (no
// closet photos, no user identity).
app.post('/api/favorites/share', (req, res) => {
const u = currentUser(req);
const existing = u.favorites_share_id;
const rotate = req.query.rotate === '1';
let id = existing;
if (!id || rotate) {
id = require('crypto').randomBytes(12).toString('hex');
db.prepare('UPDATE users SET favorites_share_id = ? WHERE id = ?').run(id, u.id);
}
res.json({ ok: true, share_id: id, url: `/share/favorites/${id}` });
});
app.get('/api/favorites/share/:share_id', (req, res) => {
const owner = db.prepare('SELECT id, display_name FROM users WHERE favorites_share_id = ?').get(req.params.share_id);
if (!owner) return res.status(404).json({ error: 'share link not found' });
const rows = db.prepare(
`SELECT f.item_kind, f.item_id, f.created_at FROM favorites f
WHERE f.user_id = ? AND f.item_kind = 'catalog'
ORDER BY f.created_at DESC LIMIT 60`
).all(owner.id);
const items = rows.map(r => {
const it = db.prepare('SELECT id, title, brand, category, color, price_cents, image_url, product_url FROM items WHERE id=?').get(r.item_id);
return it ? { ...it, fav_at: r.created_at } : null;
}).filter(Boolean);
res.json({
owner: owner.display_name || 'a WhatsMyStyle user',
items,
count: items.length,
});
});
// Public share page (server-rendered noindex HTML stub).
app.get('/share/favorites/:share_id', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share-favorites.html'));
});
// Tick 31: shareable recs snapshots. Stores the filter+sort state; resolves
// against the OWNER's current taste vector at view time so the recipient sees
// FRESH stock against the same filters (not a frozen item list).
app.post('/api/share/recs', (req, res) => {
const u = currentUser(req);
// Role gate: set-decorator + admin only.
const role = u.role || 'public';
if (role !== 'admin' && role !== 'set_decorator') {
return res.status(403).json({ error: 'role not allowed' });
}
const b = req.body || {};
const state = {
sort: typeof b.sort === 'string' ? b.sort.slice(0, 32) : 'best',
colors: Array.isArray(b.colors) ? b.colors.slice(0, 12).map(String) : [],
materials: Array.isArray(b.materials) ? b.materials.slice(0, 12).map(String) : [],
proOnly: !!b.proOnly,
brand: typeof b.brand === 'string' ? b.brand.slice(0, 64) : '',
category: typeof b.category === 'string' ? b.category.slice(0, 32) : '',
};
const share_id = require('crypto').randomBytes(12).toString('hex');
db.prepare('INSERT INTO recs_shares (share_id, user_id, state_json) VALUES (?, ?, ?)').run(
share_id, u.id, JSON.stringify(state)
);
res.json({ ok: true, share_id, url: `/share/recs/${share_id}` });
});
app.get('/api/share/recs/:share_id', (req, res) => {
const row = db.prepare('SELECT user_id, state_json, created_at FROM recs_shares WHERE share_id = ?').get(req.params.share_id);
if (!row) return res.status(404).json({ error: 'share link not found' });
const owner = db.prepare('SELECT id, display_name, taste_vec, sustainability FROM users WHERE id = ?').get(row.user_id);
if (!owner) return res.status(404).json({ error: 'owner missing' });
const state = JSON.parse(row.state_json);
// Replay against the OWNER's taste vector. Same scoring as /api/recommend
// (just inlined here so we don't have to spoof the auth layer).
let taste = JSON.parse(owner.taste_vec || '[]');
if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
const where = ['embedding IS NOT NULL', "image_url IS NOT NULL AND image_url != ''"];
const params = [];
if (state.category) { where.push('category = ?'); params.push(state.category); }
if (state.proOnly) { where.push('pro_grade = 1'); }
if (state.brand) { where.push('LOWER(brand) = LOWER(?)'); params.push(state.brand); }
const colors = (state.colors || []).map(s => String(s).toLowerCase()).filter(Boolean);
const materials = (state.materials || []).map(s => String(s).toLowerCase()).filter(Boolean);
if (colors.length) {
where.push(`(${colors.map(() => 'LOWER(color) LIKE ?').join(' OR ')})`);
params.push(...colors.map(c => `%${c}%`));
}
if (materials.length) {
where.push(`(${materials.map(() => 'LOWER(material) LIKE ?').join(' OR ')})`);
params.push(...materials.map(m => `%${m}%`));
}
const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(' AND ')}`).all(...params);
let tierFor; try { ({ tierFor } = require('./scripts/sustainability')); } catch { tierFor = () => null; }
const sustainBoost = owner.sustainability === 'top' ? 0.15 : owner.sustainability === 'nice' ? 0.05 : 0;
const scored = rows.map(r => {
const e = JSON.parse(r.embedding);
let dot = 0;
for (let i = 0; i < 32; i++) dot += taste[i] * e[i];
const tier = tierFor(r.brand);
const sustainAdj = tier ? ((tier - 3) / 5) * sustainBoost : 0;
return { ...r, score: dot + sustainAdj, sustain: tier };
});
let ordered = scored;
const s = state.sort || 'best';
if (s === 'sustain') ordered = scored.slice().sort((a, b) => (b.sustain || 0) - (a.sustain || 0) || b.score - a.score);
else if (s === 'price_asc') ordered = scored.slice().sort((a, b) => (a.price_cents ?? Number.POSITIVE_INFINITY) - (b.price_cents ?? Number.POSITIVE_INFINITY));
else if (s === 'price_desc') ordered = scored.slice().sort((a, b) => (b.price_cents ?? -1) - (a.price_cents ?? -1));
else if (s === 'newest') ordered = scored.slice().sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
else ordered = scored.slice().sort((a, b) => b.score - a.score);
const items = ordered.slice(0, 60).map(it => ({
id: it.id, title: it.title, brand: it.brand, category: it.category,
color: it.color, price_cents: it.price_cents, image_url: it.image_url,
product_url: it.product_url, sustain: it.sustain,
}));
res.json({
owner: owner.display_name || 'a WhatsMyStyle user',
state, count: items.length, items, created_at: row.created_at,
});
});
app.get('/share/recs/:share_id', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share-recs.html'));
});
// ---- closet ---------------------------------------------------------------
app.get('/api/closet', (req, res) => {
const u = currentUser(req);
const rows = db.prepare('SELECT * FROM closet WHERE user_id = ? ORDER BY id DESC').all(u.id);
// Tick 19: production credit count per closet item. Closet items can appear
// in two places — tryon_jobs.closet_id (rendered onto avatar/photo) and
// outfit_picks.outfits[].slots[*] where the slot kind is 'closet'.
const tryonCounts = new Map();
db.prepare(`SELECT closet_id, COUNT(DISTINCT production_id) c
FROM tryon_jobs WHERE user_id=? AND production_id IS NOT NULL AND closet_id IS NOT NULL
GROUP BY closet_id`).all(u.id).forEach(r => tryonCounts.set(r.closet_id, r.c));
// Also count direct closet.production_id assignments (a closet item that
// *belongs* to a production counts as one credit on itself).
const closetSelfCount = new Map();
rows.forEach(r => { if (r.production_id) closetSelfCount.set(r.id, 1); });
const withCounts = rows.map(r => ({
...r,
production_credits: (tryonCounts.get(r.id) || 0) + (closetSelfCount.get(r.id) || 0),
}));
res.json({ items: withCounts });
});
// Tick 27: bulk-upload — drop a folder of photos at once. Server caps at 50
// per call, batches the closet inserts in a single transaction. Each row
// goes through the existing closet-vision drain so categorization happens
// in the background.
app.post('/api/closet/photo/bulk', upload.array('photos', 50), (req, res) => {
const u = currentUser(req);
if (!req.files?.length) return res.status(400).json({ error: 'no files' });
let pid = null;
const raw = req.body?.production_id;
if (raw && raw !== '0' && raw !== '') {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) {
const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
if (owned) pid = n;
}
}
const insert = db.prepare(
'INSERT INTO closet (user_id, photo_path, acquired_via, production_id) VALUES (?, ?, ?, ?)'
);
const tx = db.transaction((files) => {
const ids = [];
for (const f of files) {
const r = insert.run(u.id, f.path, 'photo', pid);
ids.push(r.lastInsertRowid);
}
return ids;
});
const ids = tx(req.files);
res.json({ ok: true, count: ids.length, closet_ids: ids, production_id: pid });
});
app.post('/api/closet/photo', upload.single('photo'), (req, res) => {
const u = currentUser(req);
if (!req.file) return res.status(400).json({ error: 'no file' });
// Tick 16: optional production_id from form-data (set decorators).
let pid = null;
const raw = req.body?.production_id;
if (raw && raw !== '0' && raw !== '') {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) {
const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
if (owned) pid = n;
}
}
const result = db.prepare('INSERT INTO closet (user_id, photo_path, acquired_via, production_id) VALUES (?, ?, ?, ?)')
.run(u.id, req.file.path, 'photo', pid);
res.json({ ok: true, pending_vision: true, closet_id: result.lastInsertRowid, production_id: pid });
});
// Tick 15.8: per-item privacy toggle (shared / private). Ahead of couples
// pilot — gives users a primitive to mark closet items they don't want
// auto-included in shared-mode outfit picks once couples ships.
app.put('/api/closet/:id/privacy', (req, res) => {
const u = currentUser(req);
const v = req.body?.privacy;
if (!['shared', 'private'].includes(v)) return res.status(400).json({ error: 'privacy must be shared|private' });
const result = db.prepare('UPDATE closet SET privacy=? WHERE id=? AND user_id=?').run(v, req.params.id, u.id);
if (result.changes === 0) return res.status(404).json({ error: 'closet item not found' });
res.json({ ok: true, privacy: v });
});
// ---- connections (placeholders until OAuth wired) -------------------------
app.post('/api/connect/:kind', (req, res) => {
const u = currentUser(req);
const kind = req.params.kind;
const allowed = ['stripe_fc', 'gmail', 'camera_roll', 'social', 'location'];
if (!allowed.includes(kind)) return res.status(400).json({ error: 'unknown integration' });
db.prepare(`INSERT INTO connections (user_id, kind, state) VALUES (?, ?, 'pending')
ON CONFLICT(user_id, kind) DO UPDATE SET state='pending', updated_at=datetime('now')`)
.run(u.id, kind);
res.json({ ok: true, next: `/oauth/${kind}/start` });
});
app.get('/api/connect', (req, res) => {
const u = currentUser(req);
const rows = db.prepare('SELECT kind, state, updated_at FROM connections WHERE user_id = ?').all(u.id);
res.json({ connections: rows });
});
// Tick 23: real connectivity check per integration. Just verifies the env
// keys exist for now — full OAuth handshakes happen in /oauth/<kind>/start.
// Returns { ok, configured, last_checked, hint } so the connect screen can
// stop showing "pending" for every source.
app.get('/api/connect/test/:kind', (req, res) => {
const kind = req.params.kind;
const checks = {
stripe_fc: { keys: ['STRIPE_SECRET_KEY', 'STRIPE_FC_CLIENT_ID'], hint: 'Stripe Financial Connections — set both keys in .env' },
gmail: { keys: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], hint: 'Google OAuth client — Gmail receipt parsing needs Workspace consent screen' },
camera_roll: { keys: ['GOOGLE_PICKER_API_KEY', 'GOOGLE_CLIENT_ID'], hint: 'Google Photos via Picker API — already wired in /api/public-config' },
social: { keys: ['META_GRAPH_TOKEN'], hint: 'Meta Graph for Instagram — needs Business account + Graph token' },
location: { keys: ['GOOGLE_PLACES_API_KEY'], hint: 'Google Places — used by /api/stores' },
};
const c = checks[kind];
if (!c) return res.status(400).json({ error: 'unknown integration kind' });
const present = c.keys.map(k => ({ key: k, set: !!process.env[k] }));
const configured = present.every(p => p.set);
res.json({
ok: true,
kind,
configured,
keys: present,
hint: c.hint,
last_checked: new Date().toISOString(),
});
});
// ---- stores near me -------------------------------------------------------
app.get('/api/stores', async (req, res) => {
const { lat, lng } = req.query;
if (!lat || !lng) return res.status(400).json({ error: 'lat/lng required' });
const u = currentUser(req);
// Brands the user has interacted with (from their winning duel picks)
const brandRows = db.prepare(`
SELECT i.brand, COUNT(*) c FROM duels d
JOIN items i ON i.id = CASE d.picked WHEN 'A' THEN d.item_a WHEN 'B' THEN d.item_b ELSE 0 END
WHERE d.user_id = ? AND d.picked IN ('A','B') AND i.brand IS NOT NULL
GROUP BY i.brand ORDER BY c DESC LIMIT 8
`).all(u.id);
const likedBrands = brandRows.map(r => r.brand);
if (!process.env.GOOGLE_MAPS_API_KEY) {
return res.json({ stores: [], note: 'GOOGLE_MAPS_API_KEY not set — admin needs to wire it', liked_brands: likedBrands });
}
try {
const fetch = require('node-fetch');
const query = likedBrands.length ? likedBrands[0] + ' clothing store' : 'clothing store';
const url = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=8000&keyword=${encodeURIComponent(query)}&key=${process.env.GOOGLE_MAPS_API_KEY}`;
const r = await fetch(url);
const j = await r.json();
const stores = (j.results || []).slice(0, 20).map(p => ({
name: p.name,
address: p.vicinity,
lat: p.geometry?.location?.lat,
lng: p.geometry?.location?.lng,
rating: p.rating,
place_id: p.place_id,
}));
res.json({ stores, liked_brands: likedBrands });
} catch (e) {
res.status(500).json({ error: 'maps lookup failed', detail: e.message });
}
});
// ---- admin / seed ---------------------------------------------------------
app.post('/api/admin/seed', (req, res) => {
const seedItems = require('./scripts/seed-catalog').seed;
const result = seedItems(db);
res.json(result);
});
// ---- admin / config (persistable knobs) ----------------------------------
function adminGate(req) {
return req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.admin === '1';
}
app.get('/api/admin/config', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const rows = db.prepare('SELECT key, value, updated_at FROM app_config ORDER BY key').all();
const cfg = {};
rows.forEach(r => { try { cfg[r.key] = JSON.parse(r.value); } catch {} });
res.json({
config: cfg,
defaults: { cold_start_cutoff: 3, outlier_injection_rate: 0.2, budget_cents_per_user: 500, couples_pilot: false, embed_drainer_enabled: false },
});
});
// ---- admin / config UI (tick 13) ------------------------------------------
// Plain large-pill form. Same gate as /api/admin/config.
app.get('/admin-config', (req, res) => {
if (!adminGate(req)) return res.status(403).send('admin only');
res.sendFile(path.join(__dirname, 'public', 'admin-config.html'));
});
// Privacy page (tick 15.8). Public — no admin gate.
app.get('/privacy-policy', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'privacy-policy.html'));
});
// Tick 22: drift-ledger viewer — surfaces embedding_drifts so admin can audit
// which catalog items have shifted in their llava-projected vector.
app.get('/admin/drifts', (req, res) => {
if (!adminGate(req)) return res.status(403).send('admin only');
res.sendFile(path.join(__dirname, 'public', 'admin-drifts.html'));
});
app.get('/admin/cron', (req, res) => {
if (!adminGate(req)) return res.status(403).send('admin only');
res.sendFile(path.join(__dirname, 'public', 'admin-cron.html'));
});
// Tick 23: cron diagnostic data. Returns every recurring task with interval +
// last-run timestamp (pulled from app_config). Server-side tasks log their
// last-run; launchd tasks pull mtime from their log file.
app.get('/api/admin/cron', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const fs = require('fs');
function logMtime(p) {
try { return fs.statSync(p).mtime.toISOString(); } catch { return null; }
}
function configMtime(key) {
try {
const r = db.prepare('SELECT updated_at FROM app_config WHERE key=?').get(key);
return r?.updated_at || null;
} catch { return null; }
}
const tasks = [
{ name: 'Closet vision drain', kind: 'setInterval', interval_s: 60, last_run: null, hint: 'spawns scripts/closet-vision.js when pending rows in closet table' },
{ name: 'Tryon job drain', kind: 'setInterval', interval_s: 30, last_run: null, hint: 'spawns scripts/tryon-worker.js when queued tryon_jobs > 0' },
{ name: 'Avatar build drain', kind: 'setInterval', interval_s: 30, last_run: null, hint: 'spawns scripts/avatar-build.js for pending/building avatars' },
{ name: 'Drift sweep', kind: 'setInterval', interval_s: 86400, last_run: configMtime('drift_offset'), hint: '24h rolling re-embed check vs stored vectors' },
{ name: 'Unembedded drainer (in-process)', kind: 'setInterval', interval_s: 300, last_run: null, hint: 'gated on embed_drainer_enabled + Mac1 idle' },
{ name: 'Mac1 watcher', kind: 'setInterval', interval_s: 60, last_run: configMtime('mac1_status'), hint: 'polls 192.168.1.133:11434/api/ps + caches state' },
{ name: 'Embed drainer (launchd)', kind: 'launchd', interval_s: 300, last_run: logMtime(path.join(__dirname, 'data/logs/embed-drainer.out')), hint: 'com.steve.wms-embed-drainer.plist — survives pm2 restarts' },
];
// app_config flags that gate the tasks
const flags = {
embed_drainer_enabled: configValue('embed_drainer_enabled', false),
couples_pilot: configValue('couples_pilot', false),
};
// mac1 status snapshot
let mac1 = null;
try {
const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
if (r) mac1 = JSON.parse(r.value);
} catch {}
res.json({ tasks, flags, mac1, now: new Date().toISOString() });
});
app.get('/api/admin/drifts', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
// Recent drift events, joined to items for context.
const rows = db.prepare(`
SELECT d.id, d.item_id, d.similarity, d.checked_at,
i.title, i.brand, i.source, i.image_url, i.product_url
FROM embedding_drifts d
LEFT JOIN items i ON i.id = d.item_id
ORDER BY d.checked_at DESC, d.id DESC
LIMIT 500
`).all();
// Roll-up: drift count per item.
const perItem = db.prepare(`
SELECT item_id, COUNT(*) c, MIN(similarity) min_sim, MAX(checked_at) last_seen
FROM embedding_drifts GROUP BY item_id ORDER BY c DESC LIMIT 50
`).all();
const total = db.prepare('SELECT COUNT(*) c FROM embedding_drifts').get().c;
res.json({ total, recent: rows, per_item: perItem });
});
app.put('/api/admin/config', express.json({ limit: '8kb' }), (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const updates = req.body || {};
const ALLOWED = ['cold_start_cutoff', 'outlier_injection_rate', 'budget_cents_per_user', 'couples_pilot', 'embed_drainer_enabled'];
const stmt = db.prepare(`INSERT INTO app_config (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`);
const written = [];
for (const [k, v] of Object.entries(updates)) {
if (!ALLOWED.includes(k)) continue;
stmt.run(k, JSON.stringify(v));
written.push(k);
}
res.json({ ok: true, written });
});
// ---- admin / catalog import (clothing APIs) -------------------------------
// POST /api/admin/import-catalog — import all Shopify brands
// POST /api/admin/import-catalog { kind:'shopify' } — Shopify only
// POST /api/admin/import-catalog { kind:'shopify', id:'allbirds' } — one brand
app.post('/api/admin/import-catalog', express.json({ limit: '8kb' }), async (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const { kind, id, limit } = req.body || {};
try {
const { importAll, importOne } = require('./scripts/clothing-apis');
let results;
if (kind && id) results = [await importOne(db, kind, id)];
else results = await importAll(db, { kind, limit });
const totals = results.reduce((a, r) => {
a.imported += r.imported || 0;
a.fetched += r.fetched || 0;
if (r.ok === false || r.err) a.errors++;
if (r.skipped) a.skipped++;
return a;
}, { imported: 0, fetched: 0, errors: 0, skipped: 0 });
res.json({ ok: true, totals, results });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/admin/catalog-status — quick rollup for the /admin-config page
app.get('/api/admin/catalog-status', (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const total = db.prepare('SELECT COUNT(*) c FROM items').get().c;
const embedded = db.prepare('SELECT COUNT(*) c FROM items WHERE embedding IS NOT NULL').get().c;
const bySource = db.prepare('SELECT source, COUNT(*) c FROM items GROUP BY source ORDER BY c DESC').all();
// Tick 20: surface Mac1 state so the UI can explain why draining is paused.
let mac1 = null;
try {
const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
if (r) mac1 = JSON.parse(r.value);
} catch {}
// Tick 22: paid-API budget rollup. Sum provider_costs by month + by provider.
// Floating chip displays running monthly burn against budget_cents_per_user.
const monthStart = new Date();
monthStart.setUTCDate(1);
monthStart.setUTCHours(0, 0, 0, 0);
const monthCents = db.prepare(
`SELECT COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ?`
).get(monthStart.toISOString()).c || 0;
const byProvider = db.prepare(
`SELECT provider, COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ? GROUP BY provider ORDER BY c DESC`
).all(monthStart.toISOString());
res.json({
total, embedded, unembedded: total - embedded, by_source: bySource,
drainer_enabled: configValue('embed_drainer_enabled', false),
mac1,
budget: {
month_cents: monthCents,
budget_cents: configValue('budget_cents_per_user', 500),
by_provider: byProvider,
},
});
});
// ---- admin / debate -------------------------------------------------------
app.post('/api/admin/debate', async (req, res) => {
const { topic, context } = req.body || {};
if (!topic) return res.status(400).json({ error: 'topic required' });
try {
const { debate } = require('./scripts/debate');
const r = await debate(topic, context || '');
const { lastInsertRowid } = db.prepare(
'INSERT INTO debates (topic, context, verdict, panel) VALUES (?, ?, ?, ?)'
).run(topic, context || '', r.verdict, JSON.stringify(r.panel));
res.json({ ok: true, id: lastInsertRowid, verdict: r.verdict });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ---- couples pilot (tick 14) ----------------------------------------------
// Per the 2026-05-12 dream-team verdict (debate id 8), couples support stays
// behind app_config.couples_pilot until the consent + breakup-data-reset
// flows are vetted in pilot. While the flag is off, every endpoint returns
// 503 {enabled:false} so the schema rows can't be written by accident.
function couplesEnabled() { return configValue('couples_pilot', false) === true; }
const crypto = require('crypto');
function newInviteToken() { return crypto.randomBytes(16).toString('hex'); }
app.post('/api/couple/invite', (req, res) => {
if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
const u = currentUser(req);
// Reuse an outstanding pending invite if partner_a already issued one.
const existing = db.prepare(
`SELECT id, partner_b_user_id FROM couples WHERE partner_a_user_id=? AND status='pending' ORDER BY id DESC LIMIT 1`
).get(u.id);
if (existing) {
return res.json({ ok: true, reused: true, couple_id: existing.id, invite_token: existing.partner_b_user_id < 0 ? String(-existing.partner_b_user_id) : null });
}
// partner_b_user_id is unknown at invite time. We stash the negative of a
// token-fingerprint as the placeholder, swap to the real user_id on accept.
const token = newInviteToken();
const placeholder = -Number('0x' + token.slice(0, 8)); // negative numeric placeholder
const { lastInsertRowid } = db.prepare(
`INSERT INTO couples (partner_a_user_id, partner_b_user_id, status) VALUES (?, ?, 'pending')`
).run(u.id, placeholder);
// Persist the real token in a side row keyed off the couple id so /accept can look it up.
db.prepare(
`INSERT INTO app_config (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`
).run(`couple_invite_${lastInsertRowid}`, JSON.stringify(token));
res.json({ ok: true, couple_id: lastInsertRowid, invite_token: token });
});
app.post('/api/couple/accept/:token', (req, res) => {
if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
const u = currentUser(req);
const token = req.params.token;
// Walk pending invites to find the matching token (small N — fine).
const pending = db.prepare(`SELECT id, partner_a_user_id FROM couples WHERE status='pending'`).all();
let coupleId = null;
for (const row of pending) {
const cfg = db.prepare('SELECT value FROM app_config WHERE key=?').get(`couple_invite_${row.id}`);
if (cfg && JSON.parse(cfg.value) === token) {
if (row.partner_a_user_id === u.id) return res.status(400).json({ error: 'cannot accept your own invite' });
coupleId = row.id;
break;
}
}
if (!coupleId) return res.status(404).json({ error: 'invite not found or already accepted' });
db.prepare(`UPDATE couples SET partner_b_user_id=?, status='active', opt_in_at=datetime('now') WHERE id=?`).run(u.id, coupleId);
// Burn the token.
db.prepare(`DELETE FROM app_config WHERE key=?`).run(`couple_invite_${coupleId}`);
res.json({ ok: true, couple_id: coupleId, status: 'active' });
});
app.post('/api/couple/exit', (req, res) => {
if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
const u = currentUser(req);
const row = db.prepare(
`SELECT id FROM couples WHERE (partner_a_user_id=? OR partner_b_user_id=?) AND status='active' ORDER BY id DESC LIMIT 1`
).get(u.id, u.id);
if (!row) return res.status(404).json({ error: 'no active couple' });
// Purge cross-twin training: any tryon_jobs that anchor on partner-B's avatar
// or closet item from partner-A's session (gift mode) get hard-deleted.
// For the scaffold we mark the couple exited; deeper purge wires up when
// gifts-mode ships.
db.prepare(`UPDATE couples SET status='exited', exit_at=datetime('now') WHERE id=?`).run(row.id);
res.json({ ok: true, couple_id: row.id, status: 'exited' });
});
// ---- duel-on-me pre-render --------------------------------------------
// When the avatar is ready, we pre-render the duel items onto the user's
// twin in the background so the next /api/duel call can serve "on me" URLs.
app.get('/api/duel/on-me/:item_id', (req, res) => {
const u = currentUser(req);
const av = db.prepare("SELECT id FROM user_avatars WHERE user_id=? AND status='ready' ORDER BY id DESC LIMIT 1").get(u.id);
if (!av) return res.status(404).json({ error: 'no avatar' });
// Look for an existing done tryon for (this user, this item, mode=avatar)
const j = db.prepare(`SELECT id FROM tryon_jobs WHERE user_id=? AND item_id=? AND mode='avatar' AND status='done' ORDER BY id DESC LIMIT 1`)
.get(u.id, req.params.item_id);
if (!j) {
// queue one and return 202
const provider = process.env.DOPPLE_API_KEY ? 'dopple' :
process.env.GEMINI_API_KEY ? 'gemini-2.5-flash-image' : 'stub';
db.prepare(`INSERT INTO tryon_jobs (user_id, avatar_id, item_id, mode, provider, status) VALUES (?, ?, ?, 'avatar', ?, 'queued')`)
.run(u.id, av.id, req.params.item_id, provider);
return res.status(202).json({ queued: true });
}
res.redirect(`/api/tryon/${j.id}/image`);
});
// ---- closet photo serving -------------------------------------------------
app.get('/api/closet/photo/:id', (req, res) => {
const u = currentUser(req);
const row = db.prepare('SELECT photo_path FROM closet WHERE id = ? AND user_id = ?').get(req.params.id, u.id);
if (!row || !row.photo_path) return res.status(404).end();
res.sendFile(path.resolve(row.photo_path));
});
// ---- avatar ---------------------------------------------------------------
const avatarUpload = multer({ dest: path.join(UPLOAD_DIR, 'avatar'), limits: { fileSize: 25 * 1024 * 1024 } });
app.post('/api/avatar/photos', avatarUpload.array('photos', 8), (req, res) => {
const u = currentUser(req);
if (!req.files || !req.files.length) return res.status(400).json({ error: 'no photos' });
const paths = req.files.map(f => f.path);
// Find or create the user's avatar row
let av = db.prepare('SELECT * FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
if (!av) {
const { lastInsertRowid } = db.prepare(
'INSERT INTO user_avatars (user_id, source_photos, status) VALUES (?, ?, ?)'
).run(u.id, JSON.stringify(paths), 'pending');
av = db.prepare('SELECT * FROM user_avatars WHERE id = ?').get(lastInsertRowid);
} else {
const existing = JSON.parse(av.source_photos || '[]');
db.prepare('UPDATE user_avatars SET source_photos = ?, status = ? WHERE id = ?')
.run(JSON.stringify([...existing, ...paths]), 'pending', av.id);
}
res.json({ ok: true, avatar_id: av.id, photo_count: JSON.parse(av.source_photos || '[]').length + paths.length });
});
app.post('/api/avatar/build', (req, res) => {
const u = currentUser(req);
const av = db.prepare('SELECT * FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
if (!av) return res.status(400).json({ error: 'upload photos first' });
db.prepare("UPDATE user_avatars SET status='building' WHERE id=?").run(av.id);
// Worker is launched out-of-band (cron). Return immediately.
res.json({ ok: true, avatar_id: av.id, status: 'building', note: 'Worker will pick this up.' });
});
app.get('/api/avatar', (req, res) => {
const u = currentUser(req);
const av = db.prepare('SELECT id, status, canonical_path, body_measurements, ready_at FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
res.json({ avatar: av || null });
});
app.get('/api/avatar/render', (req, res) => {
const u = currentUser(req);
const av = db.prepare('SELECT canonical_path FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
if (!av?.canonical_path || !fs.existsSync(av.canonical_path)) return res.status(404).end();
res.sendFile(path.resolve(av.canonical_path));
});
// ---- try-on (item on avatar, OR swap outfit in old photo) -----------------
const swapUpload = multer({ dest: path.join(UPLOAD_DIR, 'swap'), limits: { fileSize: 25 * 1024 * 1024 } });
app.post('/api/tryon', swapUpload.single('source_photo'), (req, res) => {
const u = currentUser(req);
const { item_id, closet_id, mode, occasion, production_id } = req.body;
if (!item_id && !closet_id) return res.status(400).json({ error: 'item_id or closet_id required' });
// Tick 16: optional production_id binds the try-on to a set-decorator project.
let pid = null;
if (production_id && production_id !== '0' && production_id !== '') {
const n = Number(production_id);
if (Number.isFinite(n) && n > 0) {
const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
if (owned) pid = n;
}
}
// resolve garment ref — catalog or closet
let garmentExists = false;
if (item_id) garmentExists = !!db.prepare('SELECT id FROM items WHERE id=?').get(item_id);
if (closet_id) garmentExists = !!db.prepare('SELECT id FROM closet WHERE id=? AND user_id=?').get(closet_id, u.id);
if (!garmentExists) return res.status(404).json({ error: 'garment not found' });
// resolve target — avatar / new photo / old photo
const m = mode || 'avatar';
let av = null;
if (m === 'avatar') {
av = db.prepare("SELECT id FROM user_avatars WHERE user_id=? AND status='ready' ORDER BY id DESC LIMIT 1").get(u.id);
if (!av) return res.status(400).json({ error: 'avatar not ready', hint: 'upload + build an avatar first' });
}
const source_photo_path = req.file ? req.file.path : null;
if ((m === 'photo_swap' || m === 'closet_to_oldphoto') && !source_photo_path) {
return res.status(400).json({ error: `${m} needs source_photo` });
}
// pick best available provider — Dopple first per spec
let provider = 'stub';
if (process.env.DOPPLE_API_KEY) provider = 'dopple';
else if (process.env.GEMINI_API_KEY) provider = 'gemini-2.5-flash-image';
else if (process.env.IDM_VTON_URL) provider = 'idm-vton';
else if (process.env.OOTD_URL) provider = 'ootdiffusion';
const { lastInsertRowid } = db.prepare(`
INSERT INTO tryon_jobs (user_id, avatar_id, item_id, closet_id, mode, source_photo_path, occasion, provider, status, production_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?)
`).run(u.id, av?.id || null, item_id || null, closet_id || null, m, source_photo_path, occasion || null, provider, pid);
res.json({ ok: true, job_id: lastInsertRowid, provider, queued: true, production_id: pid });
});
app.get('/api/tryon/:id', (req, res) => {
const u = currentUser(req);
const j = db.prepare('SELECT * FROM tryon_jobs WHERE id = ? AND user_id = ?').get(req.params.id, u.id);
if (!j) return res.status(404).json({ error: 'not found' });
res.json({ job: j });
});
app.get('/api/tryon/:id/image', (req, res) => {
const u = currentUser(req);
const j = db.prepare('SELECT result_path FROM tryon_jobs WHERE id = ? AND user_id = ?').get(req.params.id, u.id);
if (!j?.result_path || !fs.existsSync(j.result_path)) return res.status(404).end();
res.sendFile(path.resolve(j.result_path));
});
app.get('/api/tryon', (req, res) => {
const u = currentUser(req);
const rows = db.prepare("SELECT id, item_id, mode, status, provider, created_at, done_at FROM tryon_jobs WHERE user_id = ? ORDER BY id DESC LIMIT 60").all(u.id);
res.json({ jobs: rows });
});
// ---- debate log -----------------------------------------------------------
app.get('/api/debates', (req, res) => {
const rows = db.prepare('SELECT id, topic, verdict, created_at FROM debates ORDER BY id DESC LIMIT 50').all();
res.json({ debates: rows });
});
// ---- outfit-builder -------------------------------------------------------
// Given an anchor (closet item the user owns, OR a catalog item they liked),
// build 3 complete outfits drawn from the catalog. Slots filled by picking
// the highest-taste-similarity item per missing category, with category
// complementarity (a top wants a bottom + shoes; a dress wants shoes + bag).
const SLOT_RULES = {
top: ['bottom', 'shoes', 'outerwear', 'bag'],
bottom: ['top', 'shoes', 'outerwear', 'bag'],
dress: ['shoes', 'bag', 'outerwear'],
outerwear: ['top', 'bottom','shoes', 'bag'],
shoes: ['top', 'bottom','bag'],
bag: ['top', 'bottom','shoes'],
accessory: ['top', 'bottom','shoes'],
};
function dot(a, b) { let s = 0; for (let i = 0; i < a.length && i < b.length; i++) s += a[i] * b[i]; return s; }
// Tick 15.7: deterministic outfit rationale builder.
// Reads slot titles + brands and templates a 1–2 sentence "why this works"
// blurb. NO LLM call — pure string/regex inspection so it stays cheap +
// reproducible across reloads. Renders well even when items have sparse
// metadata: degrades to a brand-pairing sentence instead of a wardrobe one.
const RATIONALE_PATTERNS = {
workwear: /blazer|trouser|suit|oxford|loafer|button[- ]?down/i,
evening: /silk|sequin|gown|tuxedo|velvet|wrap dress|cocktail/i,
casual: /tee|t-shirt|jean|denim|sneaker|hoodie|sweatpant|chino/i,
outdoorsy: /trench|parka|puffer|raincoat|hiking|barn jacket/i,
knitted: /knit|cashmere|wool|merino|cardigan|sweater/i,
leather: /leather|suede/i,
warm_hue: /cream|camel|tan|rust|gold|brown|terracotta|ivory|oat|honey/i,
cool_hue: /black|navy|grey|gray|charcoal|indigo|cobalt|teal|sage/i,
};
function tagsForSlot(slot) {
const text = `${slot?.title || ''} ${slot?.brand || ''}`;
const out = new Set();
for (const [tag, re] of Object.entries(RATIONALE_PATTERNS)) {
if (re.test(text)) out.add(tag);
}
return out;
}
function rationaleFor(outfit) {
if (!outfit || !outfit.slots) return '';
const slots = outfit.slots;
const titles = Object.entries(slots).map(([k, v]) => v ? `${k}: ${v.title}` : null).filter(Boolean);
if (!titles.length) return '';
const tagSets = Object.fromEntries(Object.entries(slots).map(([k, v]) => [k, tagsForSlot(v)]));
const all = new Set();
for (const s of Object.values(tagSets)) for (const t of s) all.add(t);
// sentence 1 — pick the dominant register
let s1 = '';
if (all.has('evening')) s1 = 'Goes evening without trying too hard.';
else if (all.has('workwear')) s1 = 'Tailored enough for a meeting, easy enough for after.';
else if (all.has('outdoorsy') && all.has('knitted')) s1 = 'A weather-ready layering set; the knit softens the outer shell.';
else if (all.has('knitted') && all.has('casual')) s1 = 'A cozy weekday pairing that won\'t read as sweats.';
else if (all.has('leather') && all.has('casual')) s1 = 'A casual base with leather to pull the look up a notch.';
else if (all.has('casual')) s1 = 'A relaxed everyday combination — easy to throw on.';
else s1 = 'A balanced pairing pulled from your taste signals.';
// sentence 2 — color contrast cue
let s2 = '';
const warm = all.has('warm_hue'), cool = all.has('cool_hue');
if (warm && cool) s2 = 'The warm/cool tension keeps it from going monochrome.';
else if (warm) s2 = 'A warm palette — easy on the skin, photographs well.';
else if (cool) s2 = 'Cool tones throughout — sharp and forgiving in low light.';
// sentence 3 — brand callout (optional)
const brands = Object.values(slots).map(v => v?.brand).filter(Boolean);
let s3 = '';
if (brands.length >= 2) {
const u = [...new Set(brands)];
if (u.length === 1) s3 = `Head-to-toe ${u[0]} keeps the silhouette consistent.`;
else if (u.length >= 2 && u.length <= 3) s3 = `${u.slice(0, 2).join(' + ')} pair well in this register.`;
}
return [s1, s2, s3].filter(Boolean).join(' ');
}
function buildOutfitsFor(db, u, anchorEmbed, anchorCategory) {
const need = SLOT_RULES[anchorCategory] || ['top', 'bottom', 'shoes'];
const candidatesByCat = {};
// Tick 26: image guard on outfit builder pool too.
const rows = db.prepare("SELECT id, title, brand, category, color, image_url, product_url, price_cents, embedding FROM items WHERE embedding IS NOT NULL AND image_url IS NOT NULL AND image_url != ''").all();
for (const r of rows) {
if (!candidatesByCat[r.category]) candidatesByCat[r.category] = [];
try {
const e = JSON.parse(r.embedding);
r._sim = dot(anchorEmbed, e);
candidatesByCat[r.category].push(r);
} catch {}
}
for (const cat of Object.keys(candidatesByCat)) {
candidatesByCat[cat].sort((a, b) => b._sim - a._sim);
}
// build 3 outfits: pick top-3 of the primary slot, then for each pick the
// top sim-and-also-uncoordinated picks for the remaining slots (so the
// 3 outfits are distinct rather than near-clones).
const used = new Set();
const outfits = [];
for (let k = 0; k < 3; k++) {
const slots = {};
let score = 0;
for (const cat of need) {
const pool = candidatesByCat[cat] || [];
const pick = pool.find(it => !used.has(`${cat}:${it.id}`));
if (pick) {
slots[cat] = { id: pick.id, title: pick.title, brand: pick.brand, image_url: pick.image_url, product_url: pick.product_url, price_cents: pick.price_cents, sim: Number(pick._sim.toFixed(3)) };
used.add(`${cat}:${pick.id}`);
score += pick._sim;
}
}
if (Object.keys(slots).length) outfits.push({ slots, score: Number(score.toFixed(3)) });
}
return outfits;
}
app.post('/api/outfits', (req, res) => {
const u = currentUser(req);
const { closet_id, item_id } = req.body || {};
if (!closet_id && !item_id) return res.status(400).json({ error: 'closet_id or item_id required' });
let anchorEmbed = null, anchorCategory = 'top';
if (closet_id) {
const c = db.prepare('SELECT category, color, vendor_guess, pattern FROM closet WHERE id=? AND user_id=?').get(closet_id, u.id);
if (!c) return res.status(404).json({ error: 'closet item missing' });
anchorCategory = c.category || 'top';
// No closet embedding yet — fall back to the user's taste vector
try { anchorEmbed = JSON.parse(u.taste_vec || '[]'); } catch { anchorEmbed = Array(32).fill(0); }
if (!Array.isArray(anchorEmbed) || anchorEmbed.length !== 32) anchorEmbed = Array(32).fill(0);
} else {
const it = db.prepare('SELECT category, embedding FROM items WHERE id=?').get(item_id);
if (!it) return res.status(404).json({ error: 'item missing' });
anchorCategory = it.category || 'top';
try { anchorEmbed = JSON.parse(it.embedding || '[]'); } catch { anchorEmbed = Array(32).fill(0); }
}
const outfits = buildOutfitsFor(db, u, anchorEmbed, anchorCategory);
// Tick 15.7: attach a deterministic "why this works" rationale per outfit.
for (const o of outfits) o.rationale = rationaleFor(o);
const { lastInsertRowid } = db.prepare(
'INSERT INTO outfit_picks (user_id, anchor_kind, anchor_id, outfits) VALUES (?, ?, ?, ?)'
).run(u.id, closet_id ? 'closet' : 'catalog', closet_id || item_id, JSON.stringify(outfits));
res.json({ ok: true, pick_id: lastInsertRowid, outfits });
});
// Tick 17: GET a stored pick + rehydrate the rationale text per outfit.
// We don't persist `rationale` on outfit_picks (deterministic template, cheap
// to recompute), so re-run rationaleFor() before returning.
app.get('/api/outfits/:id', (req, res) => {
const u = currentUser(req);
const row = db.prepare('SELECT * FROM outfit_picks WHERE id=? AND user_id=?').get(req.params.id, u.id);
if (!row) return res.status(404).json({ error: 'pick not found' });
let outfits;
try { outfits = JSON.parse(row.outfits || '[]'); }
catch { outfits = []; }
for (const o of outfits) o.rationale = rationaleFor(o);
res.json({
pick_id: row.id,
anchor_kind: row.anchor_kind,
anchor_id: row.anchor_id,
picked_index: row.picked_index,
created_at: row.created_at,
picked_at: row.picked_at,
outfits,
});
});
app.post('/api/outfits/:id/pick', (req, res) => {
const u = currentUser(req);
const idx = Number(req.body?.index);
if (![0, 1, 2].includes(idx)) return res.status(400).json({ error: 'index must be 0/1/2' });
const row = db.prepare('SELECT * FROM outfit_picks WHERE id=? AND user_id=?').get(req.params.id, u.id);
if (!row) return res.status(404).json({ error: 'pick not found' });
db.prepare("UPDATE outfit_picks SET picked_index=?, picked_at=datetime('now') WHERE id=?").run(idx, row.id);
// taste-vector update — bump dims of the items in the picked outfit
const outfits = JSON.parse(row.outfits || '[]');
const picked = outfits[idx];
if (picked) {
const itemIds = Object.values(picked.slots).map(s => s.id).filter(Boolean);
const items = itemIds.length
? db.prepare(`SELECT embedding FROM items WHERE id IN (${itemIds.map(() => '?').join(',')})`).all(...itemIds)
: [];
let taste = JSON.parse(u.taste_vec || '[]');
if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
for (const it of items) {
try {
const e = JSON.parse(it.embedding);
for (let i = 0; i < 32; i++) taste[i] = taste[i] * 0.97 + e[i] * 0.03;
} catch {}
}
db.prepare('UPDATE users SET taste_vec=? WHERE id=?').run(JSON.stringify(taste), u.id);
}
res.json({ ok: true });
});
// ---- Item credits (tick 15) ----------------------------------------------
// UX borrowed from shotonwhat.com/o/{id}/{slug}: a credit résumé for a
// person, one row per film/TV title with role + year + collaborators.
// Our analogue is per-garment: for a closet or catalog item, list every
// appearance (try-on render, outfit pick, time-travel render, photo swap)
// with the occasion, date, and the rest of the outfit it appeared with.
//
// GET /api/item-credits/:kind/:id kind ∈ { closet, catalog }
//
// Returns { item, total, credits: [{role, occasion, date, type, with: [...]}] }
// where role is the verb ("Worn", "Tried on", "Time-Travel render", "Outfit pick"),
// type is the production-type tag ("Try-On" / "Outfit" / "Photo Swap"),
// and `with` is the rest of the slots that appeared in the same beat.
app.get('/api/item-credits/:kind/:id', (req, res) => {
const u = currentUser(req);
const kind = req.params.kind;
const id = Number(req.params.id);
if (!['closet', 'catalog'].includes(kind)) return res.status(400).json({ error: 'kind must be closet|catalog' });
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
// 1. Resolve the item header.
let item = null;
if (kind === 'closet') {
const c = db.prepare('SELECT id, vendor_guess AS brand, category, color, pattern, material_guess AS material, created_at FROM closet WHERE id=? AND user_id=?').get(id, u.id);
if (!c) return res.status(404).json({ error: 'closet item not found' });
item = { kind, id: c.id, title: [c.color, c.pattern, c.category].filter(Boolean).join(' ').trim() || 'closet piece', brand: c.brand || null, category: c.category, since: c.created_at };
} else {
const it = db.prepare('SELECT id, title, brand, category, color, pattern, material, image_url FROM items WHERE id=?').get(id);
if (!it) return res.status(404).json({ error: 'catalog item not found' });
item = { kind, id: it.id, title: it.title, brand: it.brand, category: it.category, image_url: it.image_url };
}
// 2. Try-on renders that anchor on this item.
const tryonRows = kind === 'closet'
? db.prepare(`SELECT id, mode, occasion, provider, status, source_photo_path, created_at, done_at FROM tryon_jobs WHERE user_id=? AND closet_id=? ORDER BY id DESC`).all(u.id, id)
: db.prepare(`SELECT id, mode, occasion, provider, status, source_photo_path, created_at, done_at FROM tryon_jobs WHERE user_id=? AND item_id=? ORDER BY id DESC`).all(u.id, id);
// 3. Outfit picks that include this item in any slot.
// outfits column holds JSON array of {slots:{top|bottom|shoes|bag|outerwear:{id,kind,...}}, score}.
const allPicks = db.prepare(`SELECT id, anchor_kind, anchor_id, outfits, picked_index, created_at, picked_at FROM outfit_picks WHERE user_id=? ORDER BY id DESC`).all(u.id);
const outfitCredits = [];
for (const row of allPicks) {
let outfits;
try { outfits = JSON.parse(row.outfits || '[]'); } catch { continue; }
for (let oi = 0; oi < outfits.length; oi++) {
const o = outfits[oi];
const slots = o?.slots || {};
const slotEntries = Object.entries(slots);
const hit = slotEntries.find(([, v]) => v && (
(kind === 'closet' && v.kind === 'closet' && Number(v.id) === id) ||
(kind === 'catalog' && (v.kind === 'catalog' || !v.kind) && Number(v.id) === id)
));
if (!hit) continue;
const mySlot = hit[0];
const others = slotEntries
.filter(([slot]) => slot !== mySlot)
.map(([slot, v]) => v ? `${slot}: ${v.title || v.brand || 'piece'}` : null)
.filter(Boolean);
outfitCredits.push({
role: row.picked_index === oi ? 'Picked outfit' : 'Outfit candidate',
occasion: null,
date: row.picked_at || row.created_at,
type: 'Outfit',
slot: mySlot,
with: others.slice(0, 4),
});
// one row per pick (don't double-count if same item appears in multiple slot candidates)
break;
}
}
// 4. Stitch try-on rows into credits.
const tryonCredits = tryonRows.map(t => {
const role = ({
avatar: 'Avatar render',
photo_swap: 'Photo-swap render',
closet_to_oldphoto: 'Time-Travel render',
})[t.mode] || 'Try-on';
return {
role,
occasion: t.occasion || null,
date: t.done_at || t.created_at,
type: t.mode === 'closet_to_oldphoto' ? 'Time-Travel' : (t.mode === 'photo_swap' ? 'Photo Swap' : 'Try-On'),
provider: t.provider,
status: t.status,
with: t.source_photo_path ? ['source: old photo'] : [],
};
});
// 5. Merge + sort by date desc.
const credits = [...tryonCredits, ...outfitCredits]
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
res.json({
item,
total: credits.length,
credits,
});
});
// ---- Resale guardrails (no resale UX yet — see scripts/resale-guardrails.js) ----
const guardrails = require('./scripts/resale-guardrails');
app.get('/api/resale/options/:closet_id', (req, res) => {
const u = currentUser(req);
const c = db.prepare('SELECT * FROM closet WHERE id=? AND user_id=?').get(req.params.closet_id, u.id);
if (!c) return res.status(404).json({ error: 'closet item not found' });
const elig = guardrails.isEligibleForResaleNudge(c, req.query);
// Always show repair/recycle FIRST, regardless of resale eligibility
const options = guardrails.repairAndRecycleOptions({ brand: c.vendor_guess, city: u.city });
res.json({
repair_first: options.repair,
takeback_programs: options.takeback,
sell_nudge: elig.ok ? { allowed: true, reason: elig.reason } : { allowed: false, reason: elig.reason },
});
});
app.post('/api/resale/event', (req, res) => {
const u = currentUser(req);
const { closet_id, action } = req.body || {};
if (!closet_id || !action) return res.status(400).json({ error: 'closet_id + action required' });
guardrails.logResaleEventEphemeral({ user_id: u.id, closet_id, action });
res.json({ ok: true, persisted: 'ephemeral-only' });
});
// ---- Stripe Financial Connections (read-only) ----------------------------
const stripeFC = require('./scripts/stripe-fc');
app.get('/api/stripe-fc/status', (req, res) => {
res.json({ available: stripeFC.available(), permissions: stripeFC.PERMISSIONS });
});
app.post('/api/stripe-fc/session', async (req, res) => {
const u = currentUser(req);
if (!stripeFC.available()) return res.status(503).json({ error: 'Stripe FC not configured', hint: 'STRIPE_FC_SECRET_KEY missing' });
try {
const returnUrl = `${process.env.BASE_URL || 'http://127.0.0.1:9777'}/api/stripe-fc/return`;
const session = await stripeFC.createLinkSession({ accountHolderName: u.display_name, returnUrl });
db.prepare(`INSERT INTO connections (user_id, kind, state, meta) VALUES (?, 'stripe_fc', 'pending', ?)
ON CONFLICT(user_id, kind) DO UPDATE SET state='pending', meta=excluded.meta, updated_at=datetime('now')`)
.run(u.id, JSON.stringify({ session_id: session.id }));
res.json({ ok: true, client_secret: session.client_secret, session_id: session.id });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/stripe-fc/return', (req, res) => {
// Stripe redirects here after the Link flow. The client also gets a callback
// via the JS SDK — this is purely the success page.
res.send(`<!doctype html><meta charset=utf-8><title>Bank connected</title>
<body style="font-family:system-ui;padding:40px;text-align:center;">
<h1>Connected.</h1><p>You can close this window.</p>
<script>window.opener && window.opener.postMessage({stripe_fc:'success'},'*');setTimeout(()=>window.close(),1500);</script>`);
});
app.post('/api/stripe-fc/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// TODO when STRIPE_WEBHOOK_SIGNING_SECRET is set: verify signature,
// parse event, route financial_connections.transaction.created to a
// closet-insert path. For now just acknowledge.
res.json({ received: true });
});
// ---- waitlist (for /about page) ------------------------------------------
app.post('/api/waitlist', (req, res) => {
const { email, source } = req.body || {};
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ error: 'valid email required' });
try {
db.prepare('INSERT INTO waitlist (email, source) VALUES (?, ?)').run(email.toLowerCase().trim(), source || 'about-page');
} catch (e) {
if (!/UNIQUE/i.test(e.message)) throw e;
// already on list — that's fine, return ok
}
// mirror to data/waitlist.csv for easy export
const csvPath = path.join(DATA_DIR, 'waitlist.csv');
const header = fs.existsSync(csvPath) ? '' : 'email,source,created_at\n';
fs.appendFileSync(csvPath, header + `${email.toLowerCase().trim()},${source || 'about-page'},${new Date().toISOString()}\n`);
res.json({ ok: true });
});
// ---- garment extractor: pull a piece from an old photo -------------------
// User crops a rectangle on an old photo (the blue scarf, the leather jacket,
// the 1980s sweater). We save the crop as a new row in `items` with
// source='extracted' so it shows up as a swappable piece in the Studio picker.
// Then the user can swap that extracted piece onto a CURRENT photo of themselves.
const sharp = (() => { try { return require('sharp'); } catch { return null; } })();
app.post('/api/studio/extract-garment', express.json({ limit: '32kb' }), async (req, res) => {
const u = currentUser(req);
const { source_photo, bbox, category, brand_hint } = req.body || {};
// bbox normalized 0-1 floats: {x, y, w, h}
if (!source_photo || !bbox) return res.status(400).json({ error: 'source_photo + bbox required' });
if (!source_photo.startsWith(path.join(__dirname, 'data', 'uploads'))) return res.status(400).json({ error: 'bad source path' });
if (!fs.existsSync(source_photo)) return res.status(404).json({ error: 'photo not found' });
const outDir = path.join(__dirname, 'data', 'uploads', 'extracted');
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, `extract-${Date.now()}.jpg`);
try {
if (sharp) {
const meta = await sharp(source_photo).metadata();
const x = Math.max(0, Math.round(bbox.x * meta.width));
const y = Math.max(0, Math.round(bbox.y * meta.height));
const w = Math.min(meta.width - x, Math.round(bbox.w * meta.width));
const h = Math.min(meta.height - y, Math.round(bbox.h * meta.height));
if (w < 32 || h < 32) return res.status(400).json({ error: 'crop too small (need ≥ 32x32 px)' });
await sharp(source_photo).extract({ left: x, top: y, width: w, height: h }).jpeg({ quality: 90 }).toFile(outPath);
} else {
// ffmpeg fallback when sharp isn't installed
const probe = require('child_process').spawnSync('sips', ['--getProperty', 'pixelWidth', '--getProperty', 'pixelHeight', source_photo]);
const m = probe.stdout.toString();
const W = parseInt(m.match(/pixelWidth:\s*(\d+)/)?.[1] || '0', 10);
const H = parseInt(m.match(/pixelHeight:\s*(\d+)/)?.[1] || '0', 10);
if (!W || !H) return res.status(500).json({ error: 'could not measure photo' });
const x = Math.max(0, Math.round(bbox.x * W));
const y = Math.max(0, Math.round(bbox.y * H));
const w = Math.min(W - x, Math.round(bbox.w * W));
const h = Math.min(H - y, Math.round(bbox.h * H));
const r = require('child_process').spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source_photo, '-vf', `crop=${w}:${h}:${x}:${y}`, outPath]);
if (r.status !== 0) return res.status(500).json({ error: 'ffmpeg crop failed', detail: r.stderr.toString() });
}
} catch (e) {
return res.status(500).json({ error: 'crop failed: ' + e.message });
}
// Register as a virtual catalog item
const cat = category || 'top';
// Year hint pulled from the source filename when it has a 4-digit year
// (e.g. steve-2009-bday.jpg); otherwise just "your past wardrobe".
const yearMatch = path.basename(source_photo).match(/(19|20)\d{2}/);
const title = yearMatch
? `From your ${yearMatch[0]} photo · ${cat}`
: `From your past wardrobe · ${cat}`;
const itemRow = db.prepare(`INSERT INTO items (source, external_id, title, brand, category, image_url, product_url)
VALUES ('extracted', ?, ?, ?, ?, ?, ?) RETURNING id;`)
.get(`extract-${Date.now()}`, title, brand_hint || 'Your past wardrobe', cat,
'/api/studio/extracted-image?path=' + encodeURIComponent(outPath), null);
res.json({ ok: true, item_id: itemRow.id, image_url: '/api/studio/extracted-image?path=' + encodeURIComponent(outPath), category: cat });
});
app.get('/api/studio/extracted-image', (req, res) => {
const p = req.query.path;
if (!p || !p.startsWith(path.join(__dirname, 'data', 'uploads', 'extracted'))) return res.status(400).end();
if (!fs.existsSync(p)) return res.status(404).end();
res.sendFile(p);
});
// ---- studio: per-photo render history -----------------------------------
// Studio mode is: pick a source photo of yourself, then sectioned right-panel
// lets you swap each clothing category. Every render is preserved as a row in
// the tryon_jobs table keyed by source_photo_path — so the user builds a
// wardrobe history attached to that photo.
app.get('/api/studio/history', (req, res) => {
const u = currentUser(req);
const source = req.query.source_photo;
const rows = source
? db.prepare(`SELECT t.id, t.item_id, t.closet_id, t.mode, t.status, t.created_at, t.done_at,
i.title, i.brand, i.category
FROM tryon_jobs t LEFT JOIN items i ON i.id = t.item_id
WHERE t.user_id = ? AND t.source_photo_path = ?
ORDER BY t.id DESC`).all(u.id, source)
: db.prepare(`SELECT DISTINCT t.source_photo_path, COUNT(*) over (PARTITION BY t.source_photo_path) AS n_renders
FROM tryon_jobs t
WHERE t.user_id = ? AND t.source_photo_path IS NOT NULL`).all(u.id);
res.json({ source, history: rows });
});
// Studio render — same as /api/tryon but ALWAYS uses photo_swap mode against
// the chosen source photo, with category coming from the catalog item.
app.post('/api/studio/swap', express.json({ limit: '256kb' }), (req, res) => {
const u = currentUser(req);
const { source_photo, item_id, category } = req.body || {};
if (!source_photo || !item_id) return res.status(400).json({ error: 'source_photo + item_id required' });
if (process.env.GEMINI_API_KEY || process.env.FASHN_API_KEY || process.env.DOPPLE_API_KEY) {
if (spentBy(u.id) >= BUDGET_CENTS_PER_USER) {
return res.status(402).json({ error: 'budget exhausted', spent_cents: spentBy(u.id), budget_cents: BUDGET_CENTS_PER_USER });
}
}
const provider = process.env.DOPPLE_API_KEY ? 'dopple'
: process.env.FASHN_API_KEY ? 'fashn-ai'
: process.env.OPENAI_API_KEY ? 'gpt-image-1'
: process.env.GEMINI_API_KEY ? 'gemini-2.5-flash-image' : 'stub';
const { lastInsertRowid } = db.prepare(`
INSERT INTO tryon_jobs (user_id, item_id, mode, source_photo_path, provider, status, occasion)
VALUES (?, ?, 'photo_swap', ?, ?, 'queued', ?)
`).run(u.id, item_id, source_photo, provider, category || null);
res.json({ ok: true, job_id: lastInsertRowid, provider });
});
// List user's previously uploaded source photos (for the LEFT pane picker)
app.get('/api/studio/photos', (req, res) => {
const u = currentUser(req);
// Avatar source photos + any prior closet uploads — anything we have on disk
const av = db.prepare('SELECT source_photos FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
let paths = [];
try { paths = JSON.parse(av?.source_photos || '[]'); } catch {}
const closet = db.prepare('SELECT id, photo_path FROM closet WHERE user_id = ? AND photo_path IS NOT NULL ORDER BY id DESC').all(u.id);
const photos = [
...paths.map(p => ({ kind: 'avatar', path: p, url: '/api/studio/photo?path=' + encodeURIComponent(p) })),
...closet.map(c => ({ kind: 'closet', path: c.photo_path, id: c.id, url: '/api/closet/photo/' + c.id })),
];
res.json({ photos });
});
// Stream a staged photo by path (admin-ish — only paths inside data/uploads/)
app.get('/api/studio/photo', (req, res) => {
const u = currentUser(req);
const p = req.query.path;
if (!p || !p.startsWith(path.join(__dirname, 'data', 'uploads'))) return res.status(400).end();
if (!fs.existsSync(p)) return res.status(404).end();
res.sendFile(p);
});
// ---- taste history -------------------------------------------------------
app.get('/api/me/taste/history', (req, res) => {
const u = currentUser(req);
const rows = db.prepare('SELECT duels_answered, taste_vec, created_at FROM taste_history WHERE user_id=? ORDER BY id ASC').all(u.id);
const snapshots = rows.map(r => {
let v = [];
try { v = JSON.parse(r.taste_vec); } catch {}
if (!Array.isArray(v) || v.length !== 32) v = Array(32).fill(0);
return { duels_answered: r.duels_answered, taste_vec: v, created_at: r.created_at };
});
res.json({ snapshots });
});
// ---- support / donation (per monetization verdict) -----------------------
app.get('/api/support', (req, res) => {
res.json({
model: 'donation-supported',
donation_url: process.env.STRIPE_DONATION_LINK || null,
no_affiliate: true,
no_subscription: true,
rationale: "Per the dream-team monetization debate (2026-05-12), donation-supported is the only model fully aligned with our privacy + sustainability + repair-first commitments. Affiliate ruled out. Subscription rejected.",
});
});
// ---- taste-vector visualizer ---------------------------------------------
const { BASIS } = require('./scripts/embed-items');
app.get('/api/me/taste', (req, res) => {
const u = currentUser(req);
let v = [];
try { v = JSON.parse(u.taste_vec || '[]'); } catch {}
if (!Array.isArray(v) || v.length !== 32) v = Array(32).fill(0);
const dims = BASIS.map((b, i) => ({ key: b.key, value: Number(v[i].toFixed(3)) }));
const sorted = [...dims].sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
const top = sorted.filter(d => d.value > 0).slice(0, 5);
const bottom = sorted.filter(d => d.value < 0).slice(0, 3);
res.json({ dims, top, bottom, duels_answered: db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B')").get(u.id).c });
});
// ---- delete-my-stuff (CCPA/CPRA-shaped) -----------------------------------
app.post('/api/me/forget', (req, res) => {
const u = currentUser(req);
// gather every file we have for this user
const photoRows = db.prepare('SELECT photo_path FROM closet WHERE user_id = ? AND photo_path IS NOT NULL').all(u.id);
const avRows = db.prepare('SELECT source_photos, canonical_path FROM user_avatars WHERE user_id = ?').all(u.id);
const tryRows = db.prepare('SELECT result_path, source_photo_path FROM tryon_jobs WHERE user_id = ?').all(u.id);
const paths = [];
photoRows.forEach(r => r.photo_path && paths.push(r.photo_path));
avRows.forEach(r => {
try { JSON.parse(r.source_photos || '[]').forEach(p => paths.push(p)); } catch {}
if (r.canonical_path) paths.push(r.canonical_path);
});
tryRows.forEach(r => { if (r.result_path) paths.push(r.result_path); if (r.source_photo_path) paths.push(r.source_photo_path); });
for (const p of paths) {
try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch {}
}
// wipe rows
const tx = db.transaction(() => {
db.prepare('DELETE FROM tryon_jobs WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM outfit_picks WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM user_avatars WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM closet WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM duels WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM connections WHERE user_id = ?').run(u.id);
db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
});
tx();
req.session.destroy(() => {});
res.json({ ok: true, files_purged: paths.length });
});
// ---- budget / cost --------------------------------------------------------
// Per-provider unit costs (USD cents). Update as pricing changes.
// Gemini 2.5 Flash Image: $0.039/image (Aug 2025 pricing) = 3.9 cents/image.
// Stub: 0. Dopple TBD when wired.
const COST_PER_CALL = {
'fashn-ai': Number(process.env.FASHN_COST_CENTS || 8), // ~$0.08/call
'gpt-image-1': Number(process.env.OPENAI_IMAGE_COST_CENTS || 4),
'gemini-2.5-flash-image': 3.9,
'dopple': 0, // pricing TBD
'idm-vton': 0, // self-hosted
'ootdiffusion': 0,
'stub': 0,
};
// Per-user budget (USD cents). Default: $5 = 500 cents. Override via env.
const BUDGET_CENTS_PER_USER = Number(process.env.BUDGET_CENTS_PER_USER || 500);
function spentBy(userId) {
const r = db.prepare("SELECT COALESCE(SUM(cost_cents),0) c FROM provider_costs WHERE user_id=?").get(userId);
return r.c || 0;
}
function spentTotal() {
const r = db.prepare("SELECT COALESCE(SUM(cost_cents),0) c FROM provider_costs").get();
return r.c || 0;
}
app.get('/api/budget', (req, res) => {
const u = currentUser(req);
const my = spentBy(u.id);
const total = spentTotal();
res.json({
budget_cents: BUDGET_CENTS_PER_USER,
spent_cents: my,
remaining_cents: Math.max(0, BUDGET_CENTS_PER_USER - my),
over_budget: my >= BUDGET_CENTS_PER_USER,
spent_total_cents: total,
unit_costs: COST_PER_CALL,
});
});
app.get('/api/budget/log', (req, res) => {
const u = currentUser(req);
const rows = db.prepare("SELECT ts, provider, kind, cost_cents, ref_job_id FROM provider_costs WHERE user_id=? ORDER BY id DESC LIMIT 50").all(u.id);
res.json({ events: rows, total_cents: spentBy(u.id) });
});
// Expose for the worker to write
app.locals.recordCost = function recordCost({ provider, kind, user_id, ref_job_id, meta }) {
const unit = COST_PER_CALL[provider] || 0;
db.prepare("INSERT INTO provider_costs (provider, kind, cost_cents, user_id, ref_job_id, meta) VALUES (?, ?, ?, ?, ?, ?)")
.run(provider, kind, unit, user_id || null, ref_job_id || null, meta ? JSON.stringify(meta) : null);
return unit;
};
// Gate: refuse a paid try-on if the user is over budget.
app.use((req, res, next) => {
if (req.method === 'POST' && req.path === '/api/tryon') {
try {
const u = currentUser(req);
if (process.env.GEMINI_API_KEY && spentBy(u.id) >= BUDGET_CENTS_PER_USER) {
return res.status(402).json({ error: 'budget exhausted', spent_cents: spentBy(u.id), budget_cents: BUDGET_CENTS_PER_USER });
}
} catch {}
}
next();
});
// ---- health ---------------------------------------------------------------
app.get('/api/health', (req, res) => {
const items = db.prepare('SELECT COUNT(*) c FROM items').get().c;
const users = db.prepare('SELECT COUNT(*) c FROM users').get().c;
const duels = db.prepare('SELECT COUNT(*) c FROM duels').get().c;
const workers = {
closet_vision_pending: db.prepare("SELECT COUNT(*) c FROM closet WHERE category IS NULL AND photo_path IS NOT NULL").get().c,
tryon_queued: db.prepare("SELECT COUNT(*) c FROM tryon_jobs WHERE status='queued'").get().c,
avatar_building: db.prepare("SELECT COUNT(*) c FROM user_avatars WHERE status IN ('pending','building')").get().c,
};
res.json({ ok: true, items, users, duels, workers, port: PORT });
});
// ---- in-process workers ---------------------------------------------------
// Closet vision: every 60s, hand pending closet rows to llava-13b. No-op
// when ollama is unreachable. Avoids needing launchd for the demo path.
const { spawn } = require('child_process');
function drainClosetVision() {
const pending = db.prepare("SELECT COUNT(*) c FROM closet WHERE category IS NULL AND photo_path IS NOT NULL").get().c;
if (!pending) return;
const p = spawn('node', [path.join(__dirname, 'scripts', 'closet-vision.js')], { detached: true, stdio: 'ignore' });
p.unref();
}
setInterval(drainClosetVision, 60 * 1000);
// Try-on worker: every 30s drain queued tryon_jobs (handles Dopple/Gemini/stub)
function drainTryonJobs() {
const queued = db.prepare("SELECT COUNT(*) c FROM tryon_jobs WHERE status = 'queued'").get().c;
if (!queued) return;
const p = spawn('node', [path.join(__dirname, 'scripts', 'tryon-worker.js')], { detached: true, stdio: 'ignore' });
p.unref();
}
setInterval(drainTryonJobs, 30 * 1000);
// Avatar builder: every 30s drain pending/building avatars
function drainAvatarBuild() {
const pending = db.prepare("SELECT COUNT(*) c FROM user_avatars WHERE status IN ('pending','building')").get().c;
if (!pending) return;
const p = spawn('node', [path.join(__dirname, 'scripts', 'avatar-build.js')], { detached: true, stdio: 'ignore' });
p.unref();
}
setInterval(drainAvatarBuild, 30 * 1000);
// Embedding drift sweep (tick 14, expanded tick 18): every 24h, scan a rolling
// window of catalog items and write a row to embedding_drifts for any whose
// llava-projected vector deviates from the stored one by > 20% cosine.
// Rolls through via OFFSET stored in app_config.drift_offset.
// Tick 18: max-items scales dynamically (Math.max(6, ceil(total/30))) so the
// cron covers the whole catalog in ~30 days regardless of size.
async function embedDriftSchedule() {
try {
// Tick 18.5: prefer Mac1 for llava inference. embed-drift-check imports
// embed-items which reads OLLAMA_URL from process.env at call time —
// set it here for the duration of this sweep so the drift checker hits
// Mac1 too. Restored after.
const _prevOllama = process.env.OLLAMA_URL;
const _prevModel = process.env.OLLAMA_VISION_MODEL;
process.env.OLLAMA_URL = EMBED_OLLAMA_URL;
process.env.OLLAMA_VISION_MODEL = EMBED_VISION_MODEL;
const { runDriftSweep, ollamaLlavaUp } = require('./scripts/embed-drift-check');
if (!(await ollamaLlavaUp())) {
process.env.OLLAMA_URL = _prevOllama;
process.env.OLLAMA_VISION_MODEL = _prevModel;
return;
}
const offset = configValue('drift_offset', 0);
const total = db.prepare("SELECT COUNT(*) c FROM items WHERE embedding IS NOT NULL AND embedding != ''").get().c || 0;
const maxItems = Math.max(6, Math.ceil(total / 30)); // tick 18: scale with catalog
const nextOffset = total > 0 ? (offset + maxItems) % total : 0;
const summary = await runDriftSweep({ db, maxItems, offset });
db.prepare(`INSERT INTO app_config (key, value) VALUES ('drift_offset', ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`)
.run(JSON.stringify(nextOffset));
if (summary.drifted > 0) console.log(`[drift-sweep] ${summary.drifted}/${summary.scanned} drifted at offset ${offset}`);
process.env.OLLAMA_URL = _prevOllama;
process.env.OLLAMA_VISION_MODEL = _prevModel;
} catch (e) {
console.error('[drift-sweep] error', e.message);
}
}
// First run 5 min after boot so we don't slow cold-start; subsequent runs every 24h.
setTimeout(embedDriftSchedule, 5 * 60 * 1000);
setInterval(embedDriftSchedule, 24 * 60 * 60 * 1000);
// Tick 18: unembedded-tail drainer. Fires a small batch every 5 minutes,
// draining items where embedding IS NULL until the catalog is fully embedded.
// Idempotent — embed-items.js skips items it can't fetch images for.
// Uses spawn().unref() so the work runs out-of-process and the server stays
// responsive even when llava is chewing on a slow vendor image.
//
// Tick 18.5 (per Steve's "go on macstudio1 to not overload"): route the heavy
// llava inference to Mac1 (192.168.1.133:11434) so this Mac2 box stays
// responsive for the duel + try-on UI. Mac1 has llava:13b installed.
// Falls back to local Ollama if EMBED_OLLAMA_URL is unset or Mac1 is down.
const { spawn: _spawnEmbed } = require('child_process');
const EMBED_OLLAMA_URL = process.env.EMBED_OLLAMA_URL || 'http://192.168.1.133:11434';
const EMBED_VISION_MODEL = process.env.EMBED_VISION_MODEL || 'llava:13b';
// Tick 18.6: drainer gated behind app_config.embed_drainer_enabled (default
// false). Steve flipped to "go on macstudio1 to not overload" — turned out
// Mac1 was already at high concurrency (qwen3:14b loaded + 108% CPU on a
// neighbor process) and Mac2 load avg was 9+ too. Flip ON via admin-config
// once Mac1 frees up. Routing is wired; just the cron is dormant.
// Tick 20: Mac1 health watcher. Probe /api/ps before spawning. If any model
// is consuming >MAC1_BUSY_VRAM_GB, mark Mac1 busy and skip this cron tick.
// The status is cached in app_config.mac1_status with a timestamp so the
// admin UI can render "busy" / "idle" / "unreachable" without polling Mac1
// directly from the browser.
const MAC1_BUSY_VRAM_GB = 9;
async function mac1Status() {
try {
const r = await fetch(`${EMBED_OLLAMA_URL}/api/ps`, { signal: AbortSignal.timeout(2500) });
if (!r.ok) return { state: 'unreachable', detail: `HTTP ${r.status}` };
const j = await r.json();
const models = j.models || [];
const heavyModels = models.filter(m => (m.size_vram || 0) / 1e9 > MAC1_BUSY_VRAM_GB);
if (heavyModels.length > 0) {
return { state: 'busy', detail: heavyModels.map(m => `${m.name} (${(m.size_vram/1e9).toFixed(1)}G)`).join(', ') };
}
return { state: 'idle', detail: models.length ? `${models.length} warm` : 'no models loaded' };
} catch (e) {
return { state: 'unreachable', detail: e.message };
}
}
function setMac1StatusCache(s) {
db.prepare(`INSERT INTO app_config (key, value) VALUES ('mac1_status', ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`)
.run(JSON.stringify({ ...s, checked_at: new Date().toISOString() }));
}
async function drainUnembedded() {
if (configValue('embed_drainer_enabled', false) !== true) return;
const unembed = db.prepare("SELECT COUNT(*) c FROM items WHERE embedding IS NULL").get().c || 0;
if (unembed === 0) return;
// Tick 20: skip if Mac1 is busy. The drainer cron would dogpile otherwise.
const status = await mac1Status();
setMac1StatusCache(status);
if (status.state !== 'idle') return;
const batch = Math.min(20, unembed);
const p = _spawnEmbed('node', [
path.join(__dirname, 'scripts', 'embed-items.js'),
'--batch', String(batch),
'--unembedded-only',
], {
detached: true,
stdio: 'ignore',
env: { ...process.env, OLLAMA_URL: EMBED_OLLAMA_URL, OLLAMA_VISION_MODEL: EMBED_VISION_MODEL },
});
p.unref();
}
// Independent of the drainer: refresh the Mac1 status cache every 60s so the
// admin UI shows fresh state even when the drainer is disabled.
async function refreshMac1Status() { setMac1StatusCache(await mac1Status()); }
setTimeout(refreshMac1Status, 5 * 1000);
setInterval(refreshMac1Status, 60 * 1000);
// Boot-time pass after 60s — let pm2 settle first.
setTimeout(drainUnembedded, 60 * 1000);
// Then every 5 min until the tail is drained.
setInterval(drainUnembedded, 5 * 60 * 1000);
// ---- start ----------------------------------------------------------------
app.listen(PORT, '0.0.0.0', () => {
console.log(`[whatsmystyle] listening on :${PORT}`);
});