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 },
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 },
},

View File

@ -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,

View File

@ -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;

View File

@ -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<PortalClient | null>(
null,
);
const [completedItems, setCompletedItems] = React.useState<Set<string>>(
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 = () => {
<div className='space-y-3 p-4'>
{(portalClient.action_items || []).map((item) => {
const isDone =
item.status === 'done' || completedItems.has(item.id);
item.status === 'done';
return (
<button
@ -255,7 +303,8 @@ const ClientPortal = () => {
? 'border-[#35b7a5]/30 bg-[#f3fbf8]'
: 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
}`}
onClick={() => toggleItem(item.id)}
disabled={updatingItemId === item.id}
onClick={() => toggleItem(item)}
>
<span
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]'>
{reflectionSaved
? 'Reflection saved for this session.'
: 'Draft is kept on this page for now.'}
: 'Your coach will see this before the next call.'}
</p>
<button
type='button'
className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={!reflection.trim()}
onClick={() => setReflectionSaved(true)}
onClick={saveReflection}
>
Save reflection
</button>

View File

@ -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 = () => {
</div>
</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>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>