mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
f7536bc0b9
commit
4fc9d6c59c
36 changed files with 2058 additions and 158 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
176
apps/api/scripts/gc-website-assets.ts
Normal file
176
apps/api/scripts/gc-website-assets.ts
Normal 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 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<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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
106
apps/api/src/lib/metrics.ts
Normal file
106
apps/api/src/lib/metrics.ts
Normal file
|
|
@ -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_<module>_*`. 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],
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
55
apps/api/src/modules/wardrobe/routes.ts
Normal file
55
apps/api/src/modules/wardrobe/routes.ts
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -552,6 +553,33 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// lives in MinIO behind owner-RLS, not in Dexie.
|
||||
meImages: entry<LocalMeImage>(['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<LocalWardrobeGarment>([
|
||||
'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<LocalWardrobeOutfit>(['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'] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? []);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LocalWardrobeGarment>('wardrobeGarments');
|
||||
export const wardrobeOutfitsTable = db.table<LocalWardrobeOutfit>('wardrobeOutfits');
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const wardrobeModuleConfig: ModuleConfig = {
|
||||
appId: 'wardrobe',
|
||||
tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }],
|
||||
};
|
||||
139
apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
Normal file
139
apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
Normal file
|
|
@ -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<Garment[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'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<Garment[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'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<Garment | null>(async () => {
|
||||
if (!id) return null;
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'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<Outfit[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
|
||||
'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<Outfit[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>('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<Outfit | null>(async () => {
|
||||
if (!id) return null;
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>('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<Image[]>(async () => {
|
||||
if (!outfitId) return [];
|
||||
const locals = await scopedForModule<LocalImage, string>('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[]);
|
||||
}
|
||||
|
|
@ -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<Garment> {
|
||||
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<void> {
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteGarment(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, {
|
||||
garmentId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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<Outfit> {
|
||||
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<void> {
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('wardrobeOutfits', wrapped);
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOutfit(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, {
|
||||
outfitId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
203
apps/mana/apps/web/src/lib/modules/wardrobe/types.ts
Normal file
203
apps/mana/apps/web/src/lib/modules/wardrobe/types.ts
Normal file
|
|
@ -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<GarmentCategory> = 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<Garment, 'mediaIds'>): 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 ?? '',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { sitesStore } from '../stores/sites.svelte';
|
||||
import { PublishError } from '../publish';
|
||||
import RollbackDialog from './RollbackDialog.svelte';
|
||||
import type { Website } from '../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
let publishing = $state(false);
|
||||
let unpublishing = $state(false);
|
||||
let lastError = $state<string | null>(null);
|
||||
let showHistory = $state(false);
|
||||
|
||||
const hasDraftAhead = $derived.by(() => {
|
||||
if (!site.publishedVersion) return site.draftUpdatedAt !== null;
|
||||
|
|
@ -77,6 +79,13 @@
|
|||
|
||||
<div class="wb-publishbar__actions">
|
||||
{#if site.publishedVersion}
|
||||
<button
|
||||
class="wb-btn wb-btn--ghost"
|
||||
onclick={() => (showHistory = true)}
|
||||
title="Versionen einsehen / wiederherstellen"
|
||||
>
|
||||
Versionen
|
||||
</button>
|
||||
<button
|
||||
class="wb-btn wb-btn--ghost"
|
||||
onclick={onUnpublish}
|
||||
|
|
@ -103,6 +112,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showHistory}
|
||||
<RollbackDialog siteId={site.id} onClose={() => (showHistory = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-publishbar {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { sitesStore } from '../stores/sites.svelte';
|
||||
import { fetchSnapshotHistory, PublishError, type SnapshotHistoryEntry } from '../publish';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { siteId, onClose }: Props = $props();
|
||||
|
||||
let entries = $state<SnapshotHistoryEntry[] | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let rollingBackId = $state<string | null>(null);
|
||||
let actionError = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) throw new Error('Nicht angemeldet');
|
||||
entries = await fetchSnapshotHistory(siteId, token);
|
||||
} catch (err) {
|
||||
loadError =
|
||||
err instanceof PublishError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
siteId;
|
||||
void load();
|
||||
});
|
||||
|
||||
async function onRollback(snapshotId: string) {
|
||||
if (!confirm('Diese Version als aktuell veröffentlicht setzen?')) return;
|
||||
rollingBackId = snapshotId;
|
||||
actionError = null;
|
||||
try {
|
||||
await sitesStore.rollback(siteId, snapshotId);
|
||||
await load();
|
||||
} catch (err) {
|
||||
actionError =
|
||||
err instanceof PublishError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
} finally {
|
||||
rollingBackId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wb-modal__backdrop"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Schließen"
|
||||
></div>
|
||||
|
||||
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-rollback-title">
|
||||
<header class="wb-modal__head">
|
||||
<div>
|
||||
<h3 id="wb-rollback-title">Version-History</h3>
|
||||
<p>Wähle eine ältere veröffentlichte Version, um sie wieder live zu stellen.</p>
|
||||
</div>
|
||||
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div class="wb-modal__body">
|
||||
{#if loadError}
|
||||
<p class="wb-error">{loadError}</p>
|
||||
{:else if entries === null}
|
||||
<p class="wb-empty">Lade…</p>
|
||||
{:else if entries.length === 0}
|
||||
<p class="wb-empty">Noch keine veröffentlichten Versionen.</p>
|
||||
{:else}
|
||||
{#if actionError}
|
||||
<p class="wb-error">{actionError}</p>
|
||||
{/if}
|
||||
<ul class="wb-list">
|
||||
{#each entries as entry (entry.id)}
|
||||
<li class="wb-row" class:wb-row--current={entry.isCurrent}>
|
||||
<div class="wb-row__meta">
|
||||
<span class="wb-row__time">{formatDate(entry.publishedAt)}</span>
|
||||
<span class="wb-row__id">{entry.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<div class="wb-row__actions">
|
||||
{#if entry.isCurrent}
|
||||
<span class="wb-pill wb-pill--current">Aktuell live</span>
|
||||
{:else}
|
||||
<button
|
||||
class="wb-btn wb-btn--primary"
|
||||
onclick={() => onRollback(entry.id)}
|
||||
disabled={rollingBackId === entry.id || loading}
|
||||
>
|
||||
{rollingBackId === entry.id ? 'Stelle wieder her…' : 'Wiederherstellen'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 40;
|
||||
border: none;
|
||||
}
|
||||
.wb-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(92vw, 32rem);
|
||||
max-height: 80vh;
|
||||
background: rgb(15, 18, 24);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wb-modal__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.wb-modal__head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wb-modal__head p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wb-modal__close {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-modal__body {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wb-empty {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.wb-row--current {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
.wb-row__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.wb-row__time {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-row__id {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.wb-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-btn--primary {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
}
|
||||
.wb-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wb-pill {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.wb-pill--current {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: rgb(110, 231, 183);
|
||||
}
|
||||
.wb-error {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(248, 113, 113);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
</style>
|
||||
159
apps/mana/apps/web/src/lib/modules/website/publish.test.ts
Normal file
159
apps/mana/apps/web/src/lib/modules/website/publish.test.ts
Normal file
|
|
@ -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> = {}): 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> = {}
|
||||
): 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue