Add coach session review workflow

This commit is contained in:
Flatlogic Bot 2026-06-09 14:06:12 +00:00
parent 227ec4cb9a
commit 0e65518e41
2 changed files with 348 additions and 53 deletions

View File

@ -11,13 +11,21 @@ function isClientUser(req) {
function portalClientIncludes() {
return [
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ 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) => {
@ -137,6 +145,107 @@ router.post(
}),
);
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) => {

View File

@ -20,13 +20,25 @@ type Client = {
name: string;
};
type Session = {
id: string;
type MemoryDraft = {
title?: string;
ai_summary?: string;
key_topics?: string;
goals_discussed?: string;
blockers?: string;
commitments?: string;
homework?: string;
emotional_themes?: string;
important_quotes?: string;
follow_up_email?: string;
next_session_prep?: string;
private_coach_notes?: string;
shared_client_notes?: string;
};
type Session = MemoryDraft & {
id: string;
status?: string;
client?: Client;
};
@ -46,32 +58,73 @@ function Panel({
);
}
function OutputBlock({
title,
children,
function TextField({
label,
value,
onChange,
rows = 3,
}: {
title: string;
children: React.ReactNode;
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#b17a1e]'>
{title}
</p>
<div className='mt-3 leading-6 text-[#72798a]'>{children}</div>
</div>
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
{label}
</span>
<textarea
value={value}
rows={rows}
onChange={(event) => onChange(event.target.value)}
className='mt-2 w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
);
}
function StatusPill({ status }: { status?: string }) {
const isShared = status === 'shared';
return (
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
isShared
? 'bg-[#f3fbf8] text-[#35b7a5]'
: 'bg-[#fbf8f1] text-[#b17a1e]'
}`}
>
{isShared ? 'Shared' : 'Coach draft'}
</span>
);
}
const emptyDraft: MemoryDraft = {
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: '',
};
const SessionMemory = () => {
const [clients, setClients] = React.useState<Client[]>([]);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [clientId, setClientId] = React.useState('');
const [transcript, setTranscript] = React.useState('');
const [generatedMemory, setGeneratedMemory] = React.useState<Session | null>(
null,
);
const [draft, setDraft] = React.useState<MemoryDraft>(emptyDraft);
const [isGenerating, setIsGenerating] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [notice, setNotice] = React.useState('');
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
@ -90,16 +143,79 @@ const SessionMemory = () => {
loadData();
}, []);
function updateDraft(field: keyof MemoryDraft, value: string) {
setDraft((current) => {
return {
...current,
[field]: value,
};
});
setNotice('');
}
async function generateMemory() {
setIsGenerating(true);
const response = await axios.post('/coaching/session-memory/generate', {
clientId,
transcript,
});
setGeneratedMemory(response.data);
setDraft({
...emptyDraft,
...response.data,
});
setNotice('Draft generated. Review and edit before saving.');
setIsGenerating(false);
}
async function saveMemory(shareWithClient: boolean) {
setIsSaving(true);
const response = await axios.post('/coaching/session-memory/save', {
...draft,
clientId,
transcript_notes: transcript,
shareWithClient,
});
setSessions((current) => [response.data, ...current]);
setDraft(emptyDraft);
setTranscript('');
setNotice(
shareWithClient
? 'Session saved and shared with the client.'
: 'Session saved as a coach draft.',
);
setIsSaving(false);
}
async function shareSession(session: Session) {
const response = await axios.patch(`/coaching/sessions/${session.id}/share`, {
shared_client_notes: session.shared_client_notes,
});
setSessions((current) =>
current.map((currentSession) => {
if (currentSession.id === session.id) {
return response.data;
}
return currentSession;
}),
);
setNotice('Session shared with the client.');
}
async function copyFollowUp() {
await navigator.clipboard.writeText(draft.follow_up_email || '');
setNotice('Follow-up copied.');
}
const hasDraft = Boolean(
draft.title ||
draft.ai_summary ||
draft.follow_up_email ||
draft.shared_client_notes,
);
return (
<>
<Head>
@ -119,13 +235,18 @@ const SessionMemory = () => {
prep.
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
Paste session notes or a transcript, generate structured output,
then review and save the result before sharing anything with a
client.
Generate a structured draft, edit it as the coach, save it as a
private draft, or share approved notes with the client portal.
</p>
</div>
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
{notice && (
<div className='mb-4 rounded-lg border border-[#35b7a5]/20 bg-[#f3fbf8] px-4 py-3 text-sm font-semibold text-[#257f73]'>
{notice}
</div>
)}
<div className='grid gap-4 xl:grid-cols-[0.8fr_1.2fr]'>
<Panel className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Raw session input
@ -168,46 +289,105 @@ const SessionMemory = () => {
</div>
</Panel>
<div className='space-y-6'>
<div className='space-y-4'>
<Panel className='p-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
AI draft
Coach review
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Review before sharing
Edit the final memory
</h2>
</div>
<span className='rounded-full bg-[#f3fbf8] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
Coach approved
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
Not shared until approved
</span>
</div>
{generatedMemory ? (
{hasDraft ? (
<div className='mt-4 grid gap-4'>
<OutputBlock title='Summary'>
{generatedMemory.ai_summary}
</OutputBlock>
<OutputBlock title='Commitments and homework'>
{generatedMemory.homework}
</OutputBlock>
<OutputBlock title='Follow-up email'>
<p className='whitespace-pre-line'>
{generatedMemory.follow_up_email}
</p>
</OutputBlock>
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Title
</span>
<input
value={draft.title || ''}
onChange={(event) =>
updateDraft('title', event.target.value)
}
className='mt-2 w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
<TextField
label='Summary'
value={draft.ai_summary || ''}
onChange={(value) => updateDraft('ai_summary', value)}
rows={4}
/>
<TextField
label='Commitments'
value={draft.commitments || ''}
onChange={(value) => updateDraft('commitments', value)}
/>
<TextField
label='Homework'
value={draft.homework || ''}
onChange={(value) => updateDraft('homework', value)}
/>
<TextField
label='Shared client notes'
value={draft.shared_client_notes || ''}
onChange={(value) =>
updateDraft('shared_client_notes', value)
}
rows={4}
/>
<TextField
label='Follow-up email'
value={draft.follow_up_email || ''}
onChange={(value) => updateDraft('follow_up_email', value)}
rows={4}
/>
<TextField
label='Next-session prep'
value={draft.next_session_prep || ''}
onChange={(value) =>
updateDraft('next_session_prep', value)
}
/>
<TextField
label='Private coach notes'
value={draft.private_coach_notes || ''}
onChange={(value) =>
updateDraft('private_coach_notes', value)
}
/>
<div className='flex flex-wrap gap-3'>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white'
className='inline-flex items-center gap-2 rounded-full bg-[#19192d] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(false)}
>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
Save final memory
Save coach draft
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(true)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Save and share
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full border border-[#19192d]/10 bg-white px-3 py-1.5 text-sm font-semibold text-[#19192d]'
onClick={copyFollowUp}
>
<BaseIcon path={mdiContentCopy} size={18} />
Copy follow-up
@ -222,10 +402,8 @@ const SessionMemory = () => {
className='text-[#b17a1e]'
/>
<p className='mt-4 leading-6 text-[#72798a]'>
Generate a structured memory through the AppWizzy AI
proxy. The result should stay reviewable: summary,
commitments, blockers, homework, follow-up, and prep for
the next conversation.
Generate memory from raw notes. The editable draft will
appear here before anything is saved or shared.
</p>
</div>
)}
@ -242,18 +420,26 @@ const SessionMemory = () => {
<div key={session.id} className='p-5'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<StatusPill status={session.status} />
</div>
<p className='mt-1 text-sm text-[#72798a]'>
{session.client?.name}
</p>
</div>
<BaseIcon
path={mdiSendOutline}
size={18}
className='text-[#35b7a5]'
/>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white'
onClick={() => shareSession(session)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Share
</button>
)}
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{session.ai_summary}