diff --git a/backend/src/db/migrations/20260609000000-add-client-reflection-to-prep-briefs.js b/backend/src/db/migrations/20260609000000-add-client-reflection-to-prep-briefs.js new file mode 100644 index 0000000..193e08f --- /dev/null +++ b/backend/src/db/migrations/20260609000000-add-client-reflection-to-prep-briefs.js @@ -0,0 +1,16 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("prep_briefs", "client_reflection", { + type: Sequelize.DataTypes.TEXT, + }); + + await queryInterface.addColumn("prep_briefs", "client_reflection_at", { + type: Sequelize.DataTypes.DATE, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn("prep_briefs", "client_reflection_at"); + await queryInterface.removeColumn("prep_briefs", "client_reflection"); + }, +}; diff --git a/backend/src/db/models/prep_briefs.js b/backend/src/db/models/prep_briefs.js index aff8d4a..eae7bef 100644 --- a/backend/src/db/models/prep_briefs.js +++ b/backend/src/db/models/prep_briefs.js @@ -8,6 +8,8 @@ module.exports = function(sequelize, DataTypes) { open_commitments: { type: DataTypes.TEXT }, suggested_questions: { type: DataTypes.TEXT }, sensitive_topics: { type: DataTypes.TEXT }, + client_reflection: { type: DataTypes.TEXT }, + client_reflection_at: { type: DataTypes.DATE }, status: { type: DataTypes.ENUM("draft", "ready", "archived"), defaultValue: "ready" }, importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true }, }, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index aa61dd9..dc150cd 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -33,7 +33,7 @@ module.exports = { { id: clientAId, name: "Maya Chen", - email: "maya@example.com", + email: "client@hello.com", status: "active", goals: "Delegate operational decisions, build a steadier weekly planning rhythm, and prepare for a senior leadership transition.", notes: "Prefers direct feedback and concise written follow-ups. Avoid overloading with frameworks.", @@ -205,6 +205,8 @@ module.exports = { open_commitments: "Decision-rights matrix; three decisions no longer requiring founder approval.", suggested_questions: "What felt risky to delegate? Where did the team ask for more clarity? What decision still needs your direct voice?", sensitive_topics: "Founder control and trust in senior leadership.", + client_reflection: "I shared the decision-rights draft with the COO. The hardest part was naming which decisions I no longer need to approve.", + client_reflection_at: now, status: "ready", createdById: coachUserId, updatedById: coachUserId, diff --git a/backend/src/routes/coaching.js b/backend/src/routes/coaching.js index 3ebee1f..51e5b64 100644 --- a/backend/src/routes/coaching.js +++ b/backend/src/routes/coaching.js @@ -5,6 +5,19 @@ 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", 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"]] }, + ]; +} + router.get( "/summary", wrapAsync(async (req, res) => { @@ -51,6 +64,7 @@ router.get( { 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"]] }, ], }); @@ -226,14 +240,16 @@ router.post( ); router.get( - "/client-portal/:clientId", + "/client-portal/me", wrapAsync(async (req, res) => { - const client = await db.clients.findByPk(req.params.clientId, { - include: [ - { 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", where: { is_shared: true }, required: false }, - ], + 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) { @@ -245,4 +261,127 @@ router.get( }), ); +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; diff --git a/frontend/src/pages/client-portal.tsx b/frontend/src/pages/client-portal.tsx index cff52e6..37c1cb7 100644 --- a/frontend/src/pages/client-portal.tsx +++ b/frontend/src/pages/client-portal.tsx @@ -26,6 +26,11 @@ type PortalClient = { next_session_at?: string; sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>; action_items?: Array<{ id: string; title: string; status: string }>; + prep_briefs?: Array<{ + id: string; + client_reflection?: string; + client_reflection_at?: string; + }>; resources?: Array<{ id: string; title: string; @@ -70,9 +75,7 @@ const ClientPortal = () => { const [portalClient, setPortalClient] = React.useState( null, ); - const [completedItems, setCompletedItems] = React.useState>( - new Set(), - ); + const [updatingItemId, setUpdatingItemId] = React.useState(''); const [reflection, setReflection] = React.useState(''); const [reflectionSaved, setReflectionSaved] = React.useState(false); @@ -80,19 +83,21 @@ const ClientPortal = () => { React.useEffect(() => { async function loadClients() { + if (isClientUser) { + const response = await axios.get('/coaching/client-portal/me'); + setPortalClient(response.data); + setClientId(response.data.id); + const latestReflection = + response.data.prep_briefs?.[0]?.client_reflection || ''; + setReflection(latestReflection); + setReflectionSaved(Boolean(latestReflection)); + return; + } + const response = await axios.get('/coaching/clients'); setClients(response.data); if (response.data.length > 0) { - const currentClient = response.data.find((client) => { - return client.email === currentUser?.email; - }); - - if (isClientUser && currentClient) { - setClientId(currentClient.id); - return; - } - setClientId(response.data[0].id); } } @@ -108,33 +113,76 @@ const ClientPortal = () => { const response = await axios.get(`/coaching/client-portal/${clientId}`); setPortalClient(response.data); - setCompletedItems(new Set()); - setReflection(''); - setReflectionSaved(false); + const latestReflection = + response.data.prep_briefs?.[0]?.client_reflection || ''; + setReflection(latestReflection); + setReflectionSaved(Boolean(latestReflection)); } - loadPortal(); - }, [clientId]); + if (!isClientUser) { + loadPortal(); + } + }, [clientId, isClientUser]); - function toggleItem(itemId: string) { - setCompletedItems((current) => { - const next = new Set(current); - if (next.has(itemId)) { - next.delete(itemId); - } else { - next.add(itemId); + async function toggleItem(item: { id: string; status: string }) { + const nextStatus = item.status === 'done' ? 'in_progress' : 'done'; + setUpdatingItemId(item.id); + + const response = await axios.patch( + `/coaching/client-portal/action-items/${item.id}`, + { status: nextStatus }, + ); + + setPortalClient((current) => { + if (!current) { + return current; } - return next; + return { + ...current, + action_items: (current.action_items || []).map((currentItem) => { + if (currentItem.id === item.id) { + return response.data; + } + + return currentItem; + }), + }; }); + + setUpdatingItemId(''); + } + + async function saveReflection() { + const response = await axios.post('/coaching/client-portal/reflection', { + clientId: portalClient?.id, + reflection, + }); + + setPortalClient((current) => { + if (!current) { + return current; + } + + const existingBriefs = current.prep_briefs || []; + const otherBriefs = existingBriefs.filter((brief) => { + return brief.id !== response.data.id; + }); + + return { + ...current, + prep_briefs: [response.data, ...otherBriefs], + }; + }); + setReflectionSaved(true); } const openItems = (portalClient?.action_items || []).filter((item) => { - return item.status !== 'done' && !completedItems.has(item.id); + return item.status !== 'done'; }); const finishedCount = (portalClient?.action_items || []).filter((item) => item.status === 'done') - .length + completedItems.size; + .length; const latestSession = portalClient?.sessions?.[0]; return ( @@ -244,7 +292,7 @@ const ClientPortal = () => {
{(portalClient.action_items || []).map((item) => { const isDone = - item.status === 'done' || completedItems.has(item.id); + item.status === 'done'; return ( diff --git a/frontend/src/pages/clients.tsx b/frontend/src/pages/clients.tsx index 959bbb2..01a0d6c 100644 --- a/frontend/src/pages/clients.tsx +++ b/frontend/src/pages/clients.tsx @@ -21,6 +21,12 @@ type ActionItem = { status: string; }; +type PrepBrief = { + id: string; + client_reflection?: string; + client_reflection_at?: string; +}; + type Session = { id: string; title: string; @@ -40,6 +46,7 @@ type Client = { next_session_at?: string; sessions?: Session[]; action_items?: ActionItem[]; + prep_briefs?: PrepBrief[]; package?: { title?: string; duration?: string; @@ -98,6 +105,9 @@ const Clients = () => { const selectedClient = clients.find((client) => client.id === selectedClientId) || clients[0]; + const latestReflection = selectedClient?.prep_briefs?.find((brief) => { + return Boolean(brief.client_reflection); + }); function selectClient(clientId: string) { setSelectedClientId(clientId); @@ -261,6 +271,28 @@ const Clients = () => {
+ +
+

+ Pre-session reflection +

+
+
+ {latestReflection ? ( +
+

+ {latestReflection.client_reflection} +

+
+ ) : ( + + )} +
+
+ + +
+