mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(memoro): add Hono/Bun server + audio-server (replaces NestJS)
Two new services replacing the NestJS backend + audio-backend: - apps/memoro/apps/server/ (port 3015): main business logic - Memo creation, transcription orchestration, AI headline/Q&A - Space + invite management, credits, settings, cleanup - Uses @manacore/shared-hono authMiddleware (mana-auth JWT) - Service-role Supabase client with explicit user_id filters - apps/memoro/apps/audio-server/ (port 3016): audio processing - 4-tier Azure Speech fallback (fast → retry → convert → batch) - FFmpeg conversion (PCM 16kHz mono WAV) via fluent-ffmpeg - Load balancing across up to 4 Azure Speech keys - Internal-only (X-Service-Key auth) Auth proxy, space sync, and NestJS services not yet removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a0caa1f21d
commit
29515e7c4d
26 changed files with 3903 additions and 0 deletions
21
apps/memoro/apps/audio-server/package.json
Normal file
21
apps/memoro/apps/audio-server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
69
apps/memoro/apps/audio-server/src/index.ts
Normal file
69
apps/memoro/apps/audio-server/src/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
60
apps/memoro/apps/audio-server/src/lib/azure.ts
Normal file
60
apps/memoro/apps/audio-server/src/lib/azure.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
41
apps/memoro/apps/audio-server/src/lib/supabase.ts
Normal file
41
apps/memoro/apps/audio-server/src/lib/supabase.ts
Normal file
|
|
@ -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<Buffer> {
|
||||
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;
|
||||
}
|
||||
124
apps/memoro/apps/audio-server/src/routes/transcribe.ts
Normal file
124
apps/memoro/apps/audio-server/src/routes/transcribe.ts
Normal file
|
|
@ -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<TranscribeBody>();
|
||||
} 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<TranscribeBody>();
|
||||
} 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;
|
||||
}
|
||||
210
apps/memoro/apps/audio-server/src/services/batch.ts
Normal file
210
apps/memoro/apps/audio-server/src/services/batch.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<BatchJobResult> {
|
||||
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<string, unknown> = {
|
||||
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<BatchJobStatus> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
apps/memoro/apps/audio-server/src/services/ffmpeg.ts
Normal file
167
apps/memoro/apps/audio-server/src/services/ffmpeg.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
'.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<string, string> = {
|
||||
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<void> {
|
||||
await Promise.all(
|
||||
files.map((f) =>
|
||||
fs.promises.unlink(f).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function convertToAzureWav(inputBuffer: Buffer, fileExtension: string): Promise<Buffer> {
|
||||
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<Buffer>((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<number> {
|
||||
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<number>((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;
|
||||
}
|
||||
}
|
||||
451
apps/memoro/apps/audio-server/src/services/transcription.ts
Normal file
451
apps/memoro/apps/audio-server/src/services/transcription.ts
Normal file
|
|
@ -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<string, string>;
|
||||
speakerMap: Record<string, number>;
|
||||
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<void> {
|
||||
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 = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, 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<TranscriptionResult> {
|
||||
const definition: Record<string, unknown> = {
|
||||
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<string, number> = {};
|
||||
const charCounts: Record<string, number> = {};
|
||||
|
||||
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<number>();
|
||||
|
||||
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<string, string> = {};
|
||||
const speakerMap: Record<string, number> = {};
|
||||
|
||||
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<void> {
|
||||
const endpoint = isAppend
|
||||
? `${serverUrl}/api/v1/internal/append-transcription-completed`
|
||||
: `${serverUrl}/api/v1/internal/transcription-completed`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/memoro/apps/audio-server/tsconfig.json
Normal file
18
apps/memoro/apps/audio-server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
21
apps/memoro/apps/server/package.json
Normal file
21
apps/memoro/apps/server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
90
apps/memoro/apps/server/src/index.ts
Normal file
90
apps/memoro/apps/server/src/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
143
apps/memoro/apps/server/src/lib/ai.ts
Normal file
143
apps/memoro/apps/server/src/lib/ai.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string | null> {
|
||||
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<string, unknown> = {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
25
apps/memoro/apps/server/src/lib/credits.ts
Normal file
25
apps/memoro/apps/server/src/lib/credits.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
34
apps/memoro/apps/server/src/lib/supabase.ts
Normal file
34
apps/memoro/apps/server/src/lib/supabase.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
55
apps/memoro/apps/server/src/routes/cleanup.ts
Normal file
55
apps/memoro/apps/server/src/routes/cleanup.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
60
apps/memoro/apps/server/src/routes/credits.ts
Normal file
60
apps/memoro/apps/server/src/routes/credits.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
194
apps/memoro/apps/server/src/routes/internal.ts
Normal file
194
apps/memoro/apps/server/src/routes/internal.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
speakerMap?: Record<string, unknown>;
|
||||
languages?: string[];
|
||||
primary_language?: string;
|
||||
duration?: number;
|
||||
};
|
||||
route?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fallbackStage?: string;
|
||||
}>();
|
||||
|
||||
if (!body.memoId || !body.userId) {
|
||||
return c.json({ error: 'memoId and userId are required' }, 400);
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
speakerMap?: Record<string, unknown>;
|
||||
languages?: string[];
|
||||
primary_language?: string;
|
||||
duration?: number;
|
||||
};
|
||||
success: boolean;
|
||||
error?: string;
|
||||
route?: string;
|
||||
}>();
|
||||
|
||||
if (!body.memoId || !body.userId) {
|
||||
return c.json({ error: 'memoId and userId are required' }, 400);
|
||||
}
|
||||
|
||||
const 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<string, unknown> }).source ?? {};
|
||||
const additionalRecordings = [...((source.additional_recordings as unknown[]) ?? [])];
|
||||
|
||||
const recordingEntry = body.success && body.transcriptionResult
|
||||
? {
|
||||
path: (additionalRecordings[body.recordingIndex] as { path?: string } | undefined)?.path ?? '',
|
||||
transcript: body.transcriptionResult.transcript ?? '',
|
||||
utterances: body.transcriptionResult.utterances ?? [],
|
||||
speakers: body.transcriptionResult.speakers ?? {},
|
||||
speakerMap: body.transcriptionResult.speakerMap ?? {},
|
||||
languages: body.transcriptionResult.languages ?? [],
|
||||
primary_language: body.transcriptionResult.primary_language ?? 'de',
|
||||
status: 'completed',
|
||||
timestamp: now,
|
||||
updated_at: now,
|
||||
route: body.route,
|
||||
}
|
||||
: {
|
||||
...(additionalRecordings[body.recordingIndex] as Record<string, unknown> | undefined ?? {}),
|
||||
status: 'error',
|
||||
error: body.error ?? 'Transcription failed',
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
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<string, unknown> }).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 });
|
||||
});
|
||||
58
apps/memoro/apps/server/src/routes/invites.ts
Normal file
58
apps/memoro/apps/server/src/routes/invites.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
359
apps/memoro/apps/server/src/routes/memos.ts
Normal file
359
apps/memoro/apps/server/src/routes/memos.ts
Normal file
|
|
@ -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<string, unknown> }).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<string, unknown>;
|
||||
};
|
||||
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<string, unknown> }) => {
|
||||
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: <kombinierter Titel>
|
||||
INTRO: <2-3 Satz Zusammenfassung>
|
||||
CONTENT: <kombinierter Text>`;
|
||||
|
||||
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<string, unknown> };
|
||||
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);
|
||||
}
|
||||
});
|
||||
176
apps/memoro/apps/server/src/routes/settings.ts
Normal file
176
apps/memoro/apps/server/src/routes/settings.ts
Normal file
|
|
@ -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<string, unknown> } | null)?.app_settings ?? {};
|
||||
const memoroSettings = (appSettings.memoro as Record<string, unknown>) ?? {};
|
||||
|
||||
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<Record<string, unknown>>();
|
||||
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<string, unknown> } | null)?.app_settings ?? {};
|
||||
const currentMemoro = (currentSettings.memoro as Record<string, unknown>) ?? {};
|
||||
|
||||
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<string, unknown> } | null)?.app_settings ?? {};
|
||||
const currentMemoro = (currentSettings.memoro as Record<string, unknown>) ?? {};
|
||||
|
||||
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<string, unknown> = { user_id: userId, updated_at: new Date().toISOString() };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updateData[field] = body[field];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length <= 2) {
|
||||
return c.json({ error: 'No valid fields provided' }, 400);
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
235
apps/memoro/apps/server/src/routes/spaces.ts
Normal file
235
apps/memoro/apps/server/src/routes/spaces.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
213
apps/memoro/apps/server/src/services/cleanup.ts
Normal file
213
apps/memoro/apps/server/src/services/cleanup.ts
Normal file
|
|
@ -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<CleanupResult> {
|
||||
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<CleanupResult> {
|
||||
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<typeof createServiceClient>,
|
||||
audioPath: string
|
||||
): Promise<void> {
|
||||
// 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<string, unknown>) ?? {};
|
||||
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<string[]> {
|
||||
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<typeof createServiceClient>
|
||||
): Promise<string | null> {
|
||||
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<typeof createServiceClient>,
|
||||
logId: string | null,
|
||||
result: CleanupResult,
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
334
apps/memoro/apps/server/src/services/headline.ts
Normal file
334
apps/memoro/apps/server/src/services/headline.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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: <Zusammenfassung>`,
|
||||
|
||||
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: <headline>
|
||||
INTRO: <summary>`,
|
||||
|
||||
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: <titre>
|
||||
INTRO: <résumé>`,
|
||||
|
||||
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: <titular>
|
||||
INTRO: <resumen>`,
|
||||
|
||||
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: <titolo>
|
||||
INTRO: <riassunto>`,
|
||||
|
||||
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: <kop>
|
||||
INTRO: <samenvatting>`,
|
||||
|
||||
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: <título>
|
||||
INTRO: <resumo>`,
|
||||
|
||||
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: <başlık>
|
||||
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: <nagłówek>
|
||||
INTRO: <podsumowanie>`,
|
||||
};
|
||||
|
||||
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, unknown>): string {
|
||||
const source = memo.source as Record<string, unknown> | 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, unknown>): string {
|
||||
const source = memo.source as Record<string, unknown> | undefined;
|
||||
const metadata = memo.metadata as Record<string, unknown> | 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<string, unknown>;
|
||||
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<typeof createServiceClient>,
|
||||
memoId: string,
|
||||
headline: string,
|
||||
intro: string
|
||||
): Promise<void> {
|
||||
const channel = supabase.channel(`memo-updates-${memoId}`);
|
||||
await new Promise<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
306
apps/memoro/apps/server/src/services/memo.ts
Normal file
306
apps/memoro/apps/server/src/services/memo.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
speakerMap?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}> {
|
||||
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<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle transcription completion callback from the audio server.
|
||||
*/
|
||||
export async function handleTranscriptionCompleted(
|
||||
params: HandleTranscriptionParams
|
||||
): Promise<void> {
|
||||
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<string, unknown>) ?? {};
|
||||
|
||||
// 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<void> {
|
||||
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<string, unknown>
|
||||
): Promise<void> {
|
||||
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<string, unknown>) ?? {};
|
||||
const processing = (metadata.processing as Record<string, unknown>) ?? {};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
416
apps/memoro/apps/server/src/services/space.ts
Normal file
416
apps/memoro/apps/server/src/services/space.ts
Normal file
|
|
@ -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<unknown[]> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<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, 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<unknown> {
|
||||
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<unknown[]> {
|
||||
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 ?? [];
|
||||
}
|
||||
23
apps/memoro/apps/server/tsconfig.json
Normal file
23
apps/memoro/apps/server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue