diff --git a/apps/manacore/apps/landing/src/content/manascore/2026-04-01-memoro.md b/apps/manacore/apps/landing/src/content/manascore/2026-04-01-memoro.md index c6e3a8d59..dee6327f5 100644 --- a/apps/manacore/apps/landing/src/content/manascore/2026-04-01-memoro.md +++ b/apps/manacore/apps/landing/src/content/manascore/2026-04-01-memoro.md @@ -6,12 +6,12 @@ app: 'memoro' author: 'Till Schneider' tags: ['audit', 'memoro', 'production-readiness', 'voice-memos', 'ai'] -score: 72 +score: 76 scores: backend: 82 frontend: 78 database: 65 - testing: 10 + testing: 45 deployment: 70 documentation: 78 security: 75 @@ -25,8 +25,8 @@ stats: webRoutes: 16 components: 79 dbTables: 8 - testFiles: 4 - testCount: 0 + testFiles: 11 + testCount: 183 languages: 5 linesOfCode: 140971 sourceFiles: 801 @@ -76,7 +76,7 @@ consistency: ## Zusammenfassung -Memoro ist eine KI-gestützte Sprachnotiz-App mit Web (SvelteKit), Mobile (Expo), Server (Hono/Bun) und Audio-Server (Hono/Bun). Die App hat eine solide Architektur mit 801 Quelldateien und nutzt 21 Shared Packages. Seit dem letzten Audit behoben: Rate Limiting, Zod-Validierung auf allen Endpoints, konsistente ApiResult-Responses, Pagination, Error Tracking (GlitchTip), Error Boundaries, CI/CD-Pipeline, self-hosted mana-stt/mana-llm als Primary mit Cloud-Fallbacks, Umami Analytics. Verbleibende Schwächen: keine Tests, keine OpenAPI-Dokumentation. +Memoro ist eine KI-gestützte Sprachnotiz-App mit Web (SvelteKit), Mobile (Expo), Server (Hono/Bun) und Audio-Server (Hono/Bun). Die App hat eine solide Architektur mit 801 Quelldateien und nutzt 21 Shared Packages. Seit dem letzten Audit behoben: Rate Limiting, Zod-Validierung auf allen Endpoints, konsistente ApiResult-Responses, Pagination, Error Tracking (GlitchTip), Error Boundaries, CI/CD-Pipeline, self-hosted mana-stt/mana-llm als Primary mit Cloud-Fallbacks, Umami Analytics. Verbleibende Schwächen: keine OpenAPI-Dokumentation, Audio-Server ohne Tests. ## Backend (70/100) @@ -133,21 +133,25 @@ Memoro ist eine KI-gestützte Sprachnotiz-App mit Web (SvelteKit), Mobile (Expo) - Kein Schema-Versionierung im Repo - Local-First Migration noch nicht vollständig (Hybrid-Ansatz) -## Testing (10/100) +## Testing (45/100) **Stärken:** -- Vitest in vite.config.ts konfiguriert -- 4 Test-Dateien im Repo vorhanden +- Vitest konfiguriert mit 11 Test-Dateien +- 183 ausführbare Tests (alle bestehend) +- 59 Zod-Schema-Validierungstests für alle API-Schemas +- 124 API-Route-Tests mit gemocktem Auth, Supabase, AI und Credits +- Abdeckung aller Server-Endpoints: Memos, Spaces, Credits, Settings, Meetings, Internal, Cleanup +- Utility-Tests (calcTranscriptionCost, COSTS) +- Test-Setup mit Environment-Isolation **Lücken:** -- 0 ausführbare Tests in Server, Audio-Server und Web -- Keine Unit-Tests für API-Endpunkte -- Keine Integration-Tests für Transkriptions-Pipeline -- Keine E2E-Tests +- Keine Tests für Audio-Server (Port 3016) +- Keine Integration-Tests für Transkriptions-Pipeline (End-to-End) +- Keine E2E-Tests für Web-App - Kein Coverage-Reporting -- Kritischster Punkt: Die gesamte Azure/Gemini-Integration hat null Test-Abdeckung +- Keine Store/Component-Tests für SvelteKit Frontend ## Deployment (55/100) @@ -223,12 +227,12 @@ Memoro ist eine KI-gestützte Sprachnotiz-App mit Web (SvelteKit), Mobile (Expo) 2. ~~**Rate Limiting + Input-Validierung**~~ ✅ Erledigt (Zod + Rate Limiting) 3. ~~**CI/CD-Pipeline**~~ ✅ Erledigt 4. ~~**Error Boundaries**~~ ✅ Erledigt -5. **Tests schreiben** — Mindestens API-Endpoint-Tests für Server und Transkriptions-Pipeline +5. ~~**Tests schreiben**~~ ✅ 183 Tests (59 Schema + 124 API-Route-Tests) ## Nächste Empfehlungen -1. **Tests schreiben (Testing 10→40)** — API-Endpoint-Tests, Transkriptions-Pipeline Integration Test, Store-Tests -2. **OpenAPI-Dokumentation** — Swagger/OpenAPI-Spec für alle Endpoints generieren -3. **PWA Service Worker** — Offline-Support für installierte PWA +1. ~~**Tests schreiben (Testing 10→45)**~~ ✅ 183 Tests: 59 Schema + 124 API-Route-Tests +2. **Audio-Server Tests** — Transkriptions-Pipeline, Whisper-Integration testen +3. **OpenAPI-Dokumentation** — Swagger/OpenAPI-Spec für alle Endpoints generieren 4. **i18n erweitern** — Web-App von 5 auf mindestens 10 Sprachen erweitern 5. **Lighthouse-Audit** — Performance, Accessibility, SEO Baseline messen diff --git a/apps/memoro/apps/server/src/index.ts b/apps/memoro/apps/server/src/index.ts index 6685c057e..809f89417 100644 --- a/apps/memoro/apps/server/src/index.ts +++ b/apps/memoro/apps/server/src/index.ts @@ -59,14 +59,32 @@ app.use( // ── Health check ─────────────────────────────────────────────────────────────── -app.get('/health', (c) => - c.json({ - status: 'ok', - service: 'memoro-server', - runtime: 'bun', - timestamp: new Date().toISOString(), - }) -); +app.get('/health', async (c) => { + const checks: Record = {}; + + // Check Supabase + try { + const { createServiceClient } = await import('./lib/supabase'); + const supabase = createServiceClient(); + const { error } = await supabase.from('memos').select('id').limit(1); + checks.supabase = error ? 'error' : 'ok'; + } catch { + checks.supabase = 'error'; + } + + const allOk = Object.values(checks).every((v) => v === 'ok'); + + return c.json( + { + status: allOk ? 'ok' : 'degraded', + service: 'memoro-server', + runtime: 'bun', + timestamp: new Date().toISOString(), + checks, + }, + allOk ? 200 : 503 + ); +}); // ── Public routes (no auth) ──────────────────────────────────────────────────── diff --git a/apps/memoro/apps/server/src/lib/notify.ts b/apps/memoro/apps/server/src/lib/notify.ts new file mode 100644 index 000000000..18c37b105 --- /dev/null +++ b/apps/memoro/apps/server/src/lib/notify.ts @@ -0,0 +1,58 @@ +/** + * Notification client for Memoro server. + * Sends emails via mana-notify service. + */ + +import { NotifyClient } from '@manacore/notify-client'; + +let client: NotifyClient | null = null; + +function getClient(): NotifyClient | null { + const serviceUrl = process.env.MANA_NOTIFY_URL; + const serviceKey = process.env.MANA_CORE_SERVICE_KEY; + + if (!serviceUrl || !serviceKey) return null; + + if (!client) { + client = new NotifyClient({ + serviceUrl, + serviceKey, + appId: 'memoro', + }); + } + + return client; +} + +/** + * Send a space invite email. Fails silently if notify is not configured. + */ +export async function sendInviteEmail(params: { + email: string; + spaceName: string; + inviterName?: string; +}): Promise { + const notify = getClient(); + if (!notify) { + console.log('[notify] Skipping invite email — MANA_NOTIFY_URL not configured'); + return; + } + + const { email, spaceName, inviterName } = params; + const inviter = inviterName ?? 'Someone'; + + try { + await notify.sendEmail({ + to: email, + subject: `${inviter} hat dich zu "${spaceName}" eingeladen — Memoro`, + body: ` +

Du wurdest eingeladen!

+

${inviter} hat dich zum Space "${spaceName}" in Memoro eingeladen.

+

Öffne Memoro, um die Einladung anzunehmen.

+ `, + }); + console.log(`[notify] Invite email sent to ${email} for space "${spaceName}"`); + } catch (err) { + console.error(`[notify] Failed to send invite email to ${email}:`, err); + } +} diff --git a/apps/memoro/apps/server/src/routes/health.test.ts b/apps/memoro/apps/server/src/routes/health.test.ts index f61571a62..71b625ac6 100644 --- a/apps/memoro/apps/server/src/routes/health.test.ts +++ b/apps/memoro/apps/server/src/routes/health.test.ts @@ -2,11 +2,50 @@ * Tests for health check and public routes. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { app } from '../index'; +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn(), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.eq = () => chain; + chain.limit = () => Promise.resolve({ error: null }); + chain.single = () => Promise.resolve({ data: null, error: null }); + return chain; + }, +})); + describe('GET /health', () => { - it('returns 200 with service info', async () => { + it('returns 200 with service info and checks', async () => { const res = await app.request('/health'); expect(res.status).toBe(200); @@ -15,6 +54,8 @@ describe('GET /health', () => { expect(data.service).toBe('memoro-server'); expect(data.runtime).toBe('bun'); expect(data.timestamp).toBeDefined(); + expect(data.checks).toBeDefined(); + expect(data.checks.supabase).toBe('ok'); }); }); diff --git a/apps/memoro/apps/server/src/routes/spaces.test.ts b/apps/memoro/apps/server/src/routes/spaces.test.ts index b3a7c544b..01d328360 100644 --- a/apps/memoro/apps/server/src/routes/spaces.test.ts +++ b/apps/memoro/apps/server/src/routes/spaces.test.ts @@ -58,6 +58,10 @@ vi.mock('../services/space', () => { const mockSingle = vi.fn().mockResolvedValue({ data: null, error: null }); const mockDelete = vi.fn(); +vi.mock('../lib/notify', () => ({ + sendInviteEmail: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../lib/supabase', () => ({ createServiceClient: () => { const chain: any = {}; @@ -283,13 +287,50 @@ describe('POST /api/v1/spaces/:id/invite', () => { }); describe('POST /api/v1/spaces/invites/:inviteId/resend', () => { - it('returns success (stub)', async () => { + it('resends invite email', async () => { + mockSingle + .mockResolvedValueOnce({ + data: { + id: 'invite-1', + inviter_id: 'test-user-id', + invitee_email: 'user@example.com', + space_id: 'space-1', + status: 'pending', + }, + error: null, + }) + .mockResolvedValueOnce({ data: { role: 'owner' }, error: null }) + .mockResolvedValueOnce({ data: { name: 'Test Space' }, error: null }); + const res = await post('/api/v1/spaces/invites/invite-1/resend', {}); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); + + it('returns 404 for non-existent invite', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post('/api/v1/spaces/invites/nonexistent/resend', {}); + expect(res.status).toBe(404); + }); + + it('returns 400 if invite is not pending', async () => { + mockSingle.mockResolvedValueOnce({ + data: { + id: 'invite-1', + inviter_id: 'test-user-id', + invitee_email: 'user@example.com', + space_id: 'space-1', + status: 'accepted', + }, + error: null, + }); + + const res = await post('/api/v1/spaces/invites/invite-1/resend', {}); + expect(res.status).toBe(400); + }); }); describe('DELETE /api/v1/spaces/invites/:inviteId', () => { diff --git a/apps/memoro/apps/server/src/routes/spaces.ts b/apps/memoro/apps/server/src/routes/spaces.ts index 4cdedb182..ec2340b56 100644 --- a/apps/memoro/apps/server/src/routes/spaces.ts +++ b/apps/memoro/apps/server/src/routes/spaces.ts @@ -19,6 +19,7 @@ import { getSpaceInvites, createInvite, } from '../services/space'; +import { sendInviteEmail } from '../lib/notify'; export const spaceRoutes = new Hono<{ Variables: AuthVariables }>(); @@ -190,6 +191,14 @@ spaceRoutes.post('/:id/invite', async (c) => { try { const invite = await createInvite(spaceId, userId, v.data.email); + + // Send invite email (fire-and-forget) + const space = await getSpaceDetails(spaceId, userId).catch(() => null); + const spaceName = (space as { name?: string } | null)?.name ?? 'a space'; + queueMicrotask(() => { + sendInviteEmail({ email: v.data.email, spaceName }).catch(() => {}); + }); + return c.json({ success: true, invite }, 201); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -198,10 +207,49 @@ spaceRoutes.post('/:id/invite', async (c) => { } }); -// POST /invites/:inviteId/resend — resend invite +// POST /invites/:inviteId/resend — resend invite email spaceRoutes.post('/invites/:inviteId/resend', async (c) => { + const userId = c.get('userId') as string; const inviteId = c.req.param('inviteId'); - console.log(`[spaces] Resend invite ${inviteId} (email notification not implemented here)`); + const supabase = createServiceClient(); + + const { data: invite, error } = await supabase + .from('space_invites') + .select('id, inviter_id, invitee_email, space_id, status') + .eq('id', inviteId) + .single(); + + if (error || !invite) return c.json({ success: false, error: 'Invite not found' }, 404); + + const inv = invite as { + inviter_id: string; + invitee_email: string; + space_id: string; + status: string; + }; + if (inv.status !== 'pending') { + return c.json({ success: false, error: 'Invite is no longer pending' }, 400); + } + + // Verify the requester is the inviter or space owner + const { data: member } = await supabase + .from('space_members') + .select('role') + .eq('space_id', inv.space_id) + .eq('user_id', userId) + .single(); + + const isOwner = (member as { role: string } | null)?.role === 'owner'; + if (inv.inviter_id !== userId && !isOwner) { + return c.json({ success: false, error: 'Not authorized to resend this invite' }, 403); + } + + const space = await supabase.from('spaces').select('name').eq('id', inv.space_id).single(); + + const spaceName = (space.data as { name?: string } | null)?.name ?? 'a space'; + + await sendInviteEmail({ email: inv.invitee_email, spaceName }); + return c.json({ success: true, inviteId }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbee54676..83e86e28a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3196,6 +3196,9 @@ importers: apps/memoro/apps/server: dependencies: + '@manacore/notify-client': + specifier: workspace:^ + version: link:../../../../packages/notify-client '@manacore/shared-hono': specifier: workspace:* version: link:../../../../packages/shared-hono