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