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;