Skip to content

Commit 334d683

Browse files
mpstatonclaude
andcommitted
attempt(augment-from-db, people-reveal, step4): persons nest under the org card — and adding one generates its affiliation with no explicit step
Spec steps 6 + 7 become chrome, entirely inside apps/org-workbench: zero service changes, because Phase 1 sequenced every verb this needs ahead of the surfaces. PeopleReveal is a collapsible "People · N" section under the card's three lists, lazy over organization.affiliations (relevance-sorted server-side). Rows show name · role · relevance pill · counts and expand to PersonCard: identity links as a full AdditiveList (➕ → person.links.add, 🔍 dispatching the person-shaped envelope search-and-add has routed since Phase 3), corpus as count + ➕ + 🔍 (entries ride affiliation.detail later, per the Phase 1 contract). AddPersonInline is the spec's "magic" with the product's operator gate intact: name → person.candidates → ALWAYS a gate (scored matches or an explicit "create new" — zero candidates still gets the choice) → person.apply → person.affiliate with this card's org pre-bound. The edge + paired observation materialize without any affiliation UI — and the same person can gain other orgs later, N-affiliation by construction. Every person write dispatches augment-it:entity-updated{person_uuid}; the reveal refetches whether the write came from its own ➕ or the search rail. Proof: svelte-check 0/0 (90 files); org-workbench + shell build. The add-person write path is deliberately not smoke-tested headlessly — no canonical-layer pollution for a test; its verbs were live-proven in the person-db-resolver flow. Plan: context-v/plans/Augment-From-DB-Phase-4-People-Reveal-And-Add-Person.md (Shipped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014zrFkWSVgTkoQyiobdBjrd
1 parent 8e24c4c commit 334d683

9 files changed

Lines changed: 584 additions & 1 deletion
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<script lang="ts">
2+
// Add a person IN org context — spec step 6's "magic". The operator only
3+
// resolves the person (candidates are ALWAYS gated: pick a match or
4+
// explicitly create); the affiliation edge + its paired observation come
5+
// from person.affiliate with this card's org pre-bound. No affiliation UI
6+
// exists because none is needed — and the same person can gain more orgs
7+
// later (N-affiliation assumption, never 1:1).
8+
9+
import { fetchPersonCandidates, applyPerson, affiliatePerson } from './lib/org-client';
10+
import type { PersonCandidate, PersonNormRecord } from './lib/types';
11+
12+
let {
13+
org_slug,
14+
orgName,
15+
client,
16+
onadded,
17+
}: {
18+
org_slug: string;
19+
orgName: string;
20+
client: string;
21+
onadded: () => void;
22+
} = $props();
23+
24+
let open = $state(false);
25+
let name = $state('');
26+
let linkedin = $state('');
27+
let role = $state('');
28+
let phase = $state<'form' | 'gate' | 'writing'>('form');
29+
let candidates = $state<PersonCandidate[]>([]);
30+
let error = $state<string | null>(null);
31+
32+
function record(): PersonNormRecord {
33+
return {
34+
name: name.trim(),
35+
linkedin_url: linkedin.trim() || null,
36+
role: role.trim() || null,
37+
};
38+
}
39+
40+
async function findCandidates(e: SubmitEvent) {
41+
e.preventDefault();
42+
if (!name.trim()) return;
43+
error = null;
44+
try {
45+
candidates = await fetchPersonCandidates(record(), client);
46+
phase = 'gate'; // always gate — even zero candidates gets an explicit "create"
47+
} catch (err) {
48+
error = err instanceof Error ? err.message : String(err);
49+
}
50+
}
51+
52+
async function resolve(action: 'match' | 'create', person_uuid?: string) {
53+
phase = 'writing';
54+
error = null;
55+
try {
56+
const applied = await applyPerson({
57+
action,
58+
person_uuid,
59+
record: record(),
60+
client,
61+
source: 'org-workbench',
62+
});
63+
await affiliatePerson({
64+
person_uuid: applied.person_uuid,
65+
org_slug,
66+
role: role.trim() || null,
67+
client,
68+
});
69+
name = '';
70+
linkedin = '';
71+
role = '';
72+
candidates = [];
73+
phase = 'form';
74+
open = false;
75+
onadded();
76+
window.dispatchEvent(
77+
new CustomEvent('augment-it:entity-updated', { detail: { org_slug } }),
78+
);
79+
} catch (err) {
80+
error = err instanceof Error ? err.message : String(err);
81+
phase = 'gate';
82+
}
83+
}
84+
</script>
85+
86+
<div class="ow-addperson">
87+
{#if !open}
88+
<button type="button" class="ow-addperson-open" onclick={() => (open = true)}>
89+
+ Add a person to {orgName}
90+
</button>
91+
{:else}
92+
<form class="ow-addperson-form" onsubmit={findCandidates}>
93+
<input class="ow-add-url" type="text" placeholder="Full name (required)" bind:value={name} required disabled={phase !== 'form'} />
94+
<input class="ow-add-url" type="url" placeholder="LinkedIn URL (optional)" bind:value={linkedin} disabled={phase !== 'form'} />
95+
<input class="ow-add-kind" type="text" placeholder="Role at {orgName} (optional)" bind:value={role} disabled={phase !== 'form'} />
96+
{#if phase === 'form'}
97+
<span class="ow-addperson-actions">
98+
<button type="submit" class="ow-add-go" disabled={!name.trim()}>Find matches</button>
99+
<button type="button" class="ow-add-go" onclick={() => { open = false; error = null; }}>Cancel</button>
100+
</span>
101+
{/if}
102+
</form>
103+
104+
{#if phase === 'gate'}
105+
<div class="ow-gate">
106+
{#if candidates.length > 0}
107+
<p class="ow-gate-note">Existing persons that might be “{name}” — pick one or create new:</p>
108+
<ul class="ow-gate-list">
109+
{#each candidates as c (c.person_uuid)}
110+
<li>
111+
<button type="button" class="ow-gate-pick" onclick={() => resolve('match', c.person_uuid)}>
112+
<strong>{c.name ?? c.person_uuid}</strong>
113+
{#if c.headline}<span class="ow-gate-headline">{c.headline}</span>{/if}
114+
<span class="ow-gate-score">{c.score} · {c.match_reason.join(', ')}</span>
115+
</button>
116+
</li>
117+
{/each}
118+
</ul>
119+
{:else}
120+
<p class="ow-gate-note">No existing person matches “{name}”.</p>
121+
{/if}
122+
<span class="ow-addperson-actions">
123+
<button type="button" class="ow-add-go" onclick={() => resolve('create')}>
124+
Create new person + affiliate with {orgName}
125+
</button>
126+
<button type="button" class="ow-add-go" onclick={() => (phase = 'form')}>Back</button>
127+
</span>
128+
</div>
129+
{:else if phase === 'writing'}
130+
<p class="ow-gate-note">writing person + affiliation…</p>
131+
{/if}
132+
{#if error}<div class="ow-error">{error}</div>{/if}
133+
{/if}
134+
</div>

apps/org-workbench/src/OrgCard.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// never an optimistic guess.
77
88
import AdditiveList from './AdditiveList.svelte';
9+
import PeopleReveal from './PeopleReveal.svelte';
910
import { addOrgLink, addOrgStream, addOrgCorpus } from './lib/org-client';
1011
import { requestSearch } from './lib/search-request';
1112
import type { OrgDetail, SearchRequestDetail } from './lib/types';
@@ -87,5 +88,7 @@
8788
onadd={makeAdd(addOrgCorpus)}
8889
onsearch={makeSearch('corpus', (n) => `"${n}" news`)}
8990
/>
91+
92+
<PeopleReveal org_slug={org.slug} orgName={displayName} {client} />
9093
</div>
9194
</article>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<script lang="ts">
2+
// The people reveal — spec steps 6–7. A collapsible section on the org
3+
// card listing every person RELATEd to this org (relevance-sorted
4+
// server-side by organization.affiliations), each row expanding to a
5+
// PersonCard, with AddPersonInline in the footer. Refetches on its own
6+
// writes and on any person-shaped augment-it:entity-updated (e.g. a link
7+
// added from the search-and-add rail).
8+
9+
import { onMount } from 'svelte';
10+
import PersonCard from './PersonCard.svelte';
11+
import AddPersonInline from './AddPersonInline.svelte';
12+
import { fetchOrgAffiliations } from './lib/org-client';
13+
import type { AffiliatedPerson } from './lib/types';
14+
15+
let {
16+
org_slug,
17+
orgName,
18+
client,
19+
}: {
20+
org_slug: string;
21+
orgName: string;
22+
client: string;
23+
} = $props();
24+
25+
let open = $state(false);
26+
let people = $state<AffiliatedPerson[]>([]);
27+
let loaded = $state(false);
28+
let loading = $state(false);
29+
let error = $state<string | null>(null);
30+
let expanded = $state<string | null>(null); // person_uuid
31+
32+
async function load() {
33+
loading = true;
34+
error = null;
35+
try {
36+
people = await fetchOrgAffiliations(org_slug, client);
37+
loaded = true;
38+
} catch (err) {
39+
error = err instanceof Error ? err.message : String(err);
40+
} finally {
41+
loading = false;
42+
}
43+
}
44+
45+
function toggle() {
46+
open = !open;
47+
if (open && !loaded) void load();
48+
}
49+
50+
function onEntityUpdated(e: Event) {
51+
const detail = (e as CustomEvent).detail as { person_uuid?: string } | undefined;
52+
if (detail?.person_uuid && loaded && people.some((p) => p.person_uuid === detail.person_uuid)) {
53+
void load();
54+
}
55+
}
56+
57+
// A new org card means fresh people — reset and lazy-load on next reveal.
58+
$effect(() => {
59+
void org_slug;
60+
people = [];
61+
loaded = false;
62+
expanded = null;
63+
if (open) void load();
64+
});
65+
66+
onMount(() => {
67+
window.addEventListener('augment-it:entity-updated', onEntityUpdated);
68+
return () => window.removeEventListener('augment-it:entity-updated', onEntityUpdated);
69+
});
70+
</script>
71+
72+
<section class="ow-people">
73+
<header class="ow-list-head">
74+
<h3 class="ow-list-title">
75+
<button type="button" class="ow-people-toggle" onclick={toggle}>
76+
{open ? '' : ''} People{#if loaded}&nbsp;<span class="ow-list-count">{people.length}</span>{/if}
77+
</button>
78+
</h3>
79+
</header>
80+
81+
{#if open}
82+
{#if loading}
83+
<p class="ow-empty">loading people…</p>
84+
{:else if error}
85+
<div class="ow-error">{error}</div>
86+
{:else}
87+
{#if people.length === 0}
88+
<p class="ow-empty">no affiliated people yet</p>
89+
{:else}
90+
<ul class="ow-people-list">
91+
{#each people as p (p.person_uuid)}
92+
<li class="ow-person">
93+
<button
94+
type="button"
95+
class="ow-person-row"
96+
onclick={() => (expanded = expanded === p.person_uuid ? null : p.person_uuid)}
97+
>
98+
<span class="ow-person-name">{p.name ?? p.person_uuid}</span>
99+
{#if p.role}<span class="ow-person-role">{p.role}</span>{/if}
100+
{#if p.relevance}<span class="ow-person-relevance">{p.relevance}</span>{/if}
101+
<span class="ow-person-meta">
102+
{p.personal_links.length} link{p.personal_links.length === 1 ? '' : 's'} ·
103+
{p.personal_corpus_count} corpus
104+
</span>
105+
</button>
106+
{#if expanded === p.person_uuid}
107+
<PersonCard person={p} {client} onchanged={load} />
108+
{/if}
109+
</li>
110+
{/each}
111+
</ul>
112+
{/if}
113+
<AddPersonInline {org_slug} {orgName} {client} onadded={load} />
114+
{/if}
115+
{/if}
116+
</section>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script lang="ts">
2+
// One affiliated person, expanded — the nested view of spec step 7:
3+
// identity links as a full AdditiveList (➕ → person.links.add, 🔍 →
4+
// person-shaped search envelope), corpus as count + ➕ + 🔍 (entries ride
5+
// affiliation.detail in a later pass — organization.affiliations carries
6+
// the count only, by Phase 1 contract). Every write dispatches
7+
// augment-it:entity-updated { person_uuid } so the reveal refetches.
8+
9+
import AdditiveList from './AdditiveList.svelte';
10+
import { addPersonLink, addPersonCorpus } from './lib/org-client';
11+
import { requestSearch } from './lib/search-request';
12+
import type { AffiliatedPerson } from './lib/types';
13+
14+
let {
15+
person,
16+
client,
17+
onchanged,
18+
}: {
19+
person: AffiliatedPerson;
20+
client: string;
21+
onchanged: () => void;
22+
} = $props();
23+
24+
let corpusOpen = $state(false);
25+
let corpusUrl = $state('');
26+
let corpusBusy = $state(false);
27+
let corpusError = $state<string | null>(null);
28+
29+
const displayName = $derived(person.name ?? person.person_uuid);
30+
31+
function bump() {
32+
onchanged();
33+
window.dispatchEvent(
34+
new CustomEvent('augment-it:entity-updated', { detail: { person_uuid: person.person_uuid } }),
35+
);
36+
}
37+
38+
async function addLink(url: string, kind?: string) {
39+
await addPersonLink({ person_uuid: person.person_uuid, url, kind, client });
40+
bump();
41+
}
42+
43+
async function addCorpus(e: SubmitEvent) {
44+
e.preventDefault();
45+
const trimmed = corpusUrl.trim();
46+
if (!trimmed) return;
47+
corpusBusy = true;
48+
corpusError = null;
49+
try {
50+
await addPersonCorpus({ person_uuid: person.person_uuid, url: trimmed, client });
51+
corpusUrl = '';
52+
corpusOpen = false;
53+
bump();
54+
} catch (err) {
55+
corpusError = err instanceof Error ? err.message : String(err);
56+
} finally {
57+
corpusBusy = false;
58+
}
59+
}
60+
61+
function searchFor(target: 'links' | 'corpus', seed: string) {
62+
return () =>
63+
requestSearch({
64+
entity: { type: 'person', person_uuid: person.person_uuid, display_name: displayName },
65+
target,
66+
seed_term: seed,
67+
});
68+
}
69+
</script>
70+
71+
<div class="ow-person-card">
72+
{#if person.headline}<p class="ow-person-headline">{person.headline}</p>{/if}
73+
74+
<AdditiveList
75+
title="Identity & social links"
76+
entries={person.personal_links}
77+
kindHint="kind (auto: linkedin/x/…)"
78+
onadd={addLink}
79+
onsearch={searchFor('links', `"${displayName}" LinkedIn`)}
80+
/>
81+
82+
<section class="ow-list">
83+
<header class="ow-list-head">
84+
<h3 class="ow-list-title">
85+
Corpus items <span class="ow-list-count">{person.personal_corpus_count}</span>
86+
</h3>
87+
<span class="ow-list-actions">
88+
<button
89+
type="button"
90+
class="ow-plus"
91+
title="Search the web for corpus items"
92+
onclick={searchFor('corpus', `"${displayName}" interview OR profile`)}
93+
>
94+
🔍
95+
</button>
96+
<button type="button" class="ow-plus" title="Add a corpus item" onclick={() => (corpusOpen = !corpusOpen)}>
97+
{corpusOpen ? '×' : '+'}
98+
</button>
99+
</span>
100+
</header>
101+
{#if corpusOpen}
102+
<form class="ow-add" onsubmit={addCorpus}>
103+
<input class="ow-add-url" type="url" placeholder="https://…" bind:value={corpusUrl} required disabled={corpusBusy} />
104+
<button type="submit" class="ow-add-go" disabled={corpusBusy}>{corpusBusy ? '' : 'Add'}</button>
105+
</form>
106+
{#if corpusError}<div class="ow-error">{corpusError}</div>{/if}
107+
{/if}
108+
</section>
109+
</div>

0 commit comments

Comments
 (0)