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:
Till JS 2026-03-31 19:03:31 +02:00
parent a0caa1f21d
commit 29515e7c4d
26 changed files with 3903 additions and 0 deletions

View 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"
}
}

View 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,
};

View 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;
}

View 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;
}

View 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;
}

View 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,
};
}
}

View 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;
}
}

View 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}`);
}
}
}

View 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"]
}

View 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"
}
}

View 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,
};

View 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;
}
}

View 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);
}

View 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;
}

View 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);
}
});

View 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);
}
});

View 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 });
});

View 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);
}
});

View 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);
}
});

View 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 });
});

View 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 });
});

View 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
}
}

View 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. 23
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();
}
});
});
}

View 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);
}
}

View 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 ?? [];
}

View 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"]
}