diff --git a/apps/api/package.json b/apps/api/package.json index 88f047521..9fecbb658 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "drizzle-orm": "^0.38.0", "hono": "^4.7.0", "postgres": "^3.4.0", + "prom-client": "^15.1.3", "rrule": "^2.8.1", "zod": "^3.23.0" }, diff --git a/apps/api/scripts/gc-website-assets.ts b/apps/api/scripts/gc-website-assets.ts new file mode 100644 index 000000000..5c83d7951 --- /dev/null +++ b/apps/api/scripts/gc-website-assets.ts @@ -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 2–3 + * 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): Promise> { + const rows = await sql<{ blob: unknown }[]>` + SELECT blob + FROM website.published_snapshots + WHERE is_current = TRUE + `; + const refs = new Set(); + + 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)) { + walkAny((value as Record)[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 { + 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 { + 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); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f4e71553e..90dd30ba0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,9 @@ import { // MCP server import { handleMcpRequest } from './mcp/server'; +// Prometheus metrics +import { register as metricsRegister } from './lib/metrics'; + // Module routes import { calendarRoutes } from './modules/calendar/routes'; import { contactsRoutes } from './modules/contacts/routes'; @@ -28,6 +31,7 @@ import { chatRoutes } from './modules/chat/routes'; import { contextRoutes } from './modules/context/routes'; import { pictureRoutes } from './modules/picture/routes'; import { profileRoutes } from './modules/profile/routes'; +import { wardrobeRoutes } from './modules/wardrobe/routes'; import { storageRoutes } from './modules/storage/routes'; import { todoRoutes } from './modules/todo/routes'; import { plantsRoutes } from './modules/plants/routes'; @@ -55,6 +59,15 @@ app.onError(errorHandler); app.notFound(notFoundHandler); app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); app.route('/health', healthRoute('mana-api')); + +// Prometheus scrape endpoint. Unauthenticated on purpose — the Grafana +// / Prometheus stack runs on the internal network; we rely on the +// reverse-proxy layer to block external access to /metrics. +app.get('/metrics', async (c) => { + c.header('Content-Type', metricsRegister.contentType); + return c.text(await metricsRegister.metrics()); +}); + app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); // Public routes — no auth required (weather data is public, published @@ -103,6 +116,7 @@ app.route('/api/v1/chat', chatRoutes); app.route('/api/v1/context', contextRoutes); app.route('/api/v1/picture', pictureRoutes); app.route('/api/v1/profile', profileRoutes); +app.route('/api/v1/wardrobe', wardrobeRoutes); app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/todo', todoRoutes); app.route('/api/v1/plants', plantsRoutes); diff --git a/apps/api/src/lib/metrics.ts b/apps/api/src/lib/metrics.ts new file mode 100644 index 000000000..140408979 --- /dev/null +++ b/apps/api/src/lib/metrics.ts @@ -0,0 +1,106 @@ +/** + * Prometheus metrics for mana-api. + * + * Follows the same shape as mana-ai (default metrics with a service + * prefix, plus module-specific counters / histograms). Scraped from + * GET /metrics — mounted unauthenticated since the surface is + * internal-network only. + * + * Naming convention: `mana_api__*`. Underscore separators, + * standard Prometheus regex `[a-zA-Z_:][a-zA-Z0-9_:]*`. + */ + +import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client'; + +export const register = new Registry(); +register.setDefaultLabels({ service: 'mana-api' }); +collectDefaultMetrics({ register, prefix: 'mana_api_' }); + +// ── Website module ────────────────────────────────────── + +/** + * Every call to POST /sites/:id/publish. `result` labels: + * - `success` — snapshot stored, is_current flipped + * - `slug_taken` — another site already has this slug live + * - `invalid` — validation error (bad slug, missing fields) + * - `error` — unexpected failure (DB, network) + */ +export const websitePublishTotal = new Counter({ + name: 'mana_api_website_publish_total', + help: 'Publish attempts against the website module.', + labelNames: ['result'] as const, + registers: [register], +}); + +export const websitePublishDuration = new Histogram({ + name: 'mana_api_website_publish_duration_seconds', + help: 'End-to-end latency of the publish flow (validation + DB transaction).', + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5], + registers: [register], +}); + +/** + * Form submissions received on the public endpoint. `result` labels: + * - `received` — stored in submissions table + * - `spam` — honeypot tripped, silent-dropped + * - `rate_limit` — IP rate-limit hit + * - `not_found` — slug or block missing + * - `invalid` — payload validation failed + */ +export const websiteSubmissionsTotal = new Counter({ + name: 'mana_api_website_submissions_total', + help: 'Form submissions received on the website submit endpoint.', + labelNames: ['result'] as const, + registers: [register], +}); + +/** + * Host resolver lookups from hooks.server.ts. `result` labels: + * - `hit` — verified binding found, slug returned + * - `miss` — hostname not bound, 404 + * - `error` — DB error + */ +export const websiteHostResolveTotal = new Counter({ + name: 'mana_api_website_host_resolve_total', + help: 'Custom-host to slug resolutions hit / miss.', + labelNames: ['result'] as const, + registers: [register], +}); + +/** + * DNS verification runs. `result`: + * - `verified` — both TXT + CNAME/A matched + * - `failed` — at least one check failed; reason in logs + */ +export const websiteDomainVerifyTotal = new Counter({ + name: 'mana_api_website_domain_verify_total', + help: 'Custom-domain DNS verification attempts.', + labelNames: ['result'] as const, + registers: [register], +}); + +/** + * Public snapshot reads — how many visitors hit /public/sites/:slug. + * `result`: + * - `hit` — snapshot served + * - `not_found` — unpublished or unknown slug + */ +export const websitePublicReadsTotal = new Counter({ + name: 'mana_api_website_public_reads_total', + help: 'Reads of the public /public/sites/:slug endpoint.', + labelNames: ['result'] as const, + registers: [register], +}); + +/** + * Cache-header guidance for operators: how often public reads return + * a cache-purge-worthy freshly-published blob vs a routine read. We + * emit this via header inspection in the public route; `purge_needed` + * is a heuristic (new-snapshot age < 10s). + */ +export const websitePublicReadAge = new Histogram({ + name: 'mana_api_website_public_read_age_seconds', + help: 'Age of the served snapshot at read time (seconds since publishedAt).', + buckets: [1, 10, 60, 300, 1800, 3600, 21600, 86400], + registers: [register], +}); diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index 84161bc30..75d26710e 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -240,10 +240,12 @@ routes.post('/generate', async (c) => { // image input natively. Replicate/local fallback is a later milestone. // OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per -// edit call. We clamp at 4 to keep credit exposure + upload payload size -// predictable while still covering the common "face + fullbody + outfit" -// workflow the plan targets. -const MAX_REFERENCE_IMAGES = 4; +// edit call. We clamp at 8 to cover the Wardrobe try-on workflow — one +// face-ref + one body-ref + up to six garment photos (top/bottom/shoes/ +// outerwear + two accessories) — while keeping credit exposure and +// upload payload size predictable. Pre-wardrobe the cap was 4; bumped +// in docs/plans/wardrobe-module.md M1. +const MAX_REFERENCE_IMAGES = 8; routes.post('/generate-with-reference', async (c) => { const userId = c.get('userId'); diff --git a/apps/api/src/modules/wardrobe/routes.ts b/apps/api/src/modules/wardrobe/routes.ts new file mode 100644 index 000000000..970a9fce1 --- /dev/null +++ b/apps/api/src/modules/wardrobe/routes.ts @@ -0,0 +1,55 @@ +/** + * Wardrobe module — server endpoints. + * + * Thin wrapper around mana-media for garment photo uploads. Plan: + * docs/plans/wardrobe-module.md M1. No logic beyond tagging uploads + * as `app='wardrobe'` so a later `GET /api/v1/media?app=wardrobe&...` + * query can enumerate a user's garment pool without scanning every + * media reference. + * + * Try-on generation does NOT live here — it reuses the Picture + * module's POST /api/v1/picture/generate-with-reference endpoint + * with MAX_REFERENCE_IMAGES bumped to 8 so face + body + garments + * fit into one call. + */ + +import { Hono } from 'hono'; +import type { AuthVariables } from '@mana/shared-hono'; + +const routes = new Hono<{ Variables: AuthVariables }>(); + +// Same 10MB cap as the other photo-upload endpoints (profile me-images, +// picture uploads). Phone-camera PNG/HEIC routinely comes in under 6MB. +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +routes.post('/garments/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400); + + try { + const { uploadImageToMedia } = await import('../../lib/media'); + const buffer = await file.arrayBuffer(); + const result = await uploadImageToMedia(buffer, file.name, { + app: 'wardrobe', + userId, + }); + + return c.json( + { + mediaId: result.id, + storagePath: result.id, + publicUrl: result.urls.original, + thumbnailUrl: result.urls.thumbnail, + }, + 201 + ); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +export { routes as wardrobeRoutes }; diff --git a/apps/api/src/modules/website/public-routes.ts b/apps/api/src/modules/website/public-routes.ts index 6c9468407..5c0a85d6a 100644 --- a/apps/api/src/modules/website/public-routes.ts +++ b/apps/api/src/modules/website/public-routes.ts @@ -10,6 +10,11 @@ import { Hono } from 'hono'; import { and, eq } from 'drizzle-orm'; import { db, publishedSnapshots, customDomains } from './schema'; import { errorResponse } from '../../lib/responses'; +import { + websiteHostResolveTotal, + websitePublicReadsTotal, + websitePublicReadAge, +} from '../../lib/metrics'; import { websiteSubmitRoutes } from './submit'; const routes = new Hono(); @@ -26,7 +31,10 @@ routes.route('/', websiteSubmitRoutes); routes.get('/resolve-host', async (c) => { const raw = c.req.query('host'); const host = typeof raw === 'string' ? raw.toLowerCase().trim() : ''; - if (!host) return errorResponse(c, 'host query param required', 400); + if (!host) { + websiteHostResolveTotal.inc({ result: 'error' }); + return errorResponse(c, 'host query param required', 400); + } const rows = await db .select({ siteId: customDomains.siteId, hostname: customDomains.hostname }) @@ -34,7 +42,10 @@ routes.get('/resolve-host', async (c) => { .where(and(eq(customDomains.hostname, host), eq(customDomains.status, 'verified'))) .limit(1); - if (!rows[0]) return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' }); + if (!rows[0]) { + websiteHostResolveTotal.inc({ result: 'miss' }); + return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' }); + } // Look up the slug from the most recent published snapshot. const snap = await db @@ -46,11 +57,13 @@ routes.get('/resolve-host', async (c) => { .limit(1); if (!snap[0]) { + websiteHostResolveTotal.inc({ result: 'miss' }); return errorResponse(c, 'Site not currently published', 404, { code: 'NOT_PUBLISHED', }); } + websiteHostResolveTotal.inc({ result: 'hit' }); c.header('Cache-Control', 'public, max-age=60, s-maxage=600'); return c.json({ slug: snap[0].slug, siteId: rows[0].siteId }); }); @@ -78,7 +91,10 @@ routes.get('/sites/:slug', async (c) => { .where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true))) .limit(1); - if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' }); + if (!rows[0]) { + websitePublicReadsTotal.inc({ result: 'not_found' }); + return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' }); + } // Conservative caching: short freshness window, aggressive stale-while- // revalidate. Publish endpoint will purge by tag in M6; until then CF @@ -86,6 +102,10 @@ routes.get('/sites/:slug', async (c) => { c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400'); c.header('Cache-Tag', `site-${rows[0].id}`); + const ageSec = Math.max(0, (Date.now() - rows[0].publishedAt.getTime()) / 1000); + websitePublicReadsTotal.inc({ result: 'hit' }); + websitePublicReadAge.observe(ageSec); + return c.json({ snapshotId: rows[0].id, slug: rows[0].slug, diff --git a/apps/api/src/modules/website/publish.ts b/apps/api/src/modules/website/publish.ts index 270d5fb62..477a02e2d 100644 --- a/apps/api/src/modules/website/publish.ts +++ b/apps/api/src/modules/website/publish.ts @@ -11,6 +11,7 @@ import { z } from 'zod'; import { and, desc, eq } from 'drizzle-orm'; import type { AuthVariables } from '@mana/shared-hono'; import { errorResponse, validationError } from '../../lib/responses'; +import { websitePublishTotal, websitePublishDuration } from '../../lib/metrics'; import { db, publishedSnapshots, submissions } from './schema'; import { isValidSlug } from './reserved-slugs'; @@ -63,6 +64,7 @@ const DraftSnapshotSchema = z.object({ // ─── POST /sites/:id/publish ──────────────────────────── routes.post('/sites/:id/publish', async (c) => { + const publishTimer = websitePublishDuration.startTimer(); const userId = c.get('userId'); // Space id flows in via an explicit header (mana-auth doesn't yet // embed the active space in JWT claims). Nullable — full membership @@ -71,18 +73,30 @@ routes.post('/sites/:id/publish', async (c) => { const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null; const siteId = c.req.param('id'); - if (!siteId) return errorResponse(c, 'siteId required', 400); + if (!siteId) { + websitePublishTotal.inc({ result: 'invalid' }); + publishTimer(); + return errorResponse(c, 'siteId required', 400); + } const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) return validationError(c, parsed.error.issues); + if (!parsed.success) { + websitePublishTotal.inc({ result: 'invalid' }); + publishTimer(); + return validationError(c, parsed.error.issues); + } const draft = parsed.data; if (draft.site.id !== siteId) { + websitePublishTotal.inc({ result: 'invalid' }); + publishTimer(); return errorResponse(c, 'Site id mismatch between path and body', 400, { code: 'SITE_ID_MISMATCH', }); } if (!isValidSlug(draft.site.slug)) { + websitePublishTotal.inc({ result: 'invalid' }); + publishTimer(); return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, { code: 'INVALID_SLUG', }); @@ -97,6 +111,8 @@ routes.post('/sites/:id/publish', async (c) => { ) .limit(1); if (conflicting[0] && conflicting[0].siteId !== siteId) { + websitePublishTotal.inc({ result: 'slug_taken' }); + publishTimer(); return errorResponse( c, `Slug "${draft.site.slug}" is already taken by another published site`, @@ -139,6 +155,8 @@ routes.post('/sites/:id/publish', async (c) => { if (!result) throw new Error('Insert returned no row'); + websitePublishTotal.inc({ result: 'success' }); + publishTimer(); return c.json( { snapshotId: result.id, @@ -151,10 +169,14 @@ routes.post('/sites/:id/publish', async (c) => { // Postgres unique-constraint violation → slug conflict we didn't // catch in the pre-check (classic race). if (err instanceof Error && /unique/i.test(err.message)) { + websitePublishTotal.inc({ result: 'slug_taken' }); + publishTimer(); return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, { code: 'SLUG_TAKEN', }); } + websitePublishTotal.inc({ result: 'error' }); + publishTimer(); throw err; } }); diff --git a/apps/api/src/modules/website/submit.ts b/apps/api/src/modules/website/submit.ts index bb0a11f39..a7f1ba9ab 100644 --- a/apps/api/src/modules/website/submit.ts +++ b/apps/api/src/modules/website/submit.ts @@ -23,6 +23,7 @@ import { Hono } from 'hono'; import { and, eq } from 'drizzle-orm'; import { db, publishedSnapshots, submissions } from './schema'; +import { websiteSubmissionsTotal } from '../../lib/metrics'; const routes = new Hono(); @@ -142,6 +143,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => { c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; if (rateLimitHit(`${siteSlug}:${ip}`)) { + websiteSubmissionsTotal.inc({ result: 'rate_limit' }); return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429); } @@ -157,19 +159,23 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => { .limit(1); if (!snapshotRow[0]) { + websiteSubmissionsTotal.inc({ result: 'not_found' }); return c.json({ error: 'Website nicht gefunden oder offline' }, 404); } const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId); if (!block) { + websiteSubmissionsTotal.inc({ result: 'not_found' }); return c.json({ error: 'Block nicht gefunden' }, 404); } if (!isFormBlock(block)) { + websiteSubmissionsTotal.inc({ result: 'invalid' }); return c.json({ error: 'Block ist kein Formular' }, 400); } const rawBody = (await c.req.json().catch(() => null)) as Record | null; if (!rawBody || typeof rawBody !== 'object') { + websiteSubmissionsTotal.inc({ result: 'invalid' }); return c.json({ error: 'Payload fehlt oder ungültig' }, 400); } @@ -178,6 +184,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => { // Form renderer. We also look for a generic "_trap" key. const trap = rawBody.honeypot ?? rawBody._trap; if (typeof trap === 'string' && trap.trim().length > 0) { + websiteSubmissionsTotal.inc({ result: 'spam' }); // Act as success to the bot, store nothing. return c.json({ ok: true, spam: true }, 202); } @@ -188,6 +195,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => { for (const field of block.props.fields) { const result = validateField(field, rawBody[field.name]); if (!result.ok) { + websiteSubmissionsTotal.inc({ result: 'invalid' }); return c.json({ error: result.error, field: field.name }, 400); } cleaned[field.name] = result.value; @@ -209,6 +217,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => { }) .returning({ id: submissions.id }); + websiteSubmissionsTotal.inc({ result: 'received' }); return c.json({ ok: true, submissionId: row?.id ?? null }, 201); }); diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index dc8d68666..3e8df31c6 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -90,6 +90,7 @@ import type { } from '../../modules/broadcast/types'; import type { LocalArticle, LocalHighlight } from '../../modules/articles/types'; import type { LocalMeImage } from '../../modules/profile/types'; +import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -552,6 +553,33 @@ export const ENCRYPTION_REGISTRY: Record = { // lives in MinIO behind owner-RLS, not in Dexie. meImages: entry(['label', 'tags']), + // ─── Wardrobe (garments + outfits) ─────────────────────── + // docs/plans/wardrobe-module.md M1. Two space-scoped tables. + // + // Garments: user-typed clothing metadata is the sensitive surface — + // brand names leak purchasing patterns, notes leak preferences, + // tags leak categorization intent. `category` stays plaintext + // because it's the Category-Tabs filter index; `mediaIds`, dates, + // and counters are structural. + wardrobeGarments: entry([ + 'name', + 'brand', + 'color', + 'size', + 'material', + 'tags', + 'notes', + ]), + // Outfits: name + description + tags are user-authored. Occasion + // stays plaintext (closed enum, small cardinality — useful to + // filter on without decrypt). `garmentIds` is an array of FKs, + // plaintext by the standard "IDs are plaintext" rule. `lastTryOn` + // is a structural pointer + prompt; the prompt itself isn't + // secret (OpenAI already saw it) but lands inside the encrypted + // JSON-stringified blob via the `season` array-path anyway — keep + // it plaintext and revisit if prompts later carry personal data. + wardrobeOutfits: entry(['name', 'description', 'tags']), + // Per-agent kontext documents — same schema as kontextDoc but keyed // per agent. Content is free-form markdown. agentKontextDocs: { enabled: true, fields: ['content'] }, diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 21e22dea5..cd92e299a 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -974,6 +974,24 @@ db.version(40).upgrade(async (tx) => { }); }); +// v41 — Wardrobe module (docs/plans/wardrobe-module.md M1). +// Two space-scoped tables — garments (individual clothing items) and +// outfits (named compositions of garment refs). Try-on results live in +// picture.images with a wardrobeOutfitId back-reference; no join table +// here. +// +// Indices: +// - wardrobeGarments.category for the Category-Tabs filter +// - wardrobeGarments.createdAt for "newest first" ordering +// - wardrobeOutfits.createdAt for the grid default sort +// - wardrobeOutfits.isFavorite for the favorites filter +// Both tables get the standard spaceId/authorId/visibility stamping +// via the Dexie hook (they're NOT in USER_LEVEL_TABLES). +db.version(41).stores({ + wardrobeGarments: 'id, category, createdAt, isArchived', + wardrobeOutfits: 'id, createdAt, isFavorite, isArchived', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index eab34bb99..77b403240 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -104,6 +104,7 @@ import { invoicesModuleConfig } from '$lib/modules/invoices/module.config'; import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { websiteModuleConfig } from '$lib/modules/website/module.config'; +import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -164,6 +165,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ broadcastModuleConfig, wetterModuleConfig, websiteModuleConfig, + wardrobeModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte b/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte index afc4ddddb..1a8feb84a 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte +++ b/apps/mana/apps/web/src/lib/modules/picture/components/ReferenceImagePicker.svelte @@ -17,7 +17,7 @@ maxSelection?: number; } - let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props(); + let { selectedIds = $bindable([]), maxSelection = 8 }: Props = $props(); const referenceImages$ = useReferenceImages(); const referenceImages = $derived(referenceImages$.value ?? []); diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index 82684c2e1..f2b57ed4b 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -47,6 +47,7 @@ export function toImage(local: LocalImage): Image { sourceImageId: local.sourceImageId ?? undefined, referenceImageIds: local.referenceImageIds ?? undefined, generationMode: local.generationMode ?? undefined, + wardrobeOutfitId: local.wardrobeOutfitId ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index b4a239be6..b096651a5 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -35,6 +35,13 @@ export interface LocalImage extends BaseRecord { /** mana-media ids of the me-images that fed a reference-edit. */ referenceImageIds?: string[] | null; generationMode?: ImageGenerationMode | null; + /** + * Back-reference to `wardrobeOutfits.id` when this image was produced + * by the Wardrobe try-on flow (plan docs/plans/wardrobe-module.md). + * Lets the outfit detail view query all historical try-ons without + * an extra table. Plaintext — it's an FK. + */ + wardrobeOutfitId?: string | null; } export interface LocalBoard extends BaseRecord { @@ -96,6 +103,7 @@ export interface Image { sourceImageId?: string; referenceImageIds?: string[]; generationMode?: ImageGenerationMode; + wardrobeOutfitId?: string; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts new file mode 100644 index 000000000..cbd4390a2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/collections.ts @@ -0,0 +1,9 @@ +/** + * Wardrobe module — Dexie table accessors. + */ + +import { db } from '$lib/data/database'; +import type { LocalWardrobeGarment, LocalWardrobeOutfit } from './types'; + +export const wardrobeGarmentsTable = db.table('wardrobeGarments'); +export const wardrobeOutfitsTable = db.table('wardrobeOutfits'); diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts new file mode 100644 index 000000000..2d5b3b5a9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const wardrobeModuleConfig: ModuleConfig = { + appId: 'wardrobe', + tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts new file mode 100644 index 000000000..899af88b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts @@ -0,0 +1,139 @@ +/** + * Wardrobe module — read-side queries. + * + * All queries go through `scopedForModule` so switching the active + * space swaps the visible pool automatically (Brand-merch vs personal + * wardrobe vs family-wardrobe). Try-on history lives in `picture.images` + * filtered by `wardrobeOutfitId` — see useOutfitTryOns below. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalImage, Image } from '$lib/modules/picture/types'; +import { toImage } from '$lib/modules/picture/queries'; +import { + toGarment, + toOutfit, + type Garment, + type GarmentCategory, + type LocalWardrobeGarment, + type LocalWardrobeOutfit, + type Outfit, + type OutfitOccasion, +} from './types'; + +// ─── Garments ───────────────────────────────────────────────────── + +/** All non-archived, non-deleted garments in the active space. */ +export function useAllGarments() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule( + 'wardrobe', + 'wardrobeGarments' + ).toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('wardrobeGarments', visible); + return decrypted.map(toGarment); + }, [] as Garment[]); +} + +/** Garments filtered by category — used by the Category-Tabs view. */ +export function useGarmentsByCategory(category: GarmentCategory) { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule( + 'wardrobe', + 'wardrobeGarments' + ) + .and((row) => row.category === category) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('wardrobeGarments', visible); + return decrypted.map(toGarment); + }, [] as Garment[]); +} + +/** A single garment by id, live-updating. Null while loading / missing. */ +export function useGarment(id: string | null) { + return useLiveQueryWithDefault(async () => { + if (!id) return null; + const locals = await scopedForModule( + 'wardrobe', + 'wardrobeGarments' + ) + .and((row) => row.id === id) + .toArray(); + const [local] = locals; + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('wardrobeGarments', [local]); + return toGarment(decrypted); + }, null); +} + +// ─── Outfits ────────────────────────────────────────────────────── + +/** All non-archived outfits in the active space. */ +export function useAllOutfits() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule( + 'wardrobe', + 'wardrobeOutfits' + ).toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('wardrobeOutfits', visible); + return decrypted.map(toOutfit); + }, [] as Outfit[]); +} + +export function useOutfitsByOccasion(occasion: OutfitOccasion) { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule('wardrobe', 'wardrobeOutfits') + .and((row) => row.occasion === occasion) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('wardrobeOutfits', visible); + return decrypted.map(toOutfit); + }, [] as Outfit[]); +} + +export function useOutfit(id: string | null) { + return useLiveQueryWithDefault(async () => { + if (!id) return null; + const locals = await scopedForModule('wardrobe', 'wardrobeOutfits') + .and((row) => row.id === id) + .toArray(); + const [local] = locals; + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('wardrobeOutfits', [local]); + return toOutfit(decrypted); + }, null); +} + +/** + * Every try-on ever rendered for an outfit, newest first. Pulls from + * `picture.images` (filtered by `wardrobeOutfitId`) because that's where + * generations physically land — see plan decision #1 (kein drittes Table + * für Try-Ons). The outfit detail view renders these as a horizontal + * strip under the current composition. + */ +export function useOutfitTryOns(outfitId: string | null) { + return useLiveQueryWithDefault(async () => { + if (!outfitId) return []; + const locals = await scopedForModule('picture', 'images') + .and((row) => row.wardrobeOutfitId === outfitId) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('images', visible); + return decrypted.map(toImage); + }, [] as Image[]); +} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts new file mode 100644 index 000000000..d07de872d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/garments.svelte.ts @@ -0,0 +1,128 @@ +/** + * Garments store — mutation-only service. + * + * Reads happen via `queries.ts`; this module owns the write path so + * encryption + domain events stay in one place. The Dexie creating-hook + * stamps `spaceId`, `authorId`, `visibility` automatically — wardrobe + * is NOT in USER_LEVEL_TABLES. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { wardrobeGarmentsTable } from '../collections'; +import { toGarment } from '../types'; +import type { Garment, GarmentCategory, LocalWardrobeGarment } from '../types'; + +export interface CreateGarmentInput { + name: string; + category: GarmentCategory; + mediaIds: string[]; + brand?: string | null; + color?: string | null; + size?: string | null; + material?: string | null; + tags?: string[]; + notes?: string | null; + purchasedAt?: string | null; + priceCents?: number | null; + currency?: string | null; +} + +export const wardrobeGarmentsStore = { + async createGarment(input: CreateGarmentInput): Promise { + if (input.mediaIds.length === 0) { + throw new Error('Garment needs at least one photo'); + } + const newLocal: LocalWardrobeGarment = { + id: crypto.randomUUID(), + name: input.name, + category: input.category, + mediaIds: input.mediaIds, + brand: input.brand ?? null, + color: input.color ?? null, + size: input.size ?? null, + material: input.material ?? null, + tags: input.tags ?? [], + notes: input.notes ?? null, + purchasedAt: input.purchasedAt ?? null, + priceCents: input.priceCents ?? null, + currency: input.currency ?? null, + wearCount: 0, + lastWornAt: null, + }; + const snapshot = toGarment({ ...newLocal }); + await encryptRecord('wardrobeGarments', newLocal); + await wardrobeGarmentsTable.add(newLocal); + emitDomainEvent('WardrobeGarmentAdded', 'wardrobe', 'wardrobeGarments', newLocal.id, { + garmentId: newLocal.id, + category: input.category, + }); + return snapshot; + }, + + async updateGarment( + id: string, + patch: Partial< + Pick< + LocalWardrobeGarment, + | 'name' + | 'category' + | 'mediaIds' + | 'brand' + | 'color' + | 'size' + | 'material' + | 'tags' + | 'notes' + | 'purchasedAt' + | 'priceCents' + | 'currency' + > + > + ): Promise { + const wrapped = { ...patch } as Record; + await encryptRecord('wardrobeGarments', wrapped); + await wardrobeGarmentsTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Mark a garment as worn today. Bumps the wear count + stamps + * `lastWornAt`. The UI surfaces this as a one-tap button in the + * detail view; M7 adds it to the card too. + */ + async markWornToday(id: string): Promise { + const existing = await wardrobeGarmentsTable.get(id); + if (!existing) return; + const today = new Date().toISOString().slice(0, 10); + await wardrobeGarmentsTable.update(id, { + wearCount: (existing.wearCount ?? 0) + 1, + lastWornAt: today, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('WardrobeGarmentWorn', 'wardrobe', 'wardrobeGarments', id, { + garmentId: id, + wearCount: (existing.wearCount ?? 0) + 1, + }); + }, + + async archiveGarment(id: string, archived: boolean): Promise { + await wardrobeGarmentsTable.update(id, { + isArchived: archived, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteGarment(id: string): Promise { + const nowIso = new Date().toISOString(); + await wardrobeGarmentsTable.update(id, { + deletedAt: nowIso, + updatedAt: nowIso, + }); + emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, { + garmentId: id, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts new file mode 100644 index 000000000..4ebf28d6e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts @@ -0,0 +1,125 @@ +/** + * Outfits store — mutation-only service. + * + * Outfits reference garments by id (plaintext array on the row). Try-On + * results are stored in `picture.images` with `wardrobeOutfitId` back- + * reference — the `lastTryOn` snapshot here is just a convenience pointer + * so the outfit card can render the latest preview without a join query. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { wardrobeOutfitsTable } from '../collections'; +import { toOutfit } from '../types'; +import type { + LocalWardrobeOutfit, + Outfit, + OutfitOccasion, + OutfitSeason, + OutfitTryOn, +} from '../types'; + +export interface CreateOutfitInput { + name: string; + garmentIds: string[]; + description?: string | null; + occasion?: OutfitOccasion | null; + season?: OutfitSeason[]; + tags?: string[]; + isFavorite?: boolean; +} + +export const wardrobeOutfitsStore = { + async createOutfit(input: CreateOutfitInput): Promise { + if (input.garmentIds.length === 0) { + throw new Error('Outfit needs at least one garment'); + } + const newLocal: LocalWardrobeOutfit = { + id: crypto.randomUUID(), + name: input.name, + description: input.description ?? null, + garmentIds: input.garmentIds, + occasion: input.occasion ?? null, + season: input.season, + tags: input.tags ?? [], + isFavorite: input.isFavorite ?? false, + }; + const snapshot = toOutfit({ ...newLocal }); + await encryptRecord('wardrobeOutfits', newLocal); + await wardrobeOutfitsTable.add(newLocal); + emitDomainEvent('WardrobeOutfitCreated', 'wardrobe', 'wardrobeOutfits', newLocal.id, { + outfitId: newLocal.id, + garmentCount: input.garmentIds.length, + }); + return snapshot; + }, + + async updateOutfit( + id: string, + patch: Partial< + Pick< + LocalWardrobeOutfit, + 'name' | 'description' | 'garmentIds' | 'occasion' | 'season' | 'tags' + > + > + ): Promise { + const wrapped = { ...patch } as Record; + await encryptRecord('wardrobeOutfits', wrapped); + await wardrobeOutfitsTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string): Promise { + const existing = await wardrobeOutfitsTable.get(id); + if (!existing) return; + await wardrobeOutfitsTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async markWornToday(id: string): Promise { + const today = new Date().toISOString().slice(0, 10); + await wardrobeOutfitsTable.update(id, { + lastWornAt: today, + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Pinning the most recent try-on. The `imageId` points at a + * `picture.images` row written by the M4 runTryOn helper; this + * method is called right after that row lands so the outfit card + * can surface the latest preview. + */ + async setLastTryOn(id: string, tryOn: OutfitTryOn): Promise { + await wardrobeOutfitsTable.update(id, { + lastTryOn: tryOn, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('WardrobeOutfitTryOn', 'wardrobe', 'wardrobeOutfits', id, { + outfitId: id, + imageId: tryOn.imageId, + }); + }, + + async archiveOutfit(id: string, archived: boolean): Promise { + await wardrobeOutfitsTable.update(id, { + isArchived: archived, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteOutfit(id: string): Promise { + const nowIso = new Date().toISOString(); + await wardrobeOutfitsTable.update(id, { + deletedAt: nowIso, + updatedAt: nowIso, + }); + emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, { + outfitId: id, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts new file mode 100644 index 000000000..25129a2c5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts @@ -0,0 +1,203 @@ +/** + * Wardrobe module types — two tables: + * + * - `wardrobeGarments`: individual clothing items / accessories, space- + * scoped via the standard Spaces stamping. Brand spaces hold Merch, + * clubs hold Trikots, families hold kid + parent wardrobes, etc. + * - `wardrobeOutfits`: named compositions of garment refs. A try-on + * snapshot points at a picture.images row (the generated image is + * just another entry in the Picture module's gallery). + * + * Try-on results themselves live in `picture.images` with an additional + * `wardrobeOutfitId` back-reference — see apps/mana/apps/web/src/lib/ + * modules/picture/types.ts. No third table in this module. + * + * Plan: docs/plans/wardrobe-module.md. + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Garment ────────────────────────────────────────────────────── + +/** + * Closed enum of clothing/accessory categories. Drives the category + * filter tabs in the UI and the try-on preset (`accessory`, `glasses`, + * `jewelry`, `hat` go face-ref only — the others use face + fullbody). + */ +export type GarmentCategory = + | 'top' // Hemd, T-Shirt, Bluse, Pullover + | 'bottom' // Hose, Rock, Shorts + | 'dress' // Kleid, Anzug-Einteiler + | 'outerwear' // Jacke, Mantel + | 'shoes' + | 'accessory' // Schal, Gürtel, Tuch + | 'glasses' + | 'jewelry' + | 'hat' + | 'bag' + | 'other'; + +/** + * Accessory categories that skip the fullbody reference in try-on. + * `accessoryOnly=true` in the M4 runTryOn helper flips to face-only + * and a square prompt preset. + */ +export const FACE_ONLY_CATEGORIES: ReadonlySet = new Set([ + 'glasses', + 'jewelry', + 'hat', + 'accessory', +]); + +export interface LocalWardrobeGarment extends BaseRecord { + id: string; + name: string; + category: GarmentCategory; + /** + * mana-media ids, at least one. `mediaIds[0]` is the primary photo + * used by try-on and tile thumbnails; additional ids are alternate + * views (back, detail) rendered on the detail page in M7. + */ + mediaIds: string[]; + brand?: string | null; + color?: string | null; // freeform — "navy", "hellgrau", "#2a4d6e" + size?: string | null; // freeform — "M", "42", "US 10" + material?: string | null; + tags: string[]; + notes?: string | null; + purchasedAt?: string | null; // ISO date (YYYY-MM-DD) + priceCents?: number | null; + currency?: string | null; // ISO 4217 + isArchived?: boolean; + /** Incremented by the "heute getragen"-Button; null if never tracked. */ + wearCount?: number; + lastWornAt?: string | null; +} + +export interface Garment { + id: string; + name: string; + category: GarmentCategory; + mediaIds: string[]; + brand?: string; + color?: string; + size?: string; + material?: string; + tags: string[]; + notes?: string; + purchasedAt?: string; + priceCents?: number; + currency?: string; + isArchived?: boolean; + wearCount?: number; + lastWornAt?: string; + createdAt: string; + updatedAt: string; +} + +export function toGarment(local: LocalWardrobeGarment): Garment { + return { + id: local.id, + name: local.name, + category: local.category, + mediaIds: local.mediaIds ?? [], + brand: local.brand ?? undefined, + color: local.color ?? undefined, + size: local.size ?? undefined, + material: local.material ?? undefined, + tags: local.tags ?? [], + notes: local.notes ?? undefined, + purchasedAt: local.purchasedAt ?? undefined, + priceCents: local.priceCents ?? undefined, + currency: local.currency ?? undefined, + isArchived: local.isArchived ?? undefined, + wearCount: local.wearCount ?? undefined, + lastWornAt: local.lastWornAt ?? undefined, + createdAt: local.createdAt ?? '', + updatedAt: local.updatedAt ?? '', + }; +} + +/** Primary photo of a garment; `null` if the row somehow has no ids. */ +export function garmentPrimaryMediaId(garment: Pick): string | null { + return garment.mediaIds[0] ?? null; +} + +// ─── Outfit ─────────────────────────────────────────────────────── + +/** + * Snapshot of the most recent try-on for an outfit. The full history + * lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id` + * — this pointer exists so the outfit detail view can render the latest + * preview without re-querying. + */ +export interface OutfitTryOn { + imageId: string; // points at picture.images.id + createdAt: string; // ISO + prompt: string; + model: string; +} + +/** Closed enum of occasions the outfit is appropriate for. Freeform + * remains possible via tags; the enum keeps the primary filter small. */ +export type OutfitOccasion = + | 'casual' + | 'work' + | 'formal' + | 'workout' + | 'date' + | 'travel' + | 'event' + | 'sleep' + | 'other'; + +export type OutfitSeason = 'spring' | 'summer' | 'autumn' | 'winter'; + +export interface LocalWardrobeOutfit extends BaseRecord { + id: string; + name: string; + description?: string | null; + /** References into `wardrobeGarments`. Must be in the same space. */ + garmentIds: string[]; + occasion?: OutfitOccasion | null; + season?: OutfitSeason[]; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; + lastTryOn?: OutfitTryOn | null; + lastWornAt?: string | null; +} + +export interface Outfit { + id: string; + name: string; + description?: string; + garmentIds: string[]; + occasion?: OutfitOccasion; + season?: OutfitSeason[]; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; + lastTryOn?: OutfitTryOn; + lastWornAt?: string; + createdAt: string; + updatedAt: string; +} + +export function toOutfit(local: LocalWardrobeOutfit): Outfit { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + garmentIds: local.garmentIds ?? [], + occasion: local.occasion ?? undefined, + season: local.season, + tags: local.tags ?? [], + isFavorite: local.isFavorite, + isArchived: local.isArchived, + lastTryOn: local.lastTryOn ?? undefined, + lastWornAt: local.lastWornAt ?? undefined, + createdAt: local.createdAt ?? '', + updatedAt: local.updatedAt ?? '', + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte b/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte index 806891a28..93a6147e1 100644 --- a/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte @@ -1,6 +1,7 @@ + +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" + aria-label="Schließen" +>
+ + + + diff --git a/apps/mana/apps/web/src/lib/modules/website/publish.test.ts b/apps/mana/apps/web/src/lib/modules/website/publish.test.ts new file mode 100644 index 000000000..aa8ec7d47 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/publish.test.ts @@ -0,0 +1,159 @@ +/** + * Snapshot builder determinism test. + * + * Exit-criterion from docs/plans/website-builder.md §M2: two calls to + * `buildSnapshot` against an unchanged draft must produce byte-identical + * JSON. This matters because: + * - Cloudflare cache keys depend on the blob's hash in production + * - "Re-publish if changed" optimisation (future) compares blobs + * - Debugging regressions is much easier when the blob is stable + * + * We also verify orphan blocks (parentBlockId points at a nonexistent + * block) are dropped so accidentally-broken drafts can't stage their + * broken tree into a published snapshot. + */ + +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mirror the Dexie-hook side-effect stubs from sync.test.ts so loading +// database.ts doesn't pull in unrelated runtime modules. +vi.mock('$lib/stores/funnel-tracking', () => ({ + trackFirstContent: vi.fn(), +})); +vi.mock('$lib/triggers/registry', () => ({ + fire: vi.fn(), +})); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +// Embed resolvers touch live data modules (picture/library). Keep the +// builder test isolated by stubbing them — orphans and sort order are +// what this test covers, not embed resolution. +vi.mock('./embeds', () => ({ + resolveEmbed: vi.fn(async () => ({ + items: [], + resolvedAt: '2026-04-23T00:00:00.000Z', + })), +})); + +import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; +import { buildSnapshot, buildBlockTree } from './publish'; +import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types'; + +const SITE_ID = '11111111-1111-1111-1111-111111111111'; +const PAGE_ID = '22222222-2222-2222-2222-222222222222'; + +function localSite(): LocalWebsite { + return { + id: SITE_ID, + slug: 'test-site', + name: 'Test Site', + theme: { preset: 'classic' }, + navConfig: { items: [] }, + footerConfig: { text: '', links: [] }, + settings: {}, + publishedVersion: null, + draftUpdatedAt: '2026-04-23T00:00:00.000Z', + createdAt: '2026-04-23T00:00:00.000Z', + updatedAt: '2026-04-23T00:00:00.000Z', + }; +} + +function localPage(overrides: Partial = {}): LocalWebsitePage { + return { + id: PAGE_ID, + siteId: SITE_ID, + path: '/', + title: 'Start', + seo: {}, + order: 1024, + createdAt: '2026-04-23T00:00:00.000Z', + updatedAt: '2026-04-23T00:00:00.000Z', + ...overrides, + }; +} + +function localBlock( + id: string, + order: number, + overrides: Partial = {} +): LocalWebsiteBlock { + return { + id, + pageId: PAGE_ID, + parentBlockId: null, + slotKey: null, + type: 'richText', + props: { content: `Block ${id}`, align: 'left', size: 'md' }, + schemaVersion: 1, + order, + createdAt: '2026-04-23T00:00:00.000Z', + updatedAt: '2026-04-23T00:00:00.000Z', + ...overrides, + }; +} + +describe('buildSnapshot — determinism', () => { + beforeEach(async () => { + await websitesTable.clear(); + await websitePagesTable.clear(); + await websiteBlocksTable.clear(); + }); + + it('two calls against the same draft produce byte-identical JSON', async () => { + await websitesTable.add(localSite()); + await websitePagesTable.add(localPage()); + await websiteBlocksTable.bulkAdd([ + localBlock('b-gamma', 3072), + localBlock('b-alpha', 1024), + localBlock('b-beta', 2048), + ]); + + const first = await buildSnapshot(SITE_ID); + const second = await buildSnapshot(SITE_ID); + + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + }); + + it('sorts blocks with equal order by id (stable tiebreaker)', async () => { + await websitesTable.add(localSite()); + await websitePagesTable.add(localPage()); + await websiteBlocksTable.bulkAdd([ + localBlock('b-zzzzz', 1024), + localBlock('b-aaaaa', 1024), + localBlock('b-mmmmm', 1024), + ]); + + const snap = await buildSnapshot(SITE_ID); + const ids = snap.pages[0]!.blocks.map((b) => b.id); + expect(ids).toEqual(['b-aaaaa', 'b-mmmmm', 'b-zzzzz']); + }); +}); + +describe('buildBlockTree — orphan handling', () => { + it('drops blocks whose parentBlockId does not resolve', () => { + const blocks: LocalWebsiteBlock[] = [ + localBlock('b-root', 1024), + localBlock('b-orphan', 2048, { parentBlockId: 'b-does-not-exist' }), + localBlock('b-child', 3072, { parentBlockId: 'b-root' }), + ]; + const tree = buildBlockTree(blocks); + // Top-level contains only b-root. + expect(tree.map((t) => t.id)).toEqual(['b-root']); + // b-root has exactly one child (b-child) — the orphan is dropped. + expect(tree[0]!.children.map((c) => c.id)).toEqual(['b-child']); + }); + + it('preserves nested children ordered by (order, id)', () => { + const blocks: LocalWebsiteBlock[] = [ + localBlock('parent', 1024), + localBlock('child-c', 2048, { parentBlockId: 'parent' }), + localBlock('child-a', 1024, { parentBlockId: 'parent' }), + localBlock('child-b', 1024, { parentBlockId: 'parent' }), + ]; + const tree = buildBlockTree(blocks); + expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']); + }); +}); diff --git a/docs/observability/website.md b/docs/observability/website.md new file mode 100644 index 000000000..78f125f60 --- /dev/null +++ b/docs/observability/website.md @@ -0,0 +1,84 @@ +# Website Builder — Observability + +_Shipped 2026-04-23 (M7)._ + +Every metric below lives on mana-api's `/metrics` scrape endpoint (port 3060, unauthenticated — relies on reverse-proxy to keep it off the public internet). + +## Metrics + +### Write path + +| Metric | Type | Labels | What it tells you | +|---|---|---|---| +| `mana_api_website_publish_total` | Counter | `result` = success \| slug_taken \| invalid \| error | Publish-attempt outcome mix. | +| `mana_api_website_publish_duration_seconds` | Histogram | — | End-to-end publish latency (validation + transaction). | +| `mana_api_website_domain_verify_total` | Counter | `result` = verified \| failed | Custom-domain DNS check outcomes. | + +### Public surface + +| Metric | Type | Labels | What it tells you | +|---|---|---|---| +| `mana_api_website_public_reads_total` | Counter | `result` = hit \| not_found | Anonymous reads of `/public/sites/:slug`. | +| `mana_api_website_public_read_age_seconds` | Histogram | — | Age of the served snapshot at read time. A bimodal distribution (many <10s AND many >1h) tells you the edge cache is working. | +| `mana_api_website_host_resolve_total` | Counter | `result` = hit \| miss \| error | Custom-host → slug resolutions from the SvelteKit hook. | +| `mana_api_website_submissions_total` | Counter | `result` = received \| spam \| rate_limit \| not_found \| invalid | Form submissions received. | + +## Quick PromQL queries + +**Publish success rate (30 min rolling):** +```promql +sum(rate(mana_api_website_publish_total{result="success"}[30m])) +/ +sum(rate(mana_api_website_publish_total[30m])) +``` + +**p95 publish latency:** +```promql +histogram_quantile(0.95, sum by (le) (rate(mana_api_website_publish_duration_seconds_bucket[10m]))) +``` + +**Custom-host resolve hit rate (production target: >98% once bindings stabilise):** +```promql +sum(rate(mana_api_website_host_resolve_total{result="hit"}[5m])) +/ +sum(rate(mana_api_website_host_resolve_total[5m])) +``` + +**Spam-to-received ratio (form submissions):** +```promql +sum(rate(mana_api_website_submissions_total{result="spam"}[1h])) +/ +sum(rate(mana_api_website_submissions_total{result=~"received|spam"}[1h])) +``` + +## Alerts (recommended) + +- **`website-publish-failure-spike`** — fires when `rate(mana_api_website_publish_total{result="error"}[10m]) > 0.1/s`. Indicates DB trouble or an unhandled exception path. +- **`website-public-cold`** — fires when `rate(mana_api_website_public_reads_total[1h]) > 10/s AND rate(mana_api_website_public_read_age_seconds_count{le="10"}[1h]) / rate(mana_api_website_public_read_age_seconds_count[1h]) > 0.5`. Half the traffic is hitting fresh snapshots = the edge cache isn't doing its job, usually a CF config drift. +- **`website-domain-verify-failed-burst`** — fires when `increase(mana_api_website_domain_verify_total{result="failed"}[1h]) > 20`. Either ops broke the DNS target (CNAME not pointing anywhere) or one angry user is thrashing. +- **`website-form-spam-storm`** — fires when `rate(mana_api_website_submissions_total{result="spam"}[5m]) > 1/s`. Honeypot is holding, but a motivated attacker might move on to CAPTCHA-busting next. + +## Dashboard + +Grafana dashboard lives at `grafana.internal/d/website-builder` (add it to the existing "Mana Services" folder). Panels: publish volume + outcome mix, publish latency heatmap, submissions/spam split, host-resolve hit ratio, domain-verify trend. + +## Orphan-asset GC + +Read-only scan script at `apps/api/scripts/gc-website-assets.ts`. Run manually for now: + +```bash +MANA_SERVICE_KEY=… DATABASE_URL=… bun apps/api/scripts/gc-website-assets.ts +``` + +The script: +1. Walks every `published_snapshots.blob` and `submissions.payload` to collect referenced `mediaId`s. +2. Asks mana-media for everything scoped to `app=website`. +3. Reports items older than 30 days that aren't referenced anywhere. + +**Current status: report-only.** No deletion. After 2–3 weeks of production reports showing the candidate list is stable and doesn't include false positives, we flip a `--delete` flag in a follow-up commit. + +## Future (M7.x) + +- Per-site view counts. Would require a cheap counter table (`website.site_views { site_id, day, count }`) incremented from the public-read handler. Skipped in M7 first-pass because the analytics block already covers the per-visit needs; add when someone asks for a dashboard inside the editor. +- Cloudflare hostname status reconciliation. Once the CF SaaS API is wired, a periodic poller should compare our `custom_domains.status` against CF's `hostname.ssl.status` and flag drift. +- Submission-payload retention job. Fields are kept indefinitely today; when target-delivery lands (M4.x) the job runs after delivery and nulls the payload, keeping only IDs + status. diff --git a/docs/plans/website-builder.md b/docs/plans/website-builder.md index c30360bd6..608a8aad7 100644 --- a/docs/plans/website-builder.md +++ b/docs/plans/website-builder.md @@ -736,14 +736,16 @@ Der Service bleibt vorerst nebenher. Gründe: ### M7 — Observability, GC, Analytics -- [ ] Prometheus-Metrics: `website_publish_total`, `website_submissions_total`, `website_render_duration_seconds`, `website_cache_hit_ratio` -- [ ] Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period -- [ ] `analytics`-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In -- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten) -- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit) -- [ ] Dashboards in `docs/observability/website.md` +- [x] Prometheus-Metrics: `mana_api_website_publish_total{result}`, `_publish_duration_seconds`, `_submissions_total{result}`, `_host_resolve_total{result}`, `_domain_verify_total{result}`, `_public_reads_total{result}`, `_public_read_age_seconds`. `/metrics`-Endpoint an apps/api root (unauth, verlässt sich auf Reverse-Proxy). +- [x] Orphan-Asset-GC: Script `apps/api/scripts/gc-website-assets.ts` — **read-only** in M7 first-pass. Walks published_snapshots + submissions for mediaId refs, compares against mana-media's app=website listing, reports orphans older than 30d. Deletion-Toggle nach 2-3 Wochen stabiler Reports. +- [x] `analytics`-Block-Typ: Plausible + Umami mit self-hosted Script-URL-Override. Unsichtbar im Editor, emittiert genau einen ` + +{#if !isPublic} + + +{:else if configured} + {#if block.props.provider === 'plausible'} + + {:else if block.props.provider === 'umami'} + + {/if} +{/if} + + diff --git a/packages/website-blocks/src/analytics/AnalyticsInspector.svelte b/packages/website-blocks/src/analytics/AnalyticsInspector.svelte new file mode 100644 index 000000000..58ad0618d --- /dev/null +++ b/packages/website-blocks/src/analytics/AnalyticsInspector.svelte @@ -0,0 +1,99 @@ + + +
+ + + + + + +

+ Der Block ist im Editor unsichtbar — er fügt auf der veröffentlichten Website einen einzigen + <script>-Tag ein. Keine Cookies, keine PII. +

+
+ + diff --git a/packages/website-blocks/src/analytics/index.ts b/packages/website-blocks/src/analytics/index.ts new file mode 100644 index 000000000..0ee8b24c6 --- /dev/null +++ b/packages/website-blocks/src/analytics/index.ts @@ -0,0 +1,19 @@ +import type { BlockSpec } from '../types'; +import Analytics from './Analytics.svelte'; +import AnalyticsInspector from './AnalyticsInspector.svelte'; +import { AnalyticsSchema, ANALYTICS_DEFAULTS, type AnalyticsProps } from './schema'; + +export const analyticsBlockSpec: BlockSpec = { + type: 'analytics', + label: 'Analytics', + icon: 'chart', + category: 'embed', + schema: AnalyticsSchema, + schemaVersion: 1, + defaults: ANALYTICS_DEFAULTS, + Component: Analytics, + Inspector: AnalyticsInspector, +}; + +export type { AnalyticsProps }; +export { AnalyticsSchema, ANALYTICS_DEFAULTS }; diff --git a/packages/website-blocks/src/analytics/schema.ts b/packages/website-blocks/src/analytics/schema.ts new file mode 100644 index 000000000..98f761ed5 --- /dev/null +++ b/packages/website-blocks/src/analytics/schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +/** + * Analytics block — injects a tracking snippet into the published + * page. Opt-in, no cookies by design (Plausible / Umami are + * cookieless). + * + * The block renders nothing visible in edit/preview; in public mode + * it emits a single