diff --git a/apps/memoro/apps/audio-server/package.json b/apps/memoro/apps/audio-server/package.json new file mode 100644 index 000000000..31120c6ac --- /dev/null +++ b/apps/memoro/apps/audio-server/package.json @@ -0,0 +1,21 @@ +{ + "name": "@memoro/audio-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": { + "@azure/storage-blob": "^12.17.0", + "@supabase/supabase-js": "^2.49.5", + "fluent-ffmpeg": "^2.1.2", + "hono": "^4.7.0" + }, + "devDependencies": { + "@types/fluent-ffmpeg": "^2.1.21", + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + } +} diff --git a/apps/memoro/apps/audio-server/src/index.ts b/apps/memoro/apps/audio-server/src/index.ts new file mode 100644 index 000000000..46e60de24 --- /dev/null +++ b/apps/memoro/apps/audio-server/src/index.ts @@ -0,0 +1,69 @@ +import { Hono } from 'hono'; +import type { MiddlewareHandler } from 'hono'; +import { createTranscribeRoutes } from './routes/transcribe.ts'; + +const app = new Hono(); + +// ─── Service key middleware ─────────────────────────────────────────────────── + +function serviceKeyMiddleware(): MiddlewareHandler { + return async (c, next) => { + const expectedKey = process.env.SERVICE_KEY; + + if (!expectedKey) { + console.error('[Auth] SERVICE_KEY env var is not configured'); + return c.json({ error: 'Server misconfiguration' }, 500); + } + + const providedKey = c.req.header('X-Service-Key'); + + if (!providedKey || providedKey !== expectedKey) { + console.warn(`[Auth] Unauthorized request to ${c.req.path} — invalid or missing X-Service-Key`); + return c.json({ error: 'Unauthorized' }, 401); + } + + await next(); + }; +} + +// ─── Health check (no auth) ─────────────────────────────────────────────────── + +app.get('/health', (c) => { + return c.json({ + status: 'ok', + service: 'memoro-audio-server', + version: '1.0.0', + timestamp: new Date().toISOString(), + }); +}); + +// ─── Protected routes ───────────────────────────────────────────────────────── + +app.use('/api/*', serviceKeyMiddleware()); + +const transcribeRoutes = createTranscribeRoutes(); +app.route('/api/v1/transcribe', transcribeRoutes); + +// ─── 404 handler ───────────────────────────────────────────────────────────── + +app.notFound((c) => { + return c.json({ error: `Not found: ${c.req.method} ${c.req.path}` }, 404); +}); + +// ─── Error handler ──────────────────────────────────────────────────────────── + +app.onError((err, c) => { + console.error(`[Error] Unhandled error on ${c.req.method} ${c.req.path}:`, err); + return c.json({ error: 'Internal server error' }, 500); +}); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +const port = parseInt(process.env.PORT ?? '3016', 10); + +console.log(`[Server] Memoro Audio Server starting on port ${port}`); + +export default { + port, + fetch: app.fetch, +}; diff --git a/apps/memoro/apps/audio-server/src/lib/azure.ts b/apps/memoro/apps/audio-server/src/lib/azure.ts new file mode 100644 index 000000000..38b17b2d9 --- /dev/null +++ b/apps/memoro/apps/audio-server/src/lib/azure.ts @@ -0,0 +1,60 @@ +export interface SpeechServiceConfig { + key: string; + endpoint: string; + region: string; + name: string; +} + +export const BATCH_ENDPOINT_BASE = 'https://swedencentral.api.cognitive.microsoft.com/speechtotext'; + +export function getAvailableSpeechServices(): SpeechServiceConfig[] { + const region = process.env.AZURE_SPEECH_REGION || 'swedencentral'; + const endpoint = `https://${region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe`; + const batchBase = `https://${region}.api.cognitive.microsoft.com/speechtotext`; + + const services: SpeechServiceConfig[] = []; + + // Try numbered keys first (AZURE_SPEECH_KEY_1 through AZURE_SPEECH_KEY_4) + for (let i = 1; i <= 4; i++) { + const key = process.env[`AZURE_SPEECH_KEY_${i}`]; + if (key) { + services.push({ + key, + endpoint, + region, + name: `azure-speech-${i}`, + }); + } + } + + // Fall back to single key if no numbered keys found + if (services.length === 0) { + const key = process.env.AZURE_SPEECH_KEY; + if (key) { + services.push({ + key, + endpoint, + region, + name: 'azure-speech-default', + }); + } + } + + if (services.length === 0) { + throw new Error('No Azure Speech credentials configured. Set AZURE_SPEECH_KEY_1..4 or AZURE_SPEECH_KEY.'); + } + + console.log(`[Azure] Available speech services: ${services.map((s) => s.name).join(', ')}`); + + return services; +} + +export function pickRandomService(services: SpeechServiceConfig[]): SpeechServiceConfig { + if (services.length === 0) { + throw new Error('No speech services available'); + } + const index = Math.floor(Math.random() * services.length); + const service = services[index]; + console.log(`[Azure] Selected service: ${service.name} (${index + 1}/${services.length})`); + return service; +} diff --git a/apps/memoro/apps/audio-server/src/lib/supabase.ts b/apps/memoro/apps/audio-server/src/lib/supabase.ts new file mode 100644 index 000000000..07287e0da --- /dev/null +++ b/apps/memoro/apps/audio-server/src/lib/supabase.ts @@ -0,0 +1,41 @@ +import { createClient } from '@supabase/supabase-js'; + +function getSupabaseClient() { + const supabaseUrl = process.env.MEMORO_SUPABASE_URL; + const supabaseServiceKey = process.env.MEMORO_SUPABASE_SERVICE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + throw new Error('Missing required env vars: MEMORO_SUPABASE_URL, MEMORO_SUPABASE_SERVICE_KEY'); + } + + return createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + +export async function downloadAudioFromStorage(audioPath: string): Promise { + const supabase = getSupabaseClient(); + + console.log(`[Supabase] Downloading audio from storage: ${audioPath}`); + + const { data, error } = await supabase.storage.from('user-uploads').download(audioPath); + + if (error) { + console.error(`[Supabase] Failed to download audio: ${error.message}`); + throw new Error(`Failed to download audio from storage: ${error.message}`); + } + + if (!data) { + throw new Error(`No data returned for audio path: ${audioPath}`); + } + + const arrayBuffer = await data.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + console.log(`[Supabase] Downloaded audio: ${buffer.length} bytes`); + + return buffer; +} diff --git a/apps/memoro/apps/audio-server/src/routes/transcribe.ts b/apps/memoro/apps/audio-server/src/routes/transcribe.ts new file mode 100644 index 000000000..0753a15f0 --- /dev/null +++ b/apps/memoro/apps/audio-server/src/routes/transcribe.ts @@ -0,0 +1,124 @@ +import { Hono } from 'hono'; +import { downloadAudioFromStorage } from '../lib/supabase.ts'; +import { TranscriptionService } from '../services/transcription.ts'; + +interface TranscribeBody { + audioPath: string; + memoId: string; + userId: string; + spaceId?: string; + recordingLanguages?: string[]; + enableDiarization?: boolean; + isAppend?: boolean; + recordingIndex?: number; +} + +const transcriptionService = new TranscriptionService(); + +export function createTranscribeRoutes() { + const app = new Hono(); + + app.post('/', async (c) => { + let body: TranscribeBody; + + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + const { audioPath, memoId, userId, spaceId, recordingLanguages, enableDiarization, recordingIndex } = body; + + if (!audioPath || !memoId || !userId) { + return c.json({ error: 'Missing required fields: audioPath, memoId, userId' }, 400); + } + + const serviceKey = process.env.SERVICE_KEY ?? ''; + const serverUrl = process.env.MEMORO_SERVER_URL ?? 'http://localhost:3015'; + + console.log(`[Route] POST /transcribe — memoId: ${memoId}, userId: ${userId}, audioPath: ${audioPath}`); + + // Fire-and-forget: return immediately, process in background + queueMicrotask(async () => { + try { + const audioBuffer = await downloadAudioFromStorage(audioPath); + await transcriptionService.transcribeWithFallback({ + audioBuffer, + audioPath, + memoId, + userId, + spaceId, + recordingLanguages, + enableDiarization, + isAppend: false, + recordingIndex, + serviceKey, + serverUrl, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Route] Transcription background task failed for memo ${memoId}: ${msg}`); + } + }); + + return c.json({ + success: true, + memoId, + message: 'Transcription started', + }); + }); + + app.post('/append', async (c) => { + let body: TranscribeBody; + + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + const { audioPath, memoId, userId, spaceId, recordingLanguages, enableDiarization, recordingIndex } = body; + + if (!audioPath || !memoId || !userId) { + return c.json({ error: 'Missing required fields: audioPath, memoId, userId' }, 400); + } + + const serviceKey = process.env.SERVICE_KEY ?? ''; + const serverUrl = process.env.MEMORO_SERVER_URL ?? 'http://localhost:3015'; + + console.log( + `[Route] POST /transcribe/append — memoId: ${memoId}, userId: ${userId}, audioPath: ${audioPath}, recordingIndex: ${recordingIndex}`, + ); + + // Fire-and-forget: return immediately, process in background + queueMicrotask(async () => { + try { + const audioBuffer = await downloadAudioFromStorage(audioPath); + await transcriptionService.transcribeWithFallback({ + audioBuffer, + audioPath, + memoId, + userId, + spaceId, + recordingLanguages, + enableDiarization, + isAppend: true, + recordingIndex, + serviceKey, + serverUrl, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Route] Append transcription background task failed for memo ${memoId}: ${msg}`); + } + }); + + return c.json({ + success: true, + memoId, + message: 'Append transcription started', + }); + }); + + return app; +} diff --git a/apps/memoro/apps/audio-server/src/services/batch.ts b/apps/memoro/apps/audio-server/src/services/batch.ts new file mode 100644 index 000000000..37c3dda0f --- /dev/null +++ b/apps/memoro/apps/audio-server/src/services/batch.ts @@ -0,0 +1,210 @@ +import { BATCH_ENDPOINT_BASE, type SpeechServiceConfig } from '../lib/azure.ts'; +import { convertToAzureWav } from './ffmpeg.ts'; + +const DEFAULT_CANDIDATE_LOCALES = [ + 'en-US', + 'de-DE', + 'en-GB', + 'fr-FR', + 'it-IT', + 'es-ES', + 'sv-SE', + 'ru-RU', + 'nl-NL', + 'tr-TR', + 'pt-PT', +]; + +interface BatchJobResult { + jobId: string; + status: 'processing'; +} + +interface BatchJobStatus { + jobId: string; + status: string; + self?: string; + files?: string; +} + +async function getAzureBlobClients(accountName: string, accountKey: string) { + const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob'); + const credential = new StorageSharedKeyCredential(accountName, accountKey); + const blobServiceClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + credential, + ); + return { blobServiceClient, credential }; +} + +async function uploadWavToBlob( + wavBuffer: Buffer, + userId: string, + accountName: string, + accountKey: string, +): Promise { + const { BlobSASPermissions, generateBlobSASQueryParameters } = await import('@azure/storage-blob'); + const { blobServiceClient, credential } = await getAzureBlobClients(accountName, accountKey); + + const containerName = 'batch-transcription'; + const blobName = `transcriptions/${userId}/${Date.now()}.wav`; + + const containerClient = blobServiceClient.getContainerClient(containerName); + await containerClient.createIfNotExists(); + + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.upload(wavBuffer, wavBuffer.length, { + blobHTTPHeaders: { blobContentType: 'audio/wav' }, + }); + + console.log(`[Batch] Uploaded WAV to Azure Blob: ${containerName}/${blobName}`); + + const sasOptions = { + containerName, + blobName, + permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(Date.now() - 5 * 60 * 1000), + expiresOn: new Date(Date.now() + 6 * 60 * 60 * 1000), + }; + + const sasToken = generateBlobSASQueryParameters(sasOptions, credential).toString(); + return `${blockBlobClient.url}?${sasToken}`; +} + +async function ensureResultsContainerSasUrl(accountName: string, accountKey: string): Promise { + const { ContainerSASPermissions, generateBlobSASQueryParameters } = await import('@azure/storage-blob'); + const { blobServiceClient, credential } = await getAzureBlobClients(accountName, accountKey); + + const resultsContainerName = 'results'; + const containerClient = blobServiceClient.getContainerClient(resultsContainerName); + await containerClient.createIfNotExists(); + + const sasToken = generateBlobSASQueryParameters( + { + containerName: resultsContainerName, + permissions: ContainerSASPermissions.parse('rcw'), + startsOn: new Date(Date.now() - 5 * 60 * 1000), + expiresOn: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + credential, + ).toString(); + + return `https://${accountName}.blob.core.windows.net/${resultsContainerName}?${sasToken}`; +} + +export class BatchTranscriptionService { + async createBatchJob( + audioBuffer: Buffer, + userId: string, + speechService: SpeechServiceConfig, + languages?: string[], + diarization?: boolean, + ): Promise { + const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME; + const accountKey = process.env.AZURE_STORAGE_ACCOUNT_KEY; + + if (!accountName || !accountKey) { + throw new Error('Azure Storage credentials not configured (AZURE_STORAGE_ACCOUNT_NAME, AZURE_STORAGE_ACCOUNT_KEY)'); + } + + console.log(`[Batch] Creating batch transcription job for user ${userId}`); + + // Convert audio to WAV before uploading + const wavBuffer = await convertToAzureWav(audioBuffer, '.wav'); + + // Upload WAV to Azure Blob Storage + const sasUrl = await uploadWavToBlob(wavBuffer, userId, accountName, accountKey); + console.log(`[Batch] Got SAS URL for blob`); + + // Ensure results container and get its SAS URL + const destinationUrl = await ensureResultsContainerSasUrl(accountName, accountKey); + + // Build candidate locales + const mainLocale = languages?.[0] || 'de-DE'; + let candidateLocales = + languages && languages.length > 0 + ? Array.from(new Set([mainLocale, ...languages, ...DEFAULT_CANDIDATE_LOCALES])) + : DEFAULT_CANDIDATE_LOCALES; + + candidateLocales = candidateLocales.slice(0, 10); + if (candidateLocales.length < 2) { + candidateLocales = Array.from(new Set([...candidateLocales, 'en-US', 'de-DE'])).slice(0, 10); + } + + const properties: Record = { + wordLevelTimestampsEnabled: true, + punctuationMode: 'DictatedAndAutomatic', + profanityFilterMode: 'Masked', + destinationContainerUrl: destinationUrl, + timeToLive: 'PT12H', + languageIdentification: { + candidateLocales, + mode: 'Continuous', + }, + }; + + if (diarization !== false) { + properties['diarizationEnabled'] = true; + properties['speakerCount'] = 10; + } + + const transcriptionBody = { + contentUrls: [sasUrl], + locale: mainLocale, + displayName: userId, + properties, + }; + + const batchEndpoint = `${BATCH_ENDPOINT_BASE}/v3.1/transcriptions`; + console.log(`[Batch] Submitting job to: ${batchEndpoint}`); + + const response = await fetch(batchEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Ocp-Apim-Subscription-Key': speechService.key, + }, + body: JSON.stringify(transcriptionBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[Batch] Job creation failed: ${response.status} - ${errorText}`); + throw new Error(`Azure Batch API error: ${response.status} - ${errorText}`); + } + + const jobData = await response.json() as { self?: string }; + const jobId = jobData.self?.split('/').pop() ?? String(Date.now()); + + console.log(`[Batch] Job created successfully: ${jobId}`); + + return { jobId, status: 'processing' }; + } + + async getJobStatus(jobId: string, speechService: SpeechServiceConfig): Promise { + const batchEndpoint = `${BATCH_ENDPOINT_BASE}/v3.1/transcriptions/${jobId}`; + + console.log(`[Batch] Checking job status: ${jobId}`); + + const response = await fetch(batchEndpoint, { + method: 'GET', + headers: { + 'Ocp-Apim-Subscription-Key': speechService.key, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Azure Batch status check failed: ${response.status} - ${errorText}`); + } + + const data = await response.json() as { status?: string; self?: string; links?: { files?: string } }; + + return { + jobId, + status: data.status ?? 'unknown', + self: data.self, + files: data.links?.files, + }; + } +} diff --git a/apps/memoro/apps/audio-server/src/services/ffmpeg.ts b/apps/memoro/apps/audio-server/src/services/ffmpeg.ts new file mode 100644 index 000000000..4121bad62 --- /dev/null +++ b/apps/memoro/apps/audio-server/src/services/ffmpeg.ts @@ -0,0 +1,167 @@ +import * as ffmpeg from 'fluent-ffmpeg'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const FORMAT_MAP: Record = { + '.m4a': 'mp4', + '.mp4': 'mp4', + '.mp3': 'mp3', + '.wav': 'wav', + '.aac': 'aac', + '.ogg': 'ogg', + '.webm': 'webm', + '.flac': 'flac', + '.caf': 'caf', + '.wma': 'asf', + '.amr': 'amr', +}; + +const PROBE_FORMAT_MAP: Record = { + mp3: 'mp3', + mov: 'mp4', + mp4: 'mp4', + m4a: 'mp4', + wav: 'wav', + aac: 'aac', + ogg: 'ogg', + webm: 'webm', + flac: 'flac', + caf: 'caf', + asf: 'asf', + amr: 'amr', +}; + +async function probeAudioFile( + filePath: string, +): Promise<{ valid: boolean; format?: string; codec?: string; duration?: number }> { + return new Promise((resolve) => { + (ffmpeg as any).ffprobe(filePath, (err: Error | null, metadata: any) => { + if (err) { + console.warn(`[ffmpeg] Probe failed for ${filePath}: ${err.message}`); + resolve({ valid: false }); + return; + } + + const format = metadata?.format?.format_name; + const duration = metadata?.format?.duration; + const audioStream = metadata?.streams?.find((s: any) => s.codec_type === 'audio'); + const codec = audioStream?.codec_name; + + resolve({ valid: true, format, codec, duration }); + }); + }); +} + +async function cleanup(...files: string[]): Promise { + await Promise.all( + files.map((f) => + fs.promises.unlink(f).catch(() => { + // Ignore cleanup errors + }), + ), + ); +} + +export async function convertToAzureWav(inputBuffer: Buffer, fileExtension: string): Promise { + const tempDir = os.tmpdir(); + const ext = fileExtension.startsWith('.') ? fileExtension : `.${fileExtension}`; + const inputFile = path.join(tempDir, `memoro_input_${Date.now()}${ext}`); + const outputFile = path.join(tempDir, `memoro_output_${Date.now()}.wav`); + + console.log(`[ffmpeg] Converting audio (${ext}) to Azure WAV format`); + + try { + await fs.promises.writeFile(inputFile, inputBuffer); + + // Probe actual format to detect mismatches between extension and content + const probeResult = await probeAudioFile(inputFile); + let inputFormat = FORMAT_MAP[ext.toLowerCase()]; + + if (probeResult.valid && probeResult.format) { + const probedFormatName = probeResult.format.split(',')[0].trim(); + const detectedFormat = PROBE_FORMAT_MAP[probedFormatName]; + if (detectedFormat && detectedFormat !== inputFormat) { + console.warn( + `[ffmpeg] Format mismatch: extension suggests "${inputFormat}", content detected as "${detectedFormat}". Using detected format.`, + ); + inputFormat = detectedFormat; + } + console.log(`[ffmpeg] Probed format: ${probeResult.format}, codec: ${probeResult.codec}`); + } + + return await new Promise((resolve, reject) => { + const command = (ffmpeg as any)(inputFile) + .audioCodec('pcm_s16le') // PCM 16-bit little-endian + .audioFrequency(16000) // 16kHz — Azure's preferred sample rate + .audioChannels(1) // Mono + .format('wav') + .inputOptions([ + '-err_detect', + 'ignore_err', // Handle iOS spatial audio metadata (chnl box) gracefully + '-fflags', + '+genpts', // Generate presentation timestamps + ]) + .outputOptions(['-y']); + + if (inputFormat) { + command.inputFormat(inputFormat); + console.log(`[ffmpeg] Using input format: ${inputFormat} for extension: ${ext}`); + } else { + console.warn(`[ffmpeg] Unknown format for extension ${ext}, letting ffmpeg auto-detect`); + } + + command + .on('end', async () => { + try { + const converted = await fs.promises.readFile(outputFile); + await cleanup(inputFile, outputFile); + console.log(`[ffmpeg] Conversion complete: ${converted.length} bytes`); + resolve(converted); + } catch (readErr) { + await cleanup(inputFile, outputFile); + reject(readErr); + } + }) + .on('error', async (err: Error) => { + await cleanup(inputFile, outputFile); + console.error(`[ffmpeg] Conversion error for ${ext}: ${err.message}`); + reject(err); + }) + .save(outputFile); + }); + } catch (err) { + await cleanup(inputFile, outputFile); + throw err; + } +} + +export async function getAudioDuration(buffer: Buffer): Promise { + const tempDir = os.tmpdir(); + const tempFile = path.join(tempDir, `memoro_probe_${Date.now()}.tmp`); + + try { + await fs.promises.writeFile(tempFile, buffer); + + return await new Promise((resolve, reject) => { + (ffmpeg as any).ffprobe(tempFile, async (err: Error | null, metadata: any) => { + await cleanup(tempFile); + + if (err) { + reject(new Error(`Failed to probe audio duration: ${err.message}`)); + return; + } + + const duration = metadata?.format?.duration; + if (typeof duration === 'number') { + resolve(duration); + } else { + reject(new Error('Could not determine audio duration from metadata')); + } + }); + }); + } catch (err) { + await cleanup(tempFile); + throw err; + } +} diff --git a/apps/memoro/apps/audio-server/src/services/transcription.ts b/apps/memoro/apps/audio-server/src/services/transcription.ts new file mode 100644 index 000000000..7b22573b1 --- /dev/null +++ b/apps/memoro/apps/audio-server/src/services/transcription.ts @@ -0,0 +1,451 @@ +import { getAvailableSpeechServices, pickRandomService, type SpeechServiceConfig } from '../lib/azure.ts'; +import { convertToAzureWav } from './ffmpeg.ts'; +import { BatchTranscriptionService } from './batch.ts'; +import * as path from 'path'; + +const CANDIDATE_LOCALES = [ + 'de-DE', + 'en-GB', + 'fr-FR', + 'it-IT', + 'es-ES', + 'sv-SE', + 'ru-RU', + 'nl-NL', + 'tr-TR', + 'pt-PT', +]; + +const TOTAL_TIMEOUT_MS = 1_200_000; // 20 minutes +const FAST_TIMEOUT_MS = 1_200_000; // 20 minutes + +interface TranscriptionResult { + transcript: string; + utterances: Array<{ + speaker: number; + text: string; + offset: number; + duration: number; + }>; + speakers: Record; + speakerMap: Record; + languages: string[]; + primary_language: string; +} + +interface TranscribeParams { + audioBuffer: Buffer; + audioPath: string; + memoId: string; + userId: string; + spaceId?: string; + recordingLanguages?: string[]; + enableDiarization?: boolean; + isAppend?: boolean; + recordingIndex?: number; + serviceKey: string; + serverUrl: string; +} + +export class TranscriptionService { + private readonly batchService = new BatchTranscriptionService(); + + async transcribeWithFallback(params: TranscribeParams): Promise { + const { audioBuffer, audioPath, memoId, userId, recordingLanguages, enableDiarization, isAppend, recordingIndex, serviceKey, serverUrl } = params; + const startTime = Date.now(); + + const checkTimeout = (stage: string): void => { + const elapsed = Date.now() - startTime; + if (elapsed > TOTAL_TIMEOUT_MS) { + throw new Error(`Fallback chain timeout exceeded after ${elapsed}ms in stage: ${stage}`); + } + }; + + const withTimeout = (promise: Promise, timeoutMs: number, label: string): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timeout after ${timeoutMs}ms`)), timeoutMs), + ), + ]); + }; + + try { + console.log(`[Transcription] Starting fallback chain for memo ${memoId} (${audioPath})`); + + // Attempt 1: Fast realtime transcription + try { + checkTimeout('initial-fast'); + const services = getAvailableSpeechServices(); + const service = pickRandomService(services); + + const wavBuffer = await convertToAzureWav(audioBuffer, path.extname(audioPath) || '.m4a'); + + const result = await withTimeout( + this.performRealtimeTranscription(wavBuffer, service, recordingLanguages, enableDiarization), + FAST_TIMEOUT_MS, + 'Fast transcription', + ); + + await this.notifyServer(memoId, userId, result, 'fast', serviceKey, serverUrl, isAppend, recordingIndex); + console.log(`[Transcription] Fast transcription succeeded for memo ${memoId}`); + return; + } catch (fastError: unknown) { + const fastErrMsg = fastError instanceof Error ? fastError.message : String(fastError); + console.warn(`[Transcription] Fast route failed: ${fastErrMsg}`); + + // Attempt 2: Service retry with different Azure key (429 rate limit) + if (this.shouldRetryWithDifferentService(fastErrMsg)) { + try { + checkTimeout('service-retry'); + console.log(`[Transcription] Retrying with different Azure service key`); + + const services = getAvailableSpeechServices(); + if (services.length > 1) { + const service = pickRandomService(services); + const wavBuffer = await convertToAzureWav(audioBuffer, path.extname(audioPath) || '.m4a'); + const result = await withTimeout( + this.performRealtimeTranscription(wavBuffer, service, recordingLanguages, enableDiarization), + FAST_TIMEOUT_MS, + 'Service retry transcription', + ); + await this.notifyServer(memoId, userId, result, 'fast', serviceKey, serverUrl, isAppend, recordingIndex); + console.log(`[Transcription] Service retry succeeded for memo ${memoId}`); + return; + } else { + console.warn(`[Transcription] Only one Azure service configured, skipping service retry`); + } + } catch (serviceRetryError: unknown) { + const msg = serviceRetryError instanceof Error ? serviceRetryError.message : String(serviceRetryError); + console.warn(`[Transcription] Service retry failed: ${msg}`); + } + } + + // Attempt 3: FFmpeg conversion + retry (422 / format errors) + if (this.shouldRetryWithConversion(fastErrMsg)) { + try { + checkTimeout('conversion-retry'); + console.log(`[Transcription] Retrying with enhanced audio conversion`); + + const services = getAvailableSpeechServices(); + const service = pickRandomService(services); + + // Force conversion even if already attempted — use explicit wav extension + const wavBuffer = await convertToAzureWav(audioBuffer, '.wav'); + + const result = await withTimeout( + this.performRealtimeTranscription(wavBuffer, service, recordingLanguages, enableDiarization), + FAST_TIMEOUT_MS, + 'Conversion retry transcription', + ); + await this.notifyServer(memoId, userId, result, 'fast', serviceKey, serverUrl, isAppend, recordingIndex); + console.log(`[Transcription] Conversion retry succeeded for memo ${memoId}`); + return; + } catch (conversionError: unknown) { + const msg = conversionError instanceof Error ? conversionError.message : String(conversionError); + console.warn(`[Transcription] Conversion retry failed: ${msg}. Falling back to batch.`); + } + } + + // Attempt 4: Azure batch transcription fallback + checkTimeout('batch-fallback'); + console.log(`[Transcription] Falling back to Azure Batch transcription for memo ${memoId}`); + + try { + const services = getAvailableSpeechServices(); + const service = pickRandomService(services); + const batchResult = await this.batchService.createBatchJob( + audioBuffer, + userId, + service, + recordingLanguages, + enableDiarization, + ); + console.log(`[Transcription] Batch job created: ${batchResult.jobId} for memo ${memoId}`); + // Batch jobs complete asynchronously via webhook — no immediate notify here + return; + } catch (batchError: unknown) { + const msg = batchError instanceof Error ? batchError.message : String(batchError); + throw new Error(`All transcription methods failed. Batch error: ${msg}`); + } + } + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Transcription] All fallback attempts failed for memo ${memoId}: ${errorMsg}`); + + await this.notifyServerError(memoId, userId, errorMsg, serviceKey, serverUrl); + } + } + + async performRealtimeTranscription( + audioBuffer: Buffer, + speechService: SpeechServiceConfig, + languages?: string[], + diarization?: boolean, + ): Promise { + const definition: Record = { + wordLevelTimestampsEnabled: true, + punctuationMode: 'Automatic', + profanityFilterMode: 'None', + }; + + if (diarization !== false) { + definition['diarization'] = { + enabled: true, + maxSpeakers: 10, + }; + } + + const candidateLocales = + languages && languages.length > 0 ? languages : CANDIDATE_LOCALES; + + definition['languageIdentification'] = { + candidateLocales, + }; + + console.log(`[Azure] Sending realtime transcription request to ${speechService.name}`); + console.log(`[Azure] Definition: ${JSON.stringify(definition)}`); + + const formData = new FormData(); + formData.append('definition', JSON.stringify(definition)); + + const audioBlob = new Blob([audioBuffer], { type: 'audio/wav' }); + formData.append('audio', audioBlob, 'audio.wav'); + + const response = await fetch(`${speechService.endpoint}?api-version=2024-11-15`, { + method: 'POST', + headers: { + 'Ocp-Apim-Subscription-Key': speechService.key, + Accept: 'application/json', + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after') ?? 'n/a'; + const requestId = response.headers.get('x-ms-request-id') ?? 'n/a'; + const quotaReason = response.headers.get('x-ms-service-quota-reason') ?? 'n/a'; + console.error( + `[AZURE_429_ERROR] Rate limited on ${speechService.name} — retry-after: ${retryAfter}, request-id: ${requestId}, quota-reason: ${quotaReason}`, + ); + console.error(`[AZURE_429_ERROR] Body: ${errorText}`); + throw new Error(`[AZURE_429_ERROR] Azure Speech API rate limited (429): ${errorText}`); + } + + if (response.status === 422) { + const requestId = response.headers.get('x-ms-request-id') ?? 'n/a'; + console.error( + `[AZURE_422_ERROR] Format error on ${speechService.name} — request-id: ${requestId}`, + ); + console.error(`[AZURE_422_ERROR] Body: ${errorText}`); + throw new Error(`[AZURE_422_ERROR] Azure Speech API format error (422): ${errorText}`); + } + + throw new Error(`Azure Speech API error: ${response.status} - ${errorText}`); + } + + const azureResult = await response.json(); + console.log(`[Azure] Transcription response received from ${speechService.name}`); + console.log(`[Azure] Phrase count: ${azureResult?.phrases?.length ?? 0}`); + + return this.processTranscriptionResult(azureResult); + } + + processTranscriptionResult(azureResult: { + phrases?: Array<{ + text?: string; + speaker?: number; + offsetMilliseconds?: number; + durationMilliseconds?: number; + locale?: string; + words?: unknown[]; + }>; + combinedPhrases?: Array<{ text?: string }>; + locale?: string; + }): TranscriptionResult { + let transcript = ''; + let primary_language = 'de-DE'; + let languages: string[] = ['de-DE']; + + // Determine languages from phrase-level locale analysis (more accurate than top-level) + if (azureResult.phrases && azureResult.phrases.length > 0) { + const phraseCounts: Record = {}; + const charCounts: Record = {}; + + for (const phrase of azureResult.phrases) { + if (phrase.locale) { + phraseCounts[phrase.locale] = (phraseCounts[phrase.locale] ?? 0) + 1; + charCounts[phrase.locale] = (charCounts[phrase.locale] ?? 0) + (phrase.text?.length ?? 0); + } + } + + const uniqueLanguages = Object.keys(phraseCounts); + if (uniqueLanguages.length > 0) { + // Pick primary by character count — more accurate than phrase count + primary_language = uniqueLanguages.reduce((best, lang) => + (charCounts[lang] ?? 0) > (charCounts[best] ?? 0) ? lang : best, + ); + languages = uniqueLanguages; + console.log(`[Transcription] Language detection: ${JSON.stringify(charCounts)}, primary: ${primary_language}`); + } + } else if (azureResult.locale) { + primary_language = azureResult.locale; + languages = [azureResult.locale]; + } + + // Build transcript text + if (azureResult.combinedPhrases && azureResult.combinedPhrases.length > 0) { + transcript = azureResult.combinedPhrases[0]?.text ?? ''; + } else if (azureResult.phrases && azureResult.phrases.length > 0) { + transcript = azureResult.phrases.map((p) => p.text ?? '').join(' '); + } + + // Build utterances and speaker maps + const utterances: TranscriptionResult['utterances'] = []; + const speakerIdSet = new Set(); + + if (azureResult.phrases) { + for (const phrase of azureResult.phrases) { + if (phrase.speaker !== undefined && phrase.text) { + utterances.push({ + speaker: phrase.speaker, + text: phrase.text, + offset: phrase.offsetMilliseconds ?? 0, + duration: phrase.durationMilliseconds ?? 0, + }); + speakerIdSet.add(phrase.speaker); + } + } + } + + // Sort by time + utterances.sort((a, b) => a.offset - b.offset); + + // Build speaker label maps + const speakers: Record = {}; + const speakerMap: Record = {}; + + for (const speakerId of speakerIdSet) { + const label = `Speaker ${speakerId}`; + speakers[String(speakerId)] = label; + speakerMap[label] = speakerId; + } + + console.log( + `[Transcription] Processed: ${transcript.length} chars, ${utterances.length} utterances, ${speakerIdSet.size} speakers, lang: ${primary_language}`, + ); + + return { transcript, utterances, speakers, speakerMap, languages, primary_language }; + } + + shouldRetryWithDifferentService(errorMsg: string): boolean { + const has429 = /429|AZURE_429_ERROR|rate.?limit|too many requests/i.test(errorMsg); + console.log(`[Transcription] shouldRetryWithDifferentService: ${has429} (${errorMsg.substring(0, 100)})`); + return has429; + } + + shouldRetryWithConversion(errorMsg: string): boolean { + const patterns = [ + /422/, + /AZURE_422_ERROR/, + /audio.?format/i, + /InvalidAudioFormat/i, + /audio\/x-m4a/i, + /unsupported.*format/i, + /invalid.*audio/i, + /codec.*not.*supported/i, + /content.*type.*unsupported/i, + /bitrate.*not.*supported/i, + /sample.*rate.*invalid/i, + /media.*type.*not.*supported/i, + ]; + const matches = patterns.some((p) => p.test(errorMsg)); + console.log(`[Transcription] shouldRetryWithConversion: ${matches} (${errorMsg.substring(0, 100)})`); + return matches; + } + + async notifyServer( + memoId: string, + userId: string, + result: TranscriptionResult, + route: 'fast' | 'batch', + serviceKey: string, + serverUrl: string, + isAppend?: boolean, + recordingIndex?: number, + ): Promise { + const endpoint = isAppend + ? `${serverUrl}/api/v1/internal/append-transcription-completed` + : `${serverUrl}/api/v1/internal/transcription-completed`; + + const body: Record = { + memoId, + userId, + transcriptionResult: result, + route, + success: true, + }; + + if (isAppend) { + body['recordingIndex'] = recordingIndex; + } + + console.log(`[Callback] Notifying server at ${endpoint} for memo ${memoId}`); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server callback failed: ${response.status} - ${errorText}`); + } + + console.log(`[Callback] Server notified successfully for memo ${memoId}`); + } + + async notifyServerError( + memoId: string, + userId: string, + errorMsg: string, + serviceKey: string, + serverUrl: string, + ): Promise { + const endpoint = `${serverUrl}/api/v1/internal/transcription-completed`; + + console.error(`[Callback] Notifying server of transcription error for memo ${memoId}: ${errorMsg}`); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + }, + body: JSON.stringify({ + memoId, + userId, + error: errorMsg, + success: false, + timestamp: new Date().toISOString(), + }), + }); + + if (!response.ok) { + const text = await response.text(); + console.error(`[Callback] Error notification failed: ${response.status} - ${text}`); + } + } catch (notifyErr: unknown) { + const msg = notifyErr instanceof Error ? notifyErr.message : String(notifyErr); + console.error(`[Callback] Failed to notify server of error: ${msg}`); + } + } +} diff --git a/apps/memoro/apps/audio-server/tsconfig.json b/apps/memoro/apps/audio-server/tsconfig.json new file mode 100644 index 000000000..65d74b6e5 --- /dev/null +++ b/apps/memoro/apps/audio-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "outDir": "dist", + "types": ["bun-types", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/memoro/apps/server/package.json b/apps/memoro/apps/server/package.json new file mode 100644 index 000000000..573e4542e --- /dev/null +++ b/apps/memoro/apps/server/package.json @@ -0,0 +1,21 @@ +{ + "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" + } +} diff --git a/apps/memoro/apps/server/src/index.ts b/apps/memoro/apps/server/src/index.ts new file mode 100644 index 000000000..aa6d6e652 --- /dev/null +++ b/apps/memoro/apps/server/src/index.ts @@ -0,0 +1,90 @@ +/** + * Memoro Server — Hono + Bun + * + * Replaces the NestJS backend service. + * Handles: memo processing, transcription callbacks, spaces, invites, credits, settings, cleanup. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { authMiddleware, errorHandler, notFoundHandler } from '@manacore/shared-hono'; + +import { memoRoutes } from './routes/memos'; +import { spaceRoutes } from './routes/spaces'; +import { inviteRoutes } from './routes/invites'; +import { creditRoutes } from './routes/credits'; +import { internalRoutes } from './routes/internal'; +import { settingsRoutes } from './routes/settings'; +import { cleanupRoutes } from './routes/cleanup'; +import { COSTS } from './lib/credits'; + +const app = new Hono(); + +// ── Global middleware ────────────────────────────────────────────────────────── + +app.onError(errorHandler); +app.notFound(notFoundHandler); + +app.use('*', logger()); + +app.use( + '*', + cors({ + origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5173').split(','), + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: [ + 'Authorization', + 'Content-Type', + 'X-Service-Key', + 'X-Internal-API-Key', + 'X-Client-Id', + ], + credentials: true, + }) +); + +// ── Health check ─────────────────────────────────────────────────────────────── + +app.get('/health', (c) => + c.json({ + status: 'ok', + service: 'memoro-server', + runtime: 'bun', + timestamp: new Date().toISOString(), + }) +); + +// ── Public routes (no auth) ──────────────────────────────────────────────────── + +// Credits pricing is public +app.get('/api/v1/credits/pricing', (c) => { + return c.json({ costs: COSTS }); +}); + +// Internal callbacks use their own service-key auth (not JWT) +app.route('/api/v1/internal', internalRoutes); + +// Cleanup uses internal API key +app.route('/api/v1/cleanup', cleanupRoutes); + +// ── Authenticated routes ─────────────────────────────────────────────────────── + +app.use('/api/v1/*', authMiddleware()); + +app.route('/api/v1/memos', memoRoutes); +app.route('/api/v1/spaces', spaceRoutes); +app.route('/api/v1/invites', inviteRoutes); +app.route('/api/v1/credits', creditRoutes); +app.route('/api/v1/settings', settingsRoutes); + +// ── Start ────────────────────────────────────────────────────────────────────── + +const port = Number(process.env.PORT ?? 3015); + +console.log(`Memoro server (Hono + Bun) starting on port ${port}`); + +export default { + port, + fetch: app.fetch, +}; diff --git a/apps/memoro/apps/server/src/lib/ai.ts b/apps/memoro/apps/server/src/lib/ai.ts new file mode 100644 index 000000000..a4b56994f --- /dev/null +++ b/apps/memoro/apps/server/src/lib/ai.ts @@ -0,0 +1,143 @@ +/** + * AI text generation with Gemini (primary) → Azure OpenAI (fallback). + * + * Mirrors the NestJS AiService without the DI framework. + */ + +const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; +const GEMINI_MODEL = 'gemini-2.0-flash-001'; +const GEMINI_DEFAULT_TEMPERATURE = 0.7; +const GEMINI_DEFAULT_MAX_TOKENS = 1024; + +const AZURE_API_VERSION = '2024-02-01'; +const AZURE_DEFAULT_TEMPERATURE = 0.7; +const AZURE_DEFAULT_MAX_TOKENS = 1024; + +export interface GenerateOptions { + temperature?: number; + maxTokens?: number; + systemInstruction?: string; +} + +/** + * Generate text using Gemini with Azure OpenAI as fallback. + */ +export async function generateText(prompt: string, options?: GenerateOptions): Promise { + const geminiKey = process.env.GEMINI_API_KEY; + + if (geminiKey) { + const result = await callGemini(prompt, geminiKey, options); + if (result !== null) return result; + console.warn('[ai] Gemini failed, falling back to Azure OpenAI'); + } else { + console.warn('[ai] No GEMINI_API_KEY, using Azure OpenAI directly'); + } + + const azureKey = process.env.AZURE_OPENAI_KEY; + if (!azureKey) { + throw new Error('No AI provider available: both GEMINI_API_KEY and AZURE_OPENAI_KEY are missing'); + } + + const result = await callAzure(prompt, azureKey, options); + if (result !== null) return result; + + throw new Error('All AI providers failed'); +} + +async function callGemini( + prompt: string, + apiKey: string, + options?: GenerateOptions +): Promise { + const temperature = options?.temperature ?? GEMINI_DEFAULT_TEMPERATURE; + const maxOutputTokens = options?.maxTokens ?? GEMINI_DEFAULT_MAX_TOKENS; + + try { + const url = `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${apiKey}`; + + const body: Record = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature, maxOutputTokens }, + }; + + if (options?.systemInstruction) { + body.systemInstruction = { parts: [{ text: options.systemInstruction }] }; + } + + const start = Date.now(); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[ai] Gemini API error (${response.status}): ${errorText}`); + return null; + } + + const data = (await response.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; + }; + const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''; + console.debug(`[ai] Gemini responded in ${Date.now() - start}ms (${content.length} chars)`); + return content || null; + } catch (error) { + console.error(`[ai] Gemini call failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} + +async function callAzure( + prompt: string, + apiKey: string, + options?: GenerateOptions +): Promise { + const endpoint = process.env.AZURE_OPENAI_ENDPOINT; + const deployment = process.env.AZURE_OPENAI_DEPLOYMENT ?? 'gpt-4.1-mini-se'; + + if (!endpoint) { + console.error('[ai] AZURE_OPENAI_ENDPOINT not set'); + return null; + } + + const temperature = options?.temperature ?? AZURE_DEFAULT_TEMPERATURE; + const maxTokens = options?.maxTokens ?? AZURE_DEFAULT_MAX_TOKENS; + + try { + const url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${AZURE_API_VERSION}`; + const start = Date.now(); + + const messages: Array<{ role: string; content: string }> = []; + if (options?.systemInstruction) { + messages.push({ role: 'system', content: options.systemInstruction }); + } + messages.push({ role: 'user', content: prompt }); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': apiKey, + }, + body: JSON.stringify({ messages, max_tokens: maxTokens, temperature }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[ai] Azure OpenAI error (${response.status}): ${errorText}`); + return null; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content?.trim() ?? ''; + console.debug(`[ai] Azure responded in ${Date.now() - start}ms (${content.length} chars)`); + return content || null; + } catch (error) { + console.error(`[ai] Azure call failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} diff --git a/apps/memoro/apps/server/src/lib/credits.ts b/apps/memoro/apps/server/src/lib/credits.ts new file mode 100644 index 000000000..0c20abd1d --- /dev/null +++ b/apps/memoro/apps/server/src/lib/credits.ts @@ -0,0 +1,25 @@ +/** + * Credit cost constants and helper for Memoro server. + */ + +export { validateCredits, consumeCredits } from '@manacore/shared-hono'; + +export const COSTS = { + TRANSCRIPTION_PER_MINUTE: 2, + HEADLINE_GENERATION: 10, + MEMORY_CREATION: 10, + BLUEPRINT_PROCESSING: 5, + QUESTION_MEMO: 5, + NEW_MEMORY: 5, + MEMO_COMBINE: 5, + MEETING_RECORDING_PER_MINUTE: 2, +} as const; + +/** + * Calculate transcription cost based on audio duration. + * Minimum cost is 2 Mana (1 minute equivalent). + */ +export function calcTranscriptionCost(durationSeconds: number): number { + const minutes = durationSeconds / 60; + return Math.max(Math.ceil(minutes * COSTS.TRANSCRIPTION_PER_MINUTE), 2); +} diff --git a/apps/memoro/apps/server/src/lib/supabase.ts b/apps/memoro/apps/server/src/lib/supabase.ts new file mode 100644 index 000000000..dfc4b0923 --- /dev/null +++ b/apps/memoro/apps/server/src/lib/supabase.ts @@ -0,0 +1,34 @@ +/** + * Supabase service-role client for Memoro server. + * + * Uses the service key to bypass RLS. All queries MUST explicitly + * filter by `user_id` to enforce access control. + */ + +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + +let _client: SupabaseClient | null = null; + +/** + * Returns a Supabase client using the service role key. + * This bypasses Row Level Security — always filter by user_id explicitly. + */ +export function createServiceClient(): SupabaseClient { + if (_client) return _client; + + const url = process.env.MEMORO_SUPABASE_URL; + const key = process.env.MEMORO_SUPABASE_SERVICE_KEY; + + if (!url || !key) { + throw new Error('MEMORO_SUPABASE_URL and MEMORO_SUPABASE_SERVICE_KEY must be set'); + } + + _client = createClient(url, key, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + return _client; +} diff --git a/apps/memoro/apps/server/src/routes/cleanup.ts b/apps/memoro/apps/server/src/routes/cleanup.ts new file mode 100644 index 000000000..ea72a30a4 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/cleanup.ts @@ -0,0 +1,55 @@ +/** + * Cleanup routes for Memoro server. + * + * Triggers audio cleanup. Requires X-Internal-API-Key header. + */ + +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { runAudioCleanup } from '../services/cleanup'; + +export const cleanupRoutes = new Hono(); + +// Internal API key auth middleware +cleanupRoutes.use('*', async (c, next) => { + const key = c.req.header('X-Internal-API-Key'); + const expected = process.env.INTERNAL_API_KEY; + + if (!key || !expected || key !== expected) { + throw new HTTPException(401, { message: 'Invalid internal API key' }); + } + + return next(); +}); + +// POST /run — trigger cleanup (from Cloud Scheduler or external cron) +cleanupRoutes.post('/run', async (c) => { + console.log('[cleanup] Triggered via /run'); + + // Run cleanup asynchronously and return immediately + queueMicrotask(() => { + runAudioCleanup().catch((err) => + console.error('[cleanup] Background cleanup failed:', err) + ); + }); + + return c.json({ success: true, message: 'Cleanup started' }); +}); + +// POST /manual — manual trigger with optional user IDs +cleanupRoutes.post('/manual', async (c) => { + const body = await c.req.json<{ userIds?: string[] }>().catch(() => ({})); + const userIds = body.userIds ?? []; + + console.log( + `[cleanup] Manual trigger${userIds.length > 0 ? ` for ${userIds.length} users` : ' for all opted-in users'}` + ); + + try { + const result = await runAudioCleanup(userIds.length > 0 ? userIds : undefined); + return c.json({ success: true, ...result }); + } catch (err) { + console.error('[cleanup] Manual cleanup failed:', err); + return c.json({ error: 'Cleanup failed' }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/routes/credits.ts b/apps/memoro/apps/server/src/routes/credits.ts new file mode 100644 index 000000000..cf2e89dee --- /dev/null +++ b/apps/memoro/apps/server/src/routes/credits.ts @@ -0,0 +1,60 @@ +/** + * Credits routes for Memoro server. + */ + +import { Hono } from 'hono'; +import { validateCredits, consumeCredits, COSTS } from '../lib/credits'; + +export const creditRoutes = new Hono(); + +// GET /pricing — public, returns cost constants +creditRoutes.get('/pricing', (c) => { + return c.json({ costs: COSTS }); +}); + +// POST /check — validate credits (requires auth via parent router) +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); + } + + try { + const result = await validateCredits(userId, body.operation, body.amount); + return c.json(result); + } catch (err) { + console.error('[credits] Validate error:', err); + return c.json({ error: 'Failed to validate credits' }, 500); + } +}); + +// POST /consume — consume credits (requires auth via parent router) +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; + }>(); + + if (!body.operation || body.amount == null || !body.description) { + return c.json({ error: 'operation, amount, and description are required' }, 400); + } + + try { + const success = await consumeCredits( + userId, + body.operation, + body.amount, + body.description, + body.metadata + ); + return c.json({ success }); + } catch (err) { + console.error('[credits] Consume error:', err); + return c.json({ error: 'Failed to consume credits' }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/routes/internal.ts b/apps/memoro/apps/server/src/routes/internal.ts new file mode 100644 index 000000000..015a20fdd --- /dev/null +++ b/apps/memoro/apps/server/src/routes/internal.ts @@ -0,0 +1,194 @@ +/** + * Internal service-to-service routes for Memoro server. + * + * Requires X-Service-Key header matching SERVICE_KEY env var. + */ + +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { + handleTranscriptionCompleted, + updateMemoProcessingStatus, +} from '../services/memo'; +import { createServiceClient } from '../lib/supabase'; + +export const internalRoutes = new Hono(); + +// Service key auth middleware +internalRoutes.use('*', async (c, next) => { + const key = c.req.header('X-Service-Key'); + const expected = process.env.SERVICE_KEY; + + if (!key || !expected || key !== expected) { + throw new HTTPException(401, { message: 'Invalid service key' }); + } + + return 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; + speakerMap?: Record; + 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); + } + + try { + await handleTranscriptionCompleted({ + memoId: body.memoId, + userId: body.userId, + transcriptionResult: body.transcriptionResult, + route: body.route, + success: body.success, + error: body.error, + fallbackStage: body.fallbackStage, + }); + 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); + } +}); + +// 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; + speakerMap?: Record; + 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 supabase = createServiceClient(); + const now = new Date().toISOString(); + + // Fetch current memo source + const { data: memo, error: fetchError } = await supabase + .from('memos') + .select('source') + .eq('id', body.memoId) + .eq('user_id', body.userId) + .single(); + + if (fetchError || !memo) { + return c.json({ error: 'Memo not found' }, 404); + } + + const source = (memo as { source: Record }).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 | undefined ?? {}), + status: 'error', + error: body.error ?? 'Transcription failed', + updated_at: now, + }; + + additionalRecordings[body.recordingIndex] = recordingEntry; + + const { error: updateError } = await supabase + .from('memos') + .update({ + source: { ...source, additional_recordings: additionalRecordings }, + updated_at: now, + }) + .eq('id', body.memoId); + + if (updateError) { + console.error('[internal] Failed to update append transcription:', updateError); + return c.json({ error: 'Failed to update memo' }, 500); + } + + return c.json({ success: true, memoId: body.memoId, recordingIndex: body.recordingIndex }); +}); + +// 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 supabase = createServiceClient(); + + const { data: memo, error: fetchError } = await supabase + .from('memos') + .select('metadata') + .eq('id', body.memoId) + .single(); + + if (fetchError || !memo) { + return c.json({ error: 'Memo not found' }, 404); + } + + const metadata = (memo as { metadata: Record }).metadata ?? {}; + const updatedMetadata = { + ...metadata, + batchJobId: body.jobId, + batchTranscription: body.batchTranscription ?? true, + batchStartedAt: new Date().toISOString(), + }; + + const { error: updateError } = await supabase + .from('memos') + .update({ metadata: updatedMetadata, updated_at: new Date().toISOString() }) + .eq('id', body.memoId); + + if (updateError) { + return c.json({ error: 'Failed to update batch metadata' }, 500); + } + + return c.json({ success: true, memoId: body.memoId, jobId: body.jobId }); +}); diff --git a/apps/memoro/apps/server/src/routes/invites.ts b/apps/memoro/apps/server/src/routes/invites.ts new file mode 100644 index 000000000..5afe44bc9 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/invites.ts @@ -0,0 +1,58 @@ +/** + * Invite routes for Memoro server. + */ + +import { Hono } from 'hono'; +import { acceptInvite, declineInvite, getPendingInvites } from '../services/space'; + +export const inviteRoutes = new Hono(); + +// GET /pending — list pending invites for current user +inviteRoutes.get('/pending', async (c) => { + const userId = c.get('userId') as string; + try { + const invites = await getPendingInvites(userId); + return c.json({ invites }); + } catch (err) { + console.error('[invites] Get pending error:', err); + return c.json({ 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); + + try { + const result = await acceptInvite(body.inviteId, userId); + return c.json(result); + } 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({ 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); + + try { + const result = await declineInvite(body.inviteId, userId); + return c.json(result); + } 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({ error: 'Failed to decline invite' }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/routes/memos.ts b/apps/memoro/apps/server/src/routes/memos.ts new file mode 100644 index 000000000..fb418af6b --- /dev/null +++ b/apps/memoro/apps/server/src/routes/memos.ts @@ -0,0 +1,359 @@ +/** + * Memo routes for Memoro server. + */ + +import { Hono } from 'hono'; +import { v4 as uuidv4 } from 'uuid'; +import { + createMemoFromUploadedFile, + handleTranscriptionCompleted, + callAudioServer, + updateMemoProcessingStatus, +} from '../services/memo'; +import { processHeadlineForMemo } from '../services/headline'; +import { createServiceClient } from '../lib/supabase'; +import { validateCredits, consumeCredits, COSTS } from '../lib/credits'; +import { generateText } from '../lib/ai'; + +export const memoRoutes = new Hono(); + +// 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); + } + + try { + const result = await createMemoFromUploadedFile({ + userId, + filePath: body.filePath, + duration: body.duration, + spaceId: body.spaceId, + blueprintId: body.blueprintId, + memoId: body.memoId, + recordingStartedAt: body.recordingStartedAt, + location: body.location, + mediaType: body.mediaType, + }); + return c.json(result, 201); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('Insufficient credits')) return c.json({ error: msg }, 402); + console.error('[memos] Create error:', err); + return c.json({ error: 'Failed to create memo' }, 500); + } +}); + +// POST /:id/append — append transcription to existing memo +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 supabase = createServiceClient(); + + // Verify memo ownership + const { data: memo, error: memoError } = await supabase + .from('memos') + .select('id, user_id, source') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (memoError || !memo) { + return c.json({ 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); + } + + // Set processing status + const source = (memo as { source: Record }).source ?? {}; + const additionalRecordings = (source.additional_recordings as unknown[]) ?? []; + const recordingIndex = body.recordingIndex ?? additionalRecordings.length; + + // Add pending entry + const updatedRecordings = [...additionalRecordings]; + updatedRecordings[recordingIndex] = { + path: body.filePath, + status: 'processing', + timestamp: new Date().toISOString(), + }; + + await supabase + .from('memos') + .update({ + source: { ...source, additional_recordings: updatedRecordings }, + updated_at: new Date().toISOString(), + }) + .eq('id', memoId); + + // Fire transcription + queueMicrotask(() => { + callAudioServer({ + memoId, + userId, + filePath: body.filePath, + duration: body.duration, + recordingIndex, + recordingLanguages: body.recordingLanguages, + enableDiarization: body.enableDiarization, + isAppend: true, + }).catch((err) => console.error(`[memos] Append transcription call failed: ${err}`)); + }); + + return c.json({ success: true, memoId, recordingIndex }); +}); + +// POST /:id/retry-transcription +memoRoutes.post('/:id/retry-transcription', async (c) => { + const userId = c.get('userId') as string; + const memoId = c.req.param('id'); + const supabase = createServiceClient(); + + const { data: memo, error } = await supabase + .from('memos') + .select('id, user_id, source, metadata') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (error || !memo) return c.json({ error: 'Memo not found or access denied' }, 404); + + const memoData = memo as { + source: { audio_path?: string; duration?: number }; + metadata: Record; + }; + 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); + + await updateMemoProcessingStatus(memoId, 'transcription', 'pending'); + + queueMicrotask(() => { + callAudioServer({ memoId, userId, filePath, duration }).catch((err) => + console.error(`[memos] Retry transcription failed: ${err}`) + ); + }); + + return c.json({ success: true, memoId }); +}); + +// POST /:id/retry-headline +memoRoutes.post('/:id/retry-headline', async (c) => { + const userId = c.get('userId') as string; + const memoId = c.req.param('id'); + const supabase = createServiceClient(); + + // Verify ownership + const { data: memo, error } = await supabase + .from('memos') + .select('id') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (error || !memo) return c.json({ error: 'Memo not found or access denied' }, 404); + + try { + const result = await processHeadlineForMemo(memoId); + return c.json(result); + } catch (err) { + console.error(`[memos] Retry headline failed for ${memoId}:`, err); + return c.json({ 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 creditCheck = await validateCredits(userId, 'memo_combine', COSTS.MEMO_COMBINE); + if (!creditCheck.hasCredits) { + return c.json({ error: `Insufficient credits: need ${COSTS.MEMO_COMBINE}` }, 402); + } + + const supabase = createServiceClient(); + + // Verify all memos belong to user + const { data: memos, error: fetchError } = await supabase + .from('memos') + .select('id, title, source') + .in('id', body.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); + } + + // Extract transcripts + const transcripts = memos.map((m: { title: string; source: Record }) => { + const source = m.source ?? {}; + const utterances = source.utterances as Array<{ offset?: number; text?: string }> | undefined; + let text = ''; + if (utterances && utterances.length > 0) { + text = [...utterances] + .sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0)) + .map((u) => u.text) + .filter(Boolean) + .join(' '); + } else { + text = (source.transcript as string | undefined) ?? m.title; + } + return `### ${m.title}\n\n${text}`; + }); + + const prompt = `Du bist ein KI-Assistent. Kombiniere die folgenden Memos zu einem zusammenhängenden Text. Behalte alle wichtigen Informationen bei und verbinde sie flüssig. + +${transcripts.join('\n\n---\n\n')} + +Erstelle: +HEADLINE: +INTRO: <2-3 Satz Zusammenfassung> +CONTENT: `; + + try { + const response = await generateText(prompt, { temperature: 0.7, maxTokens: 2048 }); + + await consumeCredits(userId, 'memo_combine', COSTS.MEMO_COMBINE, 'Combine memos', { + memoIds: body.memoIds, + }); + + // Create combined memo + const headlineMatch = response.match(/HEADLINE:\s*(.+?)(?=\n|$)/); + const introMatch = response.match(/INTRO:\s*(.+?)(?=\nCONTENT:|$)/s); + const contentMatch = response.match(/CONTENT:\s*(.+?)$/s); + + const headline = headlineMatch?.[1]?.trim() ?? 'Kombiniertes Memo'; + const intro = introMatch?.[1]?.trim() ?? ''; + const content = contentMatch?.[1]?.trim() ?? response; + + const { data: combinedMemo, error: createError } = await supabase + .from('memos') + .insert({ + id: uuidv4(), + user_id: userId, + title: headline, + intro, + source: { + type: 'combined', + transcript: content, + source_memo_ids: body.memoIds, + }, + metadata: { + processing: { + transcription: { status: 'completed' }, + headline_and_intro: { status: 'completed' }, + }, + }, + updated_at: new Date().toISOString(), + }) + .select() + .single(); + + if (createError) throw createError; + + return c.json({ memo: combinedMemo, headline, intro }); + } catch (err) { + console.error('[memos] Combine failed:', err); + return c.json({ error: 'Failed to combine memos' }, 500); + } +}); + +// POST /:id/question — Q&A on memo transcript +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 creditCheck = await validateCredits(userId, 'question_memo', COSTS.QUESTION_MEMO); + if (!creditCheck.hasCredits) { + return c.json({ error: `Insufficient credits: need ${COSTS.QUESTION_MEMO}` }, 402); + } + + const supabase = createServiceClient(); + + const { data: memo, error: memoError } = await supabase + .from('memos') + .select('id, title, source') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (memoError || !memo) return c.json({ error: 'Memo not found or access denied' }, 404); + + const memoData = memo as { title: string; source: Record }; + const source = memoData.source ?? {}; + const utterances = source.utterances as Array<{ offset?: number; text?: string }> | undefined; + let transcript = ''; + + if (utterances && utterances.length > 0) { + transcript = [...utterances] + .sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0)) + .map((u) => u.text) + .filter(Boolean) + .join(' '); + } else { + transcript = (source.transcript as string | undefined) ?? memoData.title; + } + + if (!transcript) return c.json({ 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} + +Antworte präzise und klar. Falls die Frage nicht aus dem Transkript beantwortet werden kann, sage das explizit.`; + + try { + const answer = await generateText(prompt, { temperature: 0.5, maxTokens: 1024 }); + + await consumeCredits(userId, 'question_memo', COSTS.QUESTION_MEMO, 'Q&A on memo', { + memoId, + }); + + return c.json({ answer, memoId, question: body.question }); + } catch (err) { + console.error('[memos] Q&A failed:', err); + return c.json({ error: 'Failed to answer question' }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/routes/settings.ts b/apps/memoro/apps/server/src/routes/settings.ts new file mode 100644 index 000000000..eefb5a068 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/settings.ts @@ -0,0 +1,176 @@ +/** + * Settings routes for Memoro server. + * + * Reads/writes user settings from the Supabase `profiles` table. + */ + +import { Hono } from 'hono'; +import { createServiceClient } from '../lib/supabase'; + +export const settingsRoutes = new Hono(); + +// GET / — get all user settings +settingsRoutes.get('/', async (c) => { + const userId = c.get('userId') as string; + const supabase = createServiceClient(); + + const { data: profile, error } = await supabase + .from('profiles') + .select('*') + .eq('user_id', userId) + .maybeSingle(); + + if (error) { + console.error('[settings] Get all error:', error); + return c.json({ error: 'Failed to get settings' }, 500); + } + + return c.json({ settings: profile ?? {} }); +}); + +// GET /memoro — get memoro-specific settings +settingsRoutes.get('/memoro', async (c) => { + const userId = c.get('userId') as string; + const supabase = createServiceClient(); + + const { data: profile, error } = await supabase + .from('profiles') + .select('app_settings, display_name, avatar_url') + .eq('user_id', userId) + .maybeSingle(); + + if (error) { + console.error('[settings] Get memoro error:', error); + return c.json({ error: 'Failed to get memoro settings' }, 500); + } + + const appSettings = (profile as { app_settings?: Record } | null)?.app_settings ?? {}; + const memoroSettings = (appSettings.memoro as Record) ?? {}; + + return c.json({ 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>(); + const supabase = createServiceClient(); + + // Get current settings + const { data: profile, error: fetchError } = await supabase + .from('profiles') + .select('app_settings') + .eq('user_id', userId) + .maybeSingle(); + + if (fetchError) { + return c.json({ error: 'Failed to fetch current settings' }, 500); + } + + const currentSettings = + (profile as { app_settings?: Record } | null)?.app_settings ?? {}; + const currentMemoro = (currentSettings.memoro as Record) ?? {}; + + const updatedSettings = { + ...currentSettings, + memoro: { ...currentMemoro, ...body }, + }; + + const { error: upsertError } = await supabase.from('profiles').upsert( + { + user_id: userId, + app_settings: updatedSettings, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id' } + ); + + if (upsertError) { + console.error('[settings] Update memoro error:', upsertError); + return c.json({ error: 'Failed to update memoro settings' }, 500); + } + + return c.json({ success: true, settings: { ...currentMemoro, ...body } }); +}); + +// 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 supabase = createServiceClient(); + + const { data: profile, error: fetchError } = await supabase + .from('profiles') + .select('app_settings') + .eq('user_id', userId) + .maybeSingle(); + + if (fetchError) { + return c.json({ error: 'Failed to fetch current settings' }, 500); + } + + const currentSettings = + (profile as { app_settings?: Record } | null)?.app_settings ?? {}; + const currentMemoro = (currentSettings.memoro as Record) ?? {}; + + const updatedSettings = { + ...currentSettings, + memoro: { + ...currentMemoro, + dataUsageAcceptance: body.accepted, + dataUsageAcceptedAt: body.accepted ? new Date().toISOString() : null, + }, + }; + + const { error: upsertError } = await supabase.from('profiles').upsert( + { + user_id: userId, + app_settings: updatedSettings, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id' } + ); + + 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: true, dataUsageAcceptance: body.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 allowedFields = ['display_name', 'avatar_url', 'bio'] as const; + const updateData: Record = { 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); + } + + const supabase = createServiceClient(); + + 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: true }); +}); diff --git a/apps/memoro/apps/server/src/routes/spaces.ts b/apps/memoro/apps/server/src/routes/spaces.ts new file mode 100644 index 000000000..755385e7b --- /dev/null +++ b/apps/memoro/apps/server/src/routes/spaces.ts @@ -0,0 +1,235 @@ +/** + * Space routes for Memoro server. + */ + +import { Hono } from 'hono'; +import { createServiceClient } from '../lib/supabase'; +import { + getSpaces, + createSpace, + getSpaceDetails, + deleteSpace, + leaveSpace, + linkMemoToSpace, + unlinkMemoFromSpace, + getSpaceMemos, + getSpaceInvites, + createInvite, +} from '../services/space'; + +export const spaceRoutes = new Hono(); + +// GET / — list user's spaces +spaceRoutes.get('/', async (c) => { + const userId = c.get('userId') as string; + try { + const spaces = await getSpaces(userId); + return c.json({ spaces }); + } catch (err) { + console.error('[spaces] Get spaces error:', err); + return c.json({ 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); + + try { + const space = await createSpace(userId, body.name, body.description); + return c.json({ space }, 201); + } catch (err) { + console.error('[spaces] Create error:', err); + return c.json({ error: 'Failed to create space' }, 500); + } +}); + +// GET /:id — space details +spaceRoutes.get('/:id', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.param('id'); + + try { + const space = await getSpaceDetails(spaceId, userId); + return c.json({ 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); + } + if (msg.includes('not found')) return c.json({ error: msg }, 404); + return c.json({ error: 'Failed to get space details' }, 500); + } +}); + +// DELETE /:id — delete space (owner only) +spaceRoutes.delete('/:id', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.param('id'); + + try { + const result = await deleteSpace(spaceId, userId); + return c.json(result); + } 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); + } +}); + +// POST /:id/leave — leave space (non-owner) +spaceRoutes.post('/:id/leave', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.param('id'); + + try { + const result = await leaveSpace(spaceId, userId); + return c.json(result); + } 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); + } +}); + +// POST /:id/memos/link — link memo to space +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); + + try { + const result = await linkMemoToSpace(body.memoId, spaceId, userId); + return c.json(result); + } 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); + } + if (msg.includes('not found')) return c.json({ error: msg }, 404); + return c.json({ error: 'Failed to link memo to space' }, 500); + } +}); + +// POST /:id/memos/unlink — unlink memo from space +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); + + try { + const result = await unlinkMemoFromSpace(body.memoId, spaceId, userId); + return c.json(result); + } 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); + } +}); + +// GET /:id/memos — list space memos +spaceRoutes.get('/:id/memos', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.param('id'); + + try { + const result = await getSpaceMemos(spaceId, userId); + return c.json(result); + } 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); + } +}); + +// GET /:id/invites — list space invites +spaceRoutes.get('/:id/invites', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.param('id'); + + try { + const invites = await getSpaceInvites(spaceId, userId); + return c.json({ 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); + } +}); + +// POST /:id/invite — send invite +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); + + try { + const invite = await createInvite(spaceId, userId, body.email); + return c.json({ 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); + } +}); + +// 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 }); +}); + +// DELETE /invites/:inviteId — cancel invite +spaceRoutes.delete('/invites/:inviteId', async (c) => { + const userId = c.get('userId') as string; + const inviteId = c.req.param('inviteId'); + const supabase = createServiceClient(); + + // Verify inviter owns this invite + const { data: invite, error } = await supabase + .from('space_invites') + .select('id, inviter_id, space_id') + .eq('id', inviteId) + .single(); + + if (error || !invite) return c.json({ error: 'Invite not found' }, 404); + + const inv = invite as { inviter_id: string; space_id: string }; + + // Allow invite owner or space owner to cancel + const { data: spaceMember } = await supabase + .from('space_members') + .select('role') + .eq('space_id', inv.space_id) + .eq('user_id', userId) + .single(); + + 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); + } + + const { error: deleteError } = await supabase + .from('space_invites') + .delete() + .eq('id', inviteId); + + if (deleteError) return c.json({ error: 'Failed to cancel invite' }, 500); + return c.json({ success: true }); +}); diff --git a/apps/memoro/apps/server/src/services/cleanup.ts b/apps/memoro/apps/server/src/services/cleanup.ts new file mode 100644 index 000000000..996f08ba2 --- /dev/null +++ b/apps/memoro/apps/server/src/services/cleanup.ts @@ -0,0 +1,213 @@ +/** + * Audio cleanup service for Memoro server. + * + * Deletes audio files older than 30 days for opted-in users. + */ + +import { createServiceClient } from '../lib/supabase'; + +const RETENTION_DAYS = 30; +const BATCH_SIZE = 100; +const BUCKET = 'user-uploads'; +const MANA_CREDITS_URL = () => process.env.MANA_CREDITS_URL ?? process.env.MANA_CORE_AUTH_URL ?? 'http://localhost:3061'; +const MANA_CORE_SERVICE_KEY = () => process.env.MANA_CORE_SERVICE_KEY ?? ''; + +interface CleanupResult { + deleted: number; + errors: number; +} + +/** + * Run audio cleanup for specified users, or all opted-in users if none provided. + */ +export async function runAudioCleanup(userIds?: string[]): Promise { + const supabase = createServiceClient(); + let result: CleanupResult = { deleted: 0, errors: 0 }; + + const logId = await startCleanupLog(supabase); + + try { + const targetUserIds = userIds && userIds.length > 0 + ? userIds + : await fetchOptedInUserIds(); + + console.log(`[cleanup] Processing ${targetUserIds.length} users`); + + for (const userId of targetUserIds) { + try { + const userResult = await cleanupUserAudios(userId); + result.deleted += userResult.deleted; + result.errors += userResult.errors; + } catch (err) { + console.error(`[cleanup] Failed for user ${userId}:`, err); + result.errors++; + } + } + + await finishCleanupLog(supabase, logId, result); + console.log(`[cleanup] Done: ${result.deleted} deleted, ${result.errors} errors`); + } catch (err) { + console.error('[cleanup] Fatal error:', err); + await finishCleanupLog(supabase, logId, result, err instanceof Error ? err.message : String(err)); + result.errors++; + } + + return result; +} + +async function cleanupUserAudios(userId: string): Promise { + const supabase = createServiceClient(); + const result: CleanupResult = { deleted: 0, errors: 0 }; + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS); + const cutoffIso = cutoffDate.toISOString(); + + // List files in user's folder older than cutoff + const prefix = `${userId}/`; + const { data: files, error: listError } = await supabase.storage + .from(BUCKET) + .list(prefix, { limit: 1000 }); + + if (listError) { + console.error(`[cleanup] Failed to list files for user ${userId}: ${listError.message}`); + return { deleted: 0, errors: 1 }; + } + + if (!files || files.length === 0) return result; + + // Filter files older than cutoff + const oldFiles = files.filter((file) => { + const created = file.created_at ?? file.updated_at; + return created && created < cutoffIso; + }); + + if (oldFiles.length === 0) return result; + + console.log(`[cleanup] User ${userId}: ${oldFiles.length} files to delete`); + + // Delete in batches + for (let i = 0; i < oldFiles.length; i += BATCH_SIZE) { + const batch = oldFiles.slice(i, i + BATCH_SIZE); + const paths = batch.map((f) => `${prefix}${f.name}`); + + const { error: deleteError } = await supabase.storage.from(BUCKET).remove(paths); + + if (deleteError) { + console.error(`[cleanup] Batch delete failed: ${deleteError.message}`); + result.errors += batch.length; + } else { + result.deleted += batch.length; + + // Update memo records for deleted files + for (const file of batch) { + const filePath = `${prefix}${file.name}`; + await updateMemoAudioDeleted(supabase, filePath); + } + } + } + + return result; +} + +async function updateMemoAudioDeleted( + supabase: ReturnType, + audioPath: string +): Promise { + // Find memo(s) with this audio path + const { data: memos, error: fetchError } = await supabase + .from('memos') + .select('id, source') + .contains('source', { audio_path: audioPath }); + + if (fetchError || !memos || memos.length === 0) return; + + const now = new Date().toISOString(); + + for (const memo of memos) { + const source = (memo.source as Record) ?? {}; + const updatedSource = { + ...source, + audio_path: null, + audio_deleted: true, + audio_deleted_at: now, + }; + + await supabase + .from('memos') + .update({ source: updatedSource, updated_at: now }) + .eq('id', memo.id); + } +} + +async function fetchOptedInUserIds(): Promise { + const serviceKey = MANA_CORE_SERVICE_KEY(); + if (!serviceKey) { + console.warn('[cleanup] MANA_CORE_SERVICE_KEY not set, cannot fetch opted-in users'); + return []; + } + + try { + const response = await fetch( + `${MANA_CREDITS_URL()}/api/v1/internal/users/audio-cleanup-enabled`, + { + headers: { + 'X-Service-Key': serviceKey, + }, + } + ); + + if (!response.ok) { + console.warn(`[cleanup] Failed to fetch opted-in users: ${response.status}`); + return []; + } + + const data = (await response.json()) as { userIds?: string[] }; + return data.userIds ?? []; + } catch (err) { + console.error('[cleanup] Error fetching opted-in users:', err); + return []; + } +} + +async function startCleanupLog( + supabase: ReturnType +): Promise { + try { + const { data } = await supabase + .from('audio_cleanup_logs') + .insert({ + id: crypto.randomUUID(), + started_at: new Date().toISOString(), + status: 'running', + }) + .select('id') + .single(); + return (data as { id: string } | null)?.id ?? null; + } catch { + return null; + } +} + +async function finishCleanupLog( + supabase: ReturnType, + logId: string | null, + result: CleanupResult, + errorMessage?: string +): Promise { + if (!logId) return; + try { + await supabase + .from('audio_cleanup_logs') + .update({ + finished_at: new Date().toISOString(), + status: errorMessage ? 'failed' : 'completed', + files_deleted: result.deleted, + errors: result.errors, + error_message: errorMessage ?? null, + }) + .eq('id', logId); + } catch { + // Non-critical + } +} diff --git a/apps/memoro/apps/server/src/services/headline.ts b/apps/memoro/apps/server/src/services/headline.ts new file mode 100644 index 000000000..fe6041e46 --- /dev/null +++ b/apps/memoro/apps/server/src/services/headline.ts @@ -0,0 +1,334 @@ +/** + * Headline and intro generation service for Memoro server. + * + * Ported from the NestJS HeadlineService. + */ + +import { createServiceClient } from '../lib/supabase'; +import { generateText } from '../lib/ai'; +import { updateMemoProcessingStatus } from './memo'; + +// ── Language prompts ─────────────────────────────────────────────────────────── + +const HEADLINE_PROMPTS: Record = { + de: `Du bist ein KI-Assistent, der prägnante Überschriften und Zusammenfassungen für Sprachaufnahmen erstellt. + +Analysiere das folgende Transkript und erstelle: +1. Eine kurze, prägnante Überschrift (max. 60 Zeichen) +2. Eine kurze Zusammenfassung (2-3 Sätze) + +Antworte NUR in diesem Format (auf Deutsch): +HEADLINE: <Überschrift> +INTRO: `, + + en: `You are an AI assistant that creates concise headlines and summaries for voice recordings. + +Analyze the following transcript and create: +1. A short, concise headline (max. 60 characters) +2. A brief summary (2-3 sentences) + +Reply ONLY in this format (in English): +HEADLINE: +INTRO: `, + + fr: `Vous êtes un assistant IA qui crée des titres et des résumés concis pour les enregistrements vocaux. + +Analysez la transcription suivante et créez : +1. Un titre court et concis (max. 60 caractères) +2. Un bref résumé (2-3 phrases) + +Répondez UNIQUEMENT dans ce format (en français) : +HEADLINE: +INTRO: `, + + es: `Eres un asistente de IA que crea titulares y resúmenes concisos para grabaciones de voz. + +Analiza la siguiente transcripción y crea: +1. Un titular corto y conciso (máx. 60 caracteres) +2. Un breve resumen (2-3 oraciones) + +Responde SOLO en este formato (en español): +HEADLINE: +INTRO: `, + + it: `Sei un assistente IA che crea titoli e riassunti concisi per le registrazioni vocali. + +Analizza la seguente trascrizione e crea: +1. Un titolo breve e conciso (max. 60 caratteri) +2. Un breve riassunto (2-3 frasi) + +Rispondi SOLO in questo formato (in italiano): +HEADLINE: +INTRO: `, + + nl: `Je bent een AI-assistent die beknopte koppen en samenvattingen maakt voor spraakopnames. + +Analyseer de volgende transcriptie en maak: +1. Een korte, bondige kop (max. 60 tekens) +2. Een korte samenvatting (2-3 zinnen) + +Antwoord ALLEEN in dit formaat (in het Nederlands): +HEADLINE: +INTRO: `, + + pt: `Você é um assistente de IA que cria títulos e resumos concisos para gravações de voz. + +Analise a seguinte transcrição e crie: +1. Um título curto e conciso (máx. 60 caracteres) +2. Um breve resumo (2-3 frases) + +Responda APENAS neste formato (em português): +HEADLINE: +INTRO: `, + + ru: `Вы — ИИ-ассистент, создающий краткие заголовки и резюме для голосовых записей. + +Проанализируйте следующую расшифровку и создайте: +1. Короткий, ёмкий заголовок (макс. 60 символов) +2. Краткое резюме (2-3 предложения) + +Отвечайте ТОЛЬКО в этом формате (на русском): +HEADLINE: <заголовок> +INTRO: <резюме>`, + + ja: `あなたは音声録音の簡潔な見出しと要約を作成するAIアシスタントです。 + +以下のトランスクリプトを分析して作成してください: +1. 短くて簡潔な見出し(最大60文字) +2. 簡単な要約(2〜3文) + +ONLY このフォーマットで返答してください(日本語で): +HEADLINE: <見出し> +INTRO: <要約>`, + + ko: `당신은 음성 녹음을 위한 간결한 헤드라인과 요약을 만드는 AI 어시스턴트입니다. + +다음 트랜스크립트를 분석하여 만드세요: +1. 짧고 간결한 헤드라인 (최대 60자) +2. 간단한 요약 (2-3문장) + +ONLY 이 형식으로 답하세요 (한국어로): +HEADLINE: <헤드라인> +INTRO: <요약>`, + + zh: `您是一名AI助手,为语音录音创建简洁的标题和摘要。 + +分析以下转录并创建: +1. 简短、简洁的标题(最多60个字符) +2. 简短摘要(2-3句话) + +请仅以此格式回答(用中文): +HEADLINE: <标题> +INTRO: <摘要>`, + + tr: `Ses kayıtları için kısa başlıklar ve özetler oluşturan bir yapay zeka asistanısınız. + +Aşağıdaki transkripsiyonu analiz edin ve oluşturun: +1. Kısa ve öz bir başlık (maks. 60 karakter) +2. Kısa bir özet (2-3 cümle) + +SADECE bu formatta yanıtlayın (Türkçe olarak): +HEADLINE: +INTRO: <özet>`, + + pl: `Jesteś asystentem AI tworzącym zwięzłe nagłówki i podsumowania nagrań głosowych. + +Przeanalizuj poniższy transkrypt i utwórz: +1. Krótki, zwięzły nagłówek (maks. 60 znaków) +2. Krótkie podsumowanie (2-3 zdania) + +Odpowiedz TYLKO w tym formacie (po polsku): +HEADLINE: +INTRO: `, +}; + +const FALLBACK_PROMPT = HEADLINE_PROMPTS['de'] ?? ''; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function buildPrompt(transcript: string, language: string): string { + const base = language.split('-')[0]?.toLowerCase() ?? 'de'; + const systemPrompt = HEADLINE_PROMPTS[base] ?? HEADLINE_PROMPTS['en'] ?? FALLBACK_PROMPT; + return `${systemPrompt}\n\n${transcript}`; +} + +function parseResponse(content: string): { headline: string; intro: string } { + const headlineMatch = content.match(/HEADLINE:\s*(.+?)(?=\nINTRO:|$)/s); + const introMatch = content.match(/INTRO:\s*(.+?)$/s); + return { + headline: headlineMatch?.[1]?.trim() ?? 'Neue Aufnahme', + intro: introMatch?.[1]?.trim() ?? 'Keine Zusammenfassung verfügbar.', + }; +} + +function extractTranscript(memo: Record): string { + const source = memo.source as Record | undefined; + + // Preferred: sorted utterances + const utterances = source?.utterances as Array<{ offset?: number; text?: string }> | undefined; + if (utterances && utterances.length > 0) { + return [...utterances] + .sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0)) + .map((u) => u.text) + .filter(Boolean) + .join(' '); + } + + // Direct transcript fields + if (typeof memo.transcript === 'string' && memo.transcript) return memo.transcript; + if (typeof source?.transcript === 'string' && source.transcript) return source.transcript; + if (typeof source?.content === 'string' && source.content) return source.content; + + // Combined recordings + const additionalRecordings = source?.additional_recordings as Array<{ + utterances?: Array<{ offset?: number; text?: string }>; + transcript?: string; + }> | undefined; + if (source?.type === 'combined' && additionalRecordings) { + return additionalRecordings + .map((rec) => { + if (rec.utterances && rec.utterances.length > 0) { + return [...rec.utterances] + .sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0)) + .map((u) => u.text) + .filter(Boolean) + .join(' '); + } + return rec.transcript ?? ''; + }) + .filter(Boolean) + .join('\n\n'); + } + + return ''; +} + +function detectLanguage(memo: Record): string { + const source = memo.source as Record | undefined; + const metadata = memo.metadata as Record | undefined; + + if (typeof source?.primary_language === 'string') return source.primary_language; + const langs = source?.languages as string[] | undefined; + if (langs && langs.length > 0) return langs[0] ?? 'de'; + if (typeof metadata?.primary_language === 'string') return metadata.primary_language; + return 'de'; +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +/** + * Generate headline and intro for a given transcript. + */ +export async function generateHeadlineAndIntro( + transcript: string, + language = 'de' +): Promise<{ headline: string; intro: string }> { + const prompt = buildPrompt(transcript, language); + + try { + const content = await generateText(prompt, { temperature: 0.7, maxTokens: 512 }); + const result = parseResponse(content); + console.debug(`[headline] Generated: "${result.headline}" (lang=${language})`); + return result; + } catch (error) { + console.error( + `[headline] Generation failed: ${error instanceof Error ? error.message : error}` + ); + return { headline: 'Neue Aufnahme', intro: 'Keine Zusammenfassung verfügbar.' }; + } +} + +/** + * Full pipeline: load memo → generate headline → update memo → broadcast. + */ +export async function processHeadlineForMemo( + memoId: string +): Promise<{ headline: string; intro: string }> { + const supabase = createServiceClient(); + + await updateMemoProcessingStatus(memoId, 'headline_and_intro', 'processing'); + + try { + const { data: memo, error: memoError } = await supabase + .from('memos') + .select('*') + .eq('id', memoId) + .single(); + + if (memoError || !memo) { + throw new Error(`Memo not found: ${memoError?.message ?? 'unknown'}`); + } + + const memoRecord = memo as Record; + const transcript = extractTranscript(memoRecord); + + if (!transcript) { + await updateMemoProcessingStatus(memoId, 'headline_and_intro', 'failed', { + error: 'No transcript found in memo', + }); + throw new Error('No transcript found in memo'); + } + + const language = detectLanguage(memoRecord); + const { headline, intro } = await generateHeadlineAndIntro(transcript, language); + + const { error: updateError } = await supabase + .from('memos') + .update({ + title: headline, + intro, + updated_at: new Date().toISOString(), + }) + .eq('id', memoId); + + if (updateError) { + throw new Error(`Memo update failed: ${updateError.message}`); + } + + // Broadcast via Supabase Realtime (fire and forget) + sendBroadcast(supabase, memoId, headline, intro).catch((err) => + console.warn(`[headline] Broadcast failed for memo ${memoId}: ${err}`) + ); + + await updateMemoProcessingStatus(memoId, 'headline_and_intro', 'completed', { + headline, + language, + }); + + console.log(`[headline] Processed memo ${memoId}: "${headline}"`); + return { headline, intro }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + await updateMemoProcessingStatus(memoId, 'headline_and_intro', 'failed', { error: msg }); + throw error; + } +} + +async function sendBroadcast( + supabase: ReturnType, + memoId: string, + headline: string, + intro: string +): Promise { + const channel = supabase.channel(`memo-updates-${memoId}`); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Broadcast timeout')), 10_000); + channel.subscribe(async (status: string) => { + if (status === 'SUBSCRIBED') { + clearTimeout(timeout); + await channel.send({ + type: 'broadcast', + event: 'memo-updated', + payload: { + type: 'memo-updated', + memoId, + changes: { title: headline, intro, updated_at: new Date().toISOString() }, + source: 'headline-ai-service', + }, + }); + supabase.removeChannel(channel); + resolve(); + } + }); + }); +} diff --git a/apps/memoro/apps/server/src/services/memo.ts b/apps/memoro/apps/server/src/services/memo.ts new file mode 100644 index 000000000..de3be0b5b --- /dev/null +++ b/apps/memoro/apps/server/src/services/memo.ts @@ -0,0 +1,306 @@ +/** + * Core memo service for Memoro server. + * + * All Supabase queries use the service-role client with explicit user_id filters. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { createServiceClient } from '../lib/supabase'; +import { validateCredits, calcTranscriptionCost, consumeCredits } from '../lib/credits'; +import { processHeadlineForMemo } from './headline'; + +const AUDIO_SERVER_URL = () => process.env.AUDIO_SERVER_URL ?? 'http://localhost:3016'; +const SERVICE_KEY = () => process.env.SERVICE_KEY ?? ''; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export interface CreateMemoParams { + userId: string; + filePath: string; + duration: number; + spaceId?: string; + blueprintId?: string; + memoId?: string; + recordingStartedAt?: string; + location?: unknown; + mediaType?: string; +} + +export interface TranscriptionResult { + transcript?: string; + utterances?: Array<{ + offset: number; + duration: number; + text: string; + speaker?: string; + }>; + speakers?: Record; + speakerMap?: Record; + languages?: string[]; + primary_language?: string; + duration?: number; +} + +export interface HandleTranscriptionParams { + memoId: string; + userId: string; + transcriptionResult?: TranscriptionResult; + route?: string; + success: boolean; + error?: string; + fallbackStage?: string; +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +/** + * Create a memo from an already-uploaded audio file. + * Fires transcription asynchronously after returning. + */ +export async function createMemoFromUploadedFile(params: CreateMemoParams): Promise<{ + memoId: string; + audioPath: string; + memo: Record; +}> { + const supabase = createServiceClient(); + const { + userId, + filePath, + duration, + spaceId, + blueprintId, + memoId: providedMemoId, + recordingStartedAt, + location, + mediaType, + } = params; + + // Validate credits before processing + const cost = calcTranscriptionCost(duration); + const creditCheck = await validateCredits(userId, 'transcription', cost); + if (!creditCheck.hasCredits) { + throw new Error( + `Insufficient credits: need ${cost}, have ${creditCheck.availableCredits}` + ); + } + + const memoId = providedMemoId ?? uuidv4(); + + const memoData = { + id: memoId, + user_id: userId, + title: 'Neue Aufnahme', + source: { + audio_path: filePath, + duration, + media_type: mediaType ?? 'audio/m4a', + }, + metadata: { + processing: { + transcription: { status: 'pending' }, + headline_and_intro: { status: 'pending' }, + }, + recordingStartedAt: recordingStartedAt ?? null, + location: location ?? null, + blueprint_id: blueprintId ?? null, + }, + updated_at: new Date().toISOString(), + }; + + const { data: memo, error } = await supabase + .from('memos') + .upsert(memoData, { onConflict: 'id' }) + .select() + .single(); + + if (error || !memo) { + throw new Error(`Failed to create memo: ${error?.message ?? 'no data returned'}`); + } + + // Link to space if provided + if (spaceId) { + const { error: spaceError } = await supabase.from('memo_spaces').insert({ + memo_id: memoId, + space_id: spaceId, + created_at: new Date().toISOString(), + }); + if (spaceError) { + console.error(`[memo] Failed to link memo ${memoId} to space ${spaceId}:`, spaceError); + } + } + + // Fire transcription asynchronously + queueMicrotask(() => { + callAudioServer({ + memoId, + userId, + audioPath: filePath, + duration, + blueprintId, + }).catch((err) => { + console.error(`[memo] Audio server call failed for memo ${memoId}:`, err); + updateMemoProcessingStatus(memoId, 'transcription', 'failed', { + error: err instanceof Error ? err.message : String(err), + }).catch(() => {}); + }); + }); + + return { memoId, audioPath: filePath, memo: memo as Record }; +} + +/** + * Handle transcription completion callback from the audio server. + */ +export async function handleTranscriptionCompleted( + params: HandleTranscriptionParams +): Promise { + const { memoId, userId, transcriptionResult, route, success, error } = params; + const supabase = createServiceClient(); + + if (!success || !transcriptionResult) { + await updateMemoProcessingStatus(memoId, 'transcription', 'failed', { + error: error ?? 'Transcription failed', + route, + }); + return; + } + + const duration = transcriptionResult.duration ?? 0; + const cost = calcTranscriptionCost(duration); + + // Fetch existing source to merge (preserve audio_path, duration, media_type etc.) + const { data: existing } = await supabase + .from('memos') + .select('source') + .eq('id', memoId) + .single(); + + const existingSource = (existing?.source as Record) ?? {}; + + // Update memo source — merge transcription data with existing source fields + const { error: updateError } = await supabase + .from('memos') + .update({ + source: { + ...existingSource, + transcript: transcriptionResult.transcript ?? '', + utterances: transcriptionResult.utterances ?? [], + speakers: transcriptionResult.speakers ?? {}, + speakerMap: transcriptionResult.speakerMap ?? {}, + languages: transcriptionResult.languages ?? [], + primary_language: transcriptionResult.primary_language ?? 'de', + transcription_route: route, + }, + updated_at: new Date().toISOString(), + }) + .eq('id', memoId) + .eq('user_id', userId); + + if (updateError) { + console.error(`[memo] Failed to update transcription for memo ${memoId}:`, updateError); + return; + } + + // Mark transcription completed + await updateMemoProcessingStatus(memoId, 'transcription', 'completed', { route }); + + // Consume credits + consumeCredits(userId, 'transcription', cost, `Transcription for memo ${memoId}`, { + memoId, + durationSeconds: duration, + }).catch((err) => console.error('[memo] Failed to consume credits:', err)); + + // Fire headline generation asynchronously + queueMicrotask(() => { + processHeadlineForMemo(memoId).catch((err) => { + console.error(`[memo] Headline generation failed for memo ${memoId}:`, err); + }); + }); +} + +/** + * POST to the audio server to start transcription. + */ +export async function callAudioServer(params: { + memoId: string; + userId: string; + audioPath: string; + duration: number; + blueprintId?: string; + recordingIndex?: number; + recordingLanguages?: string[]; + enableDiarization?: boolean; + isAppend?: boolean; +}): Promise { + const url = `${AUDIO_SERVER_URL()}/api/v1/transcribe${params.isAppend ? '/append' : ''}`; + const serviceKey = SERVICE_KEY(); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + }, + body: JSON.stringify({ + memoId: params.memoId, + userId: params.userId, + audioPath: params.audioPath, + recordingIndex: params.recordingIndex, + recordingLanguages: params.recordingLanguages, + enableDiarization: params.enableDiarization, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Audio server returned ${response.status}: ${text}`); + } +} + +/** + * Update memo processing status in metadata. + */ +export async function updateMemoProcessingStatus( + memoId: string, + processName: string, + status: 'pending' | 'processing' | 'completed' | 'failed', + details?: Record +): Promise { + const supabase = createServiceClient(); + + // Fetch current metadata + const { data: memo, error: fetchError } = await supabase + .from('memos') + .select('metadata') + .eq('id', memoId) + .single(); + + if (fetchError || !memo) { + console.error(`[memo] Cannot update processing status — memo ${memoId} not found`); + return; + } + + const metadata = (memo.metadata as Record) ?? {}; + const processing = (metadata.processing as Record) ?? {}; + + const updatedMetadata = { + ...metadata, + processing: { + ...processing, + [processName]: { + status, + updated_at: new Date().toISOString(), + ...(details ?? {}), + }, + }, + }; + + const { error: updateError } = await supabase + .from('memos') + .update({ metadata: updatedMetadata, updated_at: new Date().toISOString() }) + .eq('id', memoId); + + if (updateError) { + console.error(`[memo] Failed to update processing status for ${memoId}:`, updateError); + } +} diff --git a/apps/memoro/apps/server/src/services/space.ts b/apps/memoro/apps/server/src/services/space.ts new file mode 100644 index 000000000..f46e037c8 --- /dev/null +++ b/apps/memoro/apps/server/src/services/space.ts @@ -0,0 +1,416 @@ +/** + * Space management service for Memoro server. + * + * All Supabase queries use the service-role client with explicit user_id filters. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { createServiceClient } from '../lib/supabase'; + +// ── Spaces ───────────────────────────────────────────────────────────────────── + +export async function getSpaces(userId: string): Promise { + const supabase = createServiceClient(); + + const { data, error } = await supabase + .from('spaces') + .select('*, space_members!inner(user_id, role)') + .eq('space_members.user_id', userId) + .order('created_at', { ascending: false }); + + if (error) throw new Error(`Failed to get spaces: ${error.message}`); + return data ?? []; +} + +export async function createSpace( + userId: string, + name: string, + description?: string +): Promise { + const supabase = createServiceClient(); + + if (!name?.trim()) throw new Error('Space name is required'); + + const spaceId = uuidv4(); + + const { data: space, error: spaceError } = await supabase + .from('spaces') + .insert({ + id: spaceId, + name: name.trim(), + description: description?.trim() ?? null, + owner_id: userId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .select() + .single(); + + if (spaceError || !space) { + throw new Error(`Failed to create space: ${spaceError?.message ?? 'no data returned'}`); + } + + // Add owner as member + const { error: memberError } = await supabase.from('space_members').insert({ + space_id: spaceId, + user_id: userId, + role: 'owner', + joined_at: new Date().toISOString(), + }); + + if (memberError) { + console.error(`[space] Failed to add owner as member: ${memberError.message}`); + } + + return space; +} + +export async function getSpaceDetails(spaceId: string, userId: string): Promise { + const supabase = createServiceClient(); + + // Verify user has access + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) { + throw new Error('Access denied: you are not a member of this space'); + } + + const { data: space, error } = await supabase + .from('spaces') + .select('*, space_members(user_id, role)') + .eq('id', spaceId) + .single(); + + if (error || !space) { + throw new Error(`Space not found: ${error?.message ?? 'no data returned'}`); + } + + return space; +} + +export async function deleteSpace(spaceId: string, userId: string): Promise<{ success: boolean }> { + const supabase = createServiceClient(); + + // Verify ownership + const { data: space, error: fetchError } = await supabase + .from('spaces') + .select('owner_id') + .eq('id', spaceId) + .single(); + + if (fetchError || !space) throw new Error('Space not found'); + if ((space as { owner_id: string }).owner_id !== userId) { + throw new Error('Only the space owner can delete this space'); + } + + // Clean up memo_spaces links + await supabase.from('memo_spaces').delete().eq('space_id', spaceId); + + // Delete space (cascades to space_members, invites) + const { error } = await supabase.from('spaces').delete().eq('id', spaceId); + if (error) throw new Error(`Failed to delete space: ${error.message}`); + + return { success: true }; +} + +export async function leaveSpace(spaceId: string, userId: string): Promise<{ success: boolean }> { + const supabase = createServiceClient(); + + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) throw new Error('You are not a member of this space'); + + if ((member as { role: string }).role === 'owner') { + throw new Error('Space owner cannot leave. Transfer ownership or delete the space.'); + } + + // Remove user's memo links from this space + const { data: userMemos } = await supabase + .from('memos') + .select('id') + .eq('user_id', userId); + + if (userMemos && userMemos.length > 0) { + const memoIds = userMemos.map((m: { id: string }) => m.id); + await supabase.from('memo_spaces').delete().eq('space_id', spaceId).in('memo_id', memoIds); + } + + const { error } = await supabase + .from('space_members') + .delete() + .eq('space_id', spaceId) + .eq('user_id', userId); + + if (error) throw new Error(`Failed to leave space: ${error.message}`); + return { success: true }; +} + +// ── Memo ↔ Space linking ─────────────────────────────────────────────────────── + +export async function linkMemoToSpace( + memoId: string, + spaceId: string, + userId: string +): Promise<{ success: boolean; message: string }> { + const supabase = createServiceClient(); + + // Verify memo ownership + const { data: memo, error: memoError } = await supabase + .from('memos') + .select('user_id') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (memoError || !memo) throw new Error('Memo not found or access denied'); + + // Verify space membership + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) throw new Error('Not a member of this space'); + + // Check for existing link + const { data: existing } = await supabase + .from('memo_spaces') + .select('memo_id') + .eq('memo_id', memoId) + .eq('space_id', spaceId) + .maybeSingle(); + + if (existing) return { success: true, message: 'Memo is already linked to this space' }; + + const { error } = await supabase.from('memo_spaces').insert({ + memo_id: memoId, + space_id: spaceId, + created_at: new Date().toISOString(), + }); + + if (error) throw new Error(`Failed to link memo to space: ${error.message}`); + return { success: true, message: 'Memo linked to space successfully' }; +} + +export async function unlinkMemoFromSpace( + memoId: string, + spaceId: string, + userId: string +): Promise<{ success: boolean; message: string }> { + const supabase = createServiceClient(); + + // Verify memo ownership + const { data: memo, error: memoError } = await supabase + .from('memos') + .select('user_id') + .eq('id', memoId) + .eq('user_id', userId) + .single(); + + if (memoError || !memo) throw new Error('Memo not found or access denied'); + + const { error } = await supabase + .from('memo_spaces') + .delete() + .eq('memo_id', memoId) + .eq('space_id', spaceId); + + if (error) throw new Error(`Failed to unlink memo from space: ${error.message}`); + return { success: true, message: 'Memo unlinked from space successfully' }; +} + +export async function getSpaceMemos(spaceId: string, userId: string): Promise<{ memos: unknown[] }> { + const supabase = createServiceClient(); + + // Verify membership + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) throw new Error('Not a member of this space'); + + const { data: memoSpaces, error: joinError } = await supabase + .from('memo_spaces') + .select('memo_id') + .eq('space_id', spaceId); + + if (joinError) throw new Error(`Failed to get memo links: ${joinError.message}`); + if (!memoSpaces || memoSpaces.length === 0) return { memos: [] }; + + const memoIds = memoSpaces.map((ms: { memo_id: string }) => ms.memo_id); + + const { data: memos, error: memosError } = await supabase + .from('memos') + .select('id, title, user_id, source, style, is_pinned, is_archived, is_public, metadata, created_at, updated_at') + .in('id', memoIds); + + if (memosError) throw new Error(`Failed to get memos: ${memosError.message}`); + return { memos: memos ?? [] }; +} + +// ── Invites ──────────────────────────────────────────────────────────────────── + +export async function getSpaceInvites(spaceId: string, userId: string): Promise { + const supabase = createServiceClient(); + + // Verify membership + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) throw new Error('Not a member of this space'); + + const { data, error } = await supabase + .from('space_invites') + .select('*') + .eq('space_id', spaceId) + .order('created_at', { ascending: false }); + + if (error) throw new Error(`Failed to get invites: ${error.message}`); + return data ?? []; +} + +export async function createInvite( + spaceId: string, + userId: string, + inviteeEmail: string +): Promise { + const supabase = createServiceClient(); + + if (!inviteeEmail?.trim()) throw new Error('Invitee email is required'); + + // Verify membership (only members can invite) + const { data: member, error: memberError } = await supabase + .from('space_members') + .select('role') + .eq('space_id', spaceId) + .eq('user_id', userId) + .single(); + + if (memberError || !member) throw new Error('Not a member of this space'); + + const { data: invite, error } = await supabase + .from('space_invites') + .insert({ + id: uuidv4(), + space_id: spaceId, + inviter_id: userId, + invitee_email: inviteeEmail.trim().toLowerCase(), + status: 'pending', + created_at: new Date().toISOString(), + }) + .select() + .single(); + + if (error || !invite) throw new Error(`Failed to create invite: ${error?.message ?? 'no data'}`); + return invite; +} + +export async function acceptInvite( + inviteId: string, + userId: string +): Promise<{ success: boolean }> { + const supabase = createServiceClient(); + + const { data: invite, error: fetchError } = await supabase + .from('space_invites') + .select('*') + .eq('id', inviteId) + .eq('status', 'pending') + .single(); + + if (fetchError || !invite) throw new Error('Invite not found or already processed'); + + const inv = invite as { space_id: string; invitee_email: string }; + + // Accept invite + const { error: updateError } = await supabase + .from('space_invites') + .update({ status: 'accepted', accepted_at: new Date().toISOString() }) + .eq('id', inviteId); + + if (updateError) throw new Error(`Failed to accept invite: ${updateError.message}`); + + // Add user to space_members + const { error: memberError } = await supabase.from('space_members').upsert( + { + space_id: inv.space_id, + user_id: userId, + role: 'member', + joined_at: new Date().toISOString(), + }, + { onConflict: 'space_id,user_id' } + ); + + if (memberError) { + console.error(`[space] Failed to add member after invite acceptance: ${memberError.message}`); + } + + return { success: true }; +} + +export async function declineInvite( + inviteId: string, + userId: string +): Promise<{ success: boolean }> { + const supabase = createServiceClient(); + + const { data: invite, error: fetchError } = await supabase + .from('space_invites') + .select('id, status') + .eq('id', inviteId) + .eq('status', 'pending') + .single(); + + if (fetchError || !invite) throw new Error('Invite not found or already processed'); + + const { error } = await supabase + .from('space_invites') + .update({ status: 'declined', declined_at: new Date().toISOString() }) + .eq('id', inviteId); + + if (error) throw new Error(`Failed to decline invite: ${error.message}`); + return { success: true }; +} + +export async function getPendingInvites(userId: string): Promise { + const supabase = createServiceClient(); + + // Get user email from profiles to match invites + const { data: profile } = await supabase + .from('profiles') + .select('email') + .eq('user_id', userId) + .maybeSingle(); + + const email = (profile as { email?: string } | null)?.email; + if (!email) return []; + + const { data, error } = await supabase + .from('space_invites') + .select('*, spaces(id, name, owner_id)') + .eq('invitee_email', email.toLowerCase()) + .eq('status', 'pending') + .order('created_at', { ascending: false }); + + if (error) throw new Error(`Failed to get pending invites: ${error.message}`); + return data ?? []; +} diff --git a/apps/memoro/apps/server/tsconfig.json b/apps/memoro/apps/server/tsconfig.json new file mode 100644 index 000000000..db2ab6df6 --- /dev/null +++ b/apps/memoro/apps/server/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}