40234-vm/backend/src/routes/coaching.js
2026-06-09 14:06:12 +00:00

497 lines
14 KiB
JavaScript

const express = require("express");
const db = require("../db/models");
const wrapAsync = require("../helpers").wrapAsync;
const { LocalAIApi } = require("../ai/LocalAIApi");
const router = express.Router();
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);
}
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" }],
});
res.status(200).send({
counts: {
clients,
sessions,
actionItems,
resources,
prepBriefs,
},
nextSessions,
activeClients,
});
}),
);
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.get(
"/clients/:id",
wrapAsync(async (req, res) => {
const client = await db.clients.findByPk(req.params.id, {
include: [
{ 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" },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
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,
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,
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.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.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, {
include: [
{ model: db.sessions, as: "sessions", limit: 5, order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", limit: 10, order: [["due_at", "ASC"]] },
],
});
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.",
"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,
notes: client.notes,
},
recent_sessions: client.sessions || [],
open_action_items: client.action_items || [],
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.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;