From e2b5ac38cb5077bc5175bfd1cecd3d9dd4146860 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 14:38:20 +0200 Subject: [PATCH] feat(profile): migrate auth.users.image into meImages + avatar autosync (M2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-follow-up to M1's soft Dexie schema landing (plan docs/plans/me-images-and-reference-generation.md). After this commit the source of truth for the avatar is meImages(primaryFor='avatar'); auth.users.image becomes a derived mirror that gets pushed back to Better Auth whenever the primary changes. Changes: - New migration/legacy-avatar.ts: one-shot, idempotent bootstrap. On first visit to /profile/me-images it reads profile.image via profileService.getProfile() and writes a single meImage with kind='face', primaryFor='avatar', usage.aiReference=false. The mediaId is a sentinel `legacy-avatar:` — the original bytes never went through mana-media, so verifyMediaOwnership (M3) will naturally bounce if the user ever flips aiReference on without re-uploading. Guarded per user via localStorage + existing-avatar-holder check so reruns are no-ops. - Store avatar autosync: setPrimary and deleteMeImage now push meImages(primaryFor='avatar').publicUrl back to profileService.updateProfile({ image }). The avatar slot is coupled to face-ref — setting a new face-ref primary also claims the avatar on the same row, so users don't need a second UI control to keep their profile picture fresh. Failures are logged but swallowed; meImages stays authoritative for in-app rendering. - MeImagesView triggers the migration once on mount. - EditProfileModal replaces the broken inline avatar upload (the old POST /api/v1/storage/avatar/upload endpoint never existed in the unified API) with a read-only preview + a button that closes the modal and navigates to /profile/me-images. Name + email flows are untouched. - profileService.uploadAvatar + AvatarUploadResponse + its test are deleted (no callers left after the modal rewrite). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mana/apps/web/src/lib/api/profile.test.ts | 30 --- apps/mana/apps/web/src/lib/api/profile.ts | 26 +-- .../profile/EditProfileModal.svelte | 185 ++++-------------- .../lib/modules/profile/MeImagesView.svelte | 10 + .../profile/migration/legacy-avatar.ts | 87 ++++++++ .../profile/stores/me-images.svelte.ts | 89 +++++++-- 6 files changed, 211 insertions(+), 216 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts diff --git a/apps/mana/apps/web/src/lib/api/profile.test.ts b/apps/mana/apps/web/src/lib/api/profile.test.ts index 1352ca6f3..577433633 100644 --- a/apps/mana/apps/web/src/lib/api/profile.test.ts +++ b/apps/mana/apps/web/src/lib/api/profile.test.ts @@ -157,34 +157,4 @@ describe('profileService', () => { ); }); }); - - describe('uploadAvatar', () => { - it('should upload avatar to API and update profile', async () => { - const mockFile = new File(['image-data'], 'avatar.png', { type: 'image/png' }); - - global.fetch = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ url: 'https://media.mana.how/avatar.png', mediaId: 'media-1' }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - success: true, - user: { id: 'user-1', name: 'Test', email: 'test@mana.how' }, - }), - }); - - const result = await profileService.uploadAvatar(mockFile); - - expect(result.success).toBe(true); - expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost:3060/api/v1/storage/avatar/upload', - expect.objectContaining({ method: 'POST' }) - ); - }); - }); }); diff --git a/apps/mana/apps/web/src/lib/api/profile.ts b/apps/mana/apps/web/src/lib/api/profile.ts index 51e7e9446..e7d0142dd 100644 --- a/apps/mana/apps/web/src/lib/api/profile.ts +++ b/apps/mana/apps/web/src/lib/api/profile.ts @@ -4,7 +4,7 @@ */ import { authStore } from '$lib/stores/auth.svelte'; -import { getManaAuthUrl, getManaApiUrl } from './config'; +import { getManaAuthUrl } from './config'; // Types export interface UserProfile { @@ -36,11 +36,6 @@ export interface ChangeEmailRequest { newEmail: string; } -export interface AvatarUploadResponse { - url: string; - mediaId: string; -} - // Helper function for authenticated requests async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise { const token = await authStore.getValidToken(); @@ -114,23 +109,4 @@ export const profileService = { body: JSON.stringify(data), }); }, - - /** - * Upload avatar file directly, then update profile - */ - async uploadAvatar(file: File): Promise<{ success: boolean; user: UserProfile }> { - const token = await authStore.getValidToken(); - const formData = new FormData(); - formData.append('file', file); - - const uploadResponse = await fetch(`${getManaApiUrl()}/api/v1/storage/avatar/upload`, { - method: 'POST', - headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, - body: formData, - }); - - if (!uploadResponse.ok) throw new Error('Avatar-Upload fehlgeschlagen'); - const { url } = (await uploadResponse.json()) as AvatarUploadResponse; - return this.updateProfile({ image: url }); - }, }; diff --git a/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte b/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte index 51d8754f1..914e9ffc9 100644 --- a/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte +++ b/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte @@ -1,5 +1,6 @@