managarten/apps/api/scripts/gc-website-assets.ts
Till JS 4fc9d6c59c 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>
2026-04-23 18:27:37 +02:00

176 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});