feat(memoro/server): add Zod validation, consistent ApiResult responses, and pagination

- Zod schemas for all 30+ API endpoints with proper input validation
- Consistent `{ success: true/false, ... }` response wrapper on every endpoint
- Pagination (limit/offset) on spaces, space memos, bots, and recordings list endpoints
- Validation helper (validateBody/validateQuery) for clean route handlers
- Fix rate-limiter return type in both memoro and shared-hono

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 15:18:07 +02:00
parent 3c47997598
commit 304c1e8b7c
14 changed files with 923 additions and 795 deletions

View file

@ -1,21 +1,22 @@
{
"name": "@memoro/server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@supabase/supabase-js": "^2.49.5",
"hono": "^4.7.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.5.0"
}
"name": "@memoro/server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@supabase/supabase-js": "^2.49.5",
"hono": "^4.7.0",
"uuid": "^11.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.5.0"
}
}

View file

@ -0,0 +1,61 @@
/**
* Zod validation helper for Hono route handlers.
*/
import type { Context } from 'hono';
import type { ZodType, ZodError, ZodTypeDef } from 'zod';
type ValidationResult<T> = { success: true; data: T } | { success: false; response: Response };
function formatZodError(error: ZodError): string {
return error.issues.map((i) => i.message).join(', ');
}
/**
* Validate JSON body against a Zod schema.
* Returns parsed data on success, or sends a 400 response on failure.
*/
export async function validateBody<Output, Def extends ZodTypeDef = ZodTypeDef, Input = Output>(
c: Context,
schema: ZodType<Output, Def, Input>
): Promise<ValidationResult<Output>> {
let raw: unknown;
try {
raw = await c.req.json();
} catch {
return {
success: false,
response: c.json({ success: false, error: 'Invalid JSON body' }, 400),
};
}
const result = schema.safeParse(raw);
if (!result.success) {
return {
success: false,
response: c.json({ success: false, error: formatZodError(result.error) }, 400),
};
}
return { success: true, data: result.data };
}
/**
* Validate query parameters against a Zod schema.
*/
export function validateQuery<Output, Def extends ZodTypeDef = ZodTypeDef, Input = Output>(
c: Context,
schema: ZodType<Output, Def, Input>
): ValidationResult<Output> {
const raw = c.req.query();
const result = schema.safeParse(raw);
if (!result.success) {
return {
success: false,
response: c.json({ success: false, error: formatZodError(result.error) }, 400),
};
}
return { success: true, data: result.data };
}

View file

@ -31,7 +31,7 @@ export function rateLimiter(options: RateLimiterOptions = {}): MiddlewareHandler
}
}, 5 * 60_000);
return async (c, next) => {
return async (c, next): Promise<void | Response> => {
const ip =
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
c.req.header('x-real-ip') ||

View file

@ -7,6 +7,8 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { runAudioCleanup } from '../services/cleanup';
import { validateBody } from '../lib/validate';
import { manualCleanupBody } from '../schemas';
export const cleanupRoutes = new Hono();
@ -28,9 +30,7 @@ cleanupRoutes.post('/run', async (c) => {
// Run cleanup asynchronously and return immediately
queueMicrotask(() => {
runAudioCleanup().catch((err) =>
console.error('[cleanup] Background cleanup failed:', err)
);
runAudioCleanup().catch((err) => console.error('[cleanup] Background cleanup failed:', err));
});
return c.json({ success: true, message: 'Cleanup started' });
@ -38,8 +38,9 @@ cleanupRoutes.post('/run', async (c) => {
// POST /manual — manual trigger with optional user IDs
cleanupRoutes.post('/manual', async (c) => {
const body = await c.req.json<{ userIds?: string[] }>().catch(() => ({ userIds: undefined }));
const userIds = body.userIds ?? [];
const v = await validateBody(c, manualCleanupBody);
if (!v.success) return v.response;
const userIds = v.data.userIds ?? [];
console.log(
`[cleanup] Manual trigger${userIds.length > 0 ? ` for ${userIds.length} users` : ' for all opted-in users'}`
@ -50,6 +51,6 @@ cleanupRoutes.post('/manual', async (c) => {
return c.json({ success: true, ...result });
} catch (err) {
console.error('[cleanup] Manual cleanup failed:', err);
return c.json({ error: 'Cleanup failed' }, 500);
return c.json({ success: false, error: 'Cleanup failed' }, 500);
}
});

View file

@ -6,12 +6,14 @@ import { Hono } from 'hono';
import type { AuthVariables } from '@manacore/shared-hono';
import { validateCredits, consumeCredits, COSTS } from '../lib/credits';
import { getBalance } from '@manacore/shared-hono';
import { validateBody } from '../lib/validate';
import { checkCreditsBody, consumeCreditsBody } from '../schemas';
export const creditRoutes = new Hono<{ Variables: AuthVariables }>();
// GET /pricing — public, returns cost constants
creditRoutes.get('/pricing', (c) => {
return c.json({ costs: COSTS });
return c.json({ success: true, costs: COSTS });
});
// GET /balance — authenticated, returns user's credit balance
@ -19,56 +21,50 @@ creditRoutes.get('/balance', async (c) => {
const userId = c.get('userId') as string;
try {
const balance = await getBalance(userId);
return c.json({ credits: balance.balance, totalEarned: balance.totalEarned, totalSpent: balance.totalSpent });
return c.json({
success: true,
credits: balance.balance,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
});
} catch (err) {
console.error('[credits] Balance error:', err);
return c.json({ error: 'Failed to fetch balance' }, 500);
return c.json({ success: false, error: 'Failed to fetch balance' }, 500);
}
});
// POST /check — validate credits (requires auth via parent router)
// POST /check — validate credits
creditRoutes.post('/check', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ operation: string; amount: number }>();
if (!body.operation || body.amount == null) {
return c.json({ error: 'operation and amount are required' }, 400);
}
const v = await validateBody(c, checkCreditsBody);
if (!v.success) return v.response;
try {
const result = await validateCredits(userId, body.operation, body.amount);
return c.json(result);
const result = await validateCredits(userId, v.data.operation, v.data.amount);
return c.json({ success: true, ...result });
} catch (err) {
console.error('[credits] Validate error:', err);
return c.json({ error: 'Failed to validate credits' }, 500);
return c.json({ success: false, error: 'Failed to validate credits' }, 500);
}
});
// POST /consume — consume credits (requires auth via parent router)
// POST /consume — consume credits
creditRoutes.post('/consume', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
operation: string;
amount: number;
description: string;
metadata?: Record<string, unknown>;
}>();
if (!body.operation || body.amount == null || !body.description) {
return c.json({ error: 'operation, amount, and description are required' }, 400);
}
const v = await validateBody(c, consumeCreditsBody);
if (!v.success) return v.response;
try {
const success = await consumeCredits(
const result = await consumeCredits(
userId,
body.operation,
body.amount,
body.description,
body.metadata
v.data.operation,
v.data.amount,
v.data.description,
v.data.metadata
);
return c.json({ success });
return c.json({ success: true, consumed: result });
} catch (err) {
console.error('[credits] Consume error:', err);
return c.json({ error: 'Failed to consume credits' }, 500);
return c.json({ success: false, error: 'Failed to consume credits' }, 500);
}
});

View file

@ -8,6 +8,12 @@ import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { handleTranscriptionCompleted } from '../services/memo';
import { createServiceClient } from '../lib/supabase';
import { validateBody } from '../lib/validate';
import {
transcriptionCompletedBody,
appendTranscriptionCompletedBody,
batchMetadataBody,
} from '../schemas';
export const internalRoutes = new Hono();
@ -25,68 +31,32 @@ internalRoutes.use('*', async (c, next) => {
// POST /transcription-completed — called by audio server on completion
internalRoutes.post('/transcription-completed', async (c) => {
const body = await c.req.json<{
memoId: string;
userId: string;
transcriptionResult?: {
transcript?: string;
utterances?: Array<{ offset: number; duration: number; text: string; speaker?: string }>;
speakers?: Record<string, unknown>;
speakerMap?: Record<string, unknown>;
languages?: string[];
primary_language?: string;
duration?: number;
};
route?: string;
success: boolean;
error?: string;
fallbackStage?: string;
}>();
if (!body.memoId || !body.userId) {
return c.json({ error: 'memoId and userId are required' }, 400);
}
const v = await validateBody(c, transcriptionCompletedBody);
if (!v.success) return v.response;
const body = v.data;
try {
await handleTranscriptionCompleted({
memoId: body.memoId,
userId: body.userId,
...(body.transcriptionResult ? { transcriptionResult: body.transcriptionResult } : {}),
...(body.transcriptionResult ? { transcriptionResult: body.transcriptionResult as any } : {}),
...(body.route ? { route: body.route } : {}),
success: body.success,
...(body.error ? { error: body.error } : {}),
...(body.fallbackStage ? { fallbackStage: body.fallbackStage } : {}),
});
} as any);
return c.json({ success: true, memoId: body.memoId });
} catch (err) {
console.error('[internal] Transcription completed handler failed:', err);
return c.json({ error: 'Failed to process transcription callback' }, 500);
return c.json({ success: false, error: 'Failed to process transcription callback' }, 500);
}
});
// POST /append-transcription-completed — called by audio server for append flow
internalRoutes.post('/append-transcription-completed', async (c) => {
const body = await c.req.json<{
memoId: string;
userId: string;
recordingIndex: number;
transcriptionResult?: {
transcript?: string;
utterances?: Array<{ offset: number; duration: number; text: string; speaker?: string }>;
speakers?: Record<string, unknown>;
speakerMap?: Record<string, unknown>;
languages?: string[];
primary_language?: string;
duration?: number;
};
success: boolean;
error?: string;
route?: string;
}>();
if (!body.memoId || !body.userId) {
return c.json({ error: 'memoId and userId are required' }, 400);
}
const v = await validateBody(c, appendTranscriptionCompletedBody);
if (!v.success) return v.response;
const body = v.data;
const supabase = createServiceClient();
const now = new Date().toISOString();
@ -100,32 +70,36 @@ internalRoutes.post('/append-transcription-completed', async (c) => {
.single();
if (fetchError || !memo) {
return c.json({ error: 'Memo not found' }, 404);
return c.json({ success: false, error: 'Memo not found' }, 404);
}
const source = (memo as { source: Record<string, unknown> }).source ?? {};
const additionalRecordings = [...((source.additional_recordings as unknown[]) ?? [])];
const recordingEntry = body.success && body.transcriptionResult
? {
path: (additionalRecordings[body.recordingIndex] as { path?: string } | undefined)?.path ?? '',
transcript: body.transcriptionResult.transcript ?? '',
utterances: body.transcriptionResult.utterances ?? [],
speakers: body.transcriptionResult.speakers ?? {},
speakerMap: body.transcriptionResult.speakerMap ?? {},
languages: body.transcriptionResult.languages ?? [],
primary_language: body.transcriptionResult.primary_language ?? 'de',
status: 'completed',
timestamp: now,
updated_at: now,
route: body.route,
}
: {
...(additionalRecordings[body.recordingIndex] as Record<string, unknown> | undefined ?? {}),
status: 'error',
error: body.error ?? 'Transcription failed',
updated_at: now,
};
const recordingEntry =
body.success && body.transcriptionResult
? {
path:
(additionalRecordings[body.recordingIndex] as { path?: string } | undefined)?.path ??
'',
transcript: body.transcriptionResult.transcript ?? '',
utterances: body.transcriptionResult.utterances ?? [],
speakers: body.transcriptionResult.speakers ?? {},
speakerMap: body.transcriptionResult.speakerMap ?? {},
languages: body.transcriptionResult.languages ?? [],
primary_language: body.transcriptionResult.primary_language ?? 'de',
status: 'completed',
timestamp: now,
updated_at: now,
route: body.route,
}
: {
...((additionalRecordings[body.recordingIndex] as Record<string, unknown> | undefined) ??
{}),
status: 'error',
error: body.error ?? 'Transcription failed',
updated_at: now,
};
additionalRecordings[body.recordingIndex] = recordingEntry;
@ -139,7 +113,7 @@ internalRoutes.post('/append-transcription-completed', async (c) => {
if (updateError) {
console.error('[internal] Failed to update append transcription:', updateError);
return c.json({ error: 'Failed to update memo' }, 500);
return c.json({ success: false, error: 'Failed to update memo' }, 500);
}
return c.json({ success: true, memoId: body.memoId, recordingIndex: body.recordingIndex });
@ -147,16 +121,9 @@ internalRoutes.post('/append-transcription-completed', async (c) => {
// POST /batch-metadata — update memo with batch job metadata
internalRoutes.post('/batch-metadata', async (c) => {
const body = await c.req.json<{
memoId: string;
jobId: string;
batchTranscription?: boolean;
userId?: string;
}>();
if (!body.memoId || !body.jobId) {
return c.json({ error: 'memoId and jobId are required' }, 400);
}
const v = await validateBody(c, batchMetadataBody);
if (!v.success) return v.response;
const body = v.data;
const supabase = createServiceClient();
@ -167,7 +134,7 @@ internalRoutes.post('/batch-metadata', async (c) => {
.single();
if (fetchError || !memo) {
return c.json({ error: 'Memo not found' }, 404);
return c.json({ success: false, error: 'Memo not found' }, 404);
}
const metadata = (memo as { metadata: Record<string, unknown> }).metadata ?? {};
@ -184,7 +151,7 @@ internalRoutes.post('/batch-metadata', async (c) => {
.eq('id', body.memoId);
if (updateError) {
return c.json({ error: 'Failed to update batch metadata' }, 500);
return c.json({ success: false, error: 'Failed to update batch metadata' }, 500);
}
return c.json({ success: true, memoId: body.memoId, jobId: body.jobId });

View file

@ -5,6 +5,8 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@manacore/shared-hono';
import { acceptInvite, declineInvite, getPendingInvites } from '../services/space';
import { validateBody } from '../lib/validate';
import { inviteActionBody } from '../schemas';
export const inviteRoutes = new Hono<{ Variables: AuthVariables }>();
@ -13,47 +15,45 @@ inviteRoutes.get('/pending', async (c) => {
const userId = c.get('userId') as string;
try {
const invites = await getPendingInvites(userId);
return c.json({ invites });
return c.json({ success: true, invites });
} catch (err) {
console.error('[invites] Get pending error:', err);
return c.json({ error: 'Failed to get pending invites' }, 500);
return c.json({ success: false, error: 'Failed to get pending invites' }, 500);
}
});
// POST /accept — accept an invite
inviteRoutes.post('/accept', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ inviteId: string }>();
if (!body.inviteId) return c.json({ error: 'inviteId is required' }, 400);
const v = await validateBody(c, inviteActionBody);
if (!v.success) return v.response;
try {
const result = await acceptInvite(body.inviteId, userId);
return c.json(result);
await acceptInvite(v.data.inviteId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('not found') || msg.includes('already processed')) {
return c.json({ error: msg }, 404);
return c.json({ success: false, error: msg }, 404);
}
return c.json({ error: 'Failed to accept invite' }, 500);
return c.json({ success: false, error: 'Failed to accept invite' }, 500);
}
});
// POST /decline — decline an invite
inviteRoutes.post('/decline', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ inviteId: string }>();
if (!body.inviteId) return c.json({ error: 'inviteId is required' }, 400);
const v = await validateBody(c, inviteActionBody);
if (!v.success) return v.response;
try {
const result = await declineInvite(body.inviteId, userId);
return c.json(result);
await declineInvite(v.data.inviteId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('not found') || msg.includes('already processed')) {
return c.json({ error: msg }, 404);
return c.json({ success: false, error: msg }, 404);
}
return c.json({ error: 'Failed to decline invite' }, 500);
return c.json({ success: false, error: 'Failed to decline invite' }, 500);
}
});

View file

@ -6,8 +6,9 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@manacore/shared-hono';
import { validateCredits, COSTS } from '../lib/credits';
import { validateBody, validateQuery } from '../lib/validate';
import { createBotBody, recordingToMemoBody, paginationQuery } from '../schemas';
import {
validateMeetingUrl,
createBot,
stopBot,
getBots,
@ -24,20 +25,16 @@ const MINIMUM_RECORDING_CREDITS = 10;
// POST /bots — create a meeting bot
meetingRoutes.post('/bots', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ meeting_url?: string; space_id?: string }>();
if (!body.meeting_url || !validateMeetingUrl(body.meeting_url)) {
return c.json(
{ error: 'Please provide a valid Teams, Google Meet, or Zoom meeting URL' },
400
);
}
const v = await validateBody(c, createBotBody);
if (!v.success) return v.response;
const { meeting_url, space_id } = v.data;
// Validate minimum credits
const creditCheck = await validateCredits(userId, 'meeting_recording', MINIMUM_RECORDING_CREDITS);
if (!creditCheck.hasCredits) {
return c.json(
{
success: false,
error: 'InsufficientCredits',
message: `Not enough credits to start recording. Need at least ${MINIMUM_RECORDING_CREDITS} credits.`,
details: {
@ -50,8 +47,10 @@ meetingRoutes.post('/bots', async (c) => {
}
try {
const webhookBaseUrl = (process.env.MEMORO_SERVER_URL ?? `http://localhost:${process.env.PORT ?? 3015}`).replace(/\/$/, '');
const bot = await createBot(userId, body.meeting_url, webhookBaseUrl, body.space_id);
const webhookBaseUrl = (
process.env.MEMORO_SERVER_URL ?? `http://localhost:${process.env.PORT ?? 3015}`
).replace(/\/$/, '');
const bot = await createBot(userId, meeting_url, webhookBaseUrl, space_id);
return c.json({
success: true,
bot,
@ -64,23 +63,24 @@ meetingRoutes.post('/bots', async (c) => {
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create meeting bot';
return c.json({ error: msg }, 400);
return c.json({ success: false, error: msg }, 400);
}
});
// GET /bots — list bots
// GET /bots — list bots (with pagination)
meetingRoutes.get('/bots', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.query('space_id');
const limit = Number(c.req.query('limit') ?? 50);
const offset = Number(c.req.query('offset') ?? 0);
const q = validateQuery(c, paginationQuery);
if (!q.success) return q.response;
const { limit, offset } = q.data;
try {
const bots = await getBots(userId, spaceId, limit, offset);
return c.json({ success: true, bots, total: bots.length });
return c.json({ success: true, bots, total: bots.length, limit, offset });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch bots';
return c.json({ error: msg }, 500);
return c.json({ success: false, error: msg }, 500);
}
});
@ -90,7 +90,7 @@ meetingRoutes.get('/bots/:id', async (c) => {
const botId = c.req.param('id');
const bot = await getBotById(botId, userId);
if (!bot) return c.json({ error: 'Bot not found' }, 404);
if (!bot) return c.json({ success: false, error: 'Bot not found' }, 404);
return c.json({ success: true, bot });
});
@ -106,23 +106,24 @@ meetingRoutes.post('/bots/:id/stop', async (c) => {
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to stop bot';
const status = msg === 'Bot not found' ? 404 : 400;
return c.json({ error: msg }, status);
return c.json({ success: false, error: msg }, status);
}
});
// GET /recordings — list recordings
// GET /recordings — list recordings (with pagination)
meetingRoutes.get('/recordings', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.query('space_id');
const limit = Number(c.req.query('limit') ?? 50);
const offset = Number(c.req.query('offset') ?? 0);
const q = validateQuery(c, paginationQuery);
if (!q.success) return q.response;
const { limit, offset } = q.data;
try {
const recordings = await getRecordings(userId, spaceId, limit, offset);
return c.json({ success: true, recordings, total: recordings.length });
return c.json({ success: true, recordings, total: recordings.length, limit, offset });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch recordings';
return c.json({ error: msg }, 500);
return c.json({ success: false, error: msg }, 500);
}
});
@ -132,7 +133,7 @@ meetingRoutes.get('/recordings/:id', async (c) => {
const recordingId = c.req.param('id');
const recording = await getRecordingById(recordingId, userId);
if (!recording) return c.json({ error: 'Recording not found' }, 404);
if (!recording) return c.json({ success: false, error: 'Recording not found' }, 404);
return c.json({ success: true, recording });
});
@ -141,14 +142,19 @@ meetingRoutes.get('/recordings/:id', async (c) => {
meetingRoutes.post('/recordings/:id/to-memo', async (c) => {
const userId = c.get('userId') as string;
const recordingId = c.req.param('id');
const body = await c.req.json<{ blueprintId?: string }>().catch(() => ({ blueprintId: undefined }));
const v = await validateBody(c, recordingToMemoBody).catch(() => ({
success: true as const,
data: { blueprintId: undefined },
}));
if (!v.success) return v.response;
const authHeader = c.req.header('Authorization');
const recording = await getRecordingById(recordingId, userId);
if (!recording) return c.json({ error: 'Recording not found' }, 404);
if (!recording) return c.json({ success: false, error: 'Recording not found' }, 404);
const filePath = recording.audio_url || recording.video_url;
if (!filePath) return c.json({ error: 'Recording has no audio or video file' }, 400);
if (!filePath)
return c.json({ success: false, error: 'Recording has no audio or video file' }, 400);
const duration = recording.duration_seconds ?? 45;
@ -164,16 +170,22 @@ meetingRoutes.post('/recordings/:id/to-memo', async (c) => {
filePath,
duration,
spaceId: recording.space_id,
blueprintId: body.blueprintId,
blueprintId: v.data.blueprintId,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({ message: 'Unknown error' })) as Record<string, unknown>;
return c.json({ error: (errData.message as string) || 'Failed to process recording' }, 400);
const errData = (await response.json().catch(() => ({ message: 'Unknown error' }))) as Record<
string,
unknown
>;
return c.json(
{ success: false, error: (errData.message as string) || 'Failed to process recording' },
400
);
}
const result = await response.json() as Record<string, unknown>;
const result = (await response.json()) as Record<string, unknown>;
return c.json({
success: true,
memoId: result.memoId,
@ -184,6 +196,6 @@ meetingRoutes.post('/recordings/:id/to-memo', async (c) => {
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to convert recording to memo';
return c.json({ error: msg }, 500);
return c.json({ success: false, error: msg }, 500);
}
});

View file

@ -14,26 +14,17 @@ import { processHeadlineForMemo } from '../services/headline';
import { createServiceClient } from '../lib/supabase';
import { validateCredits, consumeCredits, COSTS } from '../lib/credits';
import { generateText } from '../lib/ai';
import { validateBody } from '../lib/validate';
import { createMemoBody, appendMemoBody, combineMemoBody, questionMemoBody } from '../schemas';
export const memoRoutes = new Hono<{ Variables: AuthVariables }>();
// POST / — create memo from uploaded file
memoRoutes.post('/', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
filePath: string;
duration: number;
spaceId?: string;
blueprintId?: string;
memoId?: string;
recordingStartedAt?: string;
location?: unknown;
mediaType?: string;
}>();
if (!body.filePath || body.duration == null) {
return c.json({ error: 'filePath and duration are required' }, 400);
}
const v = await validateBody(c, createMemoBody);
if (!v.success) return v.response;
const body = v.data;
try {
const result = await createMemoFromUploadedFile({
@ -47,12 +38,12 @@ memoRoutes.post('/', async (c) => {
...(body.location !== undefined ? { location: body.location } : {}),
...(body.mediaType ? { mediaType: body.mediaType } : {}),
});
return c.json(result, 201);
return c.json({ success: true, ...result }, 201);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Insufficient credits')) return c.json({ error: msg }, 402);
if (msg.includes('Insufficient credits')) return c.json({ success: false, error: msg }, 402);
console.error('[memos] Create error:', err);
return c.json({ error: 'Failed to create memo' }, 500);
return c.json({ success: false, error: 'Failed to create memo' }, 500);
}
});
@ -60,17 +51,9 @@ memoRoutes.post('/', async (c) => {
memoRoutes.post('/:id/append', async (c) => {
const userId = c.get('userId') as string;
const memoId = c.req.param('id');
const body = await c.req.json<{
filePath: string;
duration: number;
recordingIndex?: number;
recordingLanguages?: string[];
enableDiarization?: boolean;
}>();
if (!body.filePath || body.duration == null) {
return c.json({ error: 'filePath and duration are required' }, 400);
}
const v = await validateBody(c, appendMemoBody);
if (!v.success) return v.response;
const body = v.data;
const supabase = createServiceClient();
@ -83,14 +66,14 @@ memoRoutes.post('/:id/append', async (c) => {
.single();
if (memoError || !memo) {
return c.json({ error: 'Memo not found or access denied' }, 404);
return c.json({ success: false, error: 'Memo not found or access denied' }, 404);
}
// Validate credits
const cost = Math.max(Math.ceil((body.duration / 60) * COSTS.TRANSCRIPTION_PER_MINUTE), 2);
const creditCheck = await validateCredits(userId, 'transcription', cost);
if (!creditCheck.hasCredits) {
return c.json({ error: `Insufficient credits: need ${cost}` }, 402);
return c.json({ success: false, error: `Insufficient credits: need ${cost}` }, 402);
}
// Set processing status
@ -123,7 +106,9 @@ memoRoutes.post('/:id/append', async (c) => {
duration: body.duration,
recordingIndex,
...(body.recordingLanguages ? { recordingLanguages: body.recordingLanguages } : {}),
...(body.enableDiarization !== undefined ? { enableDiarization: body.enableDiarization } : {}),
...(body.enableDiarization !== undefined
? { enableDiarization: body.enableDiarization }
: {}),
isAppend: true,
}).catch((err) => console.error(`[memos] Append transcription call failed: ${err}`));
});
@ -144,7 +129,8 @@ memoRoutes.post('/:id/retry-transcription', async (c) => {
.eq('user_id', userId)
.single();
if (error || !memo) return c.json({ error: 'Memo not found or access denied' }, 404);
if (error || !memo)
return c.json({ success: false, error: 'Memo not found or access denied' }, 404);
const memoData = memo as {
source: { audio_path?: string; duration?: number };
@ -153,7 +139,8 @@ memoRoutes.post('/:id/retry-transcription', async (c) => {
const filePath = memoData.source?.audio_path;
const duration = memoData.source?.duration ?? 0;
if (!filePath) return c.json({ error: 'No audio file associated with this memo' }, 400);
if (!filePath)
return c.json({ success: false, error: 'No audio file associated with this memo' }, 400);
await updateMemoProcessingStatus(memoId, 'transcription', 'pending');
@ -180,29 +167,31 @@ memoRoutes.post('/:id/retry-headline', async (c) => {
.eq('user_id', userId)
.single();
if (error || !memo) return c.json({ error: 'Memo not found or access denied' }, 404);
if (error || !memo)
return c.json({ success: false, error: 'Memo not found or access denied' }, 404);
try {
const result = await processHeadlineForMemo(memoId);
return c.json(result);
return c.json({ success: true, ...result });
} catch (err) {
console.error(`[memos] Retry headline failed for ${memoId}:`, err);
return c.json({ error: 'Headline generation failed' }, 500);
return c.json({ success: false, error: 'Headline generation failed' }, 500);
}
});
// POST /combine — combine multiple memos with AI
memoRoutes.post('/combine', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ memoIds: string[] }>();
if (!Array.isArray(body.memoIds) || body.memoIds.length < 2) {
return c.json({ error: 'At least 2 memoIds are required' }, 400);
}
const v = await validateBody(c, combineMemoBody);
if (!v.success) return v.response;
const { memoIds } = v.data;
const creditCheck = await validateCredits(userId, 'memo_combine', COSTS.MEMO_COMBINE);
if (!creditCheck.hasCredits) {
return c.json({ error: `Insufficient credits: need ${COSTS.MEMO_COMBINE}` }, 402);
return c.json(
{ success: false, error: `Insufficient credits: need ${COSTS.MEMO_COMBINE}` },
402
);
}
const supabase = createServiceClient();
@ -211,11 +200,11 @@ memoRoutes.post('/combine', async (c) => {
const { data: memos, error: fetchError } = await supabase
.from('memos')
.select('id, title, source')
.in('id', body.memoIds)
.in('id', memoIds)
.eq('user_id', userId);
if (fetchError || !memos || memos.length !== body.memoIds.length) {
return c.json({ error: 'One or more memos not found or access denied' }, 404);
if (fetchError || !memos || memos.length !== memoIds.length) {
return c.json({ success: false, error: 'One or more memos not found or access denied' }, 404);
}
// Extract transcripts
@ -248,7 +237,7 @@ CONTENT: <kombinierter Text>`;
const response = await generateText(prompt, { temperature: 0.7, maxTokens: 2048 });
await consumeCredits(userId, 'memo_combine', COSTS.MEMO_COMBINE, 'Combine memos', {
memoIds: body.memoIds,
memoIds,
});
// Create combined memo
@ -270,7 +259,7 @@ CONTENT: <kombinierter Text>`;
source: {
type: 'combined',
transcript: content,
source_memo_ids: body.memoIds,
source_memo_ids: memoIds,
},
metadata: {
processing: {
@ -285,10 +274,10 @@ CONTENT: <kombinierter Text>`;
if (createError) throw createError;
return c.json({ memo: combinedMemo, headline, intro });
return c.json({ success: true, memo: combinedMemo, headline, intro });
} catch (err) {
console.error('[memos] Combine failed:', err);
return c.json({ error: 'Failed to combine memos' }, 500);
return c.json({ success: false, error: 'Failed to combine memos' }, 500);
}
});
@ -296,15 +285,16 @@ CONTENT: <kombinierter Text>`;
memoRoutes.post('/:id/question', async (c) => {
const userId = c.get('userId') as string;
const memoId = c.req.param('id');
const body = await c.req.json<{ question: string }>();
if (!body.question?.trim()) {
return c.json({ error: 'question is required' }, 400);
}
const v = await validateBody(c, questionMemoBody);
if (!v.success) return v.response;
const { question } = v.data;
const creditCheck = await validateCredits(userId, 'question_memo', COSTS.QUESTION_MEMO);
if (!creditCheck.hasCredits) {
return c.json({ error: `Insufficient credits: need ${COSTS.QUESTION_MEMO}` }, 402);
return c.json(
{ success: false, error: `Insufficient credits: need ${COSTS.QUESTION_MEMO}` },
402
);
}
const supabase = createServiceClient();
@ -316,7 +306,8 @@ memoRoutes.post('/:id/question', async (c) => {
.eq('user_id', userId)
.single();
if (memoError || !memo) return c.json({ error: 'Memo not found or access denied' }, 404);
if (memoError || !memo)
return c.json({ success: false, error: 'Memo not found or access denied' }, 404);
const memoData = memo as { title: string; source: Record<string, unknown> };
const source = memoData.source ?? {};
@ -333,14 +324,15 @@ memoRoutes.post('/:id/question', async (c) => {
transcript = (source.transcript as string | undefined) ?? memoData.title;
}
if (!transcript) return c.json({ error: 'No transcript available for this memo' }, 400);
if (!transcript)
return c.json({ success: false, error: 'No transcript available for this memo' }, 400);
const prompt = `Du bist ein hilfreicher Assistent. Beantworte die folgende Frage basierend auf dem Transkript der Sprachaufnahme.
Transkript:
${transcript}
Frage: ${body.question}
Frage: ${question}
Antworte präzise und klar. Falls die Frage nicht aus dem Transkript beantwortet werden kann, sage das explizit.`;
@ -351,9 +343,9 @@ Antworte präzise und klar. Falls die Frage nicht aus dem Transkript beantwortet
memoId,
});
return c.json({ answer, memoId, question: body.question });
return c.json({ success: true, answer, memoId, question });
} catch (err) {
console.error('[memos] Q&A failed:', err);
return c.json({ error: 'Failed to answer question' }, 500);
return c.json({ success: false, error: 'Failed to answer question' }, 500);
}
});

View file

@ -7,6 +7,8 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@manacore/shared-hono';
import { createServiceClient } from '../lib/supabase';
import { validateBody } from '../lib/validate';
import { updateMemoroSettingsBody, updateDataUsageBody, updateProfileBody } from '../schemas';
export const settingsRoutes = new Hono<{ Variables: AuthVariables }>();
@ -23,10 +25,10 @@ settingsRoutes.get('/', async (c) => {
if (error) {
console.error('[settings] Get all error:', error);
return c.json({ error: 'Failed to get settings' }, 500);
return c.json({ success: false, error: 'Failed to get settings' }, 500);
}
return c.json({ settings: profile ?? {} });
return c.json({ success: true, settings: profile ?? {} });
});
// GET /memoro — get memoro-specific settings
@ -42,19 +44,22 @@ settingsRoutes.get('/memoro', async (c) => {
if (error) {
console.error('[settings] Get memoro error:', error);
return c.json({ error: 'Failed to get memoro settings' }, 500);
return c.json({ success: false, error: 'Failed to get memoro settings' }, 500);
}
const appSettings = (profile as { app_settings?: Record<string, unknown> } | null)?.app_settings ?? {};
const appSettings =
(profile as { app_settings?: Record<string, unknown> } | null)?.app_settings ?? {};
const memoroSettings = (appSettings.memoro as Record<string, unknown>) ?? {};
return c.json({ settings: memoroSettings });
return c.json({ success: true, settings: memoroSettings });
});
// PATCH /memoro — update memoro settings
settingsRoutes.patch('/memoro', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<Record<string, unknown>>();
const v = await validateBody(c, updateMemoroSettingsBody);
if (!v.success) return v.response;
const body = v.data;
const supabase = createServiceClient();
// Get current settings
@ -65,7 +70,7 @@ settingsRoutes.patch('/memoro', async (c) => {
.maybeSingle();
if (fetchError) {
return c.json({ error: 'Failed to fetch current settings' }, 500);
return c.json({ success: false, error: 'Failed to fetch current settings' }, 500);
}
const currentSettings =
@ -88,7 +93,7 @@ settingsRoutes.patch('/memoro', async (c) => {
if (upsertError) {
console.error('[settings] Update memoro error:', upsertError);
return c.json({ error: 'Failed to update memoro settings' }, 500);
return c.json({ success: false, error: 'Failed to update memoro settings' }, 500);
}
return c.json({ success: true, settings: { ...currentMemoro, ...body } });
@ -97,7 +102,9 @@ settingsRoutes.patch('/memoro', async (c) => {
// PATCH /memoro/data-usage — update data usage acceptance flag
settingsRoutes.patch('/memoro/data-usage', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ accepted: boolean }>();
const v = await validateBody(c, updateDataUsageBody);
if (!v.success) return v.response;
const { accepted } = v.data;
const supabase = createServiceClient();
const { data: profile, error: fetchError } = await supabase
@ -107,7 +114,7 @@ settingsRoutes.patch('/memoro/data-usage', async (c) => {
.maybeSingle();
if (fetchError) {
return c.json({ error: 'Failed to fetch current settings' }, 500);
return c.json({ success: false, error: 'Failed to fetch current settings' }, 500);
}
const currentSettings =
@ -118,8 +125,8 @@ settingsRoutes.patch('/memoro/data-usage', async (c) => {
...currentSettings,
memoro: {
...currentMemoro,
dataUsageAcceptance: body.accepted,
dataUsageAcceptedAt: body.accepted ? new Date().toISOString() : null,
dataUsageAcceptance: accepted,
dataUsageAcceptedAt: accepted ? new Date().toISOString() : null,
},
};
@ -134,43 +141,35 @@ settingsRoutes.patch('/memoro/data-usage', async (c) => {
if (upsertError) {
console.error('[settings] Update data-usage error:', upsertError);
return c.json({ error: 'Failed to update data usage settings' }, 500);
return c.json({ success: false, error: 'Failed to update data usage settings' }, 500);
}
return c.json({ success: true, dataUsageAcceptance: body.accepted });
return c.json({ success: true, dataUsageAcceptance: accepted });
});
// PATCH /profile — update user profile fields
settingsRoutes.patch('/profile', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
display_name?: string;
avatar_url?: string;
bio?: string;
}>();
const v = await validateBody(c, updateProfileBody);
if (!v.success) return v.response;
const body = v.data;
const allowedFields = ['display_name', 'avatar_url', 'bio'] as const;
const updateData: Record<string, unknown> = { user_id: userId, updated_at: new Date().toISOString() };
const updateData: Record<string, unknown> = {
user_id: userId,
updated_at: new Date().toISOString(),
};
for (const field of allowedFields) {
if (body[field] !== undefined) {
updateData[field] = body[field];
}
}
if (Object.keys(updateData).length <= 2) {
return c.json({ error: 'No valid fields provided' }, 400);
}
if (body.display_name !== undefined) updateData.display_name = body.display_name;
if (body.avatar_url !== undefined) updateData.avatar_url = body.avatar_url;
if (body.bio !== undefined) updateData.bio = body.bio;
const supabase = createServiceClient();
const { error } = await supabase
.from('profiles')
.upsert(updateData, { onConflict: 'user_id' });
const { error } = await supabase.from('profiles').upsert(updateData, { onConflict: 'user_id' });
if (error) {
console.error('[settings] Update profile error:', error);
return c.json({ error: 'Failed to update profile' }, 500);
return c.json({ success: false, error: 'Failed to update profile' }, 500);
}
return c.json({ success: true });

View file

@ -5,6 +5,8 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@manacore/shared-hono';
import { createServiceClient } from '../lib/supabase';
import { validateBody, validateQuery } from '../lib/validate';
import { createSpaceBody, linkMemoBody, inviteBody, paginationQuery } from '../schemas';
import {
getSpaces,
createSpace,
@ -20,31 +22,37 @@ import {
export const spaceRoutes = new Hono<{ Variables: AuthVariables }>();
// GET / — list user's spaces
// GET / — list user's spaces (with pagination)
spaceRoutes.get('/', async (c) => {
const userId = c.get('userId') as string;
const q = validateQuery(c, paginationQuery);
if (!q.success) return q.response;
const { limit, offset } = q.data;
try {
const spaces = await getSpaces(userId);
return c.json({ spaces });
const allSpaces = await getSpaces(userId);
const total = allSpaces.length;
const spaces = allSpaces.slice(offset, offset + limit);
return c.json({ success: true, spaces, total, limit, offset });
} catch (err) {
console.error('[spaces] Get spaces error:', err);
return c.json({ error: 'Failed to get spaces' }, 500);
return c.json({ success: false, error: 'Failed to get spaces' }, 500);
}
});
// POST / — create space
spaceRoutes.post('/', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{ name: string; description?: string }>();
if (!body.name?.trim()) return c.json({ error: 'name is required' }, 400);
const v = await validateBody(c, createSpaceBody);
if (!v.success) return v.response;
const { name, description } = v.data;
try {
const space = await createSpace(userId, body.name, body.description);
return c.json({ space }, 201);
const space = await createSpace(userId, name, description);
return c.json({ success: true, space }, 201);
} catch (err) {
console.error('[spaces] Create error:', err);
return c.json({ error: 'Failed to create space' }, 500);
return c.json({ success: false, error: 'Failed to create space' }, 500);
}
});
@ -55,14 +63,14 @@ spaceRoutes.get('/:id', async (c) => {
try {
const space = await getSpaceDetails(spaceId, userId);
return c.json({ space });
return c.json({ success: true, space });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Access denied') || msg.includes('not a member')) {
return c.json({ error: msg }, 403);
return c.json({ success: false, error: msg }, 403);
}
if (msg.includes('not found')) return c.json({ error: msg }, 404);
return c.json({ error: 'Failed to get space details' }, 500);
if (msg.includes('not found')) return c.json({ success: false, error: msg }, 404);
return c.json({ success: false, error: 'Failed to get space details' }, 500);
}
});
@ -72,13 +80,13 @@ spaceRoutes.delete('/:id', async (c) => {
const spaceId = c.req.param('id');
try {
const result = await deleteSpace(spaceId, userId);
return c.json(result);
await deleteSpace(spaceId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('owner')) return c.json({ error: msg }, 403);
if (msg.includes('not found')) return c.json({ error: msg }, 404);
return c.json({ error: 'Failed to delete space' }, 500);
if (msg.includes('owner')) return c.json({ success: false, error: msg }, 403);
if (msg.includes('not found')) return c.json({ success: false, error: msg }, 404);
return c.json({ success: false, error: 'Failed to delete space' }, 500);
}
});
@ -88,13 +96,13 @@ spaceRoutes.post('/:id/leave', async (c) => {
const spaceId = c.req.param('id');
try {
const result = await leaveSpace(spaceId, userId);
return c.json(result);
await leaveSpace(spaceId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('not a member')) return c.json({ error: msg }, 403);
if (msg.includes('owner')) return c.json({ error: msg }, 400);
return c.json({ error: 'Failed to leave space' }, 500);
if (msg.includes('not a member')) return c.json({ success: false, error: msg }, 403);
if (msg.includes('owner')) return c.json({ success: false, error: msg }, 400);
return c.json({ success: false, error: 'Failed to leave space' }, 500);
}
});
@ -102,20 +110,19 @@ spaceRoutes.post('/:id/leave', async (c) => {
spaceRoutes.post('/:id/memos/link', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.param('id');
const body = await c.req.json<{ memoId: string }>();
if (!body.memoId) return c.json({ error: 'memoId is required' }, 400);
const v = await validateBody(c, linkMemoBody);
if (!v.success) return v.response;
try {
const result = await linkMemoToSpace(body.memoId, spaceId, userId);
return c.json(result);
await linkMemoToSpace(v.data.memoId, spaceId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('access denied') || msg.includes('Not a member')) {
return c.json({ error: msg }, 403);
return c.json({ success: false, error: msg }, 403);
}
if (msg.includes('not found')) return c.json({ error: msg }, 404);
return c.json({ error: 'Failed to link memo to space' }, 500);
if (msg.includes('not found')) return c.json({ success: false, error: msg }, 404);
return c.json({ success: false, error: 'Failed to link memo to space' }, 500);
}
});
@ -123,33 +130,39 @@ spaceRoutes.post('/:id/memos/link', async (c) => {
spaceRoutes.post('/:id/memos/unlink', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.param('id');
const body = await c.req.json<{ memoId: string }>();
if (!body.memoId) return c.json({ error: 'memoId is required' }, 400);
const v = await validateBody(c, linkMemoBody);
if (!v.success) return v.response;
try {
const result = await unlinkMemoFromSpace(body.memoId, spaceId, userId);
return c.json(result);
await unlinkMemoFromSpace(v.data.memoId, spaceId, userId);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('access denied')) return c.json({ error: msg }, 403);
if (msg.includes('not found')) return c.json({ error: msg }, 404);
return c.json({ error: 'Failed to unlink memo from space' }, 500);
if (msg.includes('access denied')) return c.json({ success: false, error: msg }, 403);
if (msg.includes('not found')) return c.json({ success: false, error: msg }, 404);
return c.json({ success: false, error: 'Failed to unlink memo from space' }, 500);
}
});
// GET /:id/memos — list space memos
// GET /:id/memos — list space memos (with pagination)
spaceRoutes.get('/:id/memos', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.param('id');
const q = validateQuery(c, paginationQuery);
if (!q.success) return q.response;
const { limit, offset } = q.data;
try {
const result = await getSpaceMemos(spaceId, userId);
return c.json(result);
const allMemos = result.memos ?? result;
const memos = Array.isArray(allMemos) ? allMemos : [];
const total = memos.length;
const paginated = memos.slice(offset, offset + limit);
return c.json({ success: true, memos: paginated, total, limit, offset });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Not a member')) return c.json({ error: msg }, 403);
return c.json({ error: 'Failed to get space memos' }, 500);
if (msg.includes('Not a member')) return c.json({ success: false, error: msg }, 403);
return c.json({ success: false, error: 'Failed to get space memos' }, 500);
}
});
@ -160,11 +173,11 @@ spaceRoutes.get('/:id/invites', async (c) => {
try {
const invites = await getSpaceInvites(spaceId, userId);
return c.json({ invites });
return c.json({ success: true, invites });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Not a member')) return c.json({ error: msg }, 403);
return c.json({ error: 'Failed to get invites' }, 500);
if (msg.includes('Not a member')) return c.json({ success: false, error: msg }, 403);
return c.json({ success: false, error: 'Failed to get invites' }, 500);
}
});
@ -172,25 +185,22 @@ spaceRoutes.get('/:id/invites', async (c) => {
spaceRoutes.post('/:id/invite', async (c) => {
const userId = c.get('userId') as string;
const spaceId = c.req.param('id');
const body = await c.req.json<{ email: string }>();
if (!body.email?.trim()) return c.json({ error: 'email is required' }, 400);
const v = await validateBody(c, inviteBody);
if (!v.success) return v.response;
try {
const invite = await createInvite(spaceId, userId, body.email);
return c.json({ invite }, 201);
const invite = await createInvite(spaceId, userId, v.data.email);
return c.json({ success: true, invite }, 201);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Not a member')) return c.json({ error: msg }, 403);
return c.json({ error: 'Failed to create invite' }, 500);
if (msg.includes('Not a member')) return c.json({ success: false, error: msg }, 403);
return c.json({ success: false, error: 'Failed to create invite' }, 500);
}
});
// POST /invites/:inviteId/resend — resend invite
spaceRoutes.post('/invites/:inviteId/resend', async (c) => {
const inviteId = c.req.param('inviteId');
// In a full implementation, this would resend the invite email via mana-notify
// For now, return success as the invite record already exists
console.log(`[spaces] Resend invite ${inviteId} (email notification not implemented here)`);
return c.json({ success: true, inviteId });
});
@ -208,7 +218,7 @@ spaceRoutes.delete('/invites/:inviteId', async (c) => {
.eq('id', inviteId)
.single();
if (error || !invite) return c.json({ error: 'Invite not found' }, 404);
if (error || !invite) return c.json({ success: false, error: 'Invite not found' }, 404);
const inv = invite as { inviter_id: string; space_id: string };
@ -223,14 +233,11 @@ spaceRoutes.delete('/invites/:inviteId', async (c) => {
const isOwner = (spaceMember as { role: string } | null)?.role === 'owner';
if (inv.inviter_id !== userId && !isOwner) {
return c.json({ error: 'Not authorized to cancel this invite' }, 403);
return c.json({ success: false, error: 'Not authorized to cancel this invite' }, 403);
}
const { error: deleteError } = await supabase
.from('space_invites')
.delete()
.eq('id', inviteId);
const { error: deleteError } = await supabase.from('space_invites').delete().eq('id', inviteId);
if (deleteError) return c.json({ error: 'Failed to cancel invite' }, 500);
if (deleteError) return c.json({ success: false, error: 'Failed to cancel invite' }, 500);
return c.json({ success: true });
});

View file

@ -0,0 +1,194 @@
/**
* Zod validation schemas for all Memoro API endpoints.
*/
import { z } from 'zod';
// ── Shared ────────────────────────────────────────────────────────────────────
export const paginationQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
// ── Memos ─────────────────────────────────────────────────────────────────────
export const createMemoBody = z.object({
filePath: z.string().min(1, 'filePath is required'),
duration: z.number({ required_error: 'duration is required' }).min(0),
spaceId: z.string().uuid().optional(),
blueprintId: z.string().uuid().optional(),
memoId: z.string().uuid().optional(),
recordingStartedAt: z.string().optional(),
location: z.unknown().optional(),
mediaType: z.string().optional(),
});
export const appendMemoBody = z.object({
filePath: z.string().min(1, 'filePath is required'),
duration: z.number({ required_error: 'duration is required' }).min(0),
recordingIndex: z.number().int().min(0).optional(),
recordingLanguages: z.array(z.string()).optional(),
enableDiarization: z.boolean().optional(),
});
export const combineMemoBody = z.object({
memoIds: z.array(z.string().uuid()).min(2, 'At least 2 memoIds are required'),
});
export const questionMemoBody = z.object({
question: z
.string()
.min(1, 'question is required')
.transform((v) => v.trim()),
});
// ── Spaces ────────────────────────────────────────────────────────────────────
export const createSpaceBody = z.object({
name: z
.string()
.min(1, 'name is required')
.transform((v) => v.trim()),
description: z.string().optional(),
});
export const linkMemoBody = z.object({
memoId: z.string().uuid('memoId must be a valid UUID'),
});
export const inviteBody = z.object({
email: z
.string()
.email('Valid email is required')
.transform((v) => v.trim()),
});
// ── Invites ───────────────────────────────────────────────────────────────────
export const inviteActionBody = z.object({
inviteId: z.string().uuid('inviteId is required'),
});
// ── Meetings ──────────────────────────────────────────────────────────────────
const meetingUrlPattern = /^https:\/\/(teams\.microsoft\.com|meet\.google\.com|[\w-]+\.zoom\.us)\//;
export const createBotBody = z.object({
meeting_url: z
.string()
.regex(meetingUrlPattern, 'Please provide a valid Teams, Google Meet, or Zoom meeting URL'),
space_id: z.string().uuid().optional(),
});
export const recordingToMemoBody = z.object({
blueprintId: z.string().uuid().optional(),
});
// ── Credits ───────────────────────────────────────────────────────────────────
export const checkCreditsBody = z.object({
operation: z.string().min(1, 'operation is required'),
amount: z.number({ required_error: 'amount is required' }).min(0),
});
export const consumeCreditsBody = z.object({
operation: z.string().min(1, 'operation is required'),
amount: z.number({ required_error: 'amount is required' }).min(0),
description: z.string().min(1, 'description is required'),
metadata: z.record(z.unknown()).optional(),
});
// ── Settings ──────────────────────────────────────────────────────────────────
export const updateMemoroSettingsBody = z
.record(z.unknown())
.refine((obj) => Object.keys(obj).length > 0, 'At least one setting is required');
export const updateDataUsageBody = z.object({
accepted: z.boolean({ required_error: 'accepted is required' }),
});
export const updateProfileBody = z
.object({
display_name: z.string().optional(),
avatar_url: z.string().url().optional(),
bio: z.string().max(500).optional(),
})
.refine(
(obj) => Object.values(obj).some((v) => v !== undefined),
'At least one field is required'
);
// ── Internal ──────────────────────────────────────────────────────────────────
export const transcriptionCompletedBody = z.object({
memoId: z.string().min(1, 'memoId is required'),
userId: z.string().min(1, 'userId is required'),
transcriptionResult: z
.object({
transcript: z.string().optional(),
utterances: z
.array(
z.object({
offset: z.number(),
duration: z.number(),
text: z.string(),
speaker: z.string().optional(),
})
)
.optional(),
speakers: z.record(z.unknown()).optional(),
speakerMap: z.record(z.unknown()).optional(),
languages: z.array(z.string()).optional(),
primary_language: z.string().optional(),
duration: z.number().optional(),
})
.optional(),
route: z.string().optional(),
success: z.boolean(),
error: z.string().optional(),
fallbackStage: z.string().optional(),
});
export const appendTranscriptionCompletedBody = z.object({
memoId: z.string().min(1, 'memoId is required'),
userId: z.string().min(1, 'userId is required'),
recordingIndex: z.number().int().min(0),
transcriptionResult: z
.object({
transcript: z.string().optional(),
utterances: z
.array(
z.object({
offset: z.number(),
duration: z.number(),
text: z.string(),
speaker: z.string().optional(),
})
)
.optional(),
speakers: z.record(z.unknown()).optional(),
speakerMap: z.record(z.unknown()).optional(),
languages: z.array(z.string()).optional(),
primary_language: z.string().optional(),
duration: z.number().optional(),
})
.optional(),
success: z.boolean(),
error: z.string().optional(),
route: z.string().optional(),
});
export const batchMetadataBody = z.object({
memoId: z.string().min(1, 'memoId is required'),
jobId: z.string().min(1, 'jobId is required'),
batchTranscription: z.boolean().optional(),
userId: z.string().optional(),
});
// ── Cleanup ───────────────────────────────────────────────────────────────────
export const manualCleanupBody = z.object({
userIds: z.array(z.string().uuid()).optional(),
});

View file

@ -40,7 +40,7 @@ setInterval(() => {
export function rateLimitMiddleware(options: RateLimitOptions = {}) {
const { max = 100, windowMs = 60_000, keyFn } = options;
return async (c: Context, next: Next) => {
return async (c: Context, next: Next): Promise<void | Response> => {
const key = keyFn
? keyFn(c)
: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||

804
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff