mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat: top-5 ROI improvements — CI gate, auth fields, body×timeblocks, sync pull, tests
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) <noreply@anthropic.com>
This commit is contained in:
parent
04ce8e5d6f
commit
0f7ab60397
9 changed files with 387 additions and 9 deletions
21
.husky/pre-push
Executable file
21
.husky/pre-push
Executable file
|
|
@ -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"
|
||||
|
|
@ -740,10 +740,28 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
|
|||
|
||||
async function pull(appId: string): Promise<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<LocalBodyWorkout> = {
|
||||
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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue