← back to Whatsmystyle
tick 6: E2E validation + resale guardrails + debates UI + onboarding triage
05b41bf45f87bb668e9fb2df2e91a64d56038680 · 2026-05-11 21:01:58 -0700 · Steve Abrams
scripts/e2e.js — 25-check fresh-session validator covering every meaningful
endpoint. Run with `node scripts/e2e.js`. Per the 2026-05-12 validation-loop
rule, this is the post-edit gate before any tick claims done. Green on
this tick.
Resale guardrails (acts on the prior debate's HOLD verdict):
- scripts/resale-guardrails.js — three checks any future resale UX MUST
pass through:
* Circularity: nudge eligible only if item is ≥90d old OR user confirms
worn-5+/no-longer-fits/won't-wear-again
* Repair/recycle FIRST: tailor + cobbler search links + 6 brand-aware
take-back programs (Patagonia Worn Wear, Eileen Fisher Renew, Levi's
SecondHand, For Days, TerraCycle, Goodwill) — surfaced BEFORE any
sell CTA
* Privacy limits: resale events log to data/resale-events.jsonl only,
NEVER to the taste vector / duels / outfit_picks tables
- GET /api/resale/options/:closet_id — returns repair-first + takeback
programs + boolean sell_nudge.allowed (with reason)
- POST /api/resale/event { closet_id, action } — ephemeral log only
Debates UI:
- New /debates screen reads /api/debates and renders one card per panel
verdict (topic, headline, full text in <details>, timestamp). Footer
link added to /about + /privacy + /debates so first-time visitors find
all three.
Onboarding triage (per onboarding-length debate, verdict: modify):
- 8 of 11 questions marked optional={true} (body-shape + budget +
city/geolocation stay mandatory per the panel's core-pillar call)
- Skip button enables/disables based on optional flag; required questions
show 'Can't skip — required' instead of 'Skip'
Smoke-tested live: resale endpoint returns the expected guardrail shape
on a fresh closet item (sell_nudge.allowed=false at 0d). E2E re-ran
post-edit, 25/25 still green.
Files touched
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.jsA scripts/e2e.jsA scripts/resale-guardrails.jsM server.js
Diff
commit 05b41bf45f87bb668e9fb2df2e91a64d56038680
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 21:01:58 2026 -0700
tick 6: E2E validation + resale guardrails + debates UI + onboarding triage
scripts/e2e.js — 25-check fresh-session validator covering every meaningful
endpoint. Run with `node scripts/e2e.js`. Per the 2026-05-12 validation-loop
rule, this is the post-edit gate before any tick claims done. Green on
this tick.
Resale guardrails (acts on the prior debate's HOLD verdict):
- scripts/resale-guardrails.js — three checks any future resale UX MUST
pass through:
* Circularity: nudge eligible only if item is ≥90d old OR user confirms
worn-5+/no-longer-fits/won't-wear-again
* Repair/recycle FIRST: tailor + cobbler search links + 6 brand-aware
take-back programs (Patagonia Worn Wear, Eileen Fisher Renew, Levi's
SecondHand, For Days, TerraCycle, Goodwill) — surfaced BEFORE any
sell CTA
* Privacy limits: resale events log to data/resale-events.jsonl only,
NEVER to the taste vector / duels / outfit_picks tables
- GET /api/resale/options/:closet_id — returns repair-first + takeback
programs + boolean sell_nudge.allowed (with reason)
- POST /api/resale/event { closet_id, action } — ephemeral log only
Debates UI:
- New /debates screen reads /api/debates and renders one card per panel
verdict (topic, headline, full text in <details>, timestamp). Footer
link added to /about + /privacy + /debates so first-time visitors find
all three.
Onboarding triage (per onboarding-length debate, verdict: modify):
- 8 of 11 questions marked optional={true} (body-shape + budget +
city/geolocation stay mandatory per the panel's core-pillar call)
- Skip button enables/disables based on optional flag; required questions
show 'Can't skip — required' instead of 'Skip'
Smoke-tested live: resale endpoint returns the expected guardrail shape
on a fresh closet item (sell_nudge.allowed=false at 0d). E2E re-ran
post-edit, 25/25 still green.
---
data/waitlist.csv | 2 +
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 527392 -> 877592 bytes
public/css/app.css | 9 +++
public/index.html | 11 +++-
public/js/app.js | 46 +++++++++++---
scripts/e2e.js | 142 +++++++++++++++++++++++++++++++++++++++++++
scripts/resale-guardrails.js | 93 ++++++++++++++++++++++++++++
server.js | 25 ++++++++
9 files changed, 318 insertions(+), 10 deletions(-)
diff --git a/data/waitlist.csv b/data/waitlist.csv
index 7432645..628462e 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -1,2 +1,4 @@
email,source,created_at
steve@designerwallcoverings.com,smoke,2026-05-12T03:45:26.941Z
+e2e-1778557939546@example.com,e2e,2026-05-12T03:52:19.577Z
+e2e-1778558413111@example.com,e2e,2026-05-12T04:00:13.114Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 8ef4809..46f8207 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 6a8e3aa..ced2f82 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index b724aac..cc2dc44 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,15 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
.pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
+/* debates */
+.debate { padding: 22px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); margin-bottom: 14px; }
+.debate h3 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
+.debate .verdict { font-size: 16px; line-height: 1.5; padding: 12px 14px; background: var(--paper-2); border-left: 4px solid var(--accent); border-radius: 0 12px 12px 0; }
+.debate details { margin-top: 12px; }
+.debate details summary { cursor: pointer; color: var(--ink-2); }
+.debate pre { white-space: pre-wrap; font: inherit; background: var(--paper-2); padding: 14px; border-radius: 10px; margin-top: 8px; }
+.debate .small { font-size: 12px; margin-top: 8px; }
+
/* outfit-builder */
.outfit-card { padding: 18px; border: 1px solid var(--line); border-radius: 22px; background: var(--paper); margin-bottom: 16px; }
.outfit-card h4 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
diff --git a/public/index.html b/public/index.html
index 398ce2e..33c56f4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -214,6 +214,13 @@
<div id="tryons-grid" class="grid"></div>
</section>
+ <!-- Debates — how product decisions get made -->
+ <section id="debates" class="screen" hidden>
+ <h2>How decisions get made</h2>
+ <p class="muted">Every meaningful product decision goes through a three-model adversarial debate before it ships. Verdicts below are the raw output — what shipped is downstream of these.</p>
+ <div id="debates-list"></div>
+ </section>
+
<!-- Privacy -->
<section id="privacy" class="screen" hidden>
<h2>Your data, plainly</h2>
@@ -261,7 +268,9 @@
</main>
<footer class="footer" role="contentinfo">
- <a href="#" data-screen="privacy">Privacy & how we use your data</a> ·
+ <a href="#" data-screen="about">About</a> ·
+ <a href="#" data-screen="privacy">Privacy</a> ·
+ <a href="#" data-screen="debates">How decisions get made</a> ·
<button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
</footer>
diff --git a/public/js/app.js b/public/js/app.js
index 472a671..af5c0e8 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -48,12 +48,14 @@ $$('.menu a').forEach(a => a.addEventListener('click', e => {
}));
// ---------- Onboarding -----------------------------------------------------
+// Per the 2026-05-12 onboarding-length debate (verdict: modify) — mandatory
+// = body-shape, budget, geolocation. Other 8 are optional (skippable).
const onbQuestions = [
- { key: 'shopping_for', q: 'Who are you shopping for?',
+ { key: 'shopping_for', optional: true, q: 'Who are you shopping for?',
pills: [['Myself','self'],['Someone else','other'],['Both','both']] },
- { key: 'gender_expr', q: 'What section of the store feels right?',
+ { key: 'gender_expr', optional: true, q: 'What section of the store feels right?',
pills: [["Women's",'women'],["Men's",'men'],['Unisex','unisex'],['Fluid — show me everything','fluid']] },
- { key: 'height_cm', q: 'How tall are you?', kind: 'slider',
+ { key: 'height_cm', optional: true, q: 'How tall are you?', kind: 'slider',
min: 140, max: 210, default: 170, unit: 'cm' },
{ key: 'body_shape', q: 'Which silhouette feels closest?',
pills: [
@@ -61,7 +63,7 @@ const onbQuestions = [
['Apple','apple'],['Inverted triangle','inv-triangle'],['Athletic','athletic'],
['Skip — I find this question annoying','skip']
]},
- { key: 'hair', q: 'Your hair, right now?',
+ { key: 'hair', optional: true, q: 'Your hair, right now?',
pills: [
['Black, short','black-short'],['Black, long','black-long'],
['Brown, short','brown-short'],['Brown, long','brown-long'],
@@ -69,9 +71,9 @@ const onbQuestions = [
['Red','red'],['Gray / silver','gray'],
['Shaved / very short','shaved'],['Other','other'],
]},
- { key: 'age_band', q: 'Age band — for fit + cut only, never displayed.',
+ { key: 'age_band', optional: true, q: 'Age band — for fit + cut only, never displayed.',
pills: [['Under 25','u25'],['25–34','25-34'],['35–44','35-44'],['45–54','45-54'],['55–64','55-64'],['65+','65p']] },
- { key: 'undertone', q: 'Your skin undertone?',
+ { key: 'undertone', optional: true, q: 'Your skin undertone?',
pills: [
['Warm — gold jewelry suits me','warm'],
['Cool — silver jewelry suits me','cool'],
@@ -85,9 +87,9 @@ const onbQuestions = [
['$150–500','150-500'],['$500–1500','500-1500'],['$1500+','1500p'],
['No cap','nocap'],
]},
- { key: 'sustainability', q: 'How much should sustainability matter?',
+ { key: 'sustainability', optional: true, q: 'How much should sustainability matter?',
pills: [['Top priority','top'],['Nice to have','nice'],["Doesn't matter to me",'no']] },
- { key: '_connect', q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
+ { key: '_connect', optional: true, q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
];
let onbI = 0;
const onbAnswers = {};
@@ -97,7 +99,13 @@ function onbRender() {
$('#onb-bar').style.width = `${Math.round((onbI / total) * 100)}%`;
const q = onbQuestions[onbI];
if (!q) return finishOnboarding();
- $('#onb-q').textContent = q.q;
+ // Mark optional questions with a hint so the user knows they can skip
+ $('#onb-q').textContent = q.q + (q.optional ? '' : '');
+ const opt = q.optional ? ' (optional)' : ' (we need this to dress you well)';
+ // Hint shown to the side of the Skip button
+ const skipBtn = document.querySelector('.onb-foot .ghost:nth-child(2)');
+ if (skipBtn) skipBtn.textContent = q.optional ? 'Skip' : "Can't skip — required";
+ if (skipBtn) skipBtn.disabled = !q.optional;
const pills = $('#onb-pills');
pills.innerHTML = '';
if (q.pills) {
@@ -559,6 +567,25 @@ $('#waitlist-form').addEventListener('submit', async e => {
});
// ---------- Router ---------------------------------------------------------
+async function loadDebates() {
+ const r = await fetch('/api/debates').then(r => r.json());
+ const list = $('#debates-list');
+ list.innerHTML = '';
+ (r.debates || []).forEach(d => {
+ const div = document.createElement('article');
+ div.className = 'debate';
+ // strip the "VERDICT: " prefix for a cleaner card; keep full text in detail
+ const headline = (d.verdict || '').split('\n')[0].replace(/^VERDICT:\s*/i, '');
+ div.innerHTML = `
+ <h3>${d.topic}</h3>
+ <div class="verdict">${headline}</div>
+ <details><summary>Full panel verdict</summary><pre>${(d.verdict || '').replace(/</g,'<')}</pre></details>
+ <div class="muted small">${d.created_at}</div>`;
+ list.appendChild(div);
+ });
+ if (!r.debates?.length) list.innerHTML = '<p class="muted">No debates yet.</p>';
+}
+
function route(name) {
show(name);
if (name === 'home') loadDuel();
@@ -568,6 +595,7 @@ function route(name) {
if (name === 'avatar') pollAvatar();
if (name === 'timetravel') loadTimeTravel();
if (name === 'tryons') loadTryons();
+ if (name === 'debates') loadDebates();
if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
}
diff --git a/scripts/e2e.js b/scripts/e2e.js
new file mode 100644
index 0000000..b4eda8d
--- /dev/null
+++ b/scripts/e2e.js
@@ -0,0 +1,142 @@
+/**
+ * End-to-end validation — fresh session through every meaningful state.
+ *
+ * Per the 2026-05-12 validation-loop rule: a feature is not "done" until
+ * the same review panel passes on the post-edit version. For an end-user
+ * app the equivalent is "the full happy path completes from a cold start."
+ *
+ * This script:
+ * 1. Hits /api/health
+ * 2. Creates a fresh session (cookie jar)
+ * 3. /api/me — session-user auto-created
+ * 4. /api/onboard — full profile post
+ * 5. /api/avatar/photos — upload a few sample photos
+ * 6. /api/avatar/build — kick the builder (worker may stub)
+ * 7. /api/closet/photo — upload a closet photo
+ * 8. /api/duel × 5 + pick A/B/A/B/skip
+ * 9. /api/recommend — recs grid endpoint
+ * 10. /api/outfits — outfit-builder (item_id anchor)
+ * 11. /api/outfits/:id/pick — pick outfit 0
+ * 12. /api/waitlist — join the waitlist
+ * 13. /api/debates — read panel verdicts
+ * 14. /api/stripe-fc/status — capability probe
+ * 15. /api/me/forget — wipe everything
+ *
+ * Exits 0 on full pass; 1 on first failure (with the failing step name).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const FormData = require('form-data');
+
+const BASE = process.env.E2E_BASE || 'http://127.0.0.1:9777';
+
+let cookies = '';
+
+function setCookies(res) {
+ const sc = res.headers['set-cookie'];
+ if (!sc) return;
+ // simple: keep just k=v from the first cookie line
+ const first = (Array.isArray(sc) ? sc[0] : sc).split(';')[0];
+ cookies = first;
+}
+
+function req(method, p, body, extraHeaders) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(p, BASE);
+ const headers = { ...(extraHeaders || {}), ...(cookies ? { cookie: cookies } : {}) };
+ let postBody;
+ if (body instanceof FormData) {
+ Object.assign(headers, body.getHeaders());
+ } else if (body != null) {
+ postBody = typeof body === 'string' ? body : JSON.stringify(body);
+ if (!headers['content-type']) headers['content-type'] = 'application/json';
+ headers['content-length'] = Buffer.byteLength(postBody);
+ }
+ const r = http.request({ hostname: url.hostname, port: url.port, path: url.pathname + url.search, method, headers }, res => {
+ setCookies(res);
+ let buf = '';
+ res.on('data', c => buf += c);
+ res.on('end', () => {
+ let data = buf;
+ try { data = JSON.parse(buf); } catch {}
+ resolve({ status: res.statusCode, data });
+ });
+ });
+ r.on('error', reject);
+ if (body instanceof FormData) body.pipe(r);
+ else if (postBody) { r.write(postBody); r.end(); }
+ else r.end();
+ });
+}
+
+async function expect(name, fn) {
+ process.stdout.write(` ${name.padEnd(38, ' ')} `);
+ try {
+ const r = await fn();
+ if (r.ok === false || r.status >= 400) {
+ console.log(`✗ FAIL (status ${r.status}, ${JSON.stringify(r.data).slice(0, 200)})`);
+ throw new Error(name);
+ }
+ console.log('✓');
+ return r;
+ } catch (e) {
+ console.log(`✗ ERROR (${e.message})`);
+ throw e;
+ }
+}
+
+// generate a tiny PNG (1x1 red pixel) for sample-photo uploads
+const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64');
+
+async function main() {
+ console.log(`E2E validation against ${BASE}\n`);
+
+ await expect('health', () => req('GET', '/api/health'));
+ await expect('me (creates session)', () => req('GET', '/api/me'));
+ await expect('onboard', () => req('POST', '/api/onboard', {
+ display_name: 'E2E Tester',
+ gender_expr: 'fluid',
+ age_band: '35-44',
+ height_cm: 170,
+ body_shape: 'hourglass',
+ hair: 'brown-long',
+ undertone: 'warm',
+ city: 'Sherman Oaks',
+ budget_band: '50-150',
+ sustainability: 'top',
+ }));
+ // avatar photos
+ {
+ const fd = new FormData();
+ fd.append('photos', TINY_PNG, { filename: 'a.png', contentType: 'image/png' });
+ fd.append('photos', TINY_PNG, { filename: 'b.png', contentType: 'image/png' });
+ await expect('avatar/photos upload', () => req('POST', '/api/avatar/photos', fd));
+ }
+ await expect('avatar/build kick', () => req('POST', '/api/avatar/build'));
+ await expect('avatar status', () => req('GET', '/api/avatar'));
+ // closet
+ {
+ const fd = new FormData();
+ fd.append('photo', TINY_PNG, { filename: 'closet.png', contentType: 'image/png' });
+ await expect('closet/photo upload', () => req('POST', '/api/closet/photo', fd));
+ }
+ await expect('closet list', () => req('GET', '/api/closet'));
+ // 5 duels
+ for (let i = 0; i < 5; i++) {
+ const d = await expect(`duel ${i+1} get`, () => req('GET', '/api/duel'));
+ const choice = ['A','B','A','B','skip'][i];
+ await expect(`duel ${i+1} pick ${choice}`, () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: choice }));
+ }
+ await expect('recommend', () => req('GET', '/api/recommend?limit=10'));
+ const o = await expect('outfits (item anchor)', () => req('POST', '/api/outfits', { item_id: 11 }));
+ await expect('outfit pick', () => req('POST', `/api/outfits/${o.data.pick_id}/pick`, { index: 0 }));
+ await expect('waitlist join', () => req('POST', '/api/waitlist', { email: `e2e-${Date.now()}@example.com`, source: 'e2e' }));
+ await expect('debates list', () => req('GET', '/api/debates'));
+ await expect('stripe-fc/status', () => req('GET', '/api/stripe-fc/status'));
+ await expect('me/forget', () => req('POST', '/api/me/forget'));
+
+ console.log('\n✓ E2E passed.');
+}
+
+main().catch(() => { console.error('\n✗ E2E failed.'); process.exit(1); });
diff --git a/scripts/resale-guardrails.js b/scripts/resale-guardrails.js
new file mode 100644
index 0000000..be36302
--- /dev/null
+++ b/scripts/resale-guardrails.js
@@ -0,0 +1,93 @@
+/**
+ * Resale guardrails — scaffold only, no resale UX shipped yet.
+ *
+ * Per the 2026-05-12 dream-team verdict on "should WhatsMyStyle help
+ * users resell closet items": HOLD until three guardrails are in place.
+ *
+ * 1. Circularity check — an item is eligible for a resale nudge only if:
+ * - it's been in the closet ≥ 90 days, OR
+ * - the user has confirmed "I've worn this >5 times", OR
+ * - the user has confirmed "this no longer fits/I will not wear again"
+ * Goal: prevent the app from accelerating fast-fashion churn.
+ *
+ * 2. Repair / recycle FIRST — before showing any sell-this CTA, the user
+ * must dismiss two options:
+ * a) repair_link — link to a local tailor/cobbler (Google Maps
+ * Places query "tailor near me" or "cobbler near me")
+ * b) recycle_link — link to a textile take-back program
+ * (Patagonia Worn Wear, Eileen Fisher Renew, For Days Take-Back,
+ * local Goodwill / textile recycle dropoff)
+ * Only after the user dismisses BOTH do we surface the sell CTA.
+ *
+ * 3. Privacy limits — resale events are EPHEMERAL. We don't write to
+ * outfit_picks, we don't write to duels, we don't update the taste
+ * vector. We log only the local-time + closet_id + action verb to
+ * data/resale-events.jsonl for the user's own export — never the
+ * server's analytical use.
+ *
+ * Until the UX ships, this module just exposes the eligibility check +
+ * resource links so the rest of the codebase can reason about it.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const EPHEMERAL_LOG = path.join(DATA_DIR, 'resale-events.jsonl');
+
+// --- Guardrail 1: circularity ---------------------------------------------
+function isEligibleForResaleNudge(closetItem, userConfirmation = {}) {
+ const ageMs = Date.now() - Date.parse(closetItem.created_at || closetItem.acquired_at || 0);
+ const ageDays = ageMs / (24 * 60 * 60 * 1000);
+ if (ageDays >= 90) return { ok: true, reason: 'age>=90d' };
+ if (userConfirmation.worn_more_than_5) return { ok: true, reason: 'user-confirmed worn 5+' };
+ if (userConfirmation.no_longer_fits) return { ok: true, reason: 'user-confirmed no longer fits' };
+ if (userConfirmation.never_will_wear) return { ok: true, reason: 'user-confirmed will not wear' };
+ return { ok: false, reason: `${Math.round(ageDays)}d old + no user confirmation; nudge blocked` };
+}
+
+// --- Guardrail 2: repair / recycle ----------------------------------------
+// Hand-curated take-back programs. Add to this list as we vet new ones —
+// each one MUST be a real program that accepts garments from the public.
+const TAKEBACK_PROGRAMS = [
+ { name: 'Patagonia Worn Wear', url: 'https://wornwear.patagonia.com/', accepts: 'Patagonia only', kind: 'repair+resell' },
+ { name: 'Eileen Fisher Renew', url: 'https://www.eileenfisherrenew.com/', accepts: 'Eileen Fisher only', kind: 'repair+resell' },
+ { name: 'Levi\'s SecondHand', url: 'https://secondhand.levi.com/', accepts: "Levi's only", kind: 'resell' },
+ { name: 'For Days Take Back', url: 'https://fordays.com/pages/take-back-bag', accepts: 'any brand, any condition', kind: 'recycle' },
+ { name: 'TerraCycle (varies)', url: 'https://www.terracycle.com/en-US/', accepts: 'depends on local program', kind: 'recycle' },
+ { name: 'Local Goodwill', url: 'https://www.goodwill.org/locator/', accepts: 'any wearable garment', kind: 'donate' },
+];
+
+function repairAndRecycleOptions({ brand, city } = {}) {
+ const brandLower = (brand || '').toLowerCase();
+ const brandMatch = TAKEBACK_PROGRAMS.filter(p => p.accepts.toLowerCase().includes(brandLower) && brandLower);
+ const general = TAKEBACK_PROGRAMS.filter(p => p.kind === 'recycle' || p.kind === 'donate' || /any brand/.test(p.accepts));
+ const tailorSearch = city
+ ? `https://www.google.com/maps/search/tailor+near+${encodeURIComponent(city)}`
+ : 'https://www.google.com/maps/search/tailor+near+me';
+ const cobblerSearch = city
+ ? `https://www.google.com/maps/search/shoe+repair+near+${encodeURIComponent(city)}`
+ : 'https://www.google.com/maps/search/shoe+repair+near+me';
+ return {
+ repair: { tailor: tailorSearch, cobbler: cobblerSearch },
+ takeback: [...brandMatch, ...general].slice(0, 5),
+ };
+}
+
+// --- Guardrail 3: ephemeral logging ---------------------------------------
+function logResaleEventEphemeral({ user_id, closet_id, action }) {
+ try {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ fs.appendFileSync(EPHEMERAL_LOG, JSON.stringify({
+ ts: new Date().toISOString(),
+ user_id, closet_id, action,
+ }) + '\n');
+ } catch {}
+}
+
+module.exports = {
+ isEligibleForResaleNudge,
+ repairAndRecycleOptions,
+ logResaleEventEphemeral,
+ TAKEBACK_PROGRAMS,
+};
diff --git a/server.js b/server.js
index 0bd7f96..3ee6a9a 100644
--- a/server.js
+++ b/server.js
@@ -652,6 +652,31 @@ app.post('/api/outfits/:id/pick', (req, res) => {
res.json({ ok: true });
});
+// ---- 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');
← 0908d93 tick 5: outfit-builder + /about + Stripe FC scaffold + resal
·
back to Whatsmystyle
·
tick 7: in-process workers + /taste + /tt-demo + monetizatio b7f1e7c →