From 266ae01a7e76fbea2af0534c3c9f37a3dec439b2 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 11 Jun 2026 11:17:17 +0000 Subject: [PATCH] feat: expand coaching workspace template --- .gitignore | 1 + .../20260610000000-add-audio-to-sessions.js | 26 + backend/src/db/models/sessions.js | 4 + backend/src/routes/coaching.js | 630 +++++++++++++++-- frontend/src/pages/about.tsx | 185 +++++ frontend/src/pages/clients/view.tsx | 186 ++++- frontend/src/pages/dashboard.tsx | 44 +- frontend/src/pages/how-it-works.tsx | 11 +- frontend/src/pages/index.tsx | 12 +- frontend/src/pages/intake-leads.tsx | 27 +- frontend/src/pages/services.tsx | 194 +++++ frontend/src/pages/session-memory.tsx | 663 ++++++++++++++++-- frontend/src/pages/session-memory/view.tsx | 414 +++++++++++ frontend/src/pages/start-session.tsx | 1 + 14 files changed, 2235 insertions(+), 163 deletions(-) create mode 100644 backend/src/db/migrations/20260610000000-add-audio-to-sessions.js create mode 100644 frontend/src/pages/about.tsx create mode 100644 frontend/src/pages/services.tsx create mode 100644 frontend/src/pages/session-memory/view.tsx create mode 100644 frontend/src/pages/start-session.tsx 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 ( +
+
+ + + C + + Coaching Workspace + + + + Start assessment + +
+
+ ); +} + +export default function AboutCoach() { + return ( + <> + + {getPageTitle('About Coach')} + + +
+
+

+ Coaching practice +

+

+ A public coach profile connected to a private client workspace. +

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

+
+
+ + + + + + + + + + + + {(client.sessions || []).length > 0 ? ( + (client.sessions || []).map((session) => ( + + + + + + + + )) + ) : ( + + + + )} + +
SessionDateAudioStatusActions
+

+ {session.title || 'Client session'} +

+

+ {session.ai_summary || 'No coach summary yet.'} +

+
+ {displayDateTime(session.session_at)} + + {session.audio_url ? 'Saved' : 'None'} + + {session.status || 'completed'} + +
+ + +
+
+ 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 ( +
+
+ + + C + + Coaching Workspace + + + + Start assessment + +
+
+ ); +} + +export default function Services() { + return ( + <> + + {getPageTitle('Coaching Services')} + + +
+
+

+ Services and packages +

+

+ Sell coaching clearly, then manage the whole relationship in one workspace. +

+
+ +
+ + ); +} + +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. +

+
+ +
+ + {notice && ( +
+ {notice} +
+ )} + + +
+

+ Session archive +

+
+
+ + + + + + + + + + + + + {sessions.map((session) => ( + + + + + + + + + + + ))} + {sessions.length === 0 && ( + + + + )} + +
SessionClientDateAudioStatusActions
+

+ {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'} + + + +
+ + {session.status !== 'shared' && ( + + )} + +
+
+ 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'}

+ {transcript.trim() && ( + +

+ Session transcript +

+

+ Speaker view +

+
+ +
+
+ )} +
@@ -639,7 +1204,7 @@ const SessionMemory = () => { onClick={() => saveMemory(false)} > - Save coach draft + Save session draft
)} - -
-

- Recent memories -

-
-
- {sessions.map((session) => ( -
-
-
-
-

- {session.title} -

- -
-

- {session.client?.name} -

-
- {session.status !== 'shared' && ( - - )} -
-

- {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 ( +
+ {children} +
+ ); +} + +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')} + + +
+ + +
+
+
+ + + 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' && ( + + )} + +
+
+ + {session.audio_url && ( + +

+ Audio recording +

+ {audioSourceUrl ? ( +
+ )} + + +
+

+ 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';