Add functional client portal workflow

This commit is contained in:
Flatlogic Bot 2026-06-09 13:55:31 +00:00
parent fb01c003c6
commit 227ec4cb9a
6 changed files with 279 additions and 39 deletions

View File

@ -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");
},
};

View File

@ -8,6 +8,8 @@ module.exports = function(sequelize, DataTypes) {
open_commitments: { type: DataTypes.TEXT }, open_commitments: { type: DataTypes.TEXT },
suggested_questions: { type: DataTypes.TEXT }, suggested_questions: { type: DataTypes.TEXT },
sensitive_topics: { 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" }, status: { type: DataTypes.ENUM("draft", "ready", "archived"), defaultValue: "ready" },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true }, importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
}, },

View File

@ -33,7 +33,7 @@ module.exports = {
{ {
id: clientAId, id: clientAId,
name: "Maya Chen", name: "Maya Chen",
email: "maya@example.com", email: "client@hello.com",
status: "active", status: "active",
goals: "Delegate operational decisions, build a steadier weekly planning rhythm, and prepare for a senior leadership transition.", 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.", 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.", 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?", 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.", 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", status: "ready",
createdById: coachUserId, createdById: coachUserId,
updatedById: coachUserId, updatedById: coachUserId,

View File

@ -5,6 +5,19 @@ const { LocalAIApi } = require("../ai/LocalAIApi");
const router = express.Router(); 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( router.get(
"/summary", "/summary",
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
@ -51,6 +64,7 @@ router.get(
{ model: db.packages, as: "package" }, { model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", limit: 2, order: [["session_at", "DESC"]] }, { 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.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( router.get(
"/client-portal/:clientId", "/client-portal/me",
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const client = await db.clients.findByPk(req.params.clientId, { if (!isClientUser(req)) {
include: [ res.status(403).send({ error: "client_role_required" });
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] }, return;
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] }, }
{ model: db.resources, as: "resources", where: { is_shared: true }, required: false },
], const client = await db.clients.findOne({
where: { email: req.currentUser.email },
include: portalClientIncludes(),
}); });
if (!client) { 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; module.exports = router;

View File

@ -26,6 +26,11 @@ type PortalClient = {
next_session_at?: string; next_session_at?: string;
sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>; sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>;
action_items?: Array<{ id: string; title: string; status: string }>; action_items?: Array<{ id: string; title: string; status: string }>;
prep_briefs?: Array<{
id: string;
client_reflection?: string;
client_reflection_at?: string;
}>;
resources?: Array<{ resources?: Array<{
id: string; id: string;
title: string; title: string;
@ -70,9 +75,7 @@ const ClientPortal = () => {
const [portalClient, setPortalClient] = React.useState<PortalClient | null>( const [portalClient, setPortalClient] = React.useState<PortalClient | null>(
null, null,
); );
const [completedItems, setCompletedItems] = React.useState<Set<string>>( const [updatingItemId, setUpdatingItemId] = React.useState('');
new Set(),
);
const [reflection, setReflection] = React.useState(''); const [reflection, setReflection] = React.useState('');
const [reflectionSaved, setReflectionSaved] = React.useState(false); const [reflectionSaved, setReflectionSaved] = React.useState(false);
@ -80,19 +83,21 @@ const ClientPortal = () => {
React.useEffect(() => { React.useEffect(() => {
async function loadClients() { 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'); const response = await axios.get('/coaching/clients');
setClients(response.data); setClients(response.data);
if (response.data.length > 0) { 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); setClientId(response.data[0].id);
} }
} }
@ -108,33 +113,76 @@ const ClientPortal = () => {
const response = await axios.get(`/coaching/client-portal/${clientId}`); const response = await axios.get(`/coaching/client-portal/${clientId}`);
setPortalClient(response.data); setPortalClient(response.data);
setCompletedItems(new Set()); const latestReflection =
setReflection(''); response.data.prep_briefs?.[0]?.client_reflection || '';
setReflectionSaved(false); setReflection(latestReflection);
setReflectionSaved(Boolean(latestReflection));
} }
loadPortal(); if (!isClientUser) {
}, [clientId]); loadPortal();
}
}, [clientId, isClientUser]);
function toggleItem(itemId: string) { async function toggleItem(item: { id: string; status: string }) {
setCompletedItems((current) => { const nextStatus = item.status === 'done' ? 'in_progress' : 'done';
const next = new Set(current); setUpdatingItemId(item.id);
if (next.has(itemId)) {
next.delete(itemId); const response = await axios.patch(
} else { `/coaching/client-portal/action-items/${item.id}`,
next.add(itemId); { 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) => { const openItems = (portalClient?.action_items || []).filter((item) => {
return item.status !== 'done' && !completedItems.has(item.id); return item.status !== 'done';
}); });
const finishedCount = const finishedCount =
(portalClient?.action_items || []).filter((item) => item.status === 'done') (portalClient?.action_items || []).filter((item) => item.status === 'done')
.length + completedItems.size; .length;
const latestSession = portalClient?.sessions?.[0]; const latestSession = portalClient?.sessions?.[0];
return ( return (
@ -244,7 +292,7 @@ const ClientPortal = () => {
<div className='space-y-3 p-4'> <div className='space-y-3 p-4'>
{(portalClient.action_items || []).map((item) => { {(portalClient.action_items || []).map((item) => {
const isDone = const isDone =
item.status === 'done' || completedItems.has(item.id); item.status === 'done';
return ( return (
<button <button
@ -255,7 +303,8 @@ const ClientPortal = () => {
? 'border-[#35b7a5]/30 bg-[#f3fbf8]' ? 'border-[#35b7a5]/30 bg-[#f3fbf8]'
: 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]' : 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
}`} }`}
onClick={() => toggleItem(item.id)} disabled={updatingItemId === item.id}
onClick={() => toggleItem(item)}
> >
<span <span
className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full border ${ className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full border ${
@ -312,13 +361,13 @@ const ClientPortal = () => {
<p className='text-sm text-[#72798a]'> <p className='text-sm text-[#72798a]'>
{reflectionSaved {reflectionSaved
? 'Reflection saved for this session.' ? 'Reflection saved for this session.'
: 'Draft is kept on this page for now.'} : 'Your coach will see this before the next call.'}
</p> </p>
<button <button
type='button' type='button'
className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50' className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={!reflection.trim()} disabled={!reflection.trim()}
onClick={() => setReflectionSaved(true)} onClick={saveReflection}
> >
Save reflection Save reflection
</button> </button>

View File

@ -21,6 +21,12 @@ type ActionItem = {
status: string; status: string;
}; };
type PrepBrief = {
id: string;
client_reflection?: string;
client_reflection_at?: string;
};
type Session = { type Session = {
id: string; id: string;
title: string; title: string;
@ -40,6 +46,7 @@ type Client = {
next_session_at?: string; next_session_at?: string;
sessions?: Session[]; sessions?: Session[];
action_items?: ActionItem[]; action_items?: ActionItem[];
prep_briefs?: PrepBrief[];
package?: { package?: {
title?: string; title?: string;
duration?: string; duration?: string;
@ -98,6 +105,9 @@ const Clients = () => {
const selectedClient = const selectedClient =
clients.find((client) => client.id === selectedClientId) || clients[0]; clients.find((client) => client.id === selectedClientId) || clients[0];
const latestReflection = selectedClient?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
function selectClient(clientId: string) { function selectClient(clientId: string) {
setSelectedClientId(clientId); setSelectedClientId(clientId);
@ -261,6 +271,28 @@ const Clients = () => {
</div> </div>
</Panel> </Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Pre-session reflection
</h3>
</div>
<div className='p-5'>
{latestReflection ? (
<div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4'>
<p className='text-sm leading-6 text-[#72798a]'>
{latestReflection.client_reflection}
</p>
</div>
) : (
<EmptyState label='No client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<Panel> <Panel>
<div className='border-b border-[#19192d]/10 p-5'> <div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'> <h3 className='text-lg font-semibold text-[#19192d]'>