Skip to content

Commit b86f079

Browse files
mpstatonclaude
andcommitted
new(org-workbench, affiliations): promote a person's bio-page link to an affiliation in three clicks
A bio hosted on someone else's org domain is three facts, and the card captured one: the identity link. The affiliation with that org (which may not exist yet) and the observation citing the bio URL were silently dropped. Person link rows gain a "→ affiliation" action opening AddAffiliationInline — AddPersonInline's gate pattern inverted: the person is fixed, the org is being resolved. Candidates load from the bio's domain via resolver.search (the D4 clause already matches domains[*].domain); the gate always shows — pick an existing org or create a thin one. person.affiliate does the rest as it always has: match-or-create, N-per-person dedupe, and the affiliated_with observation now citing the bio URL as source. The one service change: PersonAffiliateInput gains org_domain, seeded into domains[] on the create branch only — an org born from a bio promotion would otherwise be invisible to domain matching forever. The affiliatePerson wrapper un-hardwires org_action:'match'. Closes #25. Plan: context-v/plans/Workbench-Usability-Sweep-Corpus-Visibility-Stream-Editing-Affiliation-Promotion.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent 895a6a6 commit b86f079

5 files changed

Lines changed: 209 additions & 7 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<script lang="ts">
2+
// Promote a bio-page link to an affiliation — AddPersonInline's gate
3+
// pattern INVERTED: the person is fixed (the link row's owner), the ORG is
4+
// being resolved. Seeded from the link's hostname, candidates come from
5+
// resolver.search (whose D4 clause matches domains[*].domain), and the gate
6+
// is ALWAYS shown: pick an existing org or explicitly create a thin one
7+
// (name + the bio's domain, so it stays domain-matchable). The edge + its
8+
// affiliated_with observation come from person.affiliate with the bio URL
9+
// as source — the link row keeps its identity role; this adds the two
10+
// facts it was silently dropping.
11+
// Per context-v/issues/Person-Bio-Pages-Are-Affiliation-Signals-Not-Just-Identity-Links.md.
12+
13+
import { searchOrgs, affiliatePerson } from './lib/org-client';
14+
import type { OrgSuggestion, ShapedLink } from './lib/types';
15+
16+
let {
17+
person_uuid,
18+
personName,
19+
entry,
20+
client,
21+
onadded,
22+
oncancel,
23+
}: {
24+
person_uuid: string;
25+
personName: string;
26+
entry: ShapedLink;
27+
client: string;
28+
onadded: () => void;
29+
oncancel: () => void;
30+
} = $props();
31+
32+
function hostOf(u: string): string {
33+
try {
34+
return new URL(u).hostname.replace(/^www\./, '');
35+
} catch {
36+
return '';
37+
}
38+
}
39+
40+
const domain = $derived(hostOf(entry.url));
41+
42+
let orgName = $state('');
43+
let role = $state('');
44+
let phase = $state<'gate' | 'writing'>('gate');
45+
let candidates = $state<OrgSuggestion[]>([]);
46+
let searched = $state(false);
47+
let error = $state<string | null>(null);
48+
49+
// Candidates load from the bio's domain on mount (D4 makes this nearly
50+
// free); typing a name and re-finding re-queries by name instead.
51+
$effect(() => {
52+
if (!searched && domain) void find(domain);
53+
});
54+
55+
async function find(q: string) {
56+
error = null;
57+
try {
58+
candidates = await searchOrgs(q, client);
59+
} catch (err) {
60+
error = err instanceof Error ? err.message : String(err);
61+
} finally {
62+
searched = true;
63+
}
64+
}
65+
66+
async function resolve(action: 'match' | 'create', org_slug?: string) {
67+
if (action === 'create' && !orgName.trim()) return;
68+
phase = 'writing';
69+
error = null;
70+
try {
71+
await affiliatePerson({
72+
person_uuid,
73+
org_action: action,
74+
org_slug,
75+
org_name: action === 'create' ? orgName.trim() : undefined,
76+
org_domain: action === 'create' ? domain : undefined,
77+
role: role.trim() || null,
78+
client,
79+
source: entry.url,
80+
});
81+
onadded(); // parent bumps + dispatches augment-it:entity-updated
82+
} catch (err) {
83+
error = err instanceof Error ? err.message : String(err);
84+
phase = 'gate';
85+
}
86+
}
87+
</script>
88+
89+
<div class="ow-addperson">
90+
<p class="ow-gate-note">
91+
Promote <strong>{domain || entry.url}</strong> to an affiliation for {personName} — pick the
92+
org this bio lives on, or create it:
93+
</p>
94+
95+
{#if phase === 'gate'}
96+
{#if candidates.length > 0}
97+
<ul class="ow-gate-list">
98+
{#each candidates as c (c.slug)}
99+
<li>
100+
<button type="button" class="ow-gate-pick" onclick={() => resolve('match', c.slug)}>
101+
<strong>{c.complete_name ?? c.conventional_name ?? c.slug}</strong>
102+
<span class="ow-gate-headline">{c.slug}</span>
103+
</button>
104+
</li>
105+
{/each}
106+
</ul>
107+
{:else if searched}
108+
<p class="ow-gate-note">No existing org matches “{domain}”.</p>
109+
{:else}
110+
<p class="ow-gate-note">looking for orgs matching “{domain}”…</p>
111+
{/if}
112+
113+
<form class="ow-addperson-form" onsubmit={(e) => { e.preventDefault(); void resolve('create'); }}>
114+
<input
115+
class="ow-add-url"
116+
type="text"
117+
placeholder="Org name (required to create)"
118+
bind:value={orgName}
119+
/>
120+
<input
121+
class="ow-add-kind"
122+
type="text"
123+
placeholder="Role (optional)"
124+
bind:value={role}
125+
/>
126+
<span class="ow-addperson-actions">
127+
<button type="button" class="ow-add-go" onclick={() => find(orgName.trim() || domain)}>
128+
Find matches
129+
</button>
130+
<button type="submit" class="ow-add-go" disabled={!orgName.trim()}>
131+
Create + affiliate
132+
</button>
133+
<button type="button" class="ow-add-go" onclick={oncancel}>Cancel</button>
134+
</span>
135+
</form>
136+
{:else}
137+
<p class="ow-gate-note">writing affiliation…</p>
138+
{/if}
139+
{#if error}<div class="ow-error">{error}</div>{/if}
140+
</div>

apps/org-workbench/src/PersonCard.svelte

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
// { person_uuid } so the reveal refetches.
77
88
import AdditiveList from './AdditiveList.svelte';
9+
import AddAffiliationInline from './AddAffiliationInline.svelte';
910
import { addPersonLink, addPersonCorpus } from './lib/org-client';
1011
import { requestSearch } from './lib/search-request';
11-
import type { AffiliatedPerson } from './lib/types';
12+
import type { AffiliatedPerson, ShapedLink } from './lib/types';
1213
1314
let {
1415
person,
@@ -22,6 +23,10 @@
2223
2324
const displayName = $derived(person.name ?? person.person_uuid);
2425
26+
// A bio page on another org's site is an affiliation signal, not just an
27+
// identity link — the "→ affiliation" row action opens the promotion gate.
28+
let promoteEntry = $state<ShapedLink | null>(null);
29+
2530
function bump() {
2631
onchanged();
2732
window.dispatchEvent(
@@ -58,8 +63,23 @@
5863
kindHint="kind (auto: linkedin/x/…)"
5964
onadd={addLink}
6065
onsearch={searchFor('links', `"${displayName}" LinkedIn`)}
66+
entryaction={{ label: '→ affiliation', fn: (entry) => (promoteEntry = entry) }}
6167
/>
6268

69+
{#if promoteEntry}
70+
<AddAffiliationInline
71+
person_uuid={person.person_uuid}
72+
personName={displayName}
73+
entry={promoteEntry}
74+
{client}
75+
onadded={() => {
76+
promoteEntry = null;
77+
bump();
78+
}}
79+
oncancel={() => (promoteEntry = null)}
80+
/>
81+
{/if}
82+
6383
<AdditiveList
6484
title="Corpus items"
6585
entries={person.personal_corpus}

apps/org-workbench/src/lib/org-client.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,20 +135,40 @@ export async function applyPerson(args: {
135135

136136
export async function affiliatePerson(args: {
137137
person_uuid: string;
138-
org_slug: string;
138+
// Defaults to 'match' — AddPersonInline's org-pre-bound path. The bio-page
139+
// promotion path (AddAffiliationInline) resolves the org side: 'create'
140+
// takes org_name + org_domain (seeded into domains[] so the new org is
141+
// reachable by D4 domain matching).
142+
org_action?: 'match' | 'create';
143+
org_slug?: string;
144+
org_name?: string;
145+
org_domain?: string;
139146
role?: string | null;
140147
client: string;
141148
source?: string;
142-
}): Promise<void> {
149+
}): Promise<{ org_slug: string; org_created: boolean; affiliation_created: boolean }> {
143150
const r = (await workspace.invoke('person.affiliate', {
144151
person_uuid: args.person_uuid,
145-
org_action: 'match',
152+
org_action: args.org_action ?? 'match',
146153
org_slug: args.org_slug,
154+
org_name: args.org_name,
155+
org_domain: args.org_domain,
147156
role: args.role ?? null,
148157
client: args.client,
149158
source: args.source ?? 'org-workbench',
150-
})) as { ok: boolean; affiliation_created?: boolean; error?: string };
159+
})) as {
160+
ok: boolean;
161+
org_slug?: string;
162+
org_created?: boolean;
163+
affiliation_created?: boolean;
164+
error?: string;
165+
};
151166
if (!r.ok) throw new Error(r.error || 'person.affiliate failed');
167+
return {
168+
org_slug: r.org_slug ?? args.org_slug ?? '',
169+
org_created: r.org_created ?? false,
170+
affiliation_created: r.affiliation_created ?? false,
171+
};
152172
}
153173

154174
type PersonAddArgs = { person_uuid: string; url: string; kind?: string; client: string };

services/record-surrealdb-resolver/src/person-resolver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@ export type PersonAffiliateInput = {
513513
org_action: 'match' | 'create';
514514
org_slug?: string; // required for match
515515
org_name?: string; // required for create
516+
// Optional on create — seeds domains[] so the new org is reachable by
517+
// D4 domain matching (the bio-page promotion path supplies the bio's host).
518+
org_domain?: string;
516519
role?: string | null;
517520
client: string;
518521
source?: string;
@@ -543,6 +546,7 @@ export async function applyPersonAffiliation(
543546
name: input.org_name ?? '',
544547
client: input.client,
545548
source,
549+
domain: input.org_domain,
546550
});
547551

548552
let affiliation_created = false;

services/record-surrealdb-resolver/src/resolver.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,16 @@ export async function opportunitiesForOrg(
593593
// match/create path an org-shaped record gets, not a parallel reimplementation.
594594
export async function resolveOrgRow(
595595
db: Surreal,
596-
input: { action: 'match' | 'create'; org_slug?: string; name: string; client: string; source: string },
596+
input: {
597+
action: 'match' | 'create';
598+
org_slug?: string;
599+
name: string;
600+
client: string;
601+
source: string;
602+
// Seeded into domains[] on create only — an org born from a bio-page
603+
// promotion would otherwise be invisible to D4 domain matching forever.
604+
domain?: string;
605+
},
597606
): Promise<{ org: OrgRow; created: boolean }> {
598607
if (input.action === 'match') {
599608
if (!input.org_slug) throw new Error('resolveOrgRow (match) requires org_slug');
@@ -607,15 +616,24 @@ export async function resolveOrgRow(
607616
const existing = await fetchOrgBySlug(db, slug);
608617
if (existing) return { org: existing, created: false };
609618
const completeName = input.name.trim();
619+
const domain = input.domain?.trim().toLowerCase().replace(/^www\./, '');
610620
const createdRes = await db.query(
611621
`CREATE organizations SET
612622
id = rand::uuid::v7(), slug = $slug,
613623
complete_name = $complete_name, conventional_name = $conventional_name,
624+
domains = $domains,
614625
source = $source, client_access = [$client],
615626
first_touched_by = $client, last_touched_by = $client,
616627
last_touched_at = time::now(), first_seen_at = time::now(), last_seen_at = time::now()
617628
RETURN ${ORG_FIELDS};`,
618-
{ slug, complete_name: completeName, conventional_name: completeName, source: input.source, client: input.client },
629+
{
630+
slug,
631+
complete_name: completeName,
632+
conventional_name: completeName,
633+
domains: domain ? [{ domain }] : [],
634+
source: input.source,
635+
client: input.client,
636+
},
619637
);
620638
const org = ((createdRes?.[0] as OrgRow[]) ?? [])[0] ?? null;
621639
if (!org) throw new Error('org create returned no row');

0 commit comments

Comments
 (0)