mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +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 { 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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { profileService, type UserProfile } from '$lib/api/profile';
|
||||
import { PencilSimple } from '@mana/shared-icons';
|
||||
|
||||
|
|
@ -17,13 +18,7 @@
|
|||
let editingEmail = $state(false);
|
||||
let emailSent = $state(false);
|
||||
let saving = $state(false);
|
||||
let uploadingAvatar = $state(false);
|
||||
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
|
||||
$effect(() => {
|
||||
|
|
@ -32,57 +27,19 @@
|
|||
newEmail = '';
|
||||
editingEmail = false;
|
||||
emailSent = false;
|
||||
avatarPreview = user.image || null;
|
||||
selectedFile = null;
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && !saving && !uploadingAvatar) {
|
||||
if (event.target === event.currentTarget && !saving) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
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 = '';
|
||||
}
|
||||
function openMeImages() {
|
||||
onClose();
|
||||
goto('/profile/me-images');
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
|
|
@ -96,40 +53,16 @@
|
|||
error = null;
|
||||
|
||||
try {
|
||||
let updatedUser: UserProfile;
|
||||
|
||||
// If new avatar selected, upload first
|
||||
if (selectedFile) {
|
||||
uploadingAvatar = true;
|
||||
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);
|
||||
// Avatar is now managed exclusively via /profile/me-images (plan
|
||||
// M2.5): setting a primary face in meImages syncs the URL back
|
||||
// to auth.users.image. This modal only covers the name here.
|
||||
const result = await profileService.updateProfile({ name: name.trim() });
|
||||
onSuccess(result.user);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('common.error_saving');
|
||||
} finally {
|
||||
saving = false;
|
||||
uploadingAvatar = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +102,7 @@
|
|||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && !saving && !uploadingAvatar && onClose()}
|
||||
onkeydown={(e) => e.key === 'Escape' && !saving && onClose()}
|
||||
tabindex="-1"
|
||||
role="presentation"
|
||||
>
|
||||
|
|
@ -188,79 +121,37 @@
|
|||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="space-y-4">
|
||||
<!-- Avatar Upload -->
|
||||
<!-- Profilbild (read-only preview + link to Meine Bilder) -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium mb-2">Profilbild</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar Preview -->
|
||||
<div class="relative">
|
||||
{#if avatarPreview}
|
||||
<img
|
||||
src={avatarPreview}
|
||||
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}
|
||||
{#if user?.image}
|
||||
<img
|
||||
src={user.image}
|
||||
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}
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={triggerFileInput}
|
||||
disabled={saving || uploadingAvatar}
|
||||
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
|
||||
onclick={openMeImages}
|
||||
disabled={saving}
|
||||
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>
|
||||
{#if avatarPreview}
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Dein Profilbild folgt dem Gesichts-Primärbild unter Meine Bilder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">JPG, PNG, GIF oder WebP. Max. 5MB.</p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
|
|
@ -332,7 +223,7 @@
|
|||
newEmail = '';
|
||||
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"
|
||||
>
|
||||
Ändern
|
||||
|
|
@ -351,7 +242,7 @@
|
|||
id="profile-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={saving || uploadingAvatar}
|
||||
disabled={saving}
|
||||
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"
|
||||
/>
|
||||
|
|
@ -370,17 +261,17 @@
|
|||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{#if saving || uploadingAvatar}
|
||||
{#if saving}
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
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"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{uploadingAvatar ? $_('common.uploading') : $_('common.saving')}</span>
|
||||
<span>{$_('common.saving')}</span>
|
||||
{:else}
|
||||
{$_('common.save')}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
live on the tile/slot components; this file just orchestrates.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Info, Sparkle } from '@mana/shared-icons';
|
||||
import MeImageSlotCard from './components/MeImageSlotCard.svelte';
|
||||
import MeImageTile from './components/MeImageTile.svelte';
|
||||
|
|
@ -21,8 +22,17 @@
|
|||
import { useAllMeImages, useImageByPrimary } from './queries';
|
||||
import { meImagesStore } from './stores/me-images.svelte';
|
||||
import { readImageDimensions, uploadMeImageFile } from './api/me-images';
|
||||
import { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar';
|
||||
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 faceSlot$ = useImageByPrimary('face-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 { emitDomainEvent } from '$lib/data/events';
|
||||
import { profileService } from '$lib/api/profile';
|
||||
import { meImagesTable } from '../collections';
|
||||
import { toMeImage } from '../types';
|
||||
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 = {
|
||||
async createMeImage(input: CreateMeImageInput): Promise<MeImage> {
|
||||
const newLocal: LocalMeImage = {
|
||||
|
|
@ -115,29 +157,47 @@ export const meImagesStore = {
|
|||
*
|
||||
* Pass `null` as the second argument to unset the slot on `id`
|
||||
* 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> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await meImagesTable.db.transaction('rw', meImagesTable, async () => {
|
||||
if (slot === null) {
|
||||
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
|
||||
return;
|
||||
}
|
||||
// Clear any current holder of this slot (usually zero or one).
|
||||
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 });
|
||||
}
|
||||
await meImagesTable.update(id, { primaryFor: slot, updatedAt: nowIso });
|
||||
});
|
||||
if (slot === null) {
|
||||
// Clear whatever this row currently holds. If it was the
|
||||
// avatar, we also need to sync that out to Better Auth.
|
||||
const existing = await meImagesTable.get(id);
|
||||
const wasAvatar = existing?.primaryFor === 'avatar';
|
||||
const nowIso = new Date().toISOString();
|
||||
await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso });
|
||||
emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, {
|
||||
meImageId: id,
|
||||
slot: null,
|
||||
});
|
||||
if (wasAvatar) await syncAvatarToAuth();
|
||||
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, {
|
||||
meImageId: id,
|
||||
slot,
|
||||
});
|
||||
if (slot === 'avatar' || slot === 'face-ref') {
|
||||
await syncAvatarToAuth();
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMeImage(id: string): Promise<void> {
|
||||
const existing = await meImagesTable.get(id);
|
||||
const wasAvatar = existing?.primaryFor === 'avatar';
|
||||
const nowIso = new Date().toISOString();
|
||||
await meImagesTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
|
|
@ -148,5 +208,6 @@ export const meImagesStore = {
|
|||
primaryFor: null,
|
||||
});
|
||||
emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id });
|
||||
if (wasAvatar) await syncAvatarToAuth();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue