diff --git a/.gitignore b/.gitignore
index e427ff3..96f24be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules/
*/node_modules/
*/build/
+backend/public/uploads/
diff --git a/backend/src/db/migrations/20260610000000-add-audio-to-sessions.js b/backend/src/db/migrations/20260610000000-add-audio-to-sessions.js
new file mode 100644
index 0000000..108a145
--- /dev/null
+++ b/backend/src/db/migrations/20260610000000-add-audio-to-sessions.js
@@ -0,0 +1,26 @@
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn("sessions", "audio_url", {
+ type: Sequelize.DataTypes.TEXT,
+ });
+
+ await queryInterface.addColumn("sessions", "audio_filename", {
+ type: Sequelize.DataTypes.TEXT,
+ });
+
+ await queryInterface.addColumn("sessions", "audio_mime_type", {
+ type: Sequelize.DataTypes.TEXT,
+ });
+
+ await queryInterface.addColumn("sessions", "audio_size", {
+ type: Sequelize.DataTypes.INTEGER,
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.removeColumn("sessions", "audio_size");
+ await queryInterface.removeColumn("sessions", "audio_mime_type");
+ await queryInterface.removeColumn("sessions", "audio_filename");
+ await queryInterface.removeColumn("sessions", "audio_url");
+ },
+};
diff --git a/backend/src/db/models/sessions.js b/backend/src/db/models/sessions.js
index a5de32e..cb5bc44 100644
--- a/backend/src/db/models/sessions.js
+++ b/backend/src/db/models/sessions.js
@@ -19,6 +19,10 @@ module.exports = function(sequelize, DataTypes) {
next_session_prep: { type: DataTypes.TEXT },
private_coach_notes: { type: DataTypes.TEXT },
shared_client_notes: { type: DataTypes.TEXT },
+ audio_url: { type: DataTypes.TEXT },
+ audio_filename: { type: DataTypes.TEXT },
+ audio_mime_type: { type: DataTypes.TEXT },
+ audio_size: { type: DataTypes.INTEGER },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
diff --git a/backend/src/routes/coaching.js b/backend/src/routes/coaching.js
index 0a0ed5c..4ef8d08 100644
--- a/backend/src/routes/coaching.js
+++ b/backend/src/routes/coaching.js
@@ -1,12 +1,25 @@
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";
@@ -55,6 +68,65 @@ function clientIncludes() {
];
}
+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];
@@ -70,7 +142,7 @@ function parseAudioUpload(req) {
const form = new formidable.IncomingForm({
multiples: false,
keepExtensions: true,
- maxFileSize: 100 * 1024 * 1024,
+ maxFileSize: 200 * 1024 * 1024,
});
form.parse(req, (error, _fields, files) => {
@@ -92,36 +164,166 @@ async function removeUploadedAudio(filePath) {
}
}
-async function transcribeAudioFile(audioFile) {
+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 transcriptionUrl =
- process.env.AI_TRANSCRIPTION_URL || "https://api.openai.com/v1/audio/transcriptions";
- const transcriptionModel = process.env.AI_TRANSCRIPTION_MODEL || "gpt-4o-mini-transcribe";
- const transcriptionApiKey = process.env.AI_TRANSCRIPTION_API_KEY || process.env.OPENAI_API_KEY;
+ 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");
}
- if (!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.",
- },
- };
+ 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;
}
- const audioBuffer = await fs.promises.readFile(filePath);
- const formData = new FormData();
- formData.append("file", new Blob([audioBuffer], { type: mimeType }), fileName);
- formData.append("model", transcriptionModel);
- formData.append("response_format", "json");
+ 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) {
@@ -133,9 +335,34 @@ async function transcribeAudioFile(audioFile) {
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,
+ headers: transcriptionHeaders(transcriptionApiKey),
body: formData,
});
const responseText = await response.text();
@@ -144,7 +371,17 @@ async function transcribeAudioFile(audioFile) {
try {
payload = JSON.parse(responseText);
} catch {
- throw new Error(`Transcription response is not JSON: ${responseText}`);
+ 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) {
@@ -154,7 +391,10 @@ async function transcribeAudioFile(audioFile) {
};
}
- const text = payload.text || payload.transcript || payload.output_text;
+ 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)}`);
@@ -162,10 +402,173 @@ async function transcribeAudioFile(audioFile) {
return {
status: 200,
- body: { text },
+ 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) => {
@@ -250,9 +653,16 @@ router.post(
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: lead.email,
+ email: portalUser.email,
company: lead.company,
role_title: lead.role_title,
status: "active",
@@ -270,12 +680,30 @@ router.post(
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 });
+ res.status(200).send({
+ lead,
+ client,
+ portalUser: {
+ id: portalUser.id,
+ email: portalUser.email,
+ },
+ invite_sent: inviteWasSent,
+ });
}),
);
@@ -458,6 +886,10 @@ router.post(
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,
});
@@ -502,6 +934,10 @@ router.post(
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,
});
@@ -543,6 +979,59 @@ router.post(
}),
);
+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) => {
@@ -567,6 +1056,30 @@ router.patch(
}),
);
+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) => {
@@ -582,12 +1095,7 @@ router.post(
return;
}
- const client = await db.clients.findByPk(clientId, {
- include: [
- { model: db.sessions, as: "sessions", limit: 5, order: [["session_at", "DESC"]] },
- { model: db.action_items, as: "action_items", limit: 10, order: [["due_at", "ASC"]] },
- ],
- });
+ const client = await db.clients.findByPk(clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
@@ -605,6 +1113,9 @@ router.post(
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(" "),
@@ -622,10 +1133,7 @@ router.post(
company: client.company,
role_title: client.role_title,
goals: client.goals,
- notes: client.notes,
},
- recent_sessions: client.sessions || [],
- open_action_items: client.action_items || [],
transcript,
}),
},
@@ -672,6 +1180,8 @@ router.post(
router.post(
"/session-memory/transcribe",
wrapAsync(async (req, res) => {
+ pruneTranscriptionJobs();
+
const audioFile = await parseAudioUpload(req);
if (!audioFile) {
@@ -679,14 +1189,52 @@ router.post(
return;
}
- const filePath = audioFile.filepath || audioFile.path;
+ const uploadedFilePath = audioFile.filepath || audioFile.path;
+ const audioData = await storeSessionAudioFile(audioFile);
+ const storedFilePath = publicAudioFilePath(audioData.audio_url);
+ const jobId = crypto.randomUUID();
- try {
- const result = await transcribeAudioFile(audioFile);
- res.status(result.status).send(result.body);
- } finally {
- await removeUploadedAudio(filePath);
+ 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);
}),
);
diff --git a/frontend/src/pages/about.tsx b/frontend/src/pages/about.tsx
new file mode 100644
index 0000000..53b5e62
--- /dev/null
+++ b/frontend/src/pages/about.tsx
@@ -0,0 +1,185 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import Head from 'next/head';
+import Link from 'next/link';
+import LayoutGuest from '../layouts/Guest';
+import { getPageTitle } from '../config';
+
+const ui = {
+ page: 'bg-[#fffdf9] text-[#19192d]',
+ banner: 'bg-[#19192d] text-white',
+ navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
+ ink: 'text-[#19192d]',
+ muted: 'text-[#72798a]',
+ accent: 'text-[#35b7a5]',
+ border: 'border-[#19192d]/10',
+ surface: 'bg-white',
+ softSurface: 'bg-[#fffdf9]',
+ darkPanel: 'bg-[#19192d] text-white',
+ button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
+ section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
+ card: 'rounded-none border border-[#19192d]/10 bg-white',
+ overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
+ heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
+};
+
+const credentials = [
+ 'Founder and executive coaching',
+ 'Leadership transitions',
+ 'Decision systems and operating rhythm',
+ 'Confidential client workspace',
+];
+
+const principles = [
+ [
+ 'Still human',
+ 'AI helps with memory, prep, and follow-up. The coaching judgment stays with the coach.',
+ ],
+ [
+ 'Between-session continuity',
+ 'The work does not disappear when the call ends. Notes, commitments, and resources stay connected.',
+ ],
+ [
+ 'Private by default',
+ 'Private coach notes stay private. Clients see only approved notes, commitments, and resources.',
+ ],
+];
+
+function Nav() {
+ return (
+
+ );
+}
+
+export default function AboutCoach() {
+ return (
+ <>
+
+ {getPageTitle('About Coach')}
+
+
+
+
+
+ Coaching practice
+
+
+ A public coach profile connected to a private client workspace.
+
+
+
+
+
+
+
+
About the coach
+
+ Coaching for founders and senior leaders who are carrying the room.
+
+
+ Use this page to introduce the coach, their niche, credentials,
+ and point of view. The template is built for practices where trust,
+ confidentiality, and continuity matter as much as booking the first call.
+
+
+
+ Start assessment
+
+
+ View services
+
+
+
+
+
+
+
+ Coach profile
+
+
Alex Morgan
+
+ Executive coach for founders, operators, and leadership teams.
+
+
+
+ {credentials.map((item) => (
+
+
+ ✓
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+ {principles.map(([title, copy]) => (
+
+
+ Principle
+
+
{title}
+
{copy}
+
+ ))}
+
+
+
+
+
+ Ready to talk?
+
+
+ Start with an assessment and bring context into the first call.
+
+
+
+ Start assessment
+
+
+
+
+
+
+ >
+ );
+}
+
+AboutCoach.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/clients/view.tsx b/frontend/src/pages/clients/view.tsx
index ed79ad6..5a89b0c 100644
--- a/frontend/src/pages/clients/view.tsx
+++ b/frontend/src/pages/clients/view.tsx
@@ -5,8 +5,10 @@ import {
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
+ mdiMicrophoneOutline,
mdiPlus,
mdiTarget,
+ mdiTrashCanOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
@@ -21,6 +23,7 @@ import LayoutAuthenticated from '../../layouts/Authenticated';
type ActionItem = {
id: string;
+ sessionId?: string;
title: string;
status: string;
due_at?: string;
@@ -29,6 +32,7 @@ type ActionItem = {
type PrepBrief = {
id: string;
+ sessionId?: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
@@ -47,6 +51,18 @@ type Session = {
id: string;
title: string;
ai_summary?: string;
+ transcript_notes?: string;
+ key_topics?: string;
+ commitments?: string;
+ homework?: string;
+ follow_up_email?: string;
+ next_session_prep?: string;
+ private_coach_notes?: string;
+ shared_client_notes?: string;
+ audio_url?: string;
+ audio_filename?: string;
+ audio_mime_type?: string;
+ audio_size?: number;
session_at?: string;
status?: string;
};
@@ -203,6 +219,7 @@ const ClientDetail = () => {
const [newResourceTitle, setNewResourceTitle] = React.useState('');
const [newResourceUrl, setNewResourceUrl] = React.useState('');
const [notice, setNotice] = React.useState('');
+ const [deletingSessionId, setDeletingSessionId] = React.useState('');
React.useEffect(() => {
if (!router.isReady || typeof router.query.clientId !== 'string') {
@@ -287,6 +304,43 @@ const ClientDetail = () => {
setNotice('Resource added and shared with client.');
}
+ async function deleteSession(session: Session) {
+ const confirmed = window.confirm(
+ `Delete "${session.title || 'Client session'}"? This cannot be undone.`,
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setDeletingSessionId(session.id);
+ try {
+ await axios.delete(`/coaching/sessions/${session.id}`);
+ setClient((currentClient) => {
+ if (!currentClient) {
+ return currentClient;
+ }
+
+ return {
+ ...currentClient,
+ sessions: (currentClient.sessions || []).filter(
+ (currentSession) => currentSession.id !== session.id,
+ ),
+ action_items: (currentClient.action_items || []).filter(
+ (actionItem) => actionItem.sessionId !== session.id,
+ ),
+ prep_briefs: (currentClient.prep_briefs || []).filter(
+ (prepBrief) => prepBrief.sessionId !== session.id,
+ ),
+ };
+ });
+
+ setNotice('Session deleted.');
+ } finally {
+ setDeletingSessionId('');
+ }
+ }
+
return (
<>
@@ -320,9 +374,20 @@ const ClientDetail = () => {
shared resources.
-
-
- {displayDateTime(client?.next_session_at)}
+
+ {client && (
+
+
+ Start session
+
+ )}
+
+
+ {displayDateTime(client?.next_session_at)}
+
@@ -452,6 +517,94 @@ const ClientDetail = () => {
+
+
+
+ Session timeline
+
+
+
+
+
+
+ Session
+ Date
+ Audio
+ Status
+ Actions
+
+
+
+ {(client.sessions || []).length > 0 ? (
+ (client.sessions || []).map((session) => (
+
+
+
+ {session.title || 'Client session'}
+
+
+ {session.ai_summary || 'No coach summary yet.'}
+
+
+
+ {displayDateTime(session.session_at)}
+
+
+ {session.audio_url ? 'Saved' : 'None'}
+
+
+ {session.status || 'completed'}
+
+
+
+
+ router.push(
+ `/session-memory/view?sessionId=${session.id}`,
+ )
+ }
+ >
+
+ Open
+
+ deleteSession(session)}
+ >
+
+ {deletingSessionId === session.id
+ ? 'Deleting...'
+ : 'Delete'}
+
+
+
+
+ ))
+ ) : (
+
+
+ No saved session memories yet.
+
+
+ )}
+
+
+
+
+
@@ -621,33 +774,6 @@ const ClientDetail = () => {
-
-
-
- Session timeline
-
-
-
- {(client.sessions || []).length > 0 ? (
- (client.sessions || []).map((session) => (
-
-
- {session.title}
-
-
- {session.ai_summary}
-
-
- ))
- ) : (
-
- )}
-
-
-
diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx
index 4449ca7..036f434 100644
--- a/frontend/src/pages/dashboard.tsx
+++ b/frontend/src/pages/dashboard.tsx
@@ -4,11 +4,13 @@ import {
mdiCheckCircleOutline,
mdiClockOutline,
mdiFileDocumentEditOutline,
+ mdiMicrophoneOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
+import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
import BaseIcon from '../components/BaseIcon';
@@ -89,18 +91,39 @@ function ShellCard({
}
const Dashboard = () => {
+ const router = useRouter();
const [summary, setSummary] = React.useState(emptySummary);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
async function loadSummary() {
- const response = await axios.get('/coaching/summary');
- setSummary(response.data);
- setLoading(false);
+ const token = localStorage.getItem('token');
+
+ if (!token) {
+ setLoading(false);
+ router.push('/login');
+ return;
+ }
+
+ try {
+ const response = await axios.get('/coaching/summary');
+ setSummary(response.data);
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ router.push('/login');
+ return;
+ }
+
+ throw error;
+ } finally {
+ setLoading(false);
+ }
}
loadSummary();
- }, []);
+ }, [router]);
const nextPrepBrief = summary.upcomingPrepBriefs[0];
const stats = [
@@ -155,10 +178,11 @@ const Dashboard = () => {
- Generate session memory
+
+ Start client session
{
nextPrepBrief.previous_summary}
Open prep
@@ -250,7 +274,7 @@ const Dashboard = () => {
{summary.activeClients.map((client) => (
@@ -331,7 +355,7 @@ const Dashboard = () => {
{summary.upcomingPrepBriefs.map((brief) => (
diff --git a/frontend/src/pages/how-it-works.tsx b/frontend/src/pages/how-it-works.tsx
index 139b468..81702f0 100644
--- a/frontend/src/pages/how-it-works.tsx
+++ b/frontend/src/pages/how-it-works.tsx
@@ -140,8 +140,9 @@ function Nav() {
className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}
>
How it works
-
Coachee
-
Pricing
+
About
+
Services
+
Client login
Compare
Trust
@@ -149,7 +150,7 @@ function Nav() {
href='/intake/'
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
- Start free
+ Start assessment
@@ -440,7 +441,7 @@ export default function HowItWorks() {
href='/intake/'
className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}
>
- Start free
+ Start assessment
Privacy Policy
Terms of Use
+ About
+ Services
Login
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 4720cb0..89f14af 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -168,18 +168,18 @@ export default function Starter() {
className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}
>
How it works
- Coachee
+ About
+ Services
+ Client login
Pricing
Compare
Trust
- Events
- Packages
- Start free
+ Start assessment
@@ -207,7 +207,7 @@ export default function Starter() {
href='/intake/'
className={`rounded-none px-10 py-5 text-lg font-semibold ${ui.button}`}
>
- Start free
+ Start assessment
@@ -572,6 +572,8 @@ export default function Starter() {
Privacy Policy
Terms of Use
+ About
+ Services
Login
diff --git a/frontend/src/pages/intake-leads.tsx b/frontend/src/pages/intake-leads.tsx
index b760e79..ada8e14 100644
--- a/frontend/src/pages/intake-leads.tsx
+++ b/frontend/src/pages/intake-leads.tsx
@@ -46,6 +46,7 @@ function Panel({
export default function IntakeLeads() {
const [leads, setLeads] = React.useState([]);
const [updatingLeadId, setUpdatingLeadId] = React.useState('');
+ const [notice, setNotice] = React.useState('');
async function loadLeads() {
const response = await axios.get('/coaching/intake-leads');
@@ -58,13 +59,27 @@ export default function IntakeLeads() {
async function convertLead(leadId: string) {
setUpdatingLeadId(leadId);
- await axios.post(`/coaching/intake-leads/${leadId}/convert`);
- await loadLeads();
- setUpdatingLeadId('');
+ setNotice('');
+
+ try {
+ const response = await axios.post(`/coaching/intake-leads/${leadId}/convert`);
+ await loadLeads();
+
+ if (response.data.invite_sent) {
+ setNotice('Client workspace created. Invitation email was sent.');
+ } else {
+ setNotice('Client workspace created. Email is not configured, so no invitation email was sent.');
+ }
+ } catch (error: any) {
+ setNotice(error.response?.data || 'Could not convert intake lead.');
+ } finally {
+ setUpdatingLeadId('');
+ }
}
async function archiveLead(leadId: string) {
setUpdatingLeadId(leadId);
+ setNotice('');
const response = await axios.patch(
`/coaching/intake-leads/${leadId}/status`,
{
@@ -104,6 +119,12 @@ export default function IntakeLeads() {
+ {notice && (
+
+ {notice}
+
+ )}
+
{leads.map((lead) => (
diff --git a/frontend/src/pages/services.tsx b/frontend/src/pages/services.tsx
new file mode 100644
index 0000000..cc6e464
--- /dev/null
+++ b/frontend/src/pages/services.tsx
@@ -0,0 +1,194 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import Head from 'next/head';
+import Link from 'next/link';
+import LayoutGuest from '../layouts/Guest';
+import { getPageTitle } from '../config';
+
+const ui = {
+ page: 'bg-[#fffdf9] text-[#19192d]',
+ banner: 'bg-[#19192d] text-white',
+ navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
+ ink: 'text-[#19192d]',
+ muted: 'text-[#72798a]',
+ accent: 'text-[#35b7a5]',
+ border: 'border-[#19192d]/10',
+ surface: 'bg-white',
+ darkPanel: 'bg-[#19192d] text-white',
+ button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
+ section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
+ card: 'rounded-none border border-[#19192d]/10 bg-white',
+ overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
+ heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
+};
+
+const packages = [
+ {
+ name: 'Leadership Assessment',
+ price: 'Intro',
+ copy: 'A focused first step for founders and senior leaders who want to clarify the coaching agenda.',
+ items: ['intake review', 'goals and pressure points', 'recommended coaching plan'],
+ },
+ {
+ name: 'Founder Coaching',
+ price: 'Monthly',
+ copy: 'Ongoing 1:1 coaching with session memory, commitments, resources, and between-session accountability.',
+ items: ['two sessions per month', 'shared client portal', 'coach-approved follow-up'],
+ },
+ {
+ name: 'Executive Operating Rhythm',
+ price: 'Custom',
+ copy: 'A deeper engagement for leaders navigating delegation, decision rights, and team operating cadence.',
+ items: ['leadership themes', 'decision-system work', 'next-session prep briefs'],
+ },
+];
+
+const outcomes = [
+ 'Clearer decisions',
+ 'Better delegation boundaries',
+ 'Stronger leadership presence',
+ 'Follow-through between sessions',
+ 'A private place for client resources',
+ 'Less admin after every session',
+];
+
+function Nav() {
+ return (
+
+ );
+}
+
+export default function Services() {
+ return (
+ <>
+
+ {getPageTitle('Coaching Services')}
+
+
+
+
+
+ Services and packages
+
+
+ Sell coaching clearly, then manage the whole relationship in one workspace.
+
+
+
+
+
+
+ Coaching offers
+
+ Packages for leaders who need clarity, accountability, and continuity.
+
+
+ Use these cards as starter offers. A coach can rename packages,
+ change pricing, and route every CTA into the intake flow.
+
+
+
+
+
+ {packages.map((item) => (
+
+
+
+ {item.price}
+
+
{item.name}
+
{item.copy}
+
+
+ {item.items.map((included) => (
+
+
+ ✓
+
+ {included}
+
+ ))}
+
+
+ Start intake
+
+
+ ))}
+
+
+
+
+
+
+
Expected outcomes
+
+ Make the value of coaching concrete before the first call.
+
+
+ FounderCoaching-style public sites make outcomes visible. This
+ template connects those outcomes to the working system behind
+ the scenes: clients, sessions, notes, resources, and follow-up.
+
+
+
+ {outcomes.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+ Next step
+
+
+ Start with intake, then convert the lead into a client workspace.
+
+
+
+ Start assessment
+
+
+
+
+
+
+ >
+ );
+}
+
+Services.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/session-memory.tsx b/frontend/src/pages/session-memory.tsx
index 95d29ff..605315f 100644
--- a/frontend/src/pages/session-memory.tsx
+++ b/frontend/src/pages/session-memory.tsx
@@ -7,12 +7,13 @@ import {
mdiMicrophoneOutline,
mdiStopCircleOutline,
mdiSendOutline,
+ mdiTrashCanOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
+import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
-import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
@@ -43,8 +44,49 @@ type Session = MemoryDraft & {
id: string;
status?: string;
client?: Client;
+ session_at?: string;
+ transcript_notes?: string;
+ audio_url?: string;
+ audio_filename?: string;
+ audio_mime_type?: string;
+ audio_size?: number;
};
+type AudioAttachment = {
+ audio_url: string;
+ audio_filename?: string;
+ audio_mime_type?: string;
+ audio_size?: number;
+};
+
+type TranscriptionResult = AudioAttachment & {
+ text?: string;
+ transcription_chunks?: number;
+};
+
+type TranscriptionJob = {
+ status: 'queued' | 'processing' | 'completed' | 'failed';
+ message?: string;
+ chunk_index?: number;
+ chunks_total?: number;
+ result?: TranscriptionResult;
+ error?: unknown;
+};
+
+type TranscriptionProgress = {
+ current: number;
+ total: number;
+};
+
+type TranscriptTurn = {
+ speaker: string;
+ text: string;
+};
+
+const MAX_AUDIO_FILE_BYTES = 200 * 1024 * 1024;
+const DIRECT_TRANSCRIPTION_FILE_BYTES = 24 * 1024 * 1024;
+const ACTIVE_TRANSCRIPTION_JOB_KEY = 'coaching-active-transcription-job-id';
+
function Panel({
children,
className = '',
@@ -101,6 +143,20 @@ function StatusPill({ status }: { status?: string }) {
);
}
+function formatFileSize(bytes: number) {
+ return `${Math.round(bytes / 1024 / 1024)} MB`;
+}
+
+function audioTooLargeMessage() {
+ return `Audio file is too large. Maximum supported size is ${formatFileSize(
+ MAX_AUDIO_FILE_BYTES,
+ )}.`;
+}
+
+function wait(milliseconds: number) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
function requestErrorMessage(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) {
return fallback;
@@ -108,6 +164,10 @@ function requestErrorMessage(error: unknown, fallback: string) {
const data = error.response?.data;
+ if (error.response?.status === 413 || data?.error === 'payload_too_large') {
+ return audioTooLargeMessage();
+ }
+
if (typeof data?.message === 'string') {
return data.message;
}
@@ -123,6 +183,97 @@ function requestErrorMessage(error: unknown, fallback: string) {
return fallback;
}
+function parseTranscriptTurns(value?: string) {
+ const turns: TranscriptTurn[] = [];
+
+ String(value || '')
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .forEach((line) => {
+ const match = line.match(/^([A-ZА-Я][\wА-Яа-я -]{0,30}):\s*(.+)$/u);
+
+ if (match) {
+ turns.push({
+ speaker: match[1],
+ text: match[2],
+ });
+ return;
+ }
+
+ const previousTurn = turns[turns.length - 1];
+ if (previousTurn) {
+ previousTurn.text = `${previousTurn.text} ${line}`;
+ return;
+ }
+
+ turns.push({
+ speaker: 'Transcript',
+ text: line,
+ });
+ });
+
+ return turns;
+}
+
+function TranscriptConversation({
+ value,
+ maxTurns,
+}: {
+ value?: string;
+ maxTurns?: number;
+}) {
+ const turns = parseTranscriptTurns(value);
+ const visibleTurns = maxTurns ? turns.slice(0, maxTurns) : turns;
+
+ if (turns.length === 0) {
+ return (
+
+ No transcript saved for this session.
+
+ );
+ }
+
+ return (
+
+ {visibleTurns.map((turn, index) => (
+
+
+ {turn.speaker}
+
+
{turn.text}
+
+ ))}
+ {maxTurns && turns.length > maxTurns && (
+
+ {turns.length - maxTurns} more transcript turns
+
+ )}
+
+ );
+}
+
+function displayDateTime(value?: string) {
+ if (!value) {
+ return 'No date';
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
const emptyDraft: MemoryDraft = {
title: '',
ai_summary: '',
@@ -140,23 +291,31 @@ const emptyDraft: MemoryDraft = {
};
const SessionMemory = () => {
+ const router = useRouter();
const [clients, setClients] = React.useState([]);
const [sessions, setSessions] = React.useState([]);
const [clientId, setClientId] = React.useState('');
const [transcript, setTranscript] = React.useState('');
const [draft, setDraft] = React.useState(emptyDraft);
const [audioFile, setAudioFile] = React.useState(null);
+ const [audioAttachment, setAudioAttachment] =
+ React.useState(null);
const [isRecording, setIsRecording] = React.useState(false);
const [recordingSeconds, setRecordingSeconds] = React.useState(0);
const [isGenerating, setIsGenerating] = React.useState(false);
const [isTranscribing, setIsTranscribing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
+ const [deletingSessionId, setDeletingSessionId] = React.useState('');
const [notice, setNotice] = React.useState('');
+ const [transcriptionNotice, setTranscriptionNotice] = React.useState('');
+ const [transcriptionProgress, setTranscriptionProgress] =
+ React.useState(null);
const [lastSavedSession, setLastSavedSession] =
React.useState(null);
const mediaRecorderRef = React.useRef(null);
const mediaStreamRef = React.useRef(null);
const audioChunksRef = React.useRef([]);
+ const resumedTranscriptionJobRef = React.useRef(false);
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
@@ -166,14 +325,29 @@ const SessionMemory = () => {
setClients(clientsResponse.data);
setSessions(sessionsResponse.data);
+ const requestedClientId =
+ typeof router.query.clientId === 'string' ? router.query.clientId : '';
+ const requestedClient = clientsResponse.data.find((client: Client) => {
+ return client.id === requestedClientId;
+ });
+
+ if (!clientId && requestedClient) {
+ setClientId(requestedClient.id);
+ return;
+ }
+
if (!clientId && clientsResponse.data.length > 0) {
setClientId(clientsResponse.data[0].id);
}
}
React.useEffect(() => {
+ if (!router.isReady) {
+ return;
+ }
+
loadData();
- }, []);
+ }, [router.isReady, router.query.clientId]);
React.useEffect(() => {
if (!isRecording) {
@@ -195,6 +369,43 @@ const SessionMemory = () => {
};
}, []);
+ React.useEffect(() => {
+ if (resumedTranscriptionJobRef.current) {
+ return;
+ }
+
+ const jobId = window.localStorage.getItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
+
+ if (!jobId) {
+ return;
+ }
+
+ resumedTranscriptionJobRef.current = true;
+ setIsTranscribing(true);
+ setTranscriptionNotice('Restoring background transcription status.');
+ setTranscriptionProgress(null);
+
+ pollTranscriptionJob(jobId)
+ .catch((error) => {
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
+ window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
+ setTranscriptionNotice('Previous transcription job is no longer available. Please upload the audio again.');
+ setTranscriptionProgress(null);
+ return;
+ }
+
+ if (error instanceof Error) {
+ setTranscriptionNotice(error.message);
+ return;
+ }
+
+ setTranscriptionNotice(requestErrorMessage(error, 'Audio transcription failed.'));
+ })
+ .finally(() => {
+ setIsTranscribing(false);
+ });
+ }, []);
+
function updateDraft(field: keyof MemoryDraft, value: string) {
setDraft((current) => {
return {
@@ -227,9 +438,75 @@ const SessionMemory = () => {
}
}
+ function applyTranscriptionResult(result: TranscriptionResult) {
+ setTranscript(result.text || '');
+ setAudioAttachment({
+ audio_url: result.audio_url,
+ audio_filename: result.audio_filename,
+ audio_mime_type: result.audio_mime_type,
+ audio_size: result.audio_size,
+ });
+
+ setTranscriptionProgress(null);
+
+ if (result.transcription_chunks && result.transcription_chunks > 1) {
+ setTranscriptionNotice(
+ 'Audio transcribed and saved. Review the transcript before generating memory.',
+ );
+ setNotice('');
+ } else {
+ setTranscriptionNotice(
+ 'Audio transcribed and saved. Review the transcript before generating memory.',
+ );
+ setNotice('');
+ }
+ }
+
+ async function pollTranscriptionJob(jobId: string) {
+ while (true) {
+ await wait(2500);
+ const response = await axios.get(
+ `/coaching/session-memory/transcribe/${jobId}`,
+ );
+ const job = response.data;
+
+ if (job.chunk_index && job.chunks_total) {
+ setTranscriptionProgress({
+ current: job.chunk_index,
+ total: job.chunks_total,
+ });
+ setTranscriptionNotice('Transcribing audio.');
+ } else if (job.status === 'processing') {
+ setTranscriptionNotice('Preparing audio transcription.');
+ } else if (job.message) {
+ setTranscriptionNotice(job.message);
+ }
+
+ if (job.status === 'completed') {
+ if (!job.result) {
+ throw new Error('Transcription job completed without a result.');
+ }
+
+ window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
+ applyTranscriptionResult(job.result);
+ return;
+ }
+
+ if (job.status === 'failed') {
+ window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
+ throw new Error(job.message || 'Audio transcription failed.');
+ }
+ }
+ }
+
async function transcribeAudio() {
if (!audioFile) {
- setNotice('Choose an audio file first.');
+ setTranscriptionNotice('Choose an audio file first.');
+ return;
+ }
+
+ if (audioFile.size > MAX_AUDIO_FILE_BYTES) {
+ setTranscriptionNotice(audioTooLargeMessage());
return;
}
@@ -237,6 +514,14 @@ const SessionMemory = () => {
formData.append('audio', audioFile);
setIsTranscribing(true);
+ if (audioFile.size > DIRECT_TRANSCRIPTION_FILE_BYTES) {
+ setTranscriptionProgress(null);
+ setTranscriptionNotice('Large audio detected. Uploading it before background transcription.');
+ } else {
+ setTranscriptionProgress(null);
+ setTranscriptionNotice('Uploading audio before background transcription.');
+ }
+
try {
const response = await axios.post(
'/coaching/session-memory/transcribe',
@@ -245,12 +530,26 @@ const SessionMemory = () => {
headers: { 'Content-Type': 'multipart/form-data' },
},
);
- setTranscript(response.data.text || '');
- setNotice(
- 'Audio transcribed. Review the transcript before generating memory.',
- );
+
+ if (response.status === 202 && response.data.job_id) {
+ window.localStorage.setItem(
+ ACTIVE_TRANSCRIPTION_JOB_KEY,
+ response.data.job_id,
+ );
+ setTranscriptionNotice(response.data.message || 'Audio uploaded. Transcription started.');
+ await pollTranscriptionJob(response.data.job_id);
+ return;
+ }
+
+ applyTranscriptionResult(response.data);
} catch (error) {
- setNotice(requestErrorMessage(error, 'Audio transcription failed.'));
+ setTranscriptionProgress(null);
+
+ if (error instanceof Error) {
+ setTranscriptionNotice(error.message);
+ } else {
+ setTranscriptionNotice(requestErrorMessage(error, 'Audio transcription failed.'));
+ }
} finally {
setIsTranscribing(false);
}
@@ -281,12 +580,12 @@ const SessionMemory = () => {
async function startRecording() {
if (typeof MediaRecorder === 'undefined') {
- setNotice('Audio recording is not supported in this browser.');
+ setTranscriptionNotice('Audio recording is not supported in this browser.');
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
- setNotice('Audio recording is not supported in this browser.');
+ setTranscriptionNotice('Audio recording is not supported in this browser.');
return;
}
@@ -295,7 +594,7 @@ const SessionMemory = () => {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
- setNotice('Microphone access was blocked or failed.');
+ setTranscriptionNotice('Microphone access was blocked or failed.');
return;
}
@@ -321,13 +620,16 @@ const SessionMemory = () => {
});
setAudioFile(file);
- setNotice('Recording saved. Transcribe it when you are ready.');
+ setAudioAttachment(null);
+ setTranscriptionNotice('Recording saved. Transcribe it when you are ready.');
setIsRecording(false);
stopAudioTracks();
};
setRecordingSeconds(0);
setAudioFile(null);
+ setAudioAttachment(null);
+ setTranscriptionNotice('');
setNotice('');
setIsRecording(true);
recorder.start();
@@ -356,8 +658,15 @@ const SessionMemory = () => {
setIsSaving(true);
const response = await axios.post('/coaching/session-memory/save', {
...draft,
+ title:
+ draft.title ||
+ `Session with ${selectedClient?.name || 'client'} ${new Date().toLocaleDateString()}`,
clientId,
transcript_notes: transcript,
+ audio_url: audioAttachment?.audio_url,
+ audio_filename: audioAttachment?.audio_filename,
+ audio_mime_type: audioAttachment?.audio_mime_type,
+ audio_size: audioAttachment?.audio_size,
shareWithClient,
});
@@ -365,6 +674,8 @@ const SessionMemory = () => {
setLastSavedSession(response.data);
setDraft(emptyDraft);
setTranscript('');
+ setAudioFile(null);
+ setAudioAttachment(null);
setNotice(
shareWithClient
? 'Session saved and shared with the client.'
@@ -393,6 +704,32 @@ const SessionMemory = () => {
setNotice('Session shared with the client.');
}
+ async function deleteSession(session: Session) {
+ const confirmed = window.confirm(
+ `Delete "${session.title || 'Client session'}"? This cannot be undone.`,
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setDeletingSessionId(session.id);
+ try {
+ await axios.delete(`/coaching/sessions/${session.id}`);
+ setSessions((current) =>
+ current.filter((currentSession) => currentSession.id !== session.id),
+ );
+
+ if (lastSavedSession?.id === session.id) {
+ setLastSavedSession(null);
+ }
+
+ setNotice('Session deleted.');
+ } finally {
+ setDeletingSessionId('');
+ }
+ }
+
async function copyFollowUp() {
await navigator.clipboard.writeText(draft.follow_up_email || '');
setNotice('Follow-up copied.');
@@ -405,11 +742,158 @@ const SessionMemory = () => {
draft.shared_client_notes,
);
const selectedClient = clients.find((client) => client.id === clientId);
+ const isStartSessionPage = router.pathname === '/start-session';
+ const pageTitle = isStartSessionPage ? 'Start Client Session' : 'Session Memory';
+
+ if (!isStartSessionPage) {
+ return (
+ <>
+
+ {getPageTitle(pageTitle)}
+
+
+
+
+
+
+
+
+ Session Memory
+
+
+
+ Saved client sessions
+
+
+ Review transcripts, coach notes, sharing status, and saved
+ session memories.
+
+
+
router.push('/start-session')}
+ >
+
+ Start new session
+
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+
+
+
+ Session archive
+
+
+
+
+
+
+ Session
+ Client
+ Date
+ Audio
+ Status
+ Actions
+
+
+
+ {sessions.map((session) => (
+
+
+
+
+ {session.title || 'Client session'}
+
+
+ {session.ai_summary || 'No coach summary yet.'}
+
+
+
+ {session.client?.name || 'No client'}
+
+
+ {displayDateTime(session.session_at)}
+
+
+ {session.audio_url ? 'Saved' : 'None'}
+
+
+
+
+
+
+
+ router.push(
+ `/session-memory/view?sessionId=${session.id}`,
+ )
+ }
+ >
+
+ Open
+
+ {session.status !== 'shared' && (
+ shareSession(session)}
+ >
+
+ Share
+
+ )}
+ deleteSession(session)}
+ >
+
+ {deletingSessionId === session.id
+ ? 'Deleting...'
+ : 'Delete'}
+
+
+
+
+
+ ))}
+ {sessions.length === 0 && (
+
+
+ No saved sessions yet.
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+ }
return (
<>
- {getPageTitle('Session Memory')}
+ {getPageTitle(pageTitle)}
@@ -417,16 +901,18 @@ const SessionMemory = () => {
- Session Memory
+ {isStartSessionPage ? 'Start session' : 'Session Memory'}
- Turn rough notes into follow-up, commitments, and next-session
- prep.
+ {isStartSessionPage
+ ? 'Record or upload a client session, then turn it into memory.'
+ : 'Turn rough notes into follow-up, commitments, and next-session prep.'}
- Generate a structured draft, edit it as the coach, save it as a
- private draft, or share approved notes with the client portal.
+ {isStartSessionPage
+ ? 'Choose the client, capture audio or upload a file, transcribe it, and generate the coach review.'
+ : 'Generate a structured draft, edit it as the coach, save it as a private draft, or share approved notes with the client portal.'}
@@ -442,7 +928,7 @@ const SessionMemory = () => {
Raw session input
- Extract a session
+ {isStartSessionPage ? 'Capture this session' : 'Extract a session'}
@@ -513,7 +999,18 @@ const SessionMemory = () => {
className='hidden'
onChange={(event) => {
const file = event.target.files?.[0] || null;
+ setAudioAttachment(null);
+
+ if (file && file.size > MAX_AUDIO_FILE_BYTES) {
+ setAudioFile(null);
+ setTranscriptionNotice(audioTooLargeMessage());
+ event.target.value = '';
+ return;
+ }
+
setAudioFile(file);
+ setTranscriptionNotice('');
+ setTranscriptionProgress(null);
setNotice('');
}}
/>
@@ -527,6 +1024,46 @@ const SessionMemory = () => {
{isTranscribing ? 'Transcribing...' : 'Transcribe audio'}
+ {(transcriptionNotice || transcriptionProgress) && (
+
+ {transcriptionProgress && (
+
+ )}
+ {transcriptionNotice}
+
+ )}
+ {audioAttachment?.audio_url && (
+
+
+ Audio saved for this session
+
+
+
+ {audioAttachment.audio_filename || 'Session audio'}
+
+
+ )}
@@ -538,17 +1075,45 @@ const SessionMemory = () => {
className='min-h-[220px] w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='Paste session transcript, coach notes, commitments, blockers, or a rough debrief...'
/>
-
-
+
+ >
+
+ {isGenerating
+ ? 'Generating review...'
+ : 'Generate coach review'}
+
+ saveMemory(false)}
+ >
+
+ {isSaving ? 'Saving session...' : 'Save session'}
+
+ {transcript.trim() && (
+
+
+ Session transcript
+
+
+ Speaker view
+
+
+
+
+
+ )}
+
@@ -639,7 +1204,7 @@ const SessionMemory = () => {
onClick={() => saveMemory(false)}
>
- Save coach draft
+ Save session draft
{
onClick={() => saveMemory(true)}
>
- Save and share
+ Save session and share
{
className='text-[#35b7a5]'
/>
- Generate memory from raw notes. The editable draft will
- appear here before anything is saved or shared.
+ Generate a coach review from the transcript. The editable
+ draft will appear here before anything is saved or shared.
)}
-
-
-
- Recent memories
-
-
-
- {sessions.map((session) => (
-
-
-
-
-
- {session.title}
-
-
-
-
- {session.client?.name}
-
-
- {session.status !== 'shared' && (
-
shareSession(session)}
- >
-
- Share
-
- )}
-
-
- {session.ai_summary}
-
-
- {session.key_topics}
-
-
- ))}
-
-
diff --git a/frontend/src/pages/session-memory/view.tsx b/frontend/src/pages/session-memory/view.tsx
new file mode 100644
index 0000000..33f0ff9
--- /dev/null
+++ b/frontend/src/pages/session-memory/view.tsx
@@ -0,0 +1,414 @@
+import {
+ mdiArrowLeft,
+ mdiCheckCircleOutline,
+ mdiFileDocumentEditOutline,
+ mdiSendOutline,
+ mdiTrashCanOutline,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import React from 'react';
+import type { ReactElement } from 'react';
+import BaseIcon from '../../components/BaseIcon';
+import SectionMain from '../../components/SectionMain';
+import { getPageTitle } from '../../config';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+
+type Client = {
+ id: string;
+ name: string;
+};
+
+type Session = {
+ id: string;
+ title?: string;
+ status?: string;
+ client?: Client;
+ session_at?: string;
+ transcript_notes?: string;
+ ai_summary?: string;
+ key_topics?: string;
+ goals_discussed?: string;
+ blockers?: string;
+ commitments?: string;
+ homework?: string;
+ emotional_themes?: string;
+ important_quotes?: string;
+ follow_up_email?: string;
+ next_session_prep?: string;
+ private_coach_notes?: string;
+ shared_client_notes?: string;
+ audio_url?: string;
+ audio_filename?: string;
+};
+
+type TranscriptTurn = {
+ speaker: string;
+ text: string;
+};
+
+function Panel({
+ children,
+ className = '',
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function StatusPill({ status }: { status?: string }) {
+ const isShared = status === 'shared';
+
+ return (
+
+ {isShared ? 'Shared' : 'Coach draft'}
+
+ );
+}
+
+function parseTranscriptTurns(value?: string) {
+ const turns: TranscriptTurn[] = [];
+
+ String(value || '')
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .forEach((line) => {
+ const match = line.match(/^([A-ZА-Я][\wА-Яа-я -]{0,30}):\s*(.+)$/u);
+
+ if (match) {
+ turns.push({
+ speaker: match[1],
+ text: match[2],
+ });
+ return;
+ }
+
+ const previousTurn = turns[turns.length - 1];
+ if (previousTurn) {
+ previousTurn.text = `${previousTurn.text} ${line}`;
+ return;
+ }
+
+ turns.push({
+ speaker: 'Transcript',
+ text: line,
+ });
+ });
+
+ return turns;
+}
+
+function TranscriptConversation({ value }: { value?: string }) {
+ const turns = parseTranscriptTurns(value);
+
+ if (turns.length === 0) {
+ return (
+
+ No transcript saved for this session.
+
+ );
+ }
+
+ return (
+
+ {turns.map((turn, index) => (
+
+
+ {turn.speaker}
+
+
{turn.text}
+
+ ))}
+
+ );
+}
+
+function displayDateTime(value?: string) {
+ if (!value) {
+ return 'No date';
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function DetailBlock({ label, value }: { label: string; value?: string }) {
+ if (!value) {
+ return null;
+ }
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+const SessionMemoryView = () => {
+ const router = useRouter();
+ const [session, setSession] = React.useState(null);
+ const [notice, setNotice] = React.useState('');
+ const [audioSourceUrl, setAudioSourceUrl] = React.useState('');
+ const [isDeleting, setIsDeleting] = React.useState(false);
+ const [isSharing, setIsSharing] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!router.isReady || typeof router.query.sessionId !== 'string') {
+ return;
+ }
+
+ loadSession(router.query.sessionId);
+ }, [router.isReady, router.query.sessionId]);
+
+ React.useEffect(() => {
+ if (!session?.audio_url) {
+ setAudioSourceUrl('');
+ return;
+ }
+
+ let revoked = false;
+ let objectUrl = '';
+
+ axios
+ .get(`/coaching/sessions/${session.id}/audio`, {
+ responseType: 'blob',
+ })
+ .then((response) => {
+ objectUrl = URL.createObjectURL(response.data);
+
+ if (revoked) {
+ URL.revokeObjectURL(objectUrl);
+ return;
+ }
+
+ setAudioSourceUrl(objectUrl);
+ })
+ .catch(() => {
+ setNotice('Audio file could not be loaded.');
+ });
+
+ return () => {
+ revoked = true;
+
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ }, [session?.id, session?.audio_url]);
+
+ async function loadSession(sessionId: string) {
+ const response = await axios.get(`/coaching/sessions/${sessionId}`);
+ setSession(response.data);
+ }
+
+ async function shareSession() {
+ if (!session) {
+ return;
+ }
+
+ setIsSharing(true);
+ try {
+ const response = await axios.patch(`/coaching/sessions/${session.id}/share`, {
+ shared_client_notes: session.shared_client_notes,
+ });
+ setSession(response.data);
+ setNotice('Session shared with the client.');
+ } finally {
+ setIsSharing(false);
+ }
+ }
+
+ async function deleteSession() {
+ if (!session) {
+ return;
+ }
+
+ const confirmed = window.confirm(
+ `Delete "${session.title || 'Client session'}"? This cannot be undone.`,
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setIsDeleting(true);
+ try {
+ await axios.delete(`/coaching/sessions/${session.id}`);
+ await router.push('/session-memory');
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ return (
+ <>
+
+ {getPageTitle(session?.title || 'Session')}
+
+
+
+
router.push('/session-memory')}
+ >
+
+ Session archive
+
+
+
+
+
+
+
+ Session
+
+
+
+ {session?.title || 'Client session'}
+
+
+ {session
+ ? `${session.client?.name || 'No client'} · ${displayDateTime(
+ session.session_at,
+ )}`
+ : 'Loading session...'}
+
+
+ {session &&
}
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {session ? (
+
+
+
+ Transcript
+
+
+
+
+
+
+
+
+
+ {session.status !== 'shared' && (
+
+
+ {isSharing ? 'Sharing...' : 'Share with client'}
+
+ )}
+
+
+ {isDeleting ? 'Deleting...' : 'Delete session'}
+
+
+
+
+ {session.audio_url && (
+
+
+ Audio recording
+
+ {audioSourceUrl ? (
+
+ ) : (
+
+ Loading audio...
+
+ )}
+
+ {session.audio_filename || 'Session audio'}
+
+
+ )}
+
+
+
+
+ Coach memory
+
+
+ {session.ai_summary || 'No summary saved.'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ Loading session...
+
+ )}
+
+
+ >
+ );
+};
+
+SessionMemoryView.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default SessionMemoryView;
diff --git a/frontend/src/pages/start-session.tsx b/frontend/src/pages/start-session.tsx
new file mode 100644
index 0000000..20648b4
--- /dev/null
+++ b/frontend/src/pages/start-session.tsx
@@ -0,0 +1 @@
+export { default } from './session-memory';