feat(profile,api): meImages foundation for AI reference generation (M1)

M1 of docs/plans/me-images-and-reference-generation.md — a user-owned
pool of reference images (face, fullbody, hands, …) that will back
image generation where the user appears as themselves (outfit try-on,
glasses, portraits) via OpenAI /v1/images/edits. Data layer only in
this commit; UI lands in M2, the edits endpoint in M3.

- Dexie v38: meImages table with id/kind/primaryFor/createdAt indices.
  Added to USER_LEVEL_TABLES so the hook stamps userId and skips the
  spaceId/authorId/visibility trio (one human = one face across every
  Space, not per-Space).
- Encryption registry: label + tags encrypted; kind/primaryFor/usage
  stay plaintext because they drive the indexed queries and the
  Reference picker's filtering. mediaId/URLs/dimensions are structural.
- Profile module store: createMeImage, updateMeImage,
  setAiReferenceEnabled (per-image KI opt-in — plan decision #5),
  setPrimary (transactional slot swap — only one row per primary slot),
  deleteMeImage. Emits MeImage* domain events.
- Queries: useAllMeImages, useMeImagesByKind, useReferenceImages
  (only the rows the user opted in for KI), useImageByPrimary.
- POST /api/v1/profile/me-images/upload: thin wrapper over mana-media
  with app='me' as the reference tag. No new MinIO bucket — plan
  decision #1 revised after verifying mana-media uses one bucket and
  only tags references by app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 13:50:53 +02:00
parent 32c95a3780
commit 89258eb451
10 changed files with 790 additions and 4 deletions

View file

@ -0,0 +1,54 @@
/**
* Profile module server endpoints.
*
* Upload route for me-images (docs/plans/me-images-and-reference-generation.md M1).
* Thin wrapper over mana-media the stored row lands in Dexie on the
* client after this returns. We keep server-side storage of the image
* in mana-media (CAS + thumbnails) so the Picture generator can pull
* the original bytes by `mediaId` for the eventual /v1/images/edits
* call (M3) without the client needing to re-upload each time.
*/
import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
const routes = new Hono<{ Variables: AuthVariables }>();
// Max upload size for me-images. 10MB matches /picture/upload — same
// real-world phone-camera PNG range, same mana-media pipeline downstream.
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
routes.post('/me-images/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();
// `app='me'` tags the media_references row so a later
// GET /api/v1/media?app=me&userId=X can list all me-images,
// and the /v1/images/edits path can verify ownership in O(1).
const result = await uploadImageToMedia(buffer, file.name, {
app: 'me',
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 profileRoutes };