40464-vm/backend/src/routes/coaching.js
2026-07-22 15:25:42 +00:00

1973 lines
54 KiB
JavaScript

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 {
authorizeCoachingRoute,
clientAccessWhere,
isAdministratorUser,
isClientUser: isClientAccount,
} = require("../security/coachingAuthorization");
const {
editableSessionPayload,
draftChanges,
approvalChanges,
shareChanges,
unshareChanges,
} = require("../security/sessionLifecycle");
const { retentionPolicy } = require("../security/dataLifecycle");
const { validateWorkspaceSettings } = require("../security/workspaceSettings");
const { allowedReminderMinutes } = require("../security/reminders");
const { consentChanges } = require("../security/consent");
const { directAudioUploadLimitBytes, validateAudioUpload, validateAudioContent } = require("../security/audioUpload");
const { scanFile } = require("../security/malwareScan");
const { recordFirstActivationEvent } = require("../security/activationEvents");
const { publicAppUrl } = require("../security/publicAppUrl");
const router = express.Router();
const execFile = util.promisify(childProcess.execFile);
const transcriptionChunkSeconds = Number(process.env.AI_TRANSCRIPTION_CHUNK_SECONDS || 180);
const transcriptionJobRetentionMs = 60 * 60 * 1000;
const transcriptionJobs = new Map();
const sessionAudioUploadPath = "/private-coaching-audio";
const sessionAudioUploadDir = process.env.COACHING_AUDIO_DIR || path.join(__dirname, "../../private/coaching-sessions");
const CONSENT_POLICY_VERSION = "2026-07-22-v1";
function isClientUser(req) {
return isClientAccount(req.currentUser);
}
router.use(authorizeCoachingRoute);
async function recordAuditEvent(eventType, actorId, targetType, targetId, metadata = {}) {
await db.coaching_audit_events.create({
event_type: eventType,
actorId,
target_type: targetType,
target_id: targetId,
metadata,
});
}
router.get(
"/workspace-settings",
wrapAsync(async (req, res) => {
const [settings] = await db.workspace_settings.findOrCreate({ where: { id: "default" } });
res.status(200).send(settings);
}),
);
router.patch(
"/workspace-settings",
wrapAsync(async (req, res) => {
const [settings] = await db.workspace_settings.findOrCreate({ where: { id: "default" } });
const data = req.body.data || req.body;
const nextSettings = { ...settings.toJSON(), ...data };
const errors = validateWorkspaceSettings(nextSettings);
if (errors.length > 0) {
res.status(400).send({ error: "workspace_settings_invalid", details: errors });
return;
}
const allowedFields = [
"practice_name", "logo_url", "coach_name", "coach_photo_url", "coach_bio",
"coach_credentials", "coach_niche", "brand_color", "default_booking_url",
"contact_email", "custom_domain", "social_links", "packages", "testimonials", "published",
"legal_entity_name", "legal_contact_email", "legal_terms_effective_date", "legal_review_confirmed",
"reminders_enabled", "default_reminder_minutes", "timezone",
];
const updates = { updatedById: req.currentUser.id };
for (const field of allowedFields) {
if (Object.prototype.hasOwnProperty.call(data, field)) {
updates[field] = data[field];
}
}
await settings.update(updates);
await recordAuditEvent("workspace_settings_updated", req.currentUser.id, "workspace", null, {
published: settings.published,
});
res.status(200).send(settings);
}),
);
function portalClientIncludes() {
return [
{
model: db.sessions,
as: "sessions",
attributes: ["id", "title", "session_at", "status", "shared_client_notes"],
where: { status: "shared" },
required: false,
order: [["session_at", "DESC"]],
},
{
model: db.action_items,
as: "action_items",
attributes: ["id", "title", "due_at", "status"],
order: [["due_at", "ASC"]],
},
{
model: db.resources,
as: "resources",
attributes: ["id", "title", "description", "url", "resource_type", "is_shared"],
where: { is_shared: true },
required: false,
},
{
model: db.prep_briefs,
as: "prep_briefs",
attributes: ["id", "next_session_at", "client_reflection", "client_reflection_at", "status"],
order: [["updatedAt", "DESC"]],
},
];
}
function portalClientAttributes() {
return [
"id",
"name",
"email",
"status",
"goals",
"company",
"role_title",
"next_session_at",
"last_session_at",
"ai_processing_consent_granted",
"recording_consent_granted",
"consent_withdrawn_at",
];
}
function splitActionItems(value) {
return String(value || "")
.split(/\n|;/)
.map((item) => item.replace(/^[-*]\s*/, "").trim())
.filter(Boolean)
.slice(0, 8);
}
function clientPayload(data, userId) {
return {
name: data.name,
email: data.email,
status: data.status || "active",
goals: data.goals,
notes: data.notes,
company: data.company,
role_title: data.role_title,
tags: data.tags,
next_session_at: data.next_session_at || null,
last_session_at: data.last_session_at || null,
updatedById: userId,
};
}
function clientIncludes() {
return [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] },
{ model: db.resources, as: "resources", order: [["createdAt", "DESC"]] },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
];
}
async function findAccessibleClient(req, id, options = {}) {
return db.clients.findOne({
...options,
where: {
id,
...clientAccessWhere(req.currentUser),
...(options.where || {}),
},
});
}
async function findAccessibleSession(req, id) {
return db.sessions.findOne({
where: { id },
include: [
{
model: db.clients,
as: "client",
required: true,
where: clientAccessWhere(req.currentUser),
},
],
});
}
async function ensureClientPortalUser(email, currentUserId) {
const normalizedEmail = String(email || "").trim().toLowerCase();
const clientRole = await db.roles.findOne({ where: { name: "Client" } });
if (!clientRole) {
throw new Error("Client role does not exist");
}
let user = await db.users.findOne({
where: { email: normalizedEmail },
include: [{ model: db.roles, as: "app_role" }],
});
if (user && user.app_role && user.app_role.name !== "Client") {
const message = `User ${normalizedEmail} already has ${user.app_role.name} role`;
const error = new Error(message);
error.code = 400;
throw error;
}
if (!user) {
user = await db.users.create({
firstName: normalizedEmail.split("@")[0],
email: normalizedEmail,
disabled: false,
emailVerified: true,
createdById: currentUserId,
updatedById: currentUserId,
});
} else {
await user.update({
disabled: false,
emailVerified: true,
updatedById: currentUserId,
});
}
await user.setApp_role(clientRole.id);
return user;
}
function getFirstUploadedFile(files, fieldName) {
const file = files[fieldName];
if (Array.isArray(file)) {
return file[0];
}
return file;
}
function parseAudioUpload(req) {
return new Promise((resolve, reject) => {
const form = new formidable.IncomingForm({
multiples: false,
keepExtensions: true,
maxFileSize: directAudioUploadLimitBytes,
});
form.parse(req, (error, fields, files) => {
if (error) {
reject(error);
return;
}
resolve({
audioFile: getFirstUploadedFile(files, "audio") || getFirstUploadedFile(files, "file"),
fields,
});
});
});
}
async function removeUploadedAudio(filePath) {
if (!filePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
console.warn("Failed to remove uploaded audio file", error);
}
}
function safeAudioExtension(fileName, mimeType) {
const extension = path.extname(String(fileName || "")).toLowerCase();
if (extension) {
return extension;
}
if (mimeType === "audio/webm") {
return ".webm";
}
if (mimeType === "audio/mpeg") {
return ".mp3";
}
if (mimeType === "audio/wav") {
return ".wav";
}
if (mimeType === "audio/mp4") {
return ".m4a";
}
return ".audio";
}
function publicAudioFilePath(audioUrl) {
if (!audioUrl || !String(audioUrl).startsWith(`${sessionAudioUploadPath}/`)) {
return null;
}
return path.join(sessionAudioUploadDir, path.basename(audioUrl));
}
async function storeSessionAudioFile(audioFile) {
const filePath = audioFile.filepath || audioFile.path;
const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath);
const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream";
const extension = safeAudioExtension(fileName, mimeType);
const storedFileName = `${crypto.randomUUID()}${extension}`;
const storedFilePath = path.join(sessionAudioUploadDir, storedFileName);
if (!filePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
await fs.promises.mkdir(sessionAudioUploadDir, { recursive: true });
await fs.promises.copyFile(filePath, storedFilePath);
return {
audio_url: `${sessionAudioUploadPath}/${storedFileName}`,
audio_filename: fileName,
audio_mime_type: mimeType,
audio_size: audioFile.size || null,
};
}
async function removeSessionAudioFile(audioUrl) {
const filePath = publicAudioFilePath(audioUrl);
if (!filePath) {
return;
}
await fs.promises.rm(filePath, { force: true });
}
function pruneTranscriptionJobs() {
const cutoff = Date.now() - transcriptionJobRetentionMs;
transcriptionJobs.forEach((job, jobId) => {
if (job.updated_at < cutoff) {
transcriptionJobs.delete(jobId);
}
});
}
function updateTranscriptionJob(jobId, data) {
const existingJob = transcriptionJobs.get(jobId) || {};
transcriptionJobs.set(jobId, {
...existingJob,
...data,
updated_at: Date.now(),
});
}
async function runTranscriptionJob(jobId, audioFile, audioData) {
try {
updateTranscriptionJob(jobId, {
status: "processing",
message: "Starting audio transcription.",
});
const result = await transcribeAudioFile(audioFile, (progress) => {
updateTranscriptionJob(jobId, {
status: "processing",
...progress,
});
});
if (result.status !== 200) {
updateTranscriptionJob(jobId, {
status: "failed",
error: result.body,
message:
result.body?.message ||
result.body?.error?.message ||
result.body?.error ||
"Audio transcription failed.",
});
return;
}
updateTranscriptionJob(jobId, {
status: "completed",
message: "Audio transcription completed.",
result: {
...result.body,
...audioData,
},
});
} catch (error) {
updateTranscriptionJob(jobId, {
status: "failed",
message: error.message || "Audio transcription failed.",
});
}
}
function shouldUseDiarizedTranscription(model) {
return model === "gpt-4o-transcribe-diarize";
}
function formatDiarizedTranscript(segments) {
return segments
.map((segment) => {
const speaker = segment.speaker || "Speaker";
const text = String(segment.text || "").trim();
if (!text) {
return "";
}
return `${speaker}: ${text}`;
})
.filter(Boolean)
.join("\n");
}
function transcriptionConfig() {
return {
transcriptionUrl:
process.env.AI_TRANSCRIPTION_URL || "https://api.openai.com/v1/audio/transcriptions",
transcriptionModel: process.env.AI_TRANSCRIPTION_MODEL || "gpt-4o-transcribe-diarize",
transcriptionApiKey: process.env.AI_TRANSCRIPTION_API_KEY || process.env.OPENAI_API_KEY,
};
}
function transcriptionHeaders(transcriptionApiKey) {
const headers = {};
if (process.env.PROJECT_UUID) {
headers.Authorization = `Bearer ${process.env.PROJECT_UUID}`;
headers["project-uuid"] = process.env.PROJECT_UUID;
}
if (transcriptionApiKey) {
headers.Authorization = `Bearer ${transcriptionApiKey}`;
}
return headers;
}
async function sendTranscriptionRequest({
filePath,
fileName,
mimeType,
transcriptionUrl,
transcriptionModel,
transcriptionApiKey,
}) {
const audioBuffer = await fs.promises.readFile(filePath);
const formData = new FormData();
const useDiarizedTranscription = shouldUseDiarizedTranscription(transcriptionModel);
formData.append("file", new Blob([audioBuffer], { type: mimeType }), fileName);
formData.append("model", transcriptionModel);
if (useDiarizedTranscription) {
formData.append("response_format", "diarized_json");
formData.append("chunking_strategy", "auto");
} else {
formData.append("response_format", "json");
}
const response = await fetch(transcriptionUrl, {
method: "POST",
headers: transcriptionHeaders(transcriptionApiKey),
body: formData,
});
const responseText = await response.text();
let payload;
try {
payload = JSON.parse(responseText);
} catch {
if (!response.ok) {
return {
status: response.status,
body: {
error: "transcription_provider_non_json_response",
message: `Transcription provider returned HTTP ${response.status}. The request probably timed out before the provider returned JSON.`,
},
};
}
throw new Error("transcription_provider_invalid_response");
}
if (!response.ok) {
return {
status: response.status,
body: payload,
};
}
const diarizedText = Array.isArray(payload.segments)
? formatDiarizedTranscript(payload.segments)
: "";
const text = diarizedText || payload.text || payload.transcript || payload.output_text;
if (!text) {
throw new Error(`Transcription response does not include text: ${JSON.stringify(payload)}`);
}
return {
status: 200,
body: {
text,
segments: payload.segments || [],
model: transcriptionModel,
},
};
}
async function createTranscriptionChunks(filePath) {
const chunkDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coaching-audio-"));
const chunkPattern = path.join(chunkDir, "chunk-%03d.mp3");
try {
await execFile(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-i",
filePath,
"-vn",
"-map_metadata",
"-1",
"-ac",
"1",
"-c:a",
"libmp3lame",
"-b:a",
"64k",
"-f",
"segment",
"-segment_time",
String(transcriptionChunkSeconds),
"-reset_timestamps",
"1",
chunkPattern,
],
{ maxBuffer: 1024 * 1024 * 20 },
);
} catch (error) {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
const message = error.stderr || error.message;
throw new Error(`Failed to split audio with ffmpeg: ${message}`);
}
const chunkFiles = (await fs.promises.readdir(chunkDir))
.filter((fileName) => fileName.endsWith(".mp3"))
.sort()
.map((fileName) => path.join(chunkDir, fileName));
if (chunkFiles.length === 0) {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
throw new Error("ffmpeg did not create any audio chunks");
}
return { chunkDir, chunkFiles };
}
async function transcribeLargeAudioFile(filePath, config, onProgress) {
if (onProgress) {
onProgress({ message: "Splitting large audio into smaller parts." });
}
const { chunkDir, chunkFiles } = await createTranscriptionChunks(filePath);
const texts = [];
const segments = [];
if (onProgress) {
onProgress({
message: `Audio split into ${chunkFiles.length} parts. Transcribing part 1 of ${chunkFiles.length}.`,
chunk_index: 1,
chunks_total: chunkFiles.length,
});
}
try {
for (let index = 0; index < chunkFiles.length; index += 1) {
const chunkPath = chunkFiles[index];
if (onProgress) {
onProgress({
message: `Transcribing part ${index + 1} of ${chunkFiles.length}.`,
chunk_index: index + 1,
chunks_total: chunkFiles.length,
});
}
const result = await sendTranscriptionRequest({
filePath: chunkPath,
fileName: path.basename(chunkPath),
mimeType: "audio/mpeg",
...config,
});
if (result.status !== 200) {
return {
status: result.status,
body: {
...result.body,
message:
result.body?.message ||
result.body?.error?.message ||
`Audio transcription failed on part ${index + 1} of ${chunkFiles.length}.`,
chunk_index: index + 1,
chunks_total: chunkFiles.length,
},
};
}
texts.push(result.body.text);
segments.push(...(result.body.segments || []));
}
} finally {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
}
return {
status: 200,
body: {
text: texts.join("\n\n"),
segments,
model: config.transcriptionModel,
transcription_chunks: chunkFiles.length,
},
};
}
async function transcribeAudioFile(audioFile, onProgress) {
const filePath = audioFile.filepath || audioFile.path;
const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath);
const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream";
const config = transcriptionConfig();
if (!filePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
if (!config.transcriptionApiKey && !process.env.AI_TRANSCRIPTION_URL) {
return {
status: 501,
body: {
error: "transcription_not_configured",
message:
"Set AI_TRANSCRIPTION_URL for the AppWizzy proxy or AI_TRANSCRIPTION_API_KEY/OPENAI_API_KEY for direct transcription.",
},
};
}
const stats = await fs.promises.stat(filePath);
if (stats.size > directAudioUploadLimitBytes) {
return transcribeLargeAudioFile(filePath, config, onProgress);
}
if (onProgress) {
onProgress({ message: "Transcribing audio." });
}
return sendTranscriptionRequest({
filePath,
fileName,
mimeType,
...config,
});
}
router.get(
"/summary",
wrapAsync(async (req, res) => {
const clientWhere = clientAccessWhere(req.currentUser);
const [clients, sessions, actionItems, resources, prepBriefs] = await Promise.all([
db.clients.count({ where: clientWhere }),
db.sessions.count({ include: [{ model: db.clients, as: "client", required: true, where: clientWhere }] }),
db.action_items.count({
where: { status: ["not_started", "in_progress"] },
include: [{ model: db.clients, as: "client", required: true, where: clientWhere }],
}),
db.resources.count({
where: { is_shared: true },
include: [{ model: db.clients, as: "client", required: true, where: clientWhere }],
}),
db.prep_briefs.count({
where: { status: "ready" },
include: [{ model: db.clients, as: "client", required: true, where: clientWhere }],
}),
]);
const nextSessions = await db.sessions.findAll({
limit: 4,
order: [["session_at", "DESC"]],
include: [{ model: db.clients, as: "client", required: true, where: clientWhere }],
});
const activeClients = await db.clients.findAll({
limit: 4,
order: [["next_session_at", "ASC"]],
where: clientWhere,
include: [{ model: db.packages, as: "package" }],
});
const upcomingPrepBriefs = await db.prep_briefs.findAll({
limit: 4,
order: [["next_session_at", "ASC"]],
where: { status: "ready" },
include: [{ model: db.clients, as: "client", required: true, where: clientWhere }],
});
res.status(200).send({
counts: {
clients,
sessions,
actionItems,
resources,
prepBriefs,
},
nextSessions,
activeClients,
upcomingPrepBriefs,
});
}),
);
router.get(
"/intake-leads",
wrapAsync(async (req, res) => {
const leads = await db.intake_leads.findAll({
order: [["createdAt", "DESC"]],
});
res.status(200).send(leads);
}),
);
router.patch(
"/intake-leads/:id/status",
wrapAsync(async (req, res) => {
const lead = await db.intake_leads.findByPk(req.params.id);
if (!lead) {
res.status(404).send({ error: "lead_not_found" });
return;
}
await lead.update({
status: req.body.status,
updatedById: req.currentUser.id,
});
res.status(200).send(lead);
}),
);
router.post(
"/intake-leads/:id/convert",
wrapAsync(async (req, res) => {
const lead = await db.intake_leads.findByPk(req.params.id);
if (!lead) {
res.status(404).send({ error: "lead_not_found" });
return;
}
if (lead.status === "converted") {
res.status(409).send({ error: "lead_already_converted" });
return;
}
const portalUser = await ensureClientPortalUser(lead.email, req.currentUser.id);
const client = await db.clients.create({
name: lead.name,
email: portalUser.email,
company: lead.company,
role_title: lead.role_title,
status: "active",
goals: lead.goal,
notes: [
lead.package_name && `Requested package: ${lead.package_name}`,
lead.preferred_time && `Preferred first-call time: ${lead.preferred_time}`,
lead.challenge && `Challenge: ${lead.challenge}`,
lead.desired_outcome && `Desired outcome: ${lead.desired_outcome}`,
lead.consent_ai_notes ? "Consented to AI-assisted session notes." : "AI notes consent not granted yet.",
]
.filter(Boolean)
.join("\n"),
tags: "intake",
ownerId: req.currentUser.id,
portalUserId: portalUser.id,
ai_processing_consent_granted: Boolean(lead.consent_ai_notes),
ai_processing_consent_at: lead.consent_ai_notes_at,
ai_processing_consent_policy_version: lead.consent_policy_version,
recording_consent_granted: false,
consent_updated_by_id: req.currentUser.id,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
const inviteWasSent = EmailSender.isConfigured;
if (inviteWasSent) {
await AuthService.sendPasswordResetEmail(
portalUser.email,
"invitation",
publicAppUrl(),
);
}
await lead.update({
status: "converted",
updatedById: req.currentUser.id,
});
await recordFirstActivationEvent("first_client_created", req.currentUser.id);
res.status(200).send({
lead,
client,
portalUser: {
id: portalUser.id,
email: portalUser.email,
},
invite_sent: inviteWasSent,
});
}),
);
router.get(
"/clients",
wrapAsync(async (req, res) => {
const clients = await db.clients.findAll({
where: clientAccessWhere(req.currentUser),
order: [["next_session_at", "ASC"]],
include: [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", limit: 2, order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", limit: 3, order: [["due_at", "ASC"]] },
{ model: db.prep_briefs, as: "prep_briefs", limit: 2, order: [["updatedAt", "DESC"]] },
],
});
res.status(200).send(clients);
}),
);
router.post(
"/clients",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
const client = await db.clients.create({
...clientPayload(data, req.currentUser.id),
ownerId: req.currentUser.id,
createdById: req.currentUser.id,
});
const savedClient = await db.clients.findByPk(client.id, {
include: clientIncludes(),
});
await recordFirstActivationEvent("first_client_created", req.currentUser.id);
res.status(200).send(savedClient);
}),
);
router.get(
"/clients/:id",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id, {
include: clientIncludes(),
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
router.patch(
"/clients/:id/consent",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const policyVersion = String(req.body.policy_version || CONSENT_POLICY_VERSION).trim();
const aiGranted = req.body.ai_processing_consent_granted === true;
const recordingGranted = req.body.recording_consent_granted === true;
const now = new Date();
await client.update(consentChanges({
aiGranted,
recordingGranted,
policyVersion,
actorId: req.currentUser.id,
now,
}));
await recordAuditEvent("consent_updated", req.currentUser.id, "client", client.id, {
ai_processing_consent_granted: aiGranted,
recording_consent_granted: recordingGranted,
policy_version: policyVersion,
});
res.status(200).send({
id: client.id,
ai_processing_consent_granted: client.ai_processing_consent_granted,
ai_processing_consent_at: client.ai_processing_consent_at,
ai_processing_consent_policy_version: client.ai_processing_consent_policy_version,
recording_consent_granted: client.recording_consent_granted,
recording_consent_at: client.recording_consent_at,
recording_consent_policy_version: client.recording_consent_policy_version,
consent_withdrawn_at: client.consent_withdrawn_at,
});
}),
);
router.post(
"/clients/:id/portal-invitation",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id, {
include: [{ model: db.users, as: "portal_user" }],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const portalUser = client.portal_user || await ensureClientPortalUser(client.email, req.currentUser.id);
if (client.portalUserId !== portalUser.id) {
await client.update({ portalUserId: portalUser.id, updatedById: req.currentUser.id });
}
await portalUser.update({ disabled: false, updatedById: req.currentUser.id });
if (!EmailSender.isConfigured) {
res.status(503).send({ error: "email_not_configured" });
return;
}
await AuthService.sendPasswordResetEmail(
portalUser.email,
"invitation",
publicAppUrl(),
);
await recordAuditEvent("portal_invitation_sent", req.currentUser.id, "client", client.id);
res.status(200).send({ invited: true, client_id: client.id });
}),
);
router.delete(
"/clients/:id/portal-access",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id, {
include: [{ model: db.users, as: "portal_user" }],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (client.portal_user) {
await client.portal_user.update({
disabled: true,
passwordResetToken: null,
passwordResetTokenExpiresAt: null,
updatedById: req.currentUser.id,
});
}
await recordAuditEvent("portal_access_revoked", req.currentUser.id, "client", client.id);
res.status(200).send({ revoked: true, client_id: client.id });
}),
);
router.patch(
"/clients/:id",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
await client.update(clientPayload(data, req.currentUser.id));
await db.coaching_reminder_deliveries.destroy({
where: { clientId: client.id, status: "ready" },
});
const savedClient = await findAccessibleClient(req, client.id, {
include: clientIncludes(),
});
res.status(200).send(savedClient);
}),
);
router.patch(
"/clients/:id/reminders",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const minutes = req.body.reminder_minutes === null ? null : Number(req.body.reminder_minutes);
if (minutes !== null && !allowedReminderMinutes.includes(minutes)) {
res.status(400).send({ error: "reminder_minutes_invalid" });
return;
}
await client.update({
reminders_enabled: req.body.reminders_enabled === true,
reminder_minutes: minutes,
updatedById: req.currentUser.id,
});
await db.coaching_reminder_deliveries.destroy({
where: { clientId: client.id, status: "ready" },
});
await recordAuditEvent("client_reminder_settings_updated", req.currentUser.id, "client", client.id, {
reminders_enabled: client.reminders_enabled,
reminder_minutes: client.reminder_minutes,
});
res.status(200).send({
id: client.id,
reminders_enabled: client.reminders_enabled,
reminder_minutes: client.reminder_minutes,
});
}),
);
router.get(
"/reminders",
wrapAsync(async (req, res) => {
const reminders = await db.coaching_reminder_deliveries.findAll({
where: { status: "ready" },
order: [["scheduled_for", "ASC"]],
include: [{
model: db.clients,
as: "client",
required: true,
attributes: ["id", "name", "next_session_at"],
where: clientAccessWhere(req.currentUser),
}],
});
res.status(200).send(reminders);
}),
);
router.patch(
"/reminders/:id/dismiss",
wrapAsync(async (req, res) => {
const reminder = await db.coaching_reminder_deliveries.findOne({
where: { id: req.params.id, status: "ready" },
include: [{ model: db.clients, as: "client", required: true, where: clientAccessWhere(req.currentUser) }],
});
if (!reminder) {
res.status(404).send({ error: "reminder_not_found" });
return;
}
await reminder.update({ status: "dismissed", dismissed_at: new Date() });
res.status(200).send({ id: reminder.id, status: reminder.status });
}),
);
router.get(
"/clients/:id/export",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id, {
include: clientIncludes(),
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
await recordAuditEvent("client_exported", req.currentUser.id, "client", client.id);
res.setHeader("Content-Disposition", `attachment; filename="client-${client.id}.json"`);
res.status(200).send({
exported_at: new Date().toISOString(),
policy: retentionPolicy(),
client: client.toJSON(),
});
}),
);
router.delete(
"/clients/:id",
wrapAsync(async (req, res) => {
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (req.body.confirm !== client.id) {
res.status(400).send({ error: "client_id_confirmation_required" });
return;
}
const sessions = await db.sessions.findAll({ where: { clientId: client.id }, paranoid: false });
for (const session of sessions) {
await removeSessionAudioFile(session.audio_url);
}
const portalUserId = client.portalUserId;
const transaction = await db.sequelize.transaction();
try {
await db.action_items.destroy({ where: { clientId: client.id }, force: true, transaction });
await db.prep_briefs.destroy({ where: { clientId: client.id }, force: true, transaction });
await db.resources.destroy({ where: { clientId: client.id }, force: true, transaction });
await db.coaching_reminder_deliveries.destroy({ where: { clientId: client.id }, force: true, transaction });
await db.sessions.destroy({ where: { clientId: client.id }, force: true, transaction });
await db.intake_leads.destroy({ where: { email: client.email }, force: true, transaction });
await client.destroy({ force: true, transaction });
if (portalUserId) {
const remainingClients = await db.clients.count({ where: { portalUserId }, transaction });
if (remainingClients === 0) {
await db.users.destroy({ where: { id: portalUserId }, force: true, transaction });
}
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
await recordAuditEvent("client_permanently_deleted", req.currentUser.id, "client", client.id);
res.status(200).send({ deleted: true, id: client.id });
}),
);
router.post(
"/clients/:id/action-items",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "action_title_required" });
return;
}
const actionItem = await db.action_items.create({
clientId: client.id,
title: data.title,
due_at: data.due_at || null,
status: data.status || "not_started",
notes: data.notes,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(actionItem);
}),
);
router.post(
"/clients/:id/resources",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await findAccessibleClient(req, req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "resource_title_required" });
return;
}
const resource = await db.resources.create({
clientId: client.id,
title: data.title,
description: data.description,
url: data.url,
resource_type: data.resource_type || "link",
is_shared: data.is_shared !== false,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(resource);
}),
);
router.get(
"/session-memory",
wrapAsync(async (req, res) => {
const sessions = await db.sessions.findAll({
limit: 20,
order: [["session_at", "DESC"]],
include: [{ model: db.clients, as: "client", required: true, where: clientAccessWhere(req.currentUser) }],
});
res.status(200).send(sessions);
}),
);
router.post(
"/sessions",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await findAccessibleClient(req, data.clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const session = await db.sessions.create({
clientId: client.id,
title: data.title,
session_at: data.session_at || new Date(),
status: "draft",
transcript_notes: data.transcript_notes,
ai_summary: data.ai_summary,
key_topics: data.key_topics,
goals_discussed: data.goals_discussed,
blockers: data.blockers,
commitments: data.commitments,
homework: data.homework,
emotional_themes: data.emotional_themes,
important_quotes: data.important_quotes,
follow_up_email: data.follow_up_email,
next_session_prep: data.next_session_prep,
private_coach_notes: data.private_coach_notes,
shared_client_notes: data.shared_client_notes,
audio_url: data.audio_url,
audio_filename: data.audio_filename,
audio_mime_type: data.audio_mime_type,
audio_size: data.audio_size,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(session);
}),
);
router.post(
"/session-memory/save",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
if (!data.clientId) {
res.status(400).send({ error: "client_id_required" });
return;
}
const client = await findAccessibleClient(req, data.clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const session = await db.sessions.create({
clientId: data.clientId,
title: data.title,
session_at: data.session_at || new Date(),
status: "draft",
transcript_notes: data.transcript_notes,
ai_summary: data.ai_summary,
key_topics: data.key_topics,
goals_discussed: data.goals_discussed,
blockers: data.blockers,
commitments: data.commitments,
homework: data.homework,
emotional_themes: data.emotional_themes,
important_quotes: data.important_quotes,
follow_up_email: data.follow_up_email,
next_session_prep: data.next_session_prep,
private_coach_notes: data.private_coach_notes,
shared_client_notes: data.shared_client_notes,
audio_url: data.audio_url,
audio_filename: data.audio_filename,
audio_mime_type: data.audio_mime_type,
audio_size: data.audio_size,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
const actionTitles = splitActionItems(data.commitments || data.homework);
for (const title of actionTitles) {
await db.action_items.create({
clientId: data.clientId,
sessionId: session.id,
title,
status: "not_started",
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
}
await db.prep_briefs.create({
clientId: data.clientId,
sessionId: session.id,
next_session_at: client.next_session_at,
previous_summary: data.ai_summary,
open_commitments: actionTitles.join("; "),
suggested_questions: data.next_session_prep,
sensitive_topics: data.blockers,
status: "ready",
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
const savedSession = await db.sessions.findByPk(session.id, {
include: [
{ model: db.clients, as: "client" },
{ model: db.action_items, as: "action_items", order: [["createdAt", "ASC"]] },
],
});
await recordFirstActivationEvent("first_session_memory_saved", req.currentUser.id);
res.status(200).send(savedSession);
}),
);
router.get(
"/sessions/:id",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
res.status(200).send(session);
}),
);
router.patch(
"/sessions/:id",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
const data = req.body.data || req.body;
await session.update(draftChanges(session, data, req.currentUser.id));
res.status(200).send(await findAccessibleSession(req, session.id));
}),
);
router.patch(
"/sessions/:id/approve",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
if (Object.prototype.hasOwnProperty.call(req.body, "shared_client_notes")) {
await session.update({
...editableSessionPayload({ shared_client_notes: req.body.shared_client_notes }),
updatedById: req.currentUser.id,
});
}
await session.update(approvalChanges(session, req.currentUser.id));
await recordAuditEvent("session_approved", req.currentUser.id, "session", session.id, {
revision: session.revision,
});
await recordFirstActivationEvent("first_client_share_approved", req.currentUser.id);
res.status(200).send(await findAccessibleSession(req, session.id));
}),
);
router.get(
"/sessions/:id/audio",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
if (
isClientUser(req) &&
(session.status !== "shared" || 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="${path.basename(session.audio_filename || "session-audio.webm").replace(/["\\\r\n]/g, "_")}"`,
);
fs.createReadStream(filePath).pipe(res);
}),
);
router.patch(
"/sessions/:id/share",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
await session.update(shareChanges(session, req.currentUser.id));
await recordAuditEvent("session_shared", req.currentUser.id, "session", session.id, {
revision: session.revision,
});
const savedSession = await findAccessibleSession(req, session.id);
res.status(200).send(savedSession);
}),
);
router.patch(
"/sessions/:id/unshare",
wrapAsync(async (req, res) => {
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
await session.update(unshareChanges(req.currentUser.id));
await recordAuditEvent("session_unshared", req.currentUser.id, "session", session.id, {
revision: session.revision,
});
res.status(200).send(await findAccessibleSession(req, session.id));
}),
);
router.delete(
"/sessions/:id",
wrapAsync(async (req, res) => {
if (isClientUser(req)) {
res.status(403).send({ error: "coach_only" });
return;
}
const session = await findAccessibleSession(req, req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
await db.action_items.destroy({ where: { sessionId: session.id }, force: true });
await db.prep_briefs.destroy({ where: { sessionId: session.id }, force: true });
await removeSessionAudioFile(session.audio_url);
await session.destroy({ force: true });
await recordAuditEvent("session_deleted", req.currentUser.id, "session", session.id);
res.status(200).send({ deleted: true, id: req.params.id });
}),
);
router.post(
"/session-memory/generate",
wrapAsync(async (req, res) => {
const { clientId, sessionId, transcript } = req.body;
if (!clientId) {
res.status(400).send({ error: "client_id_required" });
return;
}
if (!transcript || !String(transcript).trim()) {
res.status(400).send({ error: "transcript_required" });
return;
}
const client = await findAccessibleClient(req, clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!client.ai_processing_consent_granted) {
res.status(403).send({ error: "ai_processing_consent_required" });
return;
}
const response = await LocalAIApi.createResponse(
{
input: [
{
role: "system",
content: [
{
type: "input_text",
text: [
"You are a coaching operations assistant.",
"Extract structured session memory for a professional coaching workspace.",
"Use the transcript as the only source for new session facts.",
"Do not copy or invent prior client history, seed data, or example content.",
"If the transcript does not contain a useful value for a field, return an empty string for that field.",
"Return strict JSON only with these string fields:",
"title, ai_summary, key_topics, goals_discussed, blockers, commitments, homework, emotional_themes, important_quotes, follow_up_email, next_session_prep, private_coach_notes, shared_client_notes.",
].join(" "),
},
],
},
{
role: "user",
content: [
{
type: "input_text",
text: JSON.stringify({
client: {
name: client.name,
company: client.company,
role_title: client.role_title,
goals: client.goals,
},
transcript,
}),
},
],
},
],
},
{ poll_timeout: 180, poll_interval: 3 },
);
if (!response.success) {
res.status(502).send(response);
return;
}
const memory = LocalAIApi.decodeJsonFromResponse(response);
if (sessionId) {
const session = await findAccessibleSession(req, sessionId);
if (!session || session.clientId !== client.id) {
res.status(404).send({ error: "session_not_found" });
return;
}
await session.update(draftChanges(session, memory, req.currentUser.id));
}
res.status(200).send(memory);
}),
);
router.post(
"/session-memory/transcribe",
wrapAsync(async (req, res) => {
pruneTranscriptionJobs();
const { audioFile, fields } = await parseAudioUpload(req);
if (!audioFile) {
res.status(400).send({ error: "audio_required" });
return;
}
const uploadedFilePath = audioFile.filepath || audioFile.path;
if (!uploadedFilePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
try {
validateAudioUpload(audioFile);
await validateAudioContent(uploadedFilePath);
await scanFile(uploadedFilePath);
const clientId = Array.isArray(fields.clientId) ? fields.clientId[0] : fields.clientId;
const client = await findAccessibleClient(req, clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!client.recording_consent_granted) {
res.status(403).send({ error: "recording_consent_required" });
return;
}
if (!client.ai_processing_consent_granted) {
res.status(403).send({ error: "ai_processing_consent_required" });
return;
}
const audioData = await storeSessionAudioFile(audioFile);
const storedFilePath = publicAudioFilePath(audioData.audio_url);
const jobId = crypto.randomUUID();
if (!storedFilePath) {
throw new Error("Stored audio file does not have a readable path");
}
updateTranscriptionJob(jobId, {
status: "queued",
requested_by_id: req.currentUser.id,
message: "Audio uploaded. Waiting to start transcription.",
});
res.status(202).send({
job_id: jobId,
status: "queued",
message: "Audio uploaded. Transcription started in the background.",
});
const storedAudioFile = {
filepath: storedFilePath,
originalFilename: audioData.audio_filename,
mimetype: audioData.audio_mime_type,
size: audioData.audio_size,
};
setImmediate(() => {
runTranscriptionJob(jobId, storedAudioFile, audioData);
});
} finally {
await removeUploadedAudio(uploadedFilePath);
}
}),
);
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;
}
if (!isAdministratorUser(req.currentUser) && job.requested_by_id !== req.currentUser.id) {
res.status(404).send({ error: "transcription_job_not_found" });
return;
}
res.status(200).send(job);
}),
);
router.get(
"/client-portal/me",
wrapAsync(async (req, res) => {
if (!isClientUser(req)) {
res.status(403).send({ error: "client_role_required" });
return;
}
const client = await db.clients.findOne({
where: { portalUserId: req.currentUser.id },
attributes: portalClientAttributes(),
include: portalClientIncludes(),
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
router.get(
"/client-portal/export",
wrapAsync(async (req, res) => {
if (!isClientUser(req)) {
res.status(403).send({ error: "client_role_required" });
return;
}
const client = await db.clients.findOne({
where: { portalUserId: req.currentUser.id },
attributes: portalClientAttributes(),
include: portalClientIncludes(),
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
await recordAuditEvent("client_portal_exported", req.currentUser.id, "client", client.id);
res.setHeader("Content-Disposition", `attachment; filename="my-coaching-record-${client.id}.json"`);
res.status(200).send({ exported_at: new Date().toISOString(), client: client.toJSON() });
}),
);
router.post(
"/client-portal/privacy-request",
wrapAsync(async (req, res) => {
if (!isClientUser(req)) {
res.status(403).send({ error: "client_role_required" });
return;
}
const requestType = req.body.request_type;
if (!["withdraw_consent", "request_deletion"].includes(requestType)) {
res.status(400).send({ error: "privacy_request_type_invalid" });
return;
}
const client = await db.clients.findOne({ where: { portalUserId: req.currentUser.id } });
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (requestType === "withdraw_consent") {
await client.update(consentChanges({
aiGranted: false,
recordingGranted: false,
policyVersion: CONSENT_POLICY_VERSION,
actorId: req.currentUser.id,
}));
}
await recordAuditEvent(`client_${requestType}`, req.currentUser.id, "client", client.id, {
status: "requested",
});
res.status(200).send({ request_type: requestType, status: "recorded" });
}),
);
router.get(
"/client-portal/:clientId",
wrapAsync(async (req, res) => {
let client;
if (isClientUser(req)) {
client = await db.clients.findOne({
where: { id: req.params.clientId, portalUserId: req.currentUser.id },
attributes: portalClientAttributes(),
include: portalClientIncludes(),
});
} else {
client = await findAccessibleClient(req, req.params.clientId, {
include: portalClientIncludes(),
});
}
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
router.patch(
"/client-portal/action-items/:id",
wrapAsync(async (req, res) => {
const allowedStatuses = ["not_started", "in_progress", "done"];
const { status } = req.body;
if (!allowedStatuses.includes(status)) {
res.status(400).send({ error: "invalid_status" });
return;
}
const actionItem = await db.action_items.findByPk(req.params.id, {
include: [{ model: db.clients, as: "client" }],
});
if (!actionItem) {
res.status(404).send({ error: "action_item_not_found" });
return;
}
const clientOwnsActionItem = actionItem.client?.portalUserId === req.currentUser.id;
const staffOwnsActionItem =
isAdministratorUser(req.currentUser) || actionItem.client?.ownerId === req.currentUser.id;
if ((isClientUser(req) && !clientOwnsActionItem) || (!isClientUser(req) && !staffOwnsActionItem)) {
res.status(404).send({ error: "action_item_not_found" });
return;
}
await actionItem.update({
status,
updatedById: req.currentUser.id,
});
res.status(200).send({
id: actionItem.id,
title: actionItem.title,
due_at: actionItem.due_at,
status: actionItem.status,
});
}),
);
router.post(
"/client-portal/reflection",
wrapAsync(async (req, res) => {
const reflection = String(req.body.reflection || "").trim();
if (!reflection) {
res.status(400).send({ error: "reflection_required" });
return;
}
let client;
if (isClientUser(req)) {
client = await db.clients.findOne({ where: { portalUserId: req.currentUser.id } });
} else {
client = await findAccessibleClient(req, req.body.clientId);
}
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const latestSession = await db.sessions.findOne({
where: { clientId: client.id },
order: [["session_at", "DESC"]],
});
const openActionItems = await db.action_items.findAll({
where: { clientId: client.id, status: ["not_started", "in_progress"] },
order: [["due_at", "ASC"]],
});
const existingBrief = await db.prep_briefs.findOne({
where: { clientId: client.id },
order: [["updatedAt", "DESC"]],
});
const openCommitments = openActionItems.map((item) => item.title).join("; ");
const data = {
clientId: client.id,
sessionId: latestSession?.id,
next_session_at: client.next_session_at,
previous_summary: latestSession?.ai_summary,
open_commitments: openCommitments,
client_reflection: reflection,
client_reflection_at: new Date(),
status: "ready",
updatedById: req.currentUser.id,
};
let prepBrief;
if (existingBrief) {
prepBrief = await existingBrief.update(data);
} else {
prepBrief = await db.prep_briefs.create({
...data,
createdById: req.currentUser.id,
});
}
res.status(200).send({
id: prepBrief.id,
next_session_at: prepBrief.next_session_at,
client_reflection: prepBrief.client_reflection,
client_reflection_at: prepBrief.client_reflection_at,
status: prepBrief.status,
});
}),
);
module.exports = router;