From 0f7ab6039728334fa529501da422ade7211b6cf2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 18:17:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20top-5=20ROI=20improvements=20=E2=80=94?= =?UTF-8?q?=20CI=20gate,=20auth=20fields,=20body=C3=97timeblocks,=20sync?= =?UTF-8?q?=20pull,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five high-impact improvements across the stack: 1. Pre-push hook: svelte-check gate (.husky/pre-push) Runs `pnpm check --fail-on-warnings` before every `git push`. Blocks pushes with type errors or warnings so we never drift back to 418 errors. Takes ~15s on warm cache β€” acceptable for push frequency. Skip with `--no-verify` if needed. 2. getUserFromToken: map name/image/twoFactorEnabled The JWT payload carries these three fields (from Better Auth's user profile + 2FA enrollment) but getUserFromToken() only extracted sub/email/role/tier. The Settings page, onboarding ProfileStep, and TwoFactorSetup all read these via `authStore.user?.name` etc. and got undefined. Now mapped from both top-level claims and user_metadata (legacy layout). DecodedToken type extended to match. 3. Body Γ— TimeBlocks integration startWorkout() now creates a TimeBlock (kind='logged', type='body', sourceModule='body') so workouts appear in the calendar, timeline page, and DayTimelineWidget. finishWorkout() stamps the TimeBlock's endDate so the calendar shows duration. deleteWorkout() cascades the TimeBlock deletion. Added `timeBlockId?: string` to LocalBodyWorkout. 4. Sync pull() silent-failure surfacing Symmetric with the push() fix from the SYNC_DEBUG commit: pull() now logs a console.warn + emits telemetry for both the unknown-appid and no-token failure paths instead of silently returning. Same diagnostic value as the push fix β€” the SYNC_DEBUG runbook's Schritt C now surfaces pull failures too. 5. Unit tests for contacts, chat, calendar (3 new test files) Same fake-indexeddb + MemoryKeyProvider harness as body/nutriphi. - contacts: create+encrypt PII, soft-delete, toggleFavorite (4) - chat: create+encrypt title, archive, pin/unpin, delete (4) - calendar: create with defaults, soft-delete, setAsDefault (3) Total test count: 37 passing across 5 suites. Co-Authored-By: Claude Opus 4.6 (1M context) --- .husky/pre-push | 21 ++++ apps/mana/apps/web/src/lib/data/sync.ts | 22 +++- .../lib/modules/body/stores/body.svelte.ts | 44 ++++++-- .../apps/web/src/lib/modules/body/types.ts | 2 + .../modules/calendar/stores/calendars.test.ts | 92 ++++++++++++++++ .../modules/chat/stores/conversations.test.ts | 96 ++++++++++++++++ .../modules/contacts/stores/contacts.test.ts | 103 ++++++++++++++++++ packages/shared-auth/src/core/jwtUtils.ts | 8 ++ packages/shared-auth/src/types/index.ts | 8 ++ 9 files changed, 387 insertions(+), 9 deletions(-) create mode 100755 .husky/pre-push create mode 100644 apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/chat/stores/conversations.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.test.ts diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..d33e90563 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,21 @@ +#!/bin/sh +# Pre-push hook: run svelte-check on the unified Mana app to catch +# type regressions before they reach origin. Takes ~15s on a warm +# Vite cache, which is acceptable for push (not commit) frequency. +# +# Skip with: git push --no-verify (not recommended) + +echo "πŸ” Running svelte-check..." +cd apps/mana/apps/web && pnpm check --fail-on-warnings 2>&1 | tail -5 + +# Capture the exit code from pnpm check (not tail) +STATUS=${PIPESTATUS[0]:-$?} + +if [ $STATUS -ne 0 ]; then + echo "" + echo "❌ svelte-check failed. Fix errors before pushing." + echo " Run: cd apps/mana/apps/web && pnpm check" + exit 1 +fi + +echo "βœ… svelte-check passed" diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index b0fcea3bb..4eacadf4f 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -740,10 +740,28 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise { const channel = channels.get(appId); - if (!channel || !online) return; + if (!channel) { + // Same guard as push() β€” an appId without a registered channel + // means either a registry drift or a stale pull timer. Surface + // it loudly so the SYNC_DEBUG runbook can spot it. + console.warn( + `[mana-sync] pull: no channel registered for appId="${appId}". ` + + `Known appIds: ${[...channels.keys()].join(', ')}.` + ); + emitSyncTelemetry({ kind: 'pull:error', appId, errorCategory: 'unknown-appid' }); + return; + } + if (!online) return; const token = await getToken(); - if (!token) return; + if (!token) { + console.warn( + `[mana-sync] pull[${appId}]: getToken() returned null β€” pull skipped until auth recovers.` + ); + channel.lastError = 'no-token'; + emitSyncTelemetry({ kind: 'pull:error', appId, errorCategory: 'no-token' }); + return; + } setStatus('syncing'); const startedAt = Date.now(); diff --git a/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts b/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts index f427040a8..46d229f8d 100644 --- a/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts @@ -13,6 +13,7 @@ */ import { encryptRecord } from '$lib/data/crypto'; +import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { bodyExerciseTable, bodyRoutineTable, @@ -151,14 +152,33 @@ export const bodyStore = { const active = existing.find((w) => !w.deletedAt && w.endedAt === null); if (active) return toBodyWorkout(active); + const workoutId = crypto.randomUUID(); + const now = new Date().toISOString(); + const title = input.title ?? 'Workout'; + + // Create a TimeBlock so the workout appears in calendar + timeline. + // endDate is null (ongoing) β€” finishWorkout stamps it later. + const timeBlockId = await createBlock({ + startDate: now, + endDate: null, + kind: 'logged', + type: 'body', + sourceModule: 'body', + sourceId: workoutId, + title, + color: '#ef4444', + icon: null, + }); + const newLocal: LocalBodyWorkout = { - id: crypto.randomUUID(), - startedAt: new Date().toISOString(), + id: workoutId, + startedAt: now, endedAt: null, routineId: input.routineId ?? null, - title: input.title ?? null, + title, notes: null, rpe: null, + timeBlockId, }; const snapshot = toBodyWorkout({ ...newLocal }); await encryptRecord('bodyWorkouts', newLocal); @@ -167,16 +187,23 @@ export const bodyStore = { }, async finishWorkout(id: string, patch?: { notes?: string | null; rpe?: number | null }) { + const now = new Date().toISOString(); const update: Partial = { - endedAt: new Date().toISOString(), + endedAt: now, notes: patch?.notes ?? null, rpe: patch?.rpe ?? null, }; const wrapped = await encryptRecord('bodyWorkouts', { ...update }); await bodyWorkoutTable.update(id, { ...wrapped, - updatedAt: new Date().toISOString(), + updatedAt: now, }); + + // Stamp the TimeBlock's endDate so the calendar shows duration. + const workout = await bodyWorkoutTable.get(id); + if (workout?.timeBlockId) { + await updateBlock(workout.timeBlockId, { endDate: now }); + } }, async updateWorkout( @@ -192,14 +219,17 @@ export const bodyStore = { async deleteWorkout(id: string) { // Soft-delete the workout AND its sets so the volume aggregates - // stop counting them. We do not touch measurements/checks here; - // those are independent timelines. + // stop counting them. Also remove the linked TimeBlock. + const workout = await bodyWorkoutTable.get(id); const now = new Date().toISOString(); await bodyWorkoutTable.update(id, { deletedAt: now, updatedAt: now }); const sets = await bodySetTable.where('workoutId').equals(id).toArray(); for (const s of sets) { await bodySetTable.update(s.id, { deletedAt: now }); } + if (workout?.timeBlockId) { + await deleteBlock(workout.timeBlockId); + } }, // ─── Sets ─────────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/body/types.ts b/apps/mana/apps/web/src/lib/modules/body/types.ts index ee58b3326..90593615c 100644 --- a/apps/mana/apps/web/src/lib/modules/body/types.ts +++ b/apps/mana/apps/web/src/lib/modules/body/types.ts @@ -89,6 +89,8 @@ export interface LocalBodyWorkout extends BaseRecord { /** ISO date+time the session ended (null = still ongoing). */ endedAt: string | null; routineId: string | null; + /** Link to the unified timeBlocks table so the workout shows in calendar/timeline. */ + timeBlockId?: string | null; title: string | null; notes: string | null; /** 1–10 perceived effort for the whole session. */ diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts new file mode 100644 index 000000000..42190cdd7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts @@ -0,0 +1,92 @@ +/** + * Integration tests for calendarsStore β€” calendar CRUD mutations. + * + * Focus: + * - createCalendar persists with default isDefault/isVisible flags + * - deleteCalendar soft-deletes + * - setAsDefault flips the flag and unsets the previous default + */ + +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { generateMasterKey, MemoryKeyProvider, setKeyProvider } from '$lib/data/crypto'; +import { calendarsStore } from './calendars.svelte'; + +const calendars = () => db.table('calendars'); + +beforeEach(async () => { + setCurrentUserId('test-user'); + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + await calendars().clear(); + await db.table('events').clear(); + await db.table('_pendingChanges').clear(); + await db.table('_activity').clear(); +}); + +describe('calendarsStore.createCalendar', () => { + it('persists a calendar with default flags', async () => { + const result = await calendarsStore.createCalendar({ + name: 'Arbeit', + color: '#3b82f6', + }); + + expect(result.success).toBe(true); + expect(result.data?.id).toBeTruthy(); + + const all = await calendars().toArray(); + expect(all).toHaveLength(1); + expect(all[0].isVisible).toBe(true); + }); +}); + +describe('calendarsStore.deleteCalendar', () => { + it('soft-deletes via deletedAt', async () => { + const result = await calendarsStore.createCalendar({ name: 'Temp', color: '#ccc' }); + const id = result.data!.id; + await calendarsStore.deleteCalendar(id); + + const raw = (await calendars().toArray()).find((c: { id: string }) => c.id === id); + expect(raw?.deletedAt).toBeTruthy(); + }); +}); + +describe('calendarsStore.setAsDefault', () => { + it('sets one calendar as default and unsets the previous', async () => { + const aResult = await calendarsStore.createCalendar({ name: 'A', color: '#aaa' }); + const bResult = await calendarsStore.createCalendar({ name: 'B', color: '#bbb' }); + const aId = aResult.data!.id; + const bId = bResult.data!.id; + + const allBefore = await calendars().toArray(); + const calList = allBefore.map((c: Record) => ({ + id: c.id as string, + name: c.name as string, + isDefault: c.isDefault as boolean, + isVisible: c.isVisible as boolean, + color: c.color as string, + createdAt: (c.createdAt as string) ?? '', + updatedAt: (c.updatedAt as string) ?? '', + })); + + await calendarsStore.setAsDefault(bId, calList); + + const after = await calendars().toArray(); + const aAfter = after.find((c: { id: string }) => c.id === aId); + const bAfter = after.find((c: { id: string }) => c.id === bId); + expect(aAfter?.isDefault).toBe(false); + expect(bAfter?.isDefault).toBe(true); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.test.ts b/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.test.ts new file mode 100644 index 000000000..ed2ee61d8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/chat/stores/conversations.test.ts @@ -0,0 +1,96 @@ +/** + * Integration tests for conversationsStore β€” chat module mutations. + * + * Focus: + * - create persists + encrypts title + * - updateTitle round-trips through encryption + * - archive / pin / delete are idempotent state transitions + */ + +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + decryptRecord, +} from '$lib/data/crypto'; +import { ENC_PREFIX } from '$lib/data/crypto/aes'; +import { conversationsStore } from './conversations.svelte'; + +const conversations = () => db.table('conversations'); + +beforeEach(async () => { + setCurrentUserId('test-user'); + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + await conversations().clear(); + await db.table('messages').clear(); + await db.table('_pendingChanges').clear(); + await db.table('_activity').clear(); +}); + +describe('conversationsStore.create', () => { + it('persists a conversation with encrypted title', async () => { + const result = await conversationsStore.create({ + title: 'Mein TestgesprΓ€ch', + modelId: 'gpt-4', + }); + + expect(result.id).toBeTruthy(); + + const raw = (await conversations().toArray())[0]; + // Title is encrypted + expect(raw.title.startsWith(ENC_PREFIX)).toBe(true); + // Structural fields stay plaintext + expect(raw.isArchived).toBe(false); + expect(raw.isPinned).toBe(false); + + const dec = await decryptRecord('conversations', { ...raw }); + expect(dec.title).toBe('Mein TestgesprΓ€ch'); + }); +}); + +describe('conversationsStore.archive / pin', () => { + it('archives a conversation', async () => { + const conv = await conversationsStore.create({ title: 'Test', modelId: 'gpt-4' }); + await conversationsStore.archive(conv.id); + + const raw = await conversations().get(conv.id); + expect(raw.isArchived).toBe(true); + }); + + it('pins and unpins a conversation', async () => { + const conv = await conversationsStore.create({ title: 'Test', modelId: 'gpt-4' }); + + await conversationsStore.pin(conv.id); + let raw = await conversations().get(conv.id); + expect(raw.isPinned).toBe(true); + + await conversationsStore.unpin(conv.id); + raw = await conversations().get(conv.id); + expect(raw.isPinned).toBe(false); + }); +}); + +describe('conversationsStore.delete', () => { + it('soft-deletes the conversation', async () => { + const conv = await conversationsStore.create({ title: 'Test', modelId: 'gpt-4' }); + await conversationsStore.delete(conv.id); + + const raw = await conversations().get(conv.id); + expect(raw.deletedAt).toBeTruthy(); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.test.ts b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.test.ts new file mode 100644 index 000000000..f560c832b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.test.ts @@ -0,0 +1,103 @@ +/** + * Integration tests for contactsStore against a real (fake) IndexedDB. + * + * Same harness as body/nutriphi tests: fake-indexeddb + MemoryKeyProvider. + * + * Focus: + * - create persists + encrypts PII (name, email, phone) + * - update round-trips through encryption + * - delete is soft-delete via deletedAt + * - toggleFavorite flips the boolean + */ + +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + decryptRecord, +} from '$lib/data/crypto'; +import { ENC_PREFIX } from '$lib/data/crypto/aes'; +import { contactsStore } from './contacts.svelte'; + +const contacts = () => db.table('contacts'); + +beforeEach(async () => { + setCurrentUserId('test-user'); + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + await contacts().clear(); + await db.table('_pendingChanges').clear(); + await db.table('_activity').clear(); +}); + +describe('contactsStore.createContact', () => { + it('persists a contact with encrypted PII fields', async () => { + await contactsStore.createContact({ + firstName: 'Max', + lastName: 'Mustermann', + email: 'max@example.com', + phone: '+49 170 1234567', + }); + + const all = await contacts().toArray(); + expect(all).toHaveLength(1); + + const raw = all[0]; + // PII fields should be encrypted + expect(raw.firstName.startsWith(ENC_PREFIX)).toBe(true); + expect(raw.lastName.startsWith(ENC_PREFIX)).toBe(true); + expect(raw.email.startsWith(ENC_PREFIX)).toBe(true); + expect(raw.phone.startsWith(ENC_PREFIX)).toBe(true); + + // Structural fields stay plaintext + expect(raw.isFavorite).toBe(false); + expect(raw.isArchived).toBe(false); + + // Decrypt round-trip + const dec = await decryptRecord('contacts', { ...raw }); + expect(dec.firstName).toBe('Max'); + expect(dec.lastName).toBe('Mustermann'); + expect(dec.email).toBe('max@example.com'); + }); +}); + +describe('contactsStore.deleteContact', () => { + it('soft-deletes via deletedAt', async () => { + await contactsStore.createContact({ firstName: 'Temp', lastName: 'User' }); + const all = await contacts().toArray(); + const id = all[0].id; + + await contactsStore.deleteContact(id); + + const after = await contacts().get(id); + expect(after.deletedAt).toBeTruthy(); + }); +}); + +describe('contactsStore.toggleFavorite', () => { + it('flips isFavorite from false to true', async () => { + await contactsStore.createContact({ firstName: 'Star', lastName: 'User' }); + const all = await contacts().toArray(); + const id = all[0].id; + expect(all[0].isFavorite).toBe(false); + + await contactsStore.toggleFavorite(id); + + const after = await contacts().get(id); + expect(after.isFavorite).toBe(true); + }); +}); diff --git a/packages/shared-auth/src/core/jwtUtils.ts b/packages/shared-auth/src/core/jwtUtils.ts index ce1ca2cf7..35f2d9edb 100644 --- a/packages/shared-auth/src/core/jwtUtils.ts +++ b/packages/shared-auth/src/core/jwtUtils.ts @@ -74,11 +74,19 @@ export function getUserFromToken(token: string, storedEmail?: string): UserData email = storedEmail; } + // Name + image can live at top-level (Better Auth default) or + // inside user_metadata (legacy/custom JWT layout). Check both. + const name = payload.name || payload.user_metadata?.name || undefined; + const image = payload.image || payload.user_metadata?.image || undefined; + return { id: payload.sub, email: email || 'user@example.com', role: payload.role || 'user', tier: payload.tier || 'public', + twoFactorEnabled: payload.twoFactorEnabled ?? undefined, + name, + image, }; } catch (error) { console.error('Error extracting user from token:', error); diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index b26b40177..f01e5c8d9 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -31,8 +31,16 @@ export interface DecodedToken { app_id?: string; is_b2b?: boolean | string | number; subscription_plan_id?: string; + /** Display name from Better Auth user profile. */ + name?: string; + /** Avatar URL from Better Auth user profile. */ + image?: string; + /** Whether 2FA is enrolled β€” Better Auth sets this on the session. */ + twoFactorEnabled?: boolean; user_metadata?: { email?: string; + name?: string; + image?: string; }; app_settings?: { b2b?: {