mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:41:10 +02:00
feat(profile): migrate auth.users.image into meImages + avatar autosync (M2.5)
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:<uid>` — 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) <noreply@anthropic.com>
This commit is contained in:
parent
57be0f61b1
commit
e2b5ac38cb
6 changed files with 211 additions and 216 deletions
|
|
@ -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' })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { getManaAuthUrl, getManaApiUrl } from './config';
|
import { getManaAuthUrl } from './config';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
|
|
@ -36,11 +36,6 @@ export interface ChangeEmailRequest {
|
||||||
newEmail: string;
|
newEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvatarUploadResponse {
|
|
||||||
url: string;
|
|
||||||
mediaId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function for authenticated requests
|
// Helper function for authenticated requests
|
||||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = await authStore.getValidToken();
|
const token = await authStore.getValidToken();
|
||||||
|
|
@ -114,23 +109,4 @@ export const profileService = {
|
||||||
body: JSON.stringify(data),
|
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 });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { profileService, type UserProfile } from '$lib/api/profile';
|
import { profileService, type UserProfile } from '$lib/api/profile';
|
||||||
import { PencilSimple } from '@mana/shared-icons';
|
import { PencilSimple } from '@mana/shared-icons';
|
||||||
|
|
||||||
|
|
@ -17,13 +18,7 @@
|
||||||
let editingEmail = $state(false);
|
let editingEmail = $state(false);
|
||||||
let emailSent = $state(false);
|
let emailSent = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let uploadingAvatar = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let avatarPreview = $state<string | null>(null);
|
|
||||||
let selectedFile = $state<File | null>(null);
|
|
||||||
|
|
||||||
// File input ref
|
|
||||||
let fileInput = $state<HTMLInputElement | undefined>(undefined);
|
|
||||||
|
|
||||||
// Initialize form when modal opens
|
// Initialize form when modal opens
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -32,57 +27,19 @@
|
||||||
newEmail = '';
|
newEmail = '';
|
||||||
editingEmail = false;
|
editingEmail = false;
|
||||||
emailSent = false;
|
emailSent = false;
|
||||||
avatarPreview = user.image || null;
|
|
||||||
selectedFile = null;
|
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleBackdropClick(event: MouseEvent) {
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
if (event.target === event.currentTarget && !saving && !uploadingAvatar) {
|
if (event.target === event.currentTarget && !saving) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(event: Event) {
|
function openMeImages() {
|
||||||
const input = event.target as HTMLInputElement;
|
onClose();
|
||||||
const file = input.files?.[0];
|
goto('/profile/me-images');
|
||||||
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
error = 'Bitte wähle ein Bild aus (JPG, PNG, GIF, WebP)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (max 5MB)
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
error = 'Das Bild darf maximal 5MB groß sein';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFile = file;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
// Create preview
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
avatarPreview = e.target?.result as string;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerFileInput() {
|
|
||||||
fileInput?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAvatar() {
|
|
||||||
selectedFile = null;
|
|
||||||
avatarPreview = null;
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
|
|
@ -96,40 +53,16 @@
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let updatedUser: UserProfile;
|
// Avatar is now managed exclusively via /profile/me-images (plan
|
||||||
|
// M2.5): setting a primary face in meImages syncs the URL back
|
||||||
// If new avatar selected, upload first
|
// to auth.users.image. This modal only covers the name here.
|
||||||
if (selectedFile) {
|
const result = await profileService.updateProfile({ name: name.trim() });
|
||||||
uploadingAvatar = true;
|
onSuccess(result.user);
|
||||||
const result = await profileService.uploadAvatar(selectedFile);
|
|
||||||
updatedUser = result.user;
|
|
||||||
uploadingAvatar = false;
|
|
||||||
|
|
||||||
// Now update name if changed
|
|
||||||
if (name.trim() !== updatedUser.name) {
|
|
||||||
const nameResult = await profileService.updateProfile({ name: name.trim() });
|
|
||||||
updatedUser = nameResult.user;
|
|
||||||
}
|
|
||||||
} else if (avatarPreview === null && user?.image) {
|
|
||||||
// Avatar was removed
|
|
||||||
const result = await profileService.updateProfile({
|
|
||||||
name: name.trim(),
|
|
||||||
image: '',
|
|
||||||
});
|
|
||||||
updatedUser = result.user;
|
|
||||||
} else {
|
|
||||||
// Just update name
|
|
||||||
const result = await profileService.updateProfile({ name: name.trim() });
|
|
||||||
updatedUser = result.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess(updatedUser);
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $_('common.error_saving');
|
error = e instanceof Error ? e.message : $_('common.error_saving');
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
uploadingAvatar = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +102,7 @@
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
onkeydown={(e) => e.key === 'Escape' && !saving && !uploadingAvatar && onClose()}
|
onkeydown={(e) => e.key === 'Escape' && !saving && onClose()}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
|
|
@ -188,79 +121,37 @@
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
<form onsubmit={handleSubmit}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Avatar Upload -->
|
<!-- Profilbild (read-only preview + link to Meine Bilder) -->
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium mb-2">Profilbild</span>
|
<span class="block text-sm font-medium mb-2">Profilbild</span>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Avatar Preview -->
|
{#if user?.image}
|
||||||
<div class="relative">
|
<img
|
||||||
{#if avatarPreview}
|
src={user.image}
|
||||||
<img
|
alt="Avatar"
|
||||||
src={avatarPreview}
|
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||||
alt="Avatar"
|
|
||||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-semibold"
|
|
||||||
>
|
|
||||||
{getInitials(name || user?.name || 'U')}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if uploadingAvatar}
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg class="animate-spin h-6 w-6 text-white" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload/Remove Buttons -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onchange={handleFileSelect}
|
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xl font-semibold"
|
||||||
|
>
|
||||||
|
{getInitials(name || user?.name || 'U')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={triggerFileInput}
|
onclick={openMeImages}
|
||||||
disabled={saving || uploadingAvatar}
|
disabled={saving}
|
||||||
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 text-left"
|
||||||
>
|
>
|
||||||
Bild auswählen
|
In „Meine Bilder" verwalten →
|
||||||
</button>
|
</button>
|
||||||
{#if avatarPreview}
|
<p class="text-xs text-muted-foreground">
|
||||||
<button
|
Dein Profilbild folgt dem Gesichts-Primärbild unter Meine Bilder.
|
||||||
type="button"
|
</p>
|
||||||
onclick={removeAvatar}
|
|
||||||
disabled={saving || uploadingAvatar}
|
|
||||||
class="px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">JPG, PNG, GIF oder WebP. Max. 5MB.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
|
|
@ -332,7 +223,7 @@
|
||||||
newEmail = '';
|
newEmail = '';
|
||||||
error = null;
|
error = null;
|
||||||
}}
|
}}
|
||||||
disabled={saving || uploadingAvatar}
|
disabled={saving}
|
||||||
class="px-3 py-2 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 whitespace-nowrap"
|
class="px-3 py-2 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Ändern
|
Ändern
|
||||||
|
|
@ -351,7 +242,7 @@
|
||||||
id="profile-name"
|
id="profile-name"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
disabled={saving || uploadingAvatar}
|
disabled={saving}
|
||||||
placeholder="Dein Name"
|
placeholder="Dein Name"
|
||||||
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
|
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
|
@ -370,17 +261,17 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
disabled={saving || uploadingAvatar}
|
disabled={saving}
|
||||||
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{$_('common.cancel')}
|
{$_('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving || uploadingAvatar}
|
disabled={saving}
|
||||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{#if saving || uploadingAvatar}
|
{#if saving}
|
||||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
<circle
|
<circle
|
||||||
class="opacity-25"
|
class="opacity-25"
|
||||||
|
|
@ -396,7 +287,7 @@
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{uploadingAvatar ? $_('common.uploading') : $_('common.saving')}</span>
|
<span>{$_('common.saving')}</span>
|
||||||
{:else}
|
{:else}
|
||||||
{$_('common.save')}
|
{$_('common.save')}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
live on the tile/slot components; this file just orchestrates.
|
live on the tile/slot components; this file just orchestrates.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Info, Sparkle } from '@mana/shared-icons';
|
import { Info, Sparkle } from '@mana/shared-icons';
|
||||||
import MeImageSlotCard from './components/MeImageSlotCard.svelte';
|
import MeImageSlotCard from './components/MeImageSlotCard.svelte';
|
||||||
import MeImageTile from './components/MeImageTile.svelte';
|
import MeImageTile from './components/MeImageTile.svelte';
|
||||||
|
|
@ -21,8 +22,17 @@
|
||||||
import { useAllMeImages, useImageByPrimary } from './queries';
|
import { useAllMeImages, useImageByPrimary } from './queries';
|
||||||
import { meImagesStore } from './stores/me-images.svelte';
|
import { meImagesStore } from './stores/me-images.svelte';
|
||||||
import { readImageDimensions, uploadMeImageFile } from './api/me-images';
|
import { readImageDimensions, uploadMeImageFile } from './api/me-images';
|
||||||
|
import { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar';
|
||||||
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
|
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
|
||||||
|
|
||||||
|
// One-shot bootstrap: pull the pre-M1 auth.users.image into meImages
|
||||||
|
// as the avatar primary. Idempotent — see migration/legacy-avatar.ts.
|
||||||
|
onMount(() => {
|
||||||
|
migrateLegacyAvatarIfNeeded().catch((err) => {
|
||||||
|
console.error('[profile] legacy avatar migration failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const allImages$ = useAllMeImages();
|
const allImages$ = useAllMeImages();
|
||||||
const faceSlot$ = useImageByPrimary('face-ref');
|
const faceSlot$ = useImageByPrimary('face-ref');
|
||||||
const bodySlot$ = useImageByPrimary('body-ref');
|
const bodySlot$ = useImageByPrimary('body-ref');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* One-shot migration of the legacy `auth.users.image` URL into a
|
||||||
|
* meImages row with `primaryFor='avatar'`. Runs the first time the
|
||||||
|
* user opens /profile/me-images after M2.5 ships; idempotent after
|
||||||
|
* that (localStorage guard + primary-slot existence check).
|
||||||
|
*
|
||||||
|
* Why this lives as a client-side bootstrap rather than a Dexie
|
||||||
|
* db.version() upgrade: the legacy value lives in Better Auth
|
||||||
|
* (services/mana-auth), not in Dexie. A schema-upgrade hook has no
|
||||||
|
* way to reach it without a network call, and running network calls
|
||||||
|
* from inside a Dexie version upgrade is exactly the kind of thing
|
||||||
|
* that breaks silently on slow links. A mount-time bootstrap gives
|
||||||
|
* us explicit error handling + a retry path (next visit).
|
||||||
|
*
|
||||||
|
* The migrated row carries a sentinel mediaId (`legacy-avatar:<uid>`)
|
||||||
|
* because the original bytes were not uploaded through mana-media —
|
||||||
|
* they live wherever the old avatar upload endpoint put them. As a
|
||||||
|
* result, this row intentionally fails M3's verifyMediaOwnership if
|
||||||
|
* the user ever flips its `usage.aiReference` on and tries to use it
|
||||||
|
* for generation. That is correct: legacy avatars shouldn't silently
|
||||||
|
* start feeding OpenAI without an explicit re-upload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { profileService } from '$lib/api/profile';
|
||||||
|
import { meImagesTable } from '../collections';
|
||||||
|
import { meImagesStore } from '../stores/me-images.svelte';
|
||||||
|
|
||||||
|
export async function migrateLegacyAvatarIfNeeded(): Promise<void> {
|
||||||
|
const user = authStore.user;
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
const flagKey = `mana.profile.avatarMigration.${user.id}`;
|
||||||
|
if (typeof localStorage !== 'undefined' && localStorage.getItem(flagKey)) return;
|
||||||
|
|
||||||
|
// Already have an avatar-holder? Mark done and skip. This also
|
||||||
|
// covers the case where a user had their primary set in a prior
|
||||||
|
// browser — after sync catches up, the row is here and we should
|
||||||
|
// not create a duplicate.
|
||||||
|
const existing = await meImagesTable.where('primaryFor').equals('avatar').toArray();
|
||||||
|
if (existing.some((row) => !row.deletedAt)) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(flagKey, '1');
|
||||||
|
} catch {
|
||||||
|
// localStorage blocked — fine, next visit re-checks Dexie.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile;
|
||||||
|
try {
|
||||||
|
profile = await profileService.getProfile();
|
||||||
|
} catch {
|
||||||
|
// Offline or Better Auth down; try again on next visit.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!profile.image) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(flagKey, '1');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await meImagesStore.createMeImage({
|
||||||
|
kind: 'face',
|
||||||
|
// Sentinel mediaId: not a real mana-media reference. The generate-
|
||||||
|
// with-reference path (M3) gates on MediaClient.list({app:'me'}),
|
||||||
|
// so this id will naturally bounce if ever used for generation.
|
||||||
|
mediaId: `legacy-avatar:${user.id}`,
|
||||||
|
storagePath: profile.image,
|
||||||
|
publicUrl: profile.image,
|
||||||
|
thumbnailUrl: profile.image,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
label: 'Bisheriges Profilbild',
|
||||||
|
usage: { aiReference: false, showInProfile: true },
|
||||||
|
primaryFor: 'avatar',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(flagKey, '1');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import { encryptRecord } from '$lib/data/crypto';
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
import { emitDomainEvent } from '$lib/data/events';
|
import { emitDomainEvent } from '$lib/data/events';
|
||||||
|
import { profileService } from '$lib/api/profile';
|
||||||
import { meImagesTable } from '../collections';
|
import { meImagesTable } from '../collections';
|
||||||
import { toMeImage } from '../types';
|
import { toMeImage } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -47,6 +48,47 @@ function defaultUsage(override?: Partial<MeImageUsage>): MeImageUsage {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After any primary-avatar change, push the current holder's publicUrl
|
||||||
|
* back to Better Auth so `auth.users.image` stays in lockstep. Plan
|
||||||
|
* M2.5 — this is the only path by which auth.users.image ever gets
|
||||||
|
* written from now on; EditProfileModal's legacy inline upload is
|
||||||
|
* gone in the same commit.
|
||||||
|
*
|
||||||
|
* Best-effort: failures are logged and swallowed. The meImages row is
|
||||||
|
* authoritative for the app's own avatar rendering, so a stale
|
||||||
|
* auth.users.image is a cross-session degradation, not data loss.
|
||||||
|
*/
|
||||||
|
async function syncAvatarToAuth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows = await meImagesTable.where('primaryFor').equals('avatar').toArray();
|
||||||
|
const holder = rows.find((row) => !row.deletedAt);
|
||||||
|
const nextImage = holder?.publicUrl ?? '';
|
||||||
|
await profileService.updateProfile({ image: nextImage });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[profile] syncing avatar to Better Auth failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: swap the primary holder of `slot` to `id` (or clear it
|
||||||
|
* when id is null) in one transaction. Extracted so `setPrimary` can
|
||||||
|
* reuse it when avatar silently follows face-ref.
|
||||||
|
*/
|
||||||
|
async function setPrimaryInTx(id: string | null, slot: MeImagePrimarySlot): Promise<void> {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
await meImagesTable.db.transaction('rw', meImagesTable, async () => {
|
||||||
|
const current = await meImagesTable.where('primaryFor').equals(slot).toArray();
|
||||||
|
for (const row of current) {
|
||||||
|
if (row.id === id) continue;
|
||||||
|
await meImagesTable.update(row.id, { primaryFor: null, updatedAt: nowIso });
|
||||||
|
}
|
||||||
|
if (id !== null) {
|
||||||
|
await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const meImagesStore = {
|
export const meImagesStore = {
|
||||||
async createMeImage(input: CreateMeImageInput): Promise<MeImage> {
|
async createMeImage(input: CreateMeImageInput): Promise<MeImage> {
|
||||||
const newLocal: LocalMeImage = {
|
const newLocal: LocalMeImage = {
|
||||||
|
|
@ -115,29 +157,47 @@ export const meImagesStore = {
|
||||||
*
|
*
|
||||||
* Pass `null` as the second argument to unset the slot on `id`
|
* Pass `null` as the second argument to unset the slot on `id`
|
||||||
* without claiming it for anyone else.
|
* without claiming it for anyone else.
|
||||||
|
*
|
||||||
|
* The `avatar` slot is coupled to `face-ref`: setting a new
|
||||||
|
* face-ref also claims the avatar on the same row (plan M2.5
|
||||||
|
* decision — keeps auth.users.image in lockstep with the user's
|
||||||
|
* current reference face without a second UI control). Explicit
|
||||||
|
* avatar-only setPrimary calls (e.g. the legacy migration
|
||||||
|
* bootstrap) still work and only touch the avatar slot.
|
||||||
*/
|
*/
|
||||||
async setPrimary(id: string, slot: MeImagePrimarySlot | null): Promise<void> {
|
async setPrimary(id: string, slot: MeImagePrimarySlot | null): Promise<void> {
|
||||||
const nowIso = new Date().toISOString();
|
if (slot === null) {
|
||||||
await meImagesTable.db.transaction('rw', meImagesTable, async () => {
|
// Clear whatever this row currently holds. If it was the
|
||||||
if (slot === null) {
|
// avatar, we also need to sync that out to Better Auth.
|
||||||
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
|
const existing = await meImagesTable.get(id);
|
||||||
return;
|
const wasAvatar = existing?.primaryFor === 'avatar';
|
||||||
}
|
const nowIso = new Date().toISOString();
|
||||||
// Clear any current holder of this slot (usually zero or one).
|
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
|
||||||
const current = await meImagesTable.where('primaryFor').equals(slot).toArray();
|
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
|
||||||
for (const row of current) {
|
meImageId: id,
|
||||||
if (row.id === id) continue;
|
slot: null,
|
||||||
await meImagesTable.update(row.id, { primaryFor: null, updatedAt: nowIso });
|
});
|
||||||
}
|
if (wasAvatar) await syncAvatarToAuth();
|
||||||
await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso });
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
await setPrimaryInTx(id, slot);
|
||||||
|
// Silent twin: a fresh face-ref is also the fresh avatar.
|
||||||
|
if (slot === 'face-ref') {
|
||||||
|
await setPrimaryInTx(id, 'avatar');
|
||||||
|
}
|
||||||
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
|
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
|
||||||
meImageId: id,
|
meImageId: id,
|
||||||
slot,
|
slot,
|
||||||
});
|
});
|
||||||
|
if (slot === 'avatar' || slot === 'face-ref') {
|
||||||
|
await syncAvatarToAuth();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteMeImage(id: string): Promise<void> {
|
async deleteMeImage(id: string): Promise<void> {
|
||||||
|
const existing = await meImagesTable.get(id);
|
||||||
|
const wasAvatar = existing?.primaryFor === 'avatar';
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
await meImagesTable.update(id, {
|
await meImagesTable.update(id, {
|
||||||
deletedAt: nowIso,
|
deletedAt: nowIso,
|
||||||
|
|
@ -148,5 +208,6 @@ export const meImagesStore = {
|
||||||
primaryFor: null,
|
primaryFor: null,
|
||||||
});
|
});
|
||||||
emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id });
|
emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id });
|
||||||
|
if (wasAvatar) await syncAvatarToAuth();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue