mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
3c47997598
commit
304c1e8b7c
14 changed files with 923 additions and 795 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
apps/memoro/apps/server/src/lib/validate.ts
Normal file
61
apps/memoro/apps/server/src/lib/validate.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
194
apps/memoro/apps/server/src/schemas.ts
Normal file
194
apps/memoro/apps/server/src/schemas.ts
Normal 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(),
|
||||
});
|
||||
|
|
@ -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
804
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue