feat(p8): meeting-studio demo + smoke-calendar; P8 verification green
Task 8.7: apps/meeting-studio (Vite, port 5178) — schedule a meeting, invite attendees, toggle per-attendee recording consent, generate transcript (BLOCKED until unanimous consent — KG-14), summarize, see AI summary + action-item inbox. scripts/smoke-calendar.mjs proves the P8 guarantees end to end: direct schedule, consent gate BLOCKED→READY (KG-14), summary linked to a P7 ai_artifact, action items, attendee-visibility exclusion (Scenario 6), meeting_request genesis, simulated provider sync (idempotent). P8 verification: 95 tests green, pnpm -r build all packages+demos, boundary passes and fails on meeting-web->service, smoke-calendar PASS, prior smokes (realtime/inbox/support/adapter/route/ai) PASS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useMeetings, useMeeting, useSchedule, useConsent, useSummarize, useActionItems } from '@insignia/iios-meeting-web';
|
||||
|
||||
export const SERVICE = 'http://localhost:3200';
|
||||
|
||||
const box: React.CSSProperties = { border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 };
|
||||
const CONSENT_COLORS: Record<string, string> = { GRANTED: '#16a34a', UNKNOWN: '#d97706', DENIED: '#dc2626', REVOKED: '#dc2626' };
|
||||
|
||||
export function App({ token: _token }: { token: string }) {
|
||||
const { meetings, refresh } = useMeetings({ pollMs: 3000 });
|
||||
const schedule = useSchedule();
|
||||
const { setConsent, generateTranscript } = useConsent();
|
||||
const summarize = useSummarize();
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState('Design review');
|
||||
const [attendee, setAttendee] = useState('bob');
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const { meeting, refresh: refreshMeeting } = useMeeting(selected);
|
||||
const { actionItems, refresh: refreshItems } = useActionItems(selected);
|
||||
|
||||
const doSchedule = async () => {
|
||||
setBusy('scheduling…');
|
||||
const m = await schedule({
|
||||
meetingType: 'INTERNAL',
|
||||
title,
|
||||
startAt: '2026-07-02T10:00:00.000Z',
|
||||
attendees: [{ userId: attendee, displayName: attendee }],
|
||||
});
|
||||
setSelected(m.id);
|
||||
await refresh();
|
||||
setBusy('');
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await refreshMeeting();
|
||||
await refreshItems();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: 'sans-serif', maxWidth: 900, margin: '0 auto' }}>
|
||||
<h2>IIOS P8 — Meeting Studio</h2>
|
||||
<p style={{ color: '#666' }}>
|
||||
A meeting owns calendar semantics. No transcript/summary/action-item exists until <b>every</b> attendee grants recording consent (KG-14).
|
||||
</p>
|
||||
|
||||
<div style={box}>
|
||||
<h3>1 · Schedule a meeting</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" />
|
||||
<input value={attendee} onChange={(e) => setAttendee(e.target.value)} placeholder="attendee userId" style={{ width: 140 }} />
|
||||
<button onClick={() => void doSchedule()}>Schedule</button>
|
||||
{busy && <span style={{ color: '#666' }}>{busy}</span>}
|
||||
</div>
|
||||
<ul>
|
||||
{meetings.map((m) => (
|
||||
<li key={m.id}>
|
||||
<button onClick={() => setSelected(m.id)} style={{ fontWeight: selected === m.id ? 700 : 400 }}>
|
||||
{m.title} — {m.status}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{meeting && (
|
||||
<div style={box}>
|
||||
<h3>2 · {meeting.title} — consent & transcript</h3>
|
||||
<table style={{ borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{(meeting.participants ?? []).map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ padding: '4px 8px' }}>{p.role} · {p.actorRefId?.slice(0, 8)}</td>
|
||||
<td style={{ padding: '4px 8px', color: CONSENT_COLORS[p.recordingConsent] }}>{p.recordingConsent}</td>
|
||||
<td style={{ padding: '4px 8px', color: '#888' }}>visibility {p.visibility}</td>
|
||||
<td style={{ padding: '4px 8px' }}>
|
||||
<button onClick={async () => { await setConsent(meeting.id, p.actorRefId!, 'GRANTED'); await refreshAll(); }}>Grant</button>
|
||||
<button onClick={async () => { await setConsent(meeting.id, p.actorRefId!, 'REVOKED'); await refreshAll(); }} style={{ marginLeft: 4 }}>Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 8 }}>
|
||||
<button onClick={async () => { await generateTranscript(meeting.id); await refreshAll(); setBusy('transcript attempted — grant all consent for READY'); }}>
|
||||
Generate transcript
|
||||
</button>
|
||||
<button onClick={async () => { setBusy('summarizing…'); try { await summarize(meeting.id); setBusy(''); } catch (e) { setBusy(`blocked: ${(e as Error).message}`); } await refreshAll(); }}>
|
||||
Summarize (needs consent)
|
||||
</button>
|
||||
</div>
|
||||
{busy && <p style={{ color: '#666' }}>{busy}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meeting && (
|
||||
<div style={box}>
|
||||
<h3>3 · Action items (inbox follow-ups)</h3>
|
||||
{actionItems.length === 0 && <p style={{ color: '#666' }}>None yet — summarize a consented meeting.</p>}
|
||||
<ul>
|
||||
{actionItems.map((a) => (
|
||||
<li key={a.id}>{a.actionItem?.title ?? a.actionItemId} — {a.status}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MeetingProvider } from '@insignia/iios-meeting-web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, SERVICE } from './App';
|
||||
|
||||
const APP_ID = 'portal-demo';
|
||||
|
||||
async function devToken(userId: string): Promise<string> {
|
||||
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ appId: APP_ID, userId, name: userId }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`);
|
||||
return ((await r.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
function Root() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void devToken('meeting-studio').then(setToken);
|
||||
}, []);
|
||||
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading…</div>;
|
||||
return (
|
||||
<MeetingProvider serviceUrl={SERVICE} token={token}>
|
||||
<App token={token} />
|
||||
</MeetingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (el) createRoot(el).render(<Root />);
|
||||
Reference in New Issue
Block a user