mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
fix(mana/web): sprint 2 — auth-aware data layer + guest migration
- Single source of truth for the active user via data/current-user.ts; layout pushes authStore.user.id into it on every auth state change. - Dexie creating-hook auto-stamps userId from getEffectiveUserId(); the updating-hook strips userId from modifications so records are effectively user-immutable after creation. - BaseRecord gains an optional userId so module types inherit it without per-module declarations. All hardcoded 'guest'/'local' fallbacks in module type-converters and session timer stores are deleted; the dead userId field is removed from the public view types where it was unused (Task, Conversation, Template, Deck, Plant, Contact, etc.). - New guest-migration.ts: on first authenticated session, walks every sync-tracked table, deletes guest-owned records and re-adds them so the creating-hook re-stamps with the real user id and produces fresh insert pending-changes with the full payload. Stale guest pending- changes are cleared up-front. - Drive-by: root onMount now returns its cleanup synchronously; the previous async form silently dropped the cleanup callback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0909538827
commit
28942abede
24 changed files with 182 additions and 47 deletions
36
apps/mana/apps/web/src/lib/data/current-user.ts
Normal file
36
apps/mana/apps/web/src/lib/data/current-user.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Single source of truth for the current authenticated user id.
|
||||||
|
*
|
||||||
|
* Why a separate module?
|
||||||
|
* The data layer (database.ts hooks) needs to know who is writing each
|
||||||
|
* record so it can stamp `userId` automatically. Importing the auth store
|
||||||
|
* directly would couple the data layer to UI state and create a circular
|
||||||
|
* dependency. Instead, the root layout pushes the current user id here on
|
||||||
|
* every auth state change.
|
||||||
|
*
|
||||||
|
* Guest mode: when no user is signed in, records are stamped with the
|
||||||
|
* `GUEST_USER_ID` sentinel. The mana-sync backend treats these as anonymous
|
||||||
|
* and rejects them at the RLS layer once auth is required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GUEST_USER_ID = 'guest';
|
||||||
|
|
||||||
|
let currentUserId: string | null = null;
|
||||||
|
|
||||||
|
/** Updates the active user. Pass `null` for sign-out / guest. */
|
||||||
|
export function setCurrentUserId(id: string | null): void {
|
||||||
|
currentUserId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the active user id, or `null` if unauthenticated. */
|
||||||
|
export function getCurrentUserId(): string | null {
|
||||||
|
return currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user id to stamp on local records: real user when signed in,
|
||||||
|
* `GUEST_USER_ID` otherwise. Always non-null so it can be used as a key.
|
||||||
|
*/
|
||||||
|
export function getEffectiveUserId(): string {
|
||||||
|
return currentUserId ?? GUEST_USER_ID;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import Dexie, { type EntityTable } from 'dexie';
|
||||||
import { trackFirstContent } from '$lib/stores/funnel-tracking';
|
import { trackFirstContent } from '$lib/stores/funnel-tracking';
|
||||||
import { fire as fireTrigger } from '$lib/triggers/registry';
|
import { fire as fireTrigger } from '$lib/triggers/registry';
|
||||||
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
|
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
|
||||||
|
import { getEffectiveUserId } from './current-user';
|
||||||
|
|
||||||
// ─── Database ──────────────────────────────────────────────
|
// ─── Database ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -576,6 +577,14 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
||||||
if (_applyingServerChanges) return;
|
if (_applyingServerChanges) return;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Auto-stamp the active user. Module stores never set userId themselves,
|
||||||
|
// preventing accidental impersonation and removing all hardcoded
|
||||||
|
// 'guest'/'local' fallbacks scattered across query files.
|
||||||
|
const objRecord = obj as Record<string, unknown>;
|
||||||
|
if (objRecord.userId === undefined || objRecord.userId === null) {
|
||||||
|
objRecord.userId = getEffectiveUserId();
|
||||||
|
}
|
||||||
|
|
||||||
// Stamp every real field with the create-time so future LWW comparisons
|
// Stamp every real field with the create-time so future LWW comparisons
|
||||||
// have a baseline. Mutates obj in place — Dexie persists the mutation.
|
// have a baseline. Mutates obj in place — Dexie persists the mutation.
|
||||||
const ft: Record<string, string> = {};
|
const ft: Record<string, string> = {};
|
||||||
|
|
@ -583,7 +592,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
||||||
if (isInternalKey(key)) continue;
|
if (isInternalKey(key)) continue;
|
||||||
ft[key] = now;
|
ft[key] = now;
|
||||||
}
|
}
|
||||||
(obj as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY] = ft;
|
objRecord[FIELD_TIMESTAMPS_KEY] = ft;
|
||||||
|
|
||||||
// Build payload for pending-change WITHOUT the internal timestamp map
|
// Build payload for pending-change WITHOUT the internal timestamp map
|
||||||
const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record<string, unknown>;
|
const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record<string, unknown>;
|
||||||
|
|
@ -613,6 +622,12 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
|
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
|
||||||
|
|
||||||
|
// userId is immutable after creation. Silently strip any attempt to
|
||||||
|
// reassign it from a local update so a buggy or malicious caller can
|
||||||
|
// never re-parent records to a different user.
|
||||||
|
const mods = modifications as Record<string, unknown>;
|
||||||
|
if ('userId' in mods) delete mods.userId;
|
||||||
|
|
||||||
// Merge field timestamps: keep existing, overwrite for each modified field
|
// Merge field timestamps: keep existing, overwrite for each modified field
|
||||||
const existingFT =
|
const existingFT =
|
||||||
((obj as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY] as
|
((obj as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY] as
|
||||||
|
|
|
||||||
90
apps/mana/apps/web/src/lib/data/guest-migration.ts
Normal file
90
apps/mana/apps/web/src/lib/data/guest-migration.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Guest → User data migration.
|
||||||
|
*
|
||||||
|
* In guest mode, the Dexie creating-hook stamps every record with
|
||||||
|
* `userId = GUEST_USER_ID`. Sync push is also a no-op while there is no
|
||||||
|
* auth token, so the records live purely in the local IndexedDB.
|
||||||
|
*
|
||||||
|
* When the user signs in for the first time we want their existing local
|
||||||
|
* data to be theirs. This module walks every sync-tracked table, finds
|
||||||
|
* records owned by `guest`, and re-creates them under the active user id.
|
||||||
|
*
|
||||||
|
* Why delete-and-re-add instead of an in-place update?
|
||||||
|
* The Dexie updating-hook deliberately strips `userId` from modifications
|
||||||
|
* (immutable once created), and even if we bypassed that, an `op: 'update'`
|
||||||
|
* pending-change would only ship the userId field to the server — other
|
||||||
|
* clients pulling the change would see a fresh record with just an id and
|
||||||
|
* userId. By deleting and re-adding we generate a clean `op: 'insert'`
|
||||||
|
* with the full record payload.
|
||||||
|
*
|
||||||
|
* Deleting in guest mode is safe because nothing was ever pushed to the
|
||||||
|
* server: `_pendingChanges` is cleared as part of the migration too, so the
|
||||||
|
* delete is purely local and never reaches the sync layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, SYNC_APP_MAP, FIELD_TIMESTAMPS_KEY } from './database';
|
||||||
|
import { GUEST_USER_ID } from './current-user';
|
||||||
|
|
||||||
|
export interface GuestMigrationResult {
|
||||||
|
migratedRecords: number;
|
||||||
|
tablesTouched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-stamps every `userId === 'guest'` record under the active user id.
|
||||||
|
*
|
||||||
|
* Caller must ensure `setCurrentUserId(newUserId)` was already invoked so
|
||||||
|
* that the Dexie creating-hook picks up the right id when re-inserting.
|
||||||
|
*/
|
||||||
|
export async function migrateGuestDataToUser(newUserId: string): Promise<GuestMigrationResult> {
|
||||||
|
if (!newUserId || newUserId === GUEST_USER_ID) {
|
||||||
|
return { migratedRecords: 0, tablesTouched: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop any pending changes accumulated during guest mode — they were
|
||||||
|
// never pushed (no auth token) and reference the old guest userId. The
|
||||||
|
// re-inserts below will produce fresh pending changes that should NOT be
|
||||||
|
// wiped, so this MUST happen before the migration loop.
|
||||||
|
await db.table('_pendingChanges').clear();
|
||||||
|
|
||||||
|
let migratedRecords = 0;
|
||||||
|
let tablesTouched = 0;
|
||||||
|
|
||||||
|
for (const tables of Object.values(SYNC_APP_MAP)) {
|
||||||
|
for (const tableName of tables) {
|
||||||
|
const table = db.table(tableName);
|
||||||
|
|
||||||
|
// Filter scan: userId is not indexed (and we don't want to widen the
|
||||||
|
// schema for a one-shot migration). Tables are typically small at
|
||||||
|
// this point because guest mode only stores what one person typed.
|
||||||
|
const guestRecords = await table
|
||||||
|
.filter((r: unknown) => (r as Record<string, unknown>).userId === GUEST_USER_ID)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (guestRecords.length === 0) continue;
|
||||||
|
tablesTouched++;
|
||||||
|
|
||||||
|
// One transaction per table keeps the delete+add pair atomic and
|
||||||
|
// avoids leaving the table half-migrated if Dexie throws partway.
|
||||||
|
await db.transaction('rw', table, async () => {
|
||||||
|
for (const oldRecord of guestRecords) {
|
||||||
|
const record = oldRecord as Record<string, unknown>;
|
||||||
|
const id = record.id as string;
|
||||||
|
|
||||||
|
// Strip the bookkeeping fields the creating-hook will rebuild.
|
||||||
|
// Importantly, drop `userId` so the hook stamps the new id from
|
||||||
|
// getEffectiveUserId() instead of preserving 'guest'.
|
||||||
|
const { userId: _oldUser, [FIELD_TIMESTAMPS_KEY]: _oldFt, ...clean } = record;
|
||||||
|
void _oldUser;
|
||||||
|
void _oldFt;
|
||||||
|
|
||||||
|
await table.delete(id);
|
||||||
|
await table.add({ ...clean, id });
|
||||||
|
migratedRecords++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { migratedRecords, tablesTouched };
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import type { Calculation, SavedFormula } from '@calc/shared';
|
||||||
export function toCalculation(local: LocalCalculation): Calculation {
|
export function toCalculation(local: LocalCalculation): Calculation {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
userId: local.userId ?? '',
|
||||||
mode: local.mode,
|
mode: local.mode,
|
||||||
expression: local.expression,
|
expression: local.expression,
|
||||||
result: local.result,
|
result: local.result,
|
||||||
|
|
@ -24,7 +24,7 @@ export function toCalculation(local: LocalCalculation): Calculation {
|
||||||
export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
|
export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
userId: local.userId ?? '',
|
||||||
name: local.name,
|
name: local.name,
|
||||||
expression: local.expression,
|
expression: local.expression,
|
||||||
description: local.description ?? undefined,
|
description: local.description ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import type { LocalDeck, LocalCard, Deck, Card } from './types';
|
||||||
export function toDeck(local: LocalDeck): Deck {
|
export function toDeck(local: LocalDeck): Deck {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
title: local.name,
|
title: local.name,
|
||||||
description: local.description ?? undefined,
|
description: local.description ?? undefined,
|
||||||
color: local.color,
|
color: local.color,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export interface LocalCard extends BaseRecord {
|
||||||
|
|
||||||
export interface Deck {
|
export interface Deck {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import type {
|
||||||
export function toConversation(local: LocalConversation): Conversation {
|
export function toConversation(local: LocalConversation): Conversation {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: local.userId ?? 'guest',
|
|
||||||
modelId: local.modelId ?? '',
|
modelId: local.modelId ?? '',
|
||||||
templateId: local.templateId ?? undefined,
|
templateId: local.templateId ?? undefined,
|
||||||
spaceId: local.spaceId ?? undefined,
|
spaceId: local.spaceId ?? undefined,
|
||||||
|
|
@ -35,7 +34,6 @@ export function toConversation(local: LocalConversation): Conversation {
|
||||||
export function toTemplate(local: LocalTemplate): Template {
|
export function toTemplate(local: LocalTemplate): Template {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: local.userId ?? 'guest',
|
|
||||||
name: local.name,
|
name: local.name,
|
||||||
description: local.description || null,
|
description: local.description || null,
|
||||||
systemPrompt: local.systemPrompt,
|
systemPrompt: local.systemPrompt,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export interface LocalTemplate extends BaseRecord {
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
modelId: string;
|
modelId: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
|
@ -60,7 +59,6 @@ export interface Message {
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export function toContact(local: LocalContact): Contact {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
displayName,
|
displayName,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export interface LocalContact extends BaseRecord {
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
firstName?: string | null;
|
firstName?: string | null;
|
||||||
lastName?: string | null;
|
lastName?: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import type { BaseRecord } from '@mana/local-store';
|
||||||
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
export interface LocalMemo extends BaseRecord {
|
export interface LocalMemo extends BaseRecord {
|
||||||
userId?: string;
|
|
||||||
title: string | null;
|
title: string | null;
|
||||||
intro: string | null;
|
intro: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
|
|
@ -45,7 +44,6 @@ export interface LocalMemo extends BaseRecord {
|
||||||
|
|
||||||
export interface LocalMemory extends BaseRecord {
|
export interface LocalMemory extends BaseRecord {
|
||||||
memoId: string;
|
memoId: string;
|
||||||
userId?: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import type {
|
||||||
export function toPlant(local: LocalPlant): Plant {
|
export function toPlant(local: LocalPlant): Plant {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
name: local.name,
|
name: local.name,
|
||||||
scientificName: local.scientificName ?? undefined,
|
scientificName: local.scientificName ?? undefined,
|
||||||
commonName: local.commonName ?? undefined,
|
commonName: local.commonName ?? undefined,
|
||||||
|
|
@ -49,7 +48,6 @@ export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
plantId: local.plantId,
|
plantId: local.plantId,
|
||||||
userId: 'local',
|
|
||||||
storagePath: local.storagePath,
|
storagePath: local.storagePath,
|
||||||
publicUrl: local.publicUrl ?? undefined,
|
publicUrl: local.publicUrl ?? undefined,
|
||||||
filename: local.filename,
|
filename: local.filename,
|
||||||
|
|
@ -69,7 +67,6 @@ export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedu
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
plantId: local.plantId,
|
plantId: local.plantId,
|
||||||
userId: 'local',
|
|
||||||
frequencyDays: local.frequencyDays,
|
frequencyDays: local.frequencyDays,
|
||||||
lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined,
|
lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined,
|
||||||
nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined,
|
nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined,
|
||||||
|
|
@ -85,7 +82,6 @@ export function toWateringLog(local: LocalWateringLog): WateringLog {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
plantId: local.plantId,
|
plantId: local.plantId,
|
||||||
userId: 'local',
|
|
||||||
wateredAt: new Date(local.wateredAt),
|
wateredAt: new Date(local.wateredAt),
|
||||||
notes: local.notes ?? undefined,
|
notes: local.notes ?? undefined,
|
||||||
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
|
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ export interface LocalWateringLog extends BaseRecord {
|
||||||
|
|
||||||
export interface Plant {
|
export interface Plant {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
scientificName?: string;
|
scientificName?: string;
|
||||||
commonName?: string;
|
commonName?: string;
|
||||||
|
|
@ -83,7 +82,6 @@ export interface Plant {
|
||||||
export interface PlantPhoto {
|
export interface PlantPhoto {
|
||||||
id: string;
|
id: string;
|
||||||
plantId: string;
|
plantId: string;
|
||||||
userId: string;
|
|
||||||
storagePath: string;
|
storagePath: string;
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
@ -100,7 +98,6 @@ export interface PlantPhoto {
|
||||||
export interface WateringSchedule {
|
export interface WateringSchedule {
|
||||||
id: string;
|
id: string;
|
||||||
plantId: string;
|
plantId: string;
|
||||||
userId: string;
|
|
||||||
frequencyDays: number;
|
frequencyDays: number;
|
||||||
lastWateredAt?: Date;
|
lastWateredAt?: Date;
|
||||||
nextWateringAt?: Date;
|
nextWateringAt?: Date;
|
||||||
|
|
@ -113,7 +110,6 @@ export interface WateringSchedule {
|
||||||
export interface WateringLog {
|
export interface WateringLog {
|
||||||
id: string;
|
id: string;
|
||||||
plantId: string;
|
plantId: string;
|
||||||
userId: string;
|
|
||||||
wateredAt: Date;
|
wateredAt: Date;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type { LocalDeck, LocalSlide, Deck, Slide } from './types';
|
||||||
export function toDeck(local: LocalDeck): Deck {
|
export function toDeck(local: LocalDeck): Deck {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
title: local.title,
|
title: local.title,
|
||||||
description: local.description ?? undefined,
|
description: local.description ?? undefined,
|
||||||
themeId: local.themeId ?? undefined,
|
themeId: local.themeId ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ export interface SlideContent {
|
||||||
|
|
||||||
export interface Deck {
|
export interface Deck {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import type { LocalFile, LocalFolder, LocalFileTag } from './types';
|
||||||
|
|
||||||
export interface StorageFile {
|
export interface StorageFile {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
|
@ -30,7 +29,6 @@ export interface StorageFile {
|
||||||
|
|
||||||
export interface StorageFolder {
|
export interface StorageFolder {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
|
@ -46,7 +44,6 @@ export interface StorageFolder {
|
||||||
|
|
||||||
export interface StorageTag {
|
export interface StorageTag {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -57,7 +54,6 @@ export interface StorageTag {
|
||||||
export function toFile(local: LocalFile): StorageFile {
|
export function toFile(local: LocalFile): StorageFile {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
name: local.name,
|
name: local.name,
|
||||||
originalName: local.originalName,
|
originalName: local.originalName,
|
||||||
mimeType: local.mimeType,
|
mimeType: local.mimeType,
|
||||||
|
|
@ -77,7 +73,6 @@ export function toFile(local: LocalFile): StorageFile {
|
||||||
export function toFolder(local: LocalFolder): StorageFolder {
|
export function toFolder(local: LocalFolder): StorageFolder {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
name: local.name,
|
name: local.name,
|
||||||
description: local.description ?? null,
|
description: local.description ?? null,
|
||||||
color: local.color ?? null,
|
color: local.color ?? null,
|
||||||
|
|
@ -100,7 +95,6 @@ export function toTag(local: {
|
||||||
}): StorageTag {
|
}): StorageTag {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
name: local.name,
|
name: local.name,
|
||||||
color: local.color ?? null,
|
color: local.color ?? null,
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,6 @@ export function toSettings(local: LocalSettings): TimesSettings {
|
||||||
export function toAlarm(local: LocalAlarm): Alarm {
|
export function toAlarm(local: LocalAlarm): Alarm {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
label: local.label,
|
label: local.label,
|
||||||
time: local.time,
|
time: local.time,
|
||||||
enabled: local.enabled,
|
enabled: local.enabled,
|
||||||
|
|
@ -146,7 +145,6 @@ export function toAlarm(local: LocalAlarm): Alarm {
|
||||||
export function toCountdownTimer(local: LocalCountdownTimer): Timer {
|
export function toCountdownTimer(local: LocalCountdownTimer): Timer {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
label: local.label,
|
label: local.label,
|
||||||
durationSeconds: local.durationSeconds,
|
durationSeconds: local.durationSeconds,
|
||||||
remainingSeconds: local.remainingSeconds,
|
remainingSeconds: local.remainingSeconds,
|
||||||
|
|
@ -162,7 +160,6 @@ export function toCountdownTimer(local: LocalCountdownTimer): Timer {
|
||||||
export function toWorldClock(local: LocalWorldClock): WorldClock {
|
export function toWorldClock(local: LocalWorldClock): WorldClock {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
userId: 'local',
|
|
||||||
timezone: local.timezone,
|
timezone: local.timezone,
|
||||||
cityName: local.cityName,
|
cityName: local.cityName,
|
||||||
sortOrder: local.sortOrder,
|
sortOrder: local.sortOrder,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ export const sessionAlarmsStore = {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const alarm: Alarm = {
|
const alarm: Alarm = {
|
||||||
id: generateSessionId(),
|
id: generateSessionId(),
|
||||||
userId: 'guest',
|
|
||||||
label: input.label || null,
|
label: input.label || null,
|
||||||
time: input.time,
|
time: input.time,
|
||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ export const sessionCountdownTimersStore = {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const timer: Timer = {
|
const timer: Timer = {
|
||||||
id: generateSessionId(),
|
id: generateSessionId(),
|
||||||
userId: 'guest',
|
|
||||||
label: input.label || null,
|
label: input.label || null,
|
||||||
durationSeconds: input.durationSeconds,
|
durationSeconds: input.durationSeconds,
|
||||||
remainingSeconds: input.durationSeconds,
|
remainingSeconds: input.durationSeconds,
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,6 @@ export type TimerStatus = 'idle' | 'running' | 'paused' | 'finished';
|
||||||
|
|
||||||
export interface Alarm {
|
export interface Alarm {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
label: string | null;
|
label: string | null;
|
||||||
time: string; // HH:MM:SS format
|
time: string; // HH:MM:SS format
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -285,7 +284,6 @@ export interface UpdateAlarmInput {
|
||||||
|
|
||||||
export interface Timer {
|
export interface Timer {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
label: string | null;
|
label: string | null;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
remainingSeconds: number | null;
|
remainingSeconds: number | null;
|
||||||
|
|
@ -311,7 +309,6 @@ export interface UpdateTimerInput {
|
||||||
|
|
||||||
export interface WorldClock {
|
export interface WorldClock {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
timezone: string; // IANA timezone e.g. 'America/New_York'
|
timezone: string; // IANA timezone e.g. 'America/New_York'
|
||||||
cityName: string;
|
cityName: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export function toTask(local: LocalTask): Task {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
projectId: (local as Record<string, unknown>).projectId as string | null | undefined,
|
projectId: (local as Record<string, unknown>).projectId as string | null | undefined,
|
||||||
userId: local.userId ?? 'guest',
|
|
||||||
title: local.title,
|
title: local.title,
|
||||||
description: local.description,
|
description: local.description,
|
||||||
dueDate: local.dueDate,
|
dueDate: local.dueDate,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
export interface LocalTask extends BaseRecord {
|
export interface LocalTask extends BaseRecord {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
userId?: string;
|
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
|
|
@ -45,7 +44,6 @@ export interface LocalTaskTag extends BaseRecord {
|
||||||
|
|
||||||
export interface LocalReminder extends BaseRecord {
|
export interface LocalReminder extends BaseRecord {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
userId?: string;
|
|
||||||
minutesBefore: number;
|
minutesBefore: number;
|
||||||
type: 'push' | 'email' | 'both';
|
type: 'push' | 'email' | 'both';
|
||||||
status: 'pending' | 'sent' | 'failed';
|
status: 'pending' | 'sent' | 'failed';
|
||||||
|
|
@ -101,7 +99,6 @@ export interface LocalTodoProject extends BaseRecord {
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
userId: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,51 @@
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { networkStore } from '$lib/stores/network.svelte';
|
import { networkStore } from '$lib/stores/network.svelte';
|
||||||
import { loadAutomations } from '$lib/triggers';
|
import { loadAutomations } from '$lib/triggers';
|
||||||
|
import { setCurrentUserId } from '$lib/data/current-user';
|
||||||
|
import { migrateGuestDataToUser } from '$lib/data/guest-migration';
|
||||||
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
|
||||||
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
||||||
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
|
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
// Tracks whether we have already attempted the guest → user migration in
|
||||||
|
// this app load. The migration is idempotent (no guest records → no-op)
|
||||||
|
// so this just prevents redundant table scans on every auth state change.
|
||||||
|
let guestMigrationAttempted = false;
|
||||||
|
|
||||||
|
// Push the active user id into the data layer whenever auth state changes.
|
||||||
|
// The Dexie creating-hook reads this to auto-stamp `userId` on every record,
|
||||||
|
// so module stores never need to know who the current user is.
|
||||||
|
$effect(() => {
|
||||||
|
const userId = authStore.user?.id ?? null;
|
||||||
|
setCurrentUserId(userId);
|
||||||
|
|
||||||
|
// First time we see an authenticated user in this session, lift any
|
||||||
|
// guest records into their account so the data they typed before
|
||||||
|
// signing up follows them.
|
||||||
|
if (userId && !guestMigrationAttempted) {
|
||||||
|
guestMigrationAttempted = true;
|
||||||
|
migrateGuestDataToUser(userId).catch((err) => {
|
||||||
|
console.error('[mana] guest → user migration failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
// Initialize theme
|
// Initialize theme
|
||||||
const cleanupTheme = theme.initialize();
|
const cleanupTheme = theme.initialize();
|
||||||
|
|
||||||
// Initialize network status tracking
|
// Initialize network status tracking
|
||||||
networkStore.initialize();
|
networkStore.initialize();
|
||||||
|
|
||||||
// Initialize auth
|
// Auth + automation loading is async — fire and forget. Returning
|
||||||
await authStore.initialize();
|
// cleanup from an async onMount would silently drop it, so the async
|
||||||
|
// work runs in an inner IIFE while the outer arrow stays sync.
|
||||||
// Load cross-module automation triggers
|
void (async () => {
|
||||||
await loadAutomations();
|
await authStore.initialize();
|
||||||
|
await loadAutomations();
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupTheme();
|
cleanupTheme();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
/** Base record that all local-store entities must extend. */
|
/** Base record that all local-store entities must extend. */
|
||||||
export interface BaseRecord {
|
export interface BaseRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Owner of this record. Auto-stamped by the Dexie creating-hook from the
|
||||||
|
* active session user; module stores must never set this themselves.
|
||||||
|
*/
|
||||||
|
userId?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue