mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(memoro/server): port meetings module to Hono/Bun (Phase 7)
- services/meetings.ts: proxy service for meeting-bot API + direct Supabase queries (bots, recordings, signed URLs, credit updates) - routes/meetings.ts: authenticated routes — create/list/get/stop bots, list/get recordings, convert recording to memo via internal /api/v1/memos - routes/meetings-webhooks.ts: HMAC-verified webhook handler for recording.completed / recording.failed events, in-memory idempotency - index.ts: mount /api/v1/meetings (auth) and /meetings/webhooks (HMAC) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3f0811043e
commit
aa645c28fd
4 changed files with 607 additions and 0 deletions
|
|
@ -17,6 +17,8 @@ import { creditRoutes } from './routes/credits';
|
|||
import { internalRoutes } from './routes/internal';
|
||||
import { settingsRoutes } from './routes/settings';
|
||||
import { cleanupRoutes } from './routes/cleanup';
|
||||
import { meetingRoutes } from './routes/meetings';
|
||||
import { meetingWebhookRoutes } from './routes/meetings-webhooks';
|
||||
import { COSTS } from './lib/credits';
|
||||
|
||||
const app = new Hono();
|
||||
|
|
@ -68,6 +70,9 @@ app.route('/api/v1/internal', internalRoutes);
|
|||
// Cleanup uses internal API key
|
||||
app.route('/api/v1/cleanup', cleanupRoutes);
|
||||
|
||||
// Meeting bot webhooks — HMAC-verified, no JWT
|
||||
app.route('/meetings/webhooks', meetingWebhookRoutes);
|
||||
|
||||
// ── Authenticated routes ───────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/v1/*', authMiddleware());
|
||||
|
|
@ -77,6 +82,7 @@ app.route('/api/v1/spaces', spaceRoutes);
|
|||
app.route('/api/v1/invites', inviteRoutes);
|
||||
app.route('/api/v1/credits', creditRoutes);
|
||||
app.route('/api/v1/settings', settingsRoutes);
|
||||
app.route('/api/v1/meetings', meetingRoutes);
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
121
apps/memoro/apps/server/src/routes/meetings-webhooks.ts
Normal file
121
apps/memoro/apps/server/src/routes/meetings-webhooks.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Meeting bot webhook routes — no JWT auth, HMAC signature verification.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { consumeCredits, COSTS } from '../lib/credits';
|
||||
import { updateBotCredits, type WebhookEvent } from '../services/meetings';
|
||||
|
||||
export const meetingWebhookRoutes = new Hono();
|
||||
|
||||
const WEBHOOK_SECRET = process.env.MEETING_BOT_WEBHOOK_SECRET ?? '';
|
||||
|
||||
// In-memory idempotency store (last 1000 events)
|
||||
const processedEvents = new Set<string>();
|
||||
|
||||
function verifySignature(payload: string, signature: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return true; // Dev: no secret configured
|
||||
|
||||
if (!signature) return false;
|
||||
|
||||
const [algorithm, provided] = signature.split('=');
|
||||
if (algorithm !== 'sha256' || !provided) return false;
|
||||
|
||||
try {
|
||||
const expected = createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex');
|
||||
const providedBuf = Buffer.from(provided, 'hex');
|
||||
const expectedBuf = Buffer.from(expected, 'hex');
|
||||
if (providedBuf.length !== expectedBuf.length) return false;
|
||||
return timingSafeEqual(providedBuf, expectedBuf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function idempotencyKey(event: WebhookEvent): string {
|
||||
return `${event.bot.id}:${event.event}:${event.timestamp}`;
|
||||
}
|
||||
|
||||
function calcCredits(durationSeconds: number): number {
|
||||
const minutes = durationSeconds / 60;
|
||||
return Math.max(Math.ceil(minutes * COSTS.MEETING_RECORDING_PER_MINUTE), 2);
|
||||
}
|
||||
|
||||
// POST /bot-events
|
||||
meetingWebhookRoutes.post('/bot-events', async (c) => {
|
||||
const rawBody = await c.req.text();
|
||||
const signature = c.req.header('x-webhook-signature') ?? '';
|
||||
|
||||
if (WEBHOOK_SECRET && !verifySignature(rawBody, signature)) {
|
||||
return c.json({ error: 'Invalid webhook signature' }, 401);
|
||||
}
|
||||
|
||||
let payload: WebhookEvent;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as WebhookEvent;
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON payload' }, 400);
|
||||
}
|
||||
|
||||
const key = idempotencyKey(payload);
|
||||
if (processedEvents.has(key)) {
|
||||
return c.json({ success: true, message: 'Event already processed' });
|
||||
}
|
||||
|
||||
processedEvents.add(key);
|
||||
// Trim old entries
|
||||
if (processedEvents.size > 1000) {
|
||||
const iter = processedEvents.values();
|
||||
for (let i = 0; i < 500; i++) processedEvents.delete(iter.next().value as string);
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.event === 'recording.completed') {
|
||||
const { bot, recording } = payload;
|
||||
const durationSeconds = recording?.duration_seconds ?? 0;
|
||||
const credits = calcCredits(durationSeconds);
|
||||
|
||||
try {
|
||||
await consumeCredits(
|
||||
bot.user_id,
|
||||
'meeting_recording',
|
||||
credits,
|
||||
`Meeting recording completed - ${Math.round(durationSeconds / 60)} minutes`,
|
||||
{ botId: bot.id, recordingId: payload.recording?.id, durationSeconds },
|
||||
bot.space_id ? { type: 'guild', guildId: bot.space_id } : undefined
|
||||
);
|
||||
await updateBotCredits(bot.id, credits, durationSeconds);
|
||||
} catch (err) {
|
||||
// Don't fail the webhook — recording is still valid, credits can be reconciled
|
||||
console.error('[meetings-webhook] Failed to consume credits:', err);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Recording completed processed',
|
||||
botId: bot.id,
|
||||
recordingId: recording?.id,
|
||||
creditsConsumed: credits,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.event === 'recording.failed') {
|
||||
const { bot, error } = payload;
|
||||
console.error(`[meetings-webhook] Bot ${bot.id} failed: ${error?.code} - ${error?.message}`);
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Recording failure processed',
|
||||
botId: bot.id,
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, message: 'Unknown event type' });
|
||||
} catch (err) {
|
||||
// Remove from processed set so it can be retried
|
||||
processedEvents.delete(key);
|
||||
console.error('[meetings-webhook] Error processing event:', err);
|
||||
return c.json({ error: 'Failed to process webhook event' }, 500);
|
||||
}
|
||||
});
|
||||
189
apps/memoro/apps/server/src/routes/meetings.ts
Normal file
189
apps/memoro/apps/server/src/routes/meetings.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Meetings routes — authenticated.
|
||||
* Handles meeting bot management and recording → memo conversion.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthVariables } from '@manacore/shared-hono';
|
||||
import { validateCredits, COSTS } from '../lib/credits';
|
||||
import {
|
||||
validateMeetingUrl,
|
||||
createBot,
|
||||
stopBot,
|
||||
getBots,
|
||||
getBotById,
|
||||
getRecordings,
|
||||
getRecordingById,
|
||||
} from '../services/meetings';
|
||||
|
||||
export const meetingRoutes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// Minimum credits required to start a recording (5 minutes worth)
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// Validate minimum credits
|
||||
const creditCheck = await validateCredits(userId, 'meeting_recording', MINIMUM_RECORDING_CREDITS);
|
||||
if (!creditCheck.hasCredits) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'InsufficientCredits',
|
||||
message: `Not enough credits to start recording. Need at least ${MINIMUM_RECORDING_CREDITS} credits.`,
|
||||
details: {
|
||||
requiredCredits: MINIMUM_RECORDING_CREDITS,
|
||||
availableCredits: creditCheck.availableCredits,
|
||||
},
|
||||
},
|
||||
402
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return c.json({
|
||||
success: true,
|
||||
bot,
|
||||
message: 'Meeting bot created. It will join the meeting shortly.',
|
||||
creditInfo: {
|
||||
estimatedCostPerMinute: COSTS.MEETING_RECORDING_PER_MINUTE,
|
||||
minimumCredits: MINIMUM_RECORDING_CREDITS,
|
||||
availableCredits: creditCheck.availableCredits,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create meeting bot';
|
||||
return c.json({ error: msg }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /bots — list bots
|
||||
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);
|
||||
|
||||
try {
|
||||
const bots = await getBots(userId, spaceId, limit, offset);
|
||||
return c.json({ success: true, bots, total: bots.length });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to fetch bots';
|
||||
return c.json({ error: msg }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /bots/:id — get single bot
|
||||
meetingRoutes.get('/bots/:id', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
const botId = c.req.param('id');
|
||||
|
||||
const bot = await getBotById(botId, userId);
|
||||
if (!bot) return c.json({ error: 'Bot not found' }, 404);
|
||||
|
||||
return c.json({ success: true, bot });
|
||||
});
|
||||
|
||||
// POST /bots/:id/stop — stop a bot
|
||||
meetingRoutes.post('/bots/:id/stop', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
const botId = c.req.param('id');
|
||||
|
||||
try {
|
||||
await stopBot(botId, userId);
|
||||
return c.json({ success: true, message: 'Bot stop signal sent. Recording will end shortly.' });
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /recordings — list recordings
|
||||
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);
|
||||
|
||||
try {
|
||||
const recordings = await getRecordings(userId, spaceId, limit, offset);
|
||||
return c.json({ success: true, recordings, total: recordings.length });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to fetch recordings';
|
||||
return c.json({ error: msg }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /recordings/:id — get single recording
|
||||
meetingRoutes.get('/recordings/:id', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
const recordingId = c.req.param('id');
|
||||
|
||||
const recording = await getRecordingById(recordingId, userId);
|
||||
if (!recording) return c.json({ error: 'Recording not found' }, 404);
|
||||
|
||||
return c.json({ success: true, recording });
|
||||
});
|
||||
|
||||
// POST /recordings/:id/to-memo — convert recording to memo
|
||||
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 authHeader = c.req.header('Authorization');
|
||||
|
||||
const recording = await getRecordingById(recordingId, userId);
|
||||
if (!recording) return c.json({ 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);
|
||||
|
||||
const duration = recording.duration_seconds ?? 45;
|
||||
|
||||
try {
|
||||
const port = process.env.PORT ?? 3015;
|
||||
const response = await fetch(`http://localhost:${port}/api/v1/memos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader ?? '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filePath,
|
||||
duration,
|
||||
spaceId: recording.space_id,
|
||||
blueprintId: body.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 result = await response.json() as Record<string, unknown>;
|
||||
return c.json({
|
||||
success: true,
|
||||
memoId: result.memoId,
|
||||
memo: result.memo,
|
||||
audioPath: filePath,
|
||||
status: result.status,
|
||||
message: 'Recording converted to memo. Transcription in progress.',
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to convert recording to memo';
|
||||
return c.json({ error: msg }, 500);
|
||||
}
|
||||
});
|
||||
291
apps/memoro/apps/server/src/services/meetings.ts
Normal file
291
apps/memoro/apps/server/src/services/meetings.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* Meetings proxy service — proxies to meeting-bot service + direct Supabase queries.
|
||||
*/
|
||||
|
||||
import { createServiceClient } from '../lib/supabase';
|
||||
|
||||
export type MeetingBotState =
|
||||
| 'registering'
|
||||
| 'provisioning'
|
||||
| 'joining'
|
||||
| 'waiting_room'
|
||||
| 'joined'
|
||||
| 'recording'
|
||||
| 'recording_error'
|
||||
| 'leaving'
|
||||
| 'left'
|
||||
| 'error';
|
||||
|
||||
export type MeetingVendor = 'teams' | 'meet' | 'zoom';
|
||||
|
||||
export interface MeetingBot {
|
||||
id: string;
|
||||
created_at: string;
|
||||
ended_at?: string;
|
||||
updated_at: string;
|
||||
vendor: MeetingVendor;
|
||||
state: MeetingBotState;
|
||||
meeting_id?: string;
|
||||
meeting_code: string;
|
||||
meeting_url?: string;
|
||||
external_bot_id?: string;
|
||||
user_id: string;
|
||||
space_id?: string;
|
||||
credits_consumed?: number;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface MeetingRecording {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
file_url?: string;
|
||||
video_url?: string;
|
||||
audio_url?: string;
|
||||
transcript?: string;
|
||||
duration_seconds?: number;
|
||||
bot_id: string;
|
||||
user_id: string;
|
||||
space_id?: string;
|
||||
audio_signed_url?: string | null;
|
||||
video_signed_url?: string | null;
|
||||
}
|
||||
|
||||
export interface MeetingBotWithRecording extends MeetingBot {
|
||||
recording?: MeetingRecording;
|
||||
}
|
||||
|
||||
export interface CreateBotResponse {
|
||||
id: string;
|
||||
external_bot_id: string;
|
||||
meeting_url: string;
|
||||
state: MeetingBotState;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
event: 'recording.completed' | 'recording.failed';
|
||||
timestamp: string;
|
||||
bot: {
|
||||
id: string;
|
||||
external_bot_id: string;
|
||||
user_id: string;
|
||||
space_id?: string;
|
||||
state: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
};
|
||||
recording?: {
|
||||
id: string;
|
||||
video_url?: string;
|
||||
audio_url?: string;
|
||||
file_url?: string;
|
||||
transcript?: string;
|
||||
speakers?: object;
|
||||
duration_seconds?: number;
|
||||
created_at: string;
|
||||
};
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MEETING_BOT_API_URL = process.env.MEETING_BOT_API_URL ?? '';
|
||||
const MEETING_BOT_API_KEY = process.env.MEETING_BOT_API_KEY ?? '';
|
||||
const USER_UPLOADS_BUCKET = process.env.USER_UPLOADS_BUCKET ?? 'user-uploads';
|
||||
|
||||
export function detectPlatform(meetingUrl: string): MeetingVendor {
|
||||
if (/teams\.microsoft\.com/i.test(meetingUrl)) return 'teams';
|
||||
if (/meet\.google\.com/i.test(meetingUrl)) return 'meet';
|
||||
if (/zoom\.(us|com)/i.test(meetingUrl)) return 'zoom';
|
||||
throw new Error('Unsupported meeting platform. Use Teams, Meet, or Zoom.');
|
||||
}
|
||||
|
||||
export function validateMeetingUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
return /(teams\.microsoft\.com|meet\.google\.com|zoom\.(us|com))/i.test(url);
|
||||
}
|
||||
|
||||
export async function createBot(
|
||||
userId: string,
|
||||
meetingUrl: string,
|
||||
webhookBaseUrl: string,
|
||||
spaceId?: string
|
||||
): Promise<CreateBotResponse> {
|
||||
if (!MEETING_BOT_API_URL || !MEETING_BOT_API_KEY) {
|
||||
throw new Error('Meeting bot service not configured');
|
||||
}
|
||||
|
||||
const response = await fetch(`${MEETING_BOT_API_URL}/bots`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': MEETING_BOT_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
space_id: spaceId,
|
||||
meeting_url: meetingUrl,
|
||||
completed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`,
|
||||
failed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({})) as Record<string, unknown>;
|
||||
throw new Error((err.message as string) || `Failed to create meeting bot: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function stopBot(botId: string, userId: string): Promise<void> {
|
||||
// Verify ownership first
|
||||
const bot = await getBotById(botId, userId);
|
||||
if (!bot) throw new Error('Bot not found');
|
||||
|
||||
if (!MEETING_BOT_API_URL || !MEETING_BOT_API_KEY) {
|
||||
throw new Error('Meeting bot service not configured');
|
||||
}
|
||||
|
||||
const response = await fetch(`${MEETING_BOT_API_URL}/bots/${botId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': MEETING_BOT_API_KEY },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({})) as Record<string, unknown>;
|
||||
throw new Error((err.message as string) || `Failed to stop meeting bot: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBots(
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<MeetingBotWithRecording[]> {
|
||||
const supabase = createServiceClient();
|
||||
|
||||
let query = supabase
|
||||
.from('meeting_bots')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (spaceId) query = query.eq('space_id', spaceId);
|
||||
|
||||
const { data: bots, error } = await query;
|
||||
if (error) throw new Error('Failed to fetch bots');
|
||||
|
||||
return Promise.all(
|
||||
(bots ?? []).map(async (bot: MeetingBot) => {
|
||||
const { data: recordings } = await supabase
|
||||
.from('meeting_recordings')
|
||||
.select('*')
|
||||
.eq('bot_id', bot.id)
|
||||
.limit(1);
|
||||
return { ...bot, recording: recordings?.[0] ?? undefined };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBotById(
|
||||
botId: string,
|
||||
userId: string
|
||||
): Promise<MeetingBotWithRecording | null> {
|
||||
const supabase = createServiceClient();
|
||||
|
||||
const { data: bot, error } = await supabase
|
||||
.from('meeting_bots')
|
||||
.select('*')
|
||||
.eq('id', botId)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (error || !bot) return null;
|
||||
|
||||
const { data: recordings } = await supabase
|
||||
.from('meeting_recordings')
|
||||
.select('*')
|
||||
.eq('bot_id', botId)
|
||||
.limit(1);
|
||||
|
||||
return { ...bot, recording: recordings?.[0] ?? undefined };
|
||||
}
|
||||
|
||||
async function generateSignedUrl(storagePath: string): Promise<string | null> {
|
||||
if (!storagePath) return null;
|
||||
const supabase = createServiceClient();
|
||||
const { data, error } = await supabase.storage
|
||||
.from(USER_UPLOADS_BUCKET)
|
||||
.createSignedUrl(storagePath, 3600);
|
||||
if (error) return null;
|
||||
return data?.signedUrl ?? null;
|
||||
}
|
||||
|
||||
async function addSignedUrls(recording: MeetingRecording): Promise<MeetingRecording> {
|
||||
const [audioSignedUrl, videoSignedUrl] = await Promise.all([
|
||||
recording.audio_url ? generateSignedUrl(recording.audio_url) : null,
|
||||
recording.video_url ? generateSignedUrl(recording.video_url) : null,
|
||||
]);
|
||||
return { ...recording, audio_signed_url: audioSignedUrl, video_signed_url: videoSignedUrl };
|
||||
}
|
||||
|
||||
export async function getRecordings(
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<MeetingRecording[]> {
|
||||
const supabase = createServiceClient();
|
||||
|
||||
let query = supabase
|
||||
.from('meeting_recordings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (spaceId) query = query.eq('space_id', spaceId);
|
||||
|
||||
const { data: recordings, error } = await query;
|
||||
if (error) throw new Error('Failed to fetch recordings');
|
||||
|
||||
return Promise.all((recordings ?? []).map(addSignedUrls));
|
||||
}
|
||||
|
||||
export async function getRecordingById(
|
||||
recordingId: string,
|
||||
userId: string
|
||||
): Promise<MeetingRecording | null> {
|
||||
const supabase = createServiceClient();
|
||||
|
||||
const { data: recording, error } = await supabase
|
||||
.from('meeting_recordings')
|
||||
.select('*')
|
||||
.eq('id', recordingId)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (error || !recording) return null;
|
||||
return addSignedUrls(recording);
|
||||
}
|
||||
|
||||
export async function updateBotCredits(
|
||||
botId: string,
|
||||
creditsConsumed: number,
|
||||
durationSeconds?: number
|
||||
): Promise<void> {
|
||||
const supabase = createServiceClient();
|
||||
const update: Record<string, unknown> = {
|
||||
credits_consumed: creditsConsumed,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (durationSeconds !== undefined) update.duration_seconds = durationSeconds;
|
||||
|
||||
const { error } = await supabase.from('meeting_bots').update(update).eq('id', botId);
|
||||
if (error) throw new Error('Failed to update bot credits');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue