const express = require("express"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const os = require("os"); const childProcess = require("child_process"); const util = require("util"); const formidable = require("formidable"); const db = require("../db/models"); const wrapAsync = require("../helpers").wrapAsync; const { LocalAIApi } = require("../ai/LocalAIApi"); const AuthService = require("../services/auth"); const EmailSender = require("../services/email"); const router = express.Router(); const execFile = util.promisify(childProcess.execFile); const directAudioUploadLimitBytes = 24 * 1024 * 1024; const transcriptionChunkSeconds = Number(process.env.AI_TRANSCRIPTION_CHUNK_SECONDS || 180); const transcriptionJobRetentionMs = 60 * 60 * 1000; const transcriptionJobs = new Map(); const sessionAudioUploadPath = "/uploads/coaching-sessions"; const sessionAudioUploadDir = path.join(__dirname, "../../public", sessionAudioUploadPath); function isClientUser(req) { return req.currentUser?.app_role?.name === "Client"; } function portalClientIncludes() { return [ { model: db.sessions, as: "sessions", where: { status: "shared" }, required: false, order: [["session_at", "DESC"]] }, { model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] }, { model: db.resources, as: "resources", where: { is_shared: true }, required: false }, { model: db.prep_briefs, as: "prep_briefs", order: [["updatedAt", "DESC"]] }, ]; } function splitActionItems(value) { return String(value || "") .split(/\n|;/) .map((item) => item.replace(/^[-*]\s*/, "").trim()) .filter(Boolean) .slice(0, 8); } function clientPayload(data, userId) { return { name: data.name, email: data.email, status: data.status || "active", goals: data.goals, notes: data.notes, company: data.company, role_title: data.role_title, tags: data.tags, next_session_at: data.next_session_at || null, last_session_at: data.last_session_at || null, updatedById: userId, }; } function clientIncludes() { return [ { model: db.packages, as: "package" }, { model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] }, { model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] }, { model: db.resources, as: "resources", order: [["createdAt", "DESC"]] }, { model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] }, ]; } function requestBaseUrl(req) { const origin = req.get("origin"); if (origin) { return origin; } const referer = req.get("referer"); if (referer) { const url = new URL(referer); return `${url.protocol}//${url.host}`; } return `${req.protocol}://${req.get("host")}`; } async function ensureClientPortalUser(email, currentUserId) { const normalizedEmail = String(email || "").trim().toLowerCase(); const clientRole = await db.roles.findOne({ where: { name: "Client" } }); if (!clientRole) { throw new Error("Client role does not exist"); } let user = await db.users.findOne({ where: { email: normalizedEmail }, include: [{ model: db.roles, as: "app_role" }], }); if (user && user.app_role && user.app_role.name !== "Client") { const message = `User ${normalizedEmail} already has ${user.app_role.name} role`; const error = new Error(message); error.code = 400; throw error; } if (!user) { user = await db.users.create({ firstName: normalizedEmail.split("@")[0], email: normalizedEmail, disabled: false, emailVerified: true, createdById: currentUserId, updatedById: currentUserId, }); } else { await user.update({ disabled: false, emailVerified: true, updatedById: currentUserId, }); } await user.setApp_role(clientRole.id); return user; } function getFirstUploadedFile(files, fieldName) { const file = files[fieldName]; if (Array.isArray(file)) { return file[0]; } return file; } function parseAudioUpload(req) { return new Promise((resolve, reject) => { const form = new formidable.IncomingForm({ multiples: false, keepExtensions: true, maxFileSize: 200 * 1024 * 1024, }); form.parse(req, (error, _fields, files) => { if (error) { reject(error); return; } resolve(getFirstUploadedFile(files, "audio") || getFirstUploadedFile(files, "file")); }); }); } async function removeUploadedAudio(filePath) { try { await fs.promises.unlink(filePath); } catch (error) { console.warn("Failed to remove uploaded audio file", error); } } function safeAudioExtension(fileName, mimeType) { const extension = path.extname(String(fileName || "")).toLowerCase(); if (extension) { return extension; } if (mimeType === "audio/webm") { return ".webm"; } if (mimeType === "audio/mpeg") { return ".mp3"; } if (mimeType === "audio/wav") { return ".wav"; } if (mimeType === "audio/mp4") { return ".m4a"; } return ".audio"; } function publicAudioFilePath(audioUrl) { if (!audioUrl || !String(audioUrl).startsWith(`${sessionAudioUploadPath}/`)) { return null; } return path.join(__dirname, "../../public", audioUrl.replace(/^\//, "")); } async function storeSessionAudioFile(audioFile) { const filePath = audioFile.filepath || audioFile.path; const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath); const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream"; const extension = safeAudioExtension(fileName, mimeType); const storedFileName = `${crypto.randomUUID()}${extension}`; const storedFilePath = path.join(sessionAudioUploadDir, storedFileName); if (!filePath) { throw new Error("Uploaded audio file does not have a readable path"); } await fs.promises.mkdir(sessionAudioUploadDir, { recursive: true }); await fs.promises.copyFile(filePath, storedFilePath); return { audio_url: `${sessionAudioUploadPath}/${storedFileName}`, audio_filename: fileName, audio_mime_type: mimeType, audio_size: audioFile.size || null, }; } async function removeSessionAudioFile(audioUrl) { const filePath = publicAudioFilePath(audioUrl); if (!filePath) { return; } await fs.promises.rm(filePath, { force: true }); } function pruneTranscriptionJobs() { const cutoff = Date.now() - transcriptionJobRetentionMs; transcriptionJobs.forEach((job, jobId) => { if (job.updated_at < cutoff) { transcriptionJobs.delete(jobId); } }); } function updateTranscriptionJob(jobId, data) { const existingJob = transcriptionJobs.get(jobId) || {}; transcriptionJobs.set(jobId, { ...existingJob, ...data, updated_at: Date.now(), }); } async function runTranscriptionJob(jobId, audioFile, audioData) { try { updateTranscriptionJob(jobId, { status: "processing", message: "Starting audio transcription.", }); const result = await transcribeAudioFile(audioFile, (progress) => { updateTranscriptionJob(jobId, { status: "processing", ...progress, }); }); if (result.status !== 200) { updateTranscriptionJob(jobId, { status: "failed", error: result.body, message: result.body?.message || result.body?.error?.message || result.body?.error || "Audio transcription failed.", }); return; } updateTranscriptionJob(jobId, { status: "completed", message: "Audio transcription completed.", result: { ...result.body, ...audioData, }, }); } catch (error) { updateTranscriptionJob(jobId, { status: "failed", message: error.message || "Audio transcription failed.", }); } } function shouldUseDiarizedTranscription(model) { return model === "gpt-4o-transcribe-diarize"; } function formatDiarizedTranscript(segments) { return segments .map((segment) => { const speaker = segment.speaker || "Speaker"; const text = String(segment.text || "").trim(); if (!text) { return ""; } return `${speaker}: ${text}`; }) .filter(Boolean) .join("\n"); } function transcriptionConfig() { return { transcriptionUrl: process.env.AI_TRANSCRIPTION_URL || "https://api.openai.com/v1/audio/transcriptions", transcriptionModel: process.env.AI_TRANSCRIPTION_MODEL || "gpt-4o-transcribe-diarize", transcriptionApiKey: process.env.AI_TRANSCRIPTION_API_KEY || process.env.OPENAI_API_KEY, }; } function transcriptionHeaders(transcriptionApiKey) { const headers = {}; if (process.env.PROJECT_UUID) { headers.Authorization = `Bearer ${process.env.PROJECT_UUID}`; headers["project-uuid"] = process.env.PROJECT_UUID; } if (transcriptionApiKey) { headers.Authorization = `Bearer ${transcriptionApiKey}`; } return headers; } async function sendTranscriptionRequest({ filePath, fileName, mimeType, transcriptionUrl, transcriptionModel, transcriptionApiKey, }) { const audioBuffer = await fs.promises.readFile(filePath); const formData = new FormData(); const useDiarizedTranscription = shouldUseDiarizedTranscription(transcriptionModel); formData.append("file", new Blob([audioBuffer], { type: mimeType }), fileName); formData.append("model", transcriptionModel); if (useDiarizedTranscription) { formData.append("response_format", "diarized_json"); formData.append("chunking_strategy", "auto"); } else { formData.append("response_format", "json"); } const response = await fetch(transcriptionUrl, { method: "POST", headers: transcriptionHeaders(transcriptionApiKey), body: formData, }); const responseText = await response.text(); let payload; try { payload = JSON.parse(responseText); } catch { if (!response.ok) { return { status: response.status, body: { error: "transcription_provider_non_json_response", message: `Transcription provider returned HTTP ${response.status}. The request probably timed out before the provider returned JSON.`, }, }; } throw new Error(`Transcription response is not JSON: ${responseText.slice(0, 500)}`); } if (!response.ok) { return { status: response.status, body: payload, }; } const diarizedText = Array.isArray(payload.segments) ? formatDiarizedTranscript(payload.segments) : ""; const text = diarizedText || payload.text || payload.transcript || payload.output_text; if (!text) { throw new Error(`Transcription response does not include text: ${JSON.stringify(payload)}`); } return { status: 200, body: { text, segments: payload.segments || [], model: transcriptionModel, }, }; } async function createTranscriptionChunks(filePath) { const chunkDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coaching-audio-")); const chunkPattern = path.join(chunkDir, "chunk-%03d.mp3"); try { await execFile( "ffmpeg", [ "-hide_banner", "-loglevel", "error", "-i", filePath, "-vn", "-map_metadata", "-1", "-ac", "1", "-c:a", "libmp3lame", "-b:a", "64k", "-f", "segment", "-segment_time", String(transcriptionChunkSeconds), "-reset_timestamps", "1", chunkPattern, ], { maxBuffer: 1024 * 1024 * 20 }, ); } catch (error) { await fs.promises.rm(chunkDir, { recursive: true, force: true }); const message = error.stderr || error.message; throw new Error(`Failed to split audio with ffmpeg: ${message}`); } const chunkFiles = (await fs.promises.readdir(chunkDir)) .filter((fileName) => fileName.endsWith(".mp3")) .sort() .map((fileName) => path.join(chunkDir, fileName)); if (chunkFiles.length === 0) { await fs.promises.rm(chunkDir, { recursive: true, force: true }); throw new Error("ffmpeg did not create any audio chunks"); } return { chunkDir, chunkFiles }; } async function transcribeLargeAudioFile(filePath, config, onProgress) { if (onProgress) { onProgress({ message: "Splitting large audio into smaller parts." }); } const { chunkDir, chunkFiles } = await createTranscriptionChunks(filePath); const texts = []; const segments = []; if (onProgress) { onProgress({ message: `Audio split into ${chunkFiles.length} parts. Transcribing part 1 of ${chunkFiles.length}.`, chunk_index: 1, chunks_total: chunkFiles.length, }); } try { for (let index = 0; index < chunkFiles.length; index += 1) { const chunkPath = chunkFiles[index]; if (onProgress) { onProgress({ message: `Transcribing part ${index + 1} of ${chunkFiles.length}.`, chunk_index: index + 1, chunks_total: chunkFiles.length, }); } const result = await sendTranscriptionRequest({ filePath: chunkPath, fileName: path.basename(chunkPath), mimeType: "audio/mpeg", ...config, }); if (result.status !== 200) { return { status: result.status, body: { ...result.body, message: result.body?.message || result.body?.error?.message || `Audio transcription failed on part ${index + 1} of ${chunkFiles.length}.`, chunk_index: index + 1, chunks_total: chunkFiles.length, }, }; } texts.push(result.body.text); segments.push(...(result.body.segments || [])); } } finally { await fs.promises.rm(chunkDir, { recursive: true, force: true }); } return { status: 200, body: { text: texts.join("\n\n"), segments, model: config.transcriptionModel, transcription_chunks: chunkFiles.length, }, }; } async function transcribeAudioFile(audioFile, onProgress) { const filePath = audioFile.filepath || audioFile.path; const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath); const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream"; const config = transcriptionConfig(); if (!filePath) { throw new Error("Uploaded audio file does not have a readable path"); } if (!config.transcriptionApiKey && !process.env.AI_TRANSCRIPTION_URL) { return { status: 501, body: { error: "transcription_not_configured", message: "Set AI_TRANSCRIPTION_URL for the AppWizzy proxy or AI_TRANSCRIPTION_API_KEY/OPENAI_API_KEY for direct transcription.", }, }; } const stats = await fs.promises.stat(filePath); if (stats.size > directAudioUploadLimitBytes) { return transcribeLargeAudioFile(filePath, config, onProgress); } if (onProgress) { onProgress({ message: "Transcribing audio." }); } return sendTranscriptionRequest({ filePath, fileName, mimeType, ...config, }); } router.get( "/summary", wrapAsync(async (req, res) => { const [clients, sessions, actionItems, resources, prepBriefs] = await Promise.all([ db.clients.count(), db.sessions.count(), db.action_items.count({ where: { status: ["not_started", "in_progress"] } }), db.resources.count({ where: { is_shared: true } }), db.prep_briefs.count({ where: { status: "ready" } }), ]); const nextSessions = await db.sessions.findAll({ limit: 4, order: [["session_at", "DESC"]], include: [{ model: db.clients, as: "client" }], }); const activeClients = await db.clients.findAll({ limit: 4, order: [["next_session_at", "ASC"]], include: [{ model: db.packages, as: "package" }], }); const upcomingPrepBriefs = await db.prep_briefs.findAll({ limit: 4, order: [["next_session_at", "ASC"]], where: { status: "ready" }, include: [{ model: db.clients, as: "client" }], }); res.status(200).send({ counts: { clients, sessions, actionItems, resources, prepBriefs, }, nextSessions, activeClients, upcomingPrepBriefs, }); }), ); router.get( "/intake-leads", wrapAsync(async (req, res) => { const leads = await db.intake_leads.findAll({ order: [["createdAt", "DESC"]], }); res.status(200).send(leads); }), ); router.patch( "/intake-leads/:id/status", wrapAsync(async (req, res) => { const lead = await db.intake_leads.findByPk(req.params.id); if (!lead) { res.status(404).send({ error: "lead_not_found" }); return; } await lead.update({ status: req.body.status, updatedById: req.currentUser.id, }); res.status(200).send(lead); }), ); router.post( "/intake-leads/:id/convert", wrapAsync(async (req, res) => { const lead = await db.intake_leads.findByPk(req.params.id); if (!lead) { res.status(404).send({ error: "lead_not_found" }); return; } if (lead.status === "converted") { res.status(409).send({ error: "lead_already_converted" }); return; } const portalUser = await ensureClientPortalUser(lead.email, req.currentUser.id); const client = await db.clients.create({ name: lead.name, email: portalUser.email, company: lead.company, role_title: lead.role_title, status: "active", goals: lead.goal, notes: [ lead.challenge && `Challenge: ${lead.challenge}`, lead.desired_outcome && `Desired outcome: ${lead.desired_outcome}`, lead.consent_ai_notes ? "Consented to AI-assisted session notes." : "AI notes consent not granted yet.", ] .filter(Boolean) .join("\n"), tags: "intake", ownerId: req.currentUser.id, createdById: req.currentUser.id, updatedById: req.currentUser.id, }); const inviteWasSent = EmailSender.isConfigured; if (inviteWasSent) { await AuthService.sendPasswordResetEmail( portalUser.email, "invitation", requestBaseUrl(req), ); } await lead.update({ status: "converted", updatedById: req.currentUser.id, }); res.status(200).send({ lead, client, portalUser: { id: portalUser.id, email: portalUser.email, }, invite_sent: inviteWasSent, }); }), ); router.get( "/clients", wrapAsync(async (req, res) => { const clients = await db.clients.findAll({ order: [["next_session_at", "ASC"]], include: [ { model: db.packages, as: "package" }, { model: db.sessions, as: "sessions", limit: 2, order: [["session_at", "DESC"]] }, { model: db.action_items, as: "action_items", limit: 3, order: [["due_at", "ASC"]] }, { model: db.prep_briefs, as: "prep_briefs", limit: 2, order: [["updatedAt", "DESC"]] }, ], }); res.status(200).send(clients); }), ); router.post( "/clients", wrapAsync(async (req, res) => { const data = req.body.data || req.body; if (!String(data.name || "").trim()) { res.status(400).send({ error: "client_name_required" }); return; } const client = await db.clients.create({ ...clientPayload(data, req.currentUser.id), ownerId: req.currentUser.id, createdById: req.currentUser.id, }); const savedClient = await db.clients.findByPk(client.id, { include: clientIncludes(), }); res.status(200).send(savedClient); }), ); router.get( "/clients/:id", wrapAsync(async (req, res) => { const client = await db.clients.findByPk(req.params.id, { include: clientIncludes(), }); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } res.status(200).send(client); }), ); router.patch( "/clients/:id", wrapAsync(async (req, res) => { const data = req.body.data || req.body; const client = await db.clients.findByPk(req.params.id); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } if (!String(data.name || "").trim()) { res.status(400).send({ error: "client_name_required" }); return; } await client.update(clientPayload(data, req.currentUser.id)); const savedClient = await db.clients.findByPk(client.id, { include: clientIncludes(), }); res.status(200).send(savedClient); }), ); router.post( "/clients/:id/action-items", wrapAsync(async (req, res) => { const data = req.body.data || req.body; const client = await db.clients.findByPk(req.params.id); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } if (!String(data.title || "").trim()) { res.status(400).send({ error: "action_title_required" }); return; } const actionItem = await db.action_items.create({ clientId: client.id, title: data.title, due_at: data.due_at || null, status: data.status || "not_started", notes: data.notes, createdById: req.currentUser.id, updatedById: req.currentUser.id, }); res.status(200).send(actionItem); }), ); router.post( "/clients/:id/resources", wrapAsync(async (req, res) => { const data = req.body.data || req.body; const client = await db.clients.findByPk(req.params.id); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } if (!String(data.title || "").trim()) { res.status(400).send({ error: "resource_title_required" }); return; } const resource = await db.resources.create({ clientId: client.id, title: data.title, description: data.description, url: data.url, resource_type: data.resource_type || "link", is_shared: data.is_shared !== false, createdById: req.currentUser.id, updatedById: req.currentUser.id, }); res.status(200).send(resource); }), ); router.get( "/session-memory", wrapAsync(async (req, res) => { const sessions = await db.sessions.findAll({ limit: 20, order: [["session_at", "DESC"]], include: [{ model: db.clients, as: "client" }], }); res.status(200).send(sessions); }), ); router.post( "/sessions", wrapAsync(async (req, res) => { const data = req.body.data || req.body; const session = await db.sessions.create({ clientId: data.clientId, title: data.title, session_at: data.session_at || new Date(), status: data.status || "completed", transcript_notes: data.transcript_notes, ai_summary: data.ai_summary, key_topics: data.key_topics, goals_discussed: data.goals_discussed, blockers: data.blockers, commitments: data.commitments, homework: data.homework, emotional_themes: data.emotional_themes, important_quotes: data.important_quotes, follow_up_email: data.follow_up_email, next_session_prep: data.next_session_prep, private_coach_notes: data.private_coach_notes, shared_client_notes: data.shared_client_notes, audio_url: data.audio_url, audio_filename: data.audio_filename, audio_mime_type: data.audio_mime_type, audio_size: data.audio_size, createdById: req.currentUser.id, updatedById: req.currentUser.id, }); res.status(200).send(session); }), ); router.post( "/session-memory/save", wrapAsync(async (req, res) => { const data = req.body.data || req.body; if (!data.clientId) { res.status(400).send({ error: "client_id_required" }); return; } const client = await db.clients.findByPk(data.clientId); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } const status = data.shareWithClient ? "shared" : "draft_review"; const session = await db.sessions.create({ clientId: data.clientId, title: data.title, session_at: data.session_at || new Date(), status, transcript_notes: data.transcript_notes, ai_summary: data.ai_summary, key_topics: data.key_topics, goals_discussed: data.goals_discussed, blockers: data.blockers, commitments: data.commitments, homework: data.homework, emotional_themes: data.emotional_themes, important_quotes: data.important_quotes, follow_up_email: data.follow_up_email, next_session_prep: data.next_session_prep, private_coach_notes: data.private_coach_notes, shared_client_notes: data.shared_client_notes, audio_url: data.audio_url, audio_filename: data.audio_filename, audio_mime_type: data.audio_mime_type, audio_size: data.audio_size, createdById: req.currentUser.id, updatedById: req.currentUser.id, }); const actionTitles = splitActionItems(data.commitments || data.homework); for (const title of actionTitles) { await db.action_items.create({ clientId: data.clientId, sessionId: session.id, title, status: "not_started", createdById: req.currentUser.id, updatedById: req.currentUser.id, }); } await db.prep_briefs.create({ clientId: data.clientId, sessionId: session.id, next_session_at: client.next_session_at, previous_summary: data.ai_summary, open_commitments: actionTitles.join("; "), suggested_questions: data.next_session_prep, sensitive_topics: data.blockers, status: "ready", createdById: req.currentUser.id, updatedById: req.currentUser.id, }); const savedSession = await db.sessions.findByPk(session.id, { include: [ { model: db.clients, as: "client" }, { model: db.action_items, as: "action_items", order: [["createdAt", "ASC"]] }, ], }); res.status(200).send(savedSession); }), ); router.get( "/sessions/:id", wrapAsync(async (req, res) => { const session = await db.sessions.findByPk(req.params.id, { include: [{ model: db.clients, as: "client" }], }); if (!session) { res.status(404).send({ error: "session_not_found" }); return; } res.status(200).send(session); }), ); router.get( "/sessions/:id/audio", wrapAsync(async (req, res) => { const session = await db.sessions.findByPk(req.params.id, { include: [{ model: db.clients, as: "client" }], }); if (!session) { res.status(404).send({ error: "session_not_found" }); return; } if (isClientUser(req) && session.client?.email !== req.currentUser.email) { res.status(404).send({ error: "session_not_found" }); return; } const filePath = publicAudioFilePath(session.audio_url); if (!filePath) { res.status(404).send({ error: "audio_not_found" }); return; } const stat = await fs.promises.stat(filePath); res.setHeader("Content-Type", session.audio_mime_type || "audio/webm"); res.setHeader("Content-Length", stat.size); res.setHeader( "Content-Disposition", `inline; filename="${session.audio_filename || "session-audio.webm"}"`, ); fs.createReadStream(filePath).pipe(res); }), ); router.patch( "/sessions/:id/share", wrapAsync(async (req, res) => { const session = await db.sessions.findByPk(req.params.id); if (!session) { res.status(404).send({ error: "session_not_found" }); return; } await session.update({ shared_client_notes: req.body.shared_client_notes || session.shared_client_notes, status: "shared", updatedById: req.currentUser.id, }); const savedSession = await db.sessions.findByPk(session.id, { include: [{ model: db.clients, as: "client" }], }); res.status(200).send(savedSession); }), ); router.delete( "/sessions/:id", wrapAsync(async (req, res) => { if (isClientUser(req)) { res.status(403).send({ error: "coach_only" }); return; } const session = await db.sessions.findByPk(req.params.id); if (!session) { res.status(404).send({ error: "session_not_found" }); return; } await db.action_items.destroy({ where: { sessionId: session.id } }); await db.prep_briefs.destroy({ where: { sessionId: session.id } }); await removeSessionAudioFile(session.audio_url); await session.destroy(); res.status(200).send({ deleted: true, id: req.params.id }); }), ); router.post( "/session-memory/generate", wrapAsync(async (req, res) => { const { clientId, sessionId, transcript } = req.body; if (!clientId) { res.status(400).send({ error: "client_id_required" }); return; } if (!transcript || !String(transcript).trim()) { res.status(400).send({ error: "transcript_required" }); return; } const client = await db.clients.findByPk(clientId); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } const response = await LocalAIApi.createResponse( { input: [ { role: "system", content: [ { type: "input_text", text: [ "You are a coaching operations assistant.", "Extract structured session memory for a professional coaching workspace.", "Use the transcript as the only source for new session facts.", "Do not copy or invent prior client history, seed data, or example content.", "If the transcript does not contain a useful value for a field, return an empty string for that field.", "Return strict JSON only with these string fields:", "title, ai_summary, key_topics, goals_discussed, blockers, commitments, homework, emotional_themes, important_quotes, follow_up_email, next_session_prep, private_coach_notes, shared_client_notes.", ].join(" "), }, ], }, { role: "user", content: [ { type: "input_text", text: JSON.stringify({ client: { name: client.name, company: client.company, role_title: client.role_title, goals: client.goals, }, transcript, }), }, ], }, ], }, { poll_timeout: 180, poll_interval: 3 }, ); if (!response.success) { res.status(502).send(response); return; } const memory = LocalAIApi.decodeJsonFromResponse(response); if (sessionId) { await db.sessions.update( { title: memory.title, ai_summary: memory.ai_summary, key_topics: memory.key_topics, goals_discussed: memory.goals_discussed, blockers: memory.blockers, commitments: memory.commitments, homework: memory.homework, emotional_themes: memory.emotional_themes, important_quotes: memory.important_quotes, follow_up_email: memory.follow_up_email, next_session_prep: memory.next_session_prep, private_coach_notes: memory.private_coach_notes, shared_client_notes: memory.shared_client_notes, updatedById: req.currentUser.id, }, { where: { id: sessionId } }, ); } res.status(200).send(memory); }), ); router.post( "/session-memory/transcribe", wrapAsync(async (req, res) => { pruneTranscriptionJobs(); const audioFile = await parseAudioUpload(req); if (!audioFile) { res.status(400).send({ error: "audio_required" }); return; } const uploadedFilePath = audioFile.filepath || audioFile.path; const audioData = await storeSessionAudioFile(audioFile); const storedFilePath = publicAudioFilePath(audioData.audio_url); const jobId = crypto.randomUUID(); if (!storedFilePath) { throw new Error("Stored audio file does not have a readable path"); } updateTranscriptionJob(jobId, { status: "queued", message: "Audio uploaded. Waiting to start transcription.", }); res.status(202).send({ job_id: jobId, status: "queued", message: "Audio uploaded. Transcription started in the background.", }); await removeUploadedAudio(uploadedFilePath); const storedAudioFile = { filepath: storedFilePath, originalFilename: audioData.audio_filename, mimetype: audioData.audio_mime_type, size: audioData.audio_size, }; setImmediate(() => { runTranscriptionJob(jobId, storedAudioFile, audioData); }); }), ); router.get( "/session-memory/transcribe/:jobId", wrapAsync(async (req, res) => { const job = transcriptionJobs.get(req.params.jobId); if (!job) { res.status(404).send({ error: "transcription_job_not_found" }); return; } res.status(200).send(job); }), ); router.get( "/client-portal/me", wrapAsync(async (req, res) => { if (!isClientUser(req)) { res.status(403).send({ error: "client_role_required" }); return; } const client = await db.clients.findOne({ where: { email: req.currentUser.email }, include: portalClientIncludes(), }); if (!client) { res.status(404).send({ error: "client_not_found" }); return; } res.status(200).send(client); }), ); router.get( "/client-portal/:clientId", wrapAsync(async (req, res) => { let client; if (isClientUser(req)) { client = await db.clients.findOne({ where: { id: req.params.clientId, email: req.currentUser.email }, include: portalClientIncludes(), }); } else { client = await db.clients.findByPk(req.params.clientId, { include: portalClientIncludes(), }); } if (!client) { res.status(404).send({ error: "client_not_found" }); return; } res.status(200).send(client); }), ); router.patch( "/client-portal/action-items/:id", wrapAsync(async (req, res) => { const allowedStatuses = ["not_started", "in_progress", "done"]; const { status } = req.body; if (!allowedStatuses.includes(status)) { res.status(400).send({ error: "invalid_status" }); return; } const actionItem = await db.action_items.findByPk(req.params.id, { include: [{ model: db.clients, as: "client" }], }); if (!actionItem) { res.status(404).send({ error: "action_item_not_found" }); return; } if (isClientUser(req) && actionItem.client?.email !== req.currentUser.email) { res.status(404).send({ error: "action_item_not_found" }); return; } await actionItem.update({ status, updatedById: req.currentUser.id, }); res.status(200).send(actionItem); }), ); router.post( "/client-portal/reflection", wrapAsync(async (req, res) => { const reflection = String(req.body.reflection || "").trim(); if (!reflection) { res.status(400).send({ error: "reflection_required" }); return; } let client; if (isClientUser(req)) { client = await db.clients.findOne({ where: { email: req.currentUser.email } }); } else { client = await db.clients.findByPk(req.body.clientId); } if (!client) { res.status(404).send({ error: "client_not_found" }); return; } const latestSession = await db.sessions.findOne({ where: { clientId: client.id }, order: [["session_at", "DESC"]], }); const openActionItems = await db.action_items.findAll({ where: { clientId: client.id, status: ["not_started", "in_progress"] }, order: [["due_at", "ASC"]], }); const existingBrief = await db.prep_briefs.findOne({ where: { clientId: client.id }, order: [["updatedAt", "DESC"]], }); const openCommitments = openActionItems.map((item) => item.title).join("; "); const data = { clientId: client.id, sessionId: latestSession?.id, next_session_at: client.next_session_at, previous_summary: latestSession?.ai_summary, open_commitments: openCommitments, client_reflection: reflection, client_reflection_at: new Date(), status: "ready", updatedById: req.currentUser.id, }; let prepBrief; if (existingBrief) { prepBrief = await existingBrief.update(data); } else { prepBrief = await db.prep_briefs.create({ ...data, createdById: req.currentUser.id, }); } res.status(200).send(prepBrief); }), ); module.exports = router;