feat(memoro/server): implement invite email, health checks, and update ManaScore

- Implement invite resend via @manacore/notify-client (was a stub)
- Send invite email on space invite creation (fire-and-forget)
- Extend /health endpoint with Supabase connectivity check
- Returns 503 "degraded" if dependency checks fail
- Update ManaScore: 72→76 (testing 10→45, 183 tests documented)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 16:18:28 +02:00
parent 32e8edfb66
commit 34136894d0
7 changed files with 243 additions and 30 deletions

View file

@ -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

View file

@ -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<string, 'ok' | 'error'> = {};
// 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) ────────────────────────────────────────────────────

View file

@ -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<void> {
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: `
<h2>Du wurdest eingeladen!</h2>
<p><strong>${inviter}</strong> hat dich zum Space <strong>"${spaceName}"</strong> in Memoro eingeladen.</p>
<p>Öffne Memoro, um die Einladung anzunehmen.</p>
`,
});
console.log(`[notify] Invite email sent to ${email} for space "${spaceName}"`);
} catch (err) {
console.error(`[notify] Failed to send invite email to ${email}:`, err);
}
}

View file

@ -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');
});
});

View file

@ -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', () => {

View file

@ -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 });
});

3
pnpm-lock.yaml generated
View file

@ -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