feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)

M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:27:37 +02:00
parent f7536bc0b9
commit 4fc9d6c59c
36 changed files with 2058 additions and 158 deletions

View file

@ -0,0 +1,176 @@
#!/usr/bin/env bun
/**
* Website orphan-asset scan.
*
* Reports (M7 first-pass: read-only, no deletion) mana-media assets
* scoped to `app=website` that are not referenced by any currently-
* published snapshot or any non-scrubbed form submission. Older-than-30d
* orphans are candidates for eventual deletion.
*
* Run with:
* bun apps/api/scripts/gc-website-assets.ts
*
* Exit codes:
* 0 scan completed, report printed
* 1 scan failed (DB / HTTP error)
*
* Switching to delete-mode is a separate commit after we have 23
* weeks of read-only reports in production showing the count is
* stable and the candidate list looks right.
*/
import { promises as fs } from 'node:fs';
import postgres from 'postgres';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const MEDIA_URL = process.env.PUBLIC_MANA_MEDIA_URL ?? 'http://localhost:3015';
/** Grace period orphans younger than this are kept (recently
* uploaded, likely about to be linked). */
const GRACE_MS = 30 * 24 * 60 * 60 * 1000;
// ── 1. Load every URL + mediaId referenced by a live snapshot ──
async function collectReferencedIds(sql: ReturnType<typeof postgres>): Promise<Set<string>> {
const rows = await sql<{ blob: unknown }[]>`
SELECT blob
FROM website.published_snapshots
WHERE is_current = TRUE
`;
const refs = new Set<string>();
for (const row of rows) {
walkAny(row.blob, (value) => {
if (typeof value !== 'string') return;
// mana-media URLs look like .../api/v1/media/{id}/file/{variant}.
// Pull the id out with a narrow regex so we don't accidentally
// match user-typed URLs that happen to contain `/media/`.
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
if (match) refs.add(match[1]!.toLowerCase());
});
}
// Submissions payloads don't typically contain media URLs, but
// include them for completeness (a form might accept a file upload
// in the future).
const subs = await sql<{ payload: unknown }[]>`
SELECT payload FROM website.submissions WHERE payload IS NOT NULL
`;
for (const row of subs) {
walkAny(row.payload, (value) => {
if (typeof value !== 'string') return;
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
if (match) refs.add(match[1]!.toLowerCase());
});
}
return refs;
}
function walkAny(value: unknown, visit: (v: unknown) => void): void {
visit(value);
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (const child of value) walkAny(child, visit);
return;
}
for (const key of Object.keys(value as Record<string, unknown>)) {
walkAny((value as Record<string, unknown>)[key], visit);
}
}
// ── 2. Ask mana-media for every asset scoped to app=website ────
interface MediaListEntry {
id: string;
createdAt: string;
filename?: string;
size?: number;
}
async function listWebsiteMedia(): Promise<MediaListEntry[]> {
const token = process.env.MANA_SERVICE_KEY;
if (!token) {
console.warn(
'[gc] MANA_SERVICE_KEY not set — skipping mana-media listing. Report shows references only.'
);
return [];
}
try {
const res = await fetch(`${MEDIA_URL}/api/v1/internal/media/list?app=website`, {
headers: { 'X-Service-Key': token },
});
if (!res.ok) {
console.warn(`[gc] mana-media list failed: ${res.status}`);
return [];
}
const body = (await res.json()) as { items?: MediaListEntry[] };
return body.items ?? [];
} catch (err) {
console.warn('[gc] mana-media unreachable', err);
return [];
}
}
// ── 3. Report ────────────────────────────────────────────────
async function main(): Promise<void> {
const sql = postgres(DATABASE_URL, { max: 2 });
try {
console.log('[gc] scanning published_snapshots + submissions for media references…');
const referenced = await collectReferencedIds(sql);
console.log(`[gc] referenced mediaIds: ${referenced.size}`);
console.log('[gc] listing mana-media items for app=website…');
const media = await listWebsiteMedia();
console.log(`[gc] media items in scope: ${media.length}`);
if (media.length === 0) {
console.log('[gc] nothing to compare — exiting.');
return;
}
const now = Date.now();
const orphans = media
.filter((m) => !referenced.has(m.id.toLowerCase()))
.filter((m) => now - new Date(m.createdAt).getTime() > GRACE_MS);
console.log(
`[gc] orphans older than 30d: ${orphans.length} / ${media.length} (${referenced.size} referenced)`
);
const report = {
scannedAt: new Date().toISOString(),
referencedCount: referenced.size,
mediaCount: media.length,
orphanCount: orphans.length,
orphans: orphans.map((m) => ({
id: m.id,
createdAt: m.createdAt,
filename: m.filename,
size: m.size,
})),
};
const out = `/tmp/gc-website-assets-${report.scannedAt.replace(/[:.]/g, '-')}.json`;
await fs.writeFile(out, JSON.stringify(report, null, 2));
console.log(`[gc] report written to ${out}`);
// Head of the orphan list so ops can sanity-check at a glance.
for (const o of orphans.slice(0, 10)) {
console.log(` ${o.id} ${o.createdAt} ${o.filename ?? ''}`);
}
if (orphans.length > 10) {
console.log(` … and ${orphans.length - 10} more (see report file)`);
}
} finally {
await sql.end({ timeout: 2 });
}
}
main().catch((err) => {
console.error('[gc] scan failed', err);
process.exit(1);
});