mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
32e8edfb66
commit
34136894d0
7 changed files with 243 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
58
apps/memoro/apps/server/src/lib/notify.ts
Normal file
58
apps/memoro/apps/server/src/lib/notify.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue