1973 lines
54 KiB
JavaScript
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;
|