Add coach session review workflow
This commit is contained in:
parent
227ec4cb9a
commit
0e65518e41
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user