Add functional client portal workflow
This commit is contained in:
parent
fb01c003c6
commit
227ec4cb9a
@ -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");
|
||||
},
|
||||
};
|
||||
@ -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 },
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]'>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user