Skip to content

Commit 23a07cc

Browse files
authored
[Improvement: SparseTree] Implement Next Planned Feature (#70)
* feat(relationships): add manual relationship linking for parents, spouses, and children Implements Phase 15.20 - users can now link existing people or create new person stubs as parents, spouses, or children from the PersonDetail page. - Add link-relationship/unlink-relationship API endpoints with validation - Add quick-search endpoint (FTS5 with database scoping) for modal search - Add createPersonStub to id-mapping service for manual person creation - Create RelationshipModal with search + create tabs, debounced search - Add "+ Add" buttons to Parents, Spouses, and Children sections - Replace placeholder modal with functional RelationshipModal * docs: mark Phase 15.20 (relationship linking) as complete * fix(relationships): address PR #70 review feedback Server fixes: - quick-search: replace LEFT JOIN vital_event with subquery (MIN+GROUP BY) to prevent duplicate rows when persons have multiple birth records - link-relationship: scope source/target to dbId, reject cross-database links, add new stubs to database_membership in same transaction - link-relationship: validate gender against CHECK constraint values (male/female/unknown), coerce based on relationshipType when invalid - link-relationship: wrap stub creation + membership + edge insertion in a single transaction so failures roll back all writes - link-relationship: pre-check duplicates before any writes; extract checkDuplicateEdge helper - link/unlink-relationship: validate targetId as canonical ULID - unlink-relationship: scope deletes via membership pre-checks; remove redundant EXISTS guards in DELETE statements for consistency - createPersonStub: wrap insert + FTS update in transaction - extract isPersonInDatabase helper shared by link/unlink Client fixes: - RelationshipModal: handle quickSearchPersons errors with toast, ensure searching state always clears via finally - RelationshipModal: reset searching/linkingId on modal open so a re-opened modal never shows stale spinner - RelationshipModal: drop unused color field from TYPE_CONFIG - api.ts: type linkRelationship/unlinkRelationship as RelationshipType instead of string; move type to client/src/types/relationship.ts - PersonDetail: extract reloadPerson helper; refresh parent/spouse/child data after linking instead of only updating the person object - PersonDetail: handle errors in onLinked with toast feedback - PersonDetail: determine missing parent role from gender, not array index (parents.filter(Boolean) collapses sparse positions) * fix(relationships): address round 2 review feedback - person.routes: resolve route :dbId to internal db_id via resolveDbId() in quick-search, link-relationship, and unlink-relationship handlers, matching the pattern used by integrity/auditor/search services. Without this, callers passing a legacy/FamilySearch ID silently fail membership checks and create orphan database_membership rows. - quick-search: add ORDER BY display_name, person_id for deterministic autocomplete results - PersonDetail: import RelationshipType from types/relationship instead of from RelationshipModal (reduces coupling) - RelationshipModal: drop now-unused re-export of RelationshipType * fix(relationships): address round 3 review feedback - person.routes link-relationship: use INSERT OR IGNORE for edge inserts to defend against races between pre-check and write; check changes count and return 409 if no row was inserted (duplicate snuck in) - person.routes link-relationship: refresh database_info.person_count cache when a new membership row is added so the database listing reflects new persons immediately - api.ts: tighten linkRelationship/unlinkRelationship response types from string to RelationshipType for full type-safety end to end * fix(relationships): address round 4 review feedback + add tests - person.routes link-relationship: trim newPerson.name and reject blank or whitespace-only values to prevent stub persons with empty display names - RelationshipModal: type onLinked as () => void | Promise<void> and await it before closing the modal, so the modal stays open visually until the parent's reload completes - tests: add tests/integration/api/relationships.spec.ts with 17 cases covering quick-search (db scoping, query length, no-match), link (validation, self-link, cross-db, stub creation, duplicate, parent), and unlink (delete, 404, cross-db rejection) - tests/integration/setup: register inline simplified versions of quick-search, link-relationship, and unlink-relationship endpoints matching production validation, scoping, and idempotent insert behavior * fix(relationships): address round 5 review feedback - person.routes link-relationship: insert membership only AFTER confirming edge insert was new. Previously, INSERT OR IGNORE racing with a concurrent edge insert would still write the membership row even when returning 409. Now race-induced 409s never mutate state. - tests/setup link-relationship: same ordering fix; reword 'mirrors production validation' comment to explicitly call out divergences (no canonical-ULID checks, no resolveDbId, simple stub IDs) * fix(relationships): guard doSearch against out-of-order responses Track a monotonically increasing search request id and drop responses whose id no longer matches the latest. Without this, fast typing could let an earlier search resolve after a later one and overwrite results or flip the spinner off prematurely. * test(relationships): include birthYear in test quick-search response Match production response shape so client/contract regressions on the birthYear field are caught in tests. * fix(relationships): guard against late results and mid-link unmount - doSearch: bump request id (and clear searching) when query falls below 2 chars, so a previously-issued >=2-char request resolving late cannot repopulate stale results - RelationshipModal: introduce safeClose() that no-ops while a link/create is in flight, and disable the X close button when linking. Backdrop click and X button no longer unmount the modal mid-await, preventing setState/onLinked from running on a dead component * fix(relationships): drop unused async on synchronous route handlers quick-search, link-relationship, and unlink-relationship handlers do only synchronous SQLite work but were marked async, which would cause thrown exceptions to become unhandled Promise rejections in Express 4 (no express-async-errors registered). Removing async lets Express catch synchronous throws via the standard error middleware. * fix(relationships): reject cross-database link targets Edge tables (parent_edge, spouse_edge) are not scoped by db_id, so silently auto-importing an existing person into the source database during link-relationship would leak relationships across databases. - person.routes link-relationship: require existing targets to already be members of the source database; return 403 otherwise. Stub creation remains the only path that adds a new membership row, since stubs are brand-new persons that didn't exist before this request. - tests/setup link-relationship: mirror the rejection - tests: add coverage for the cross-database rejection (existing person with no membership in source db → 403 + no edge inserted) * fix(relationships): handle unknown gender + birthYear=0 in modal - PersonDetail parent inference: filter to known male/female genders before deciding which parent role is missing. Previously, an existing parent with gender 'unknown' or with parentData not yet loaded would fall through and default to 'father' even when the existing parent was actually the father, leading to a confusing duplicate error. - RelationshipModal: use != null instead of truthy check for birthYear, so a valid birthYear of 0 isn't hidden and replaced with the truncated ID fallback * fix(relationships): normalize quick-search query param req.query.q may be string | string[] | undefined; calling .trim() directly on the cast crashed with a 500 if a client sent multiple ?q= parameters. Normalize to the first value before length checks.
1 parent 2794da2 commit 23a07cc

9 files changed

Lines changed: 1325 additions & 101 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ High-level project roadmap. For detailed phase documentation, see [docs/roadmap.
3636
| 15.17 | Data integrity + bulk discovery ||
3737
| 15.18 | Separate provider download from auto-apply ||
3838
| 15.19 | Normalize FamilySearch as downstream provider ||
39-
| 15.20 | Relationship linking (parents, spouses, children) | 📋 |
39+
| 15.20 | Relationship linking (parents, spouses, children) | |
4040
| 15.22 | Ancestry free hints automation ||
4141
| 15.23 | Migration Map visualization ||
4242
| 16 | Multi-platform sync architecture | 📋 |

client/src/components/person/PersonDetail.tsx

Lines changed: 139 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { UploadToFamilySearchDialog } from './UploadToFamilySearchDialog';
1414
import { UploadToAncestryDialog } from './UploadToAncestryDialog';
1515
import { ProviderDataTable } from './ProviderDataTable';
1616
import { LinkPlatformDialog } from './LinkPlatformDialog';
17+
import { RelationshipModal } from './RelationshipModal';
18+
import type { RelationshipType } from '../../types/relationship';
19+
1720
import { PersonAuditIssues } from './PersonAuditIssues';
1821

1922
interface CachedLineage {
@@ -210,7 +213,7 @@ export function PersonDetail() {
210213
const [syncLoading, setSyncLoading] = useState(false);
211214
const [showUploadDialog, setShowUploadDialog] = useState(false);
212215
const [showAncestryUploadDialog, setShowAncestryUploadDialog] = useState(false);
213-
const [showRelationshipModal, setShowRelationshipModal] = useState(false);
216+
const [relationshipModalType, setRelationshipModalType] = useState<RelationshipType | null>(null);
214217
const [hintsProcessing, setHintsProcessing] = useState(false);
215218

216219
// Local overrides state
@@ -221,6 +224,81 @@ export function PersonDetail() {
221224
const [addingAlias, setAddingAlias] = useState(false);
222225
const [addingOccupation, setAddingOccupation] = useState(false);
223226

227+
// Refreshes person + all family data (parents, spouses, children, family photos).
228+
// Called on initial load and after a relationship is linked.
229+
const reloadPerson = useCallback(async (signal?: AbortSignal) => {
230+
if (!dbId || !personId) return;
231+
232+
const personData = await api.getPerson(dbId, personId);
233+
if (signal?.aborted) return;
234+
setPerson(personData);
235+
236+
const validParentIds = personData.parents.filter((id): id is string => id != null);
237+
const allFamilyIds: string[] = [
238+
...validParentIds,
239+
...(personData.spouses || []),
240+
...personData.children,
241+
];
242+
243+
// Fetch parent data
244+
if (validParentIds.length > 0) {
245+
const parentResults = await Promise.all(
246+
validParentIds.map((pid: string) => api.getPerson(dbId, pid).catch(() => null))
247+
);
248+
if (signal?.aborted) return;
249+
const parents: Record<string, PersonWithId> = {};
250+
parentResults.forEach((p: PersonWithId | null, idx: number) => {
251+
if (p) parents[validParentIds[idx]] = p;
252+
});
253+
setParentData(parents);
254+
} else {
255+
setParentData({});
256+
}
257+
258+
// Fetch spouse data
259+
if (personData.spouses && personData.spouses.length > 0) {
260+
const spouseResults = await Promise.all(
261+
personData.spouses.map((sid: string) => api.getPerson(dbId, sid).catch(() => null))
262+
);
263+
if (signal?.aborted) return;
264+
const spouses: Record<string, PersonWithId> = {};
265+
spouseResults.forEach((s: PersonWithId | null, idx: number) => {
266+
if (s && personData.spouses) spouses[personData.spouses[idx]] = s;
267+
});
268+
setSpouseData(spouses);
269+
} else {
270+
setSpouseData({});
271+
}
272+
273+
// Fetch children data
274+
if (personData.children.length > 0) {
275+
const childResults = await Promise.all(
276+
personData.children.map((cid: string) => api.getPerson(dbId, cid).catch(() => null))
277+
);
278+
if (signal?.aborted) return;
279+
const children: Record<string, PersonWithId> = {};
280+
childResults.forEach((c: PersonWithId | null, idx: number) => {
281+
if (c) children[personData.children[idx]] = c;
282+
});
283+
setChildData(children);
284+
} else {
285+
setChildData({});
286+
}
287+
288+
// Check photos for all family members (batch)
289+
if (allFamilyIds.length > 0) {
290+
const photoChecks = await Promise.all(
291+
allFamilyIds.map((id: string) => api.hasPhoto(id).then(r => ({ id, exists: r?.exists ?? false })).catch(() => ({ id, exists: false })))
292+
);
293+
if (signal?.aborted) return;
294+
const photos: Record<string, boolean> = {};
295+
photoChecks.forEach(({ id, exists }) => { photos[id] = exists; });
296+
setFamilyPhotos(photos);
297+
} else {
298+
setFamilyPhotos({});
299+
}
300+
}, [dbId, personId]);
301+
224302
useEffect(() => {
225303
if (!dbId || !personId) return;
226304

@@ -260,77 +338,21 @@ export function PersonDetail() {
260338
}).catch(() => []);
261339

262340
Promise.all([
263-
api.getPerson(dbId, personId),
264341
api.getDatabase(dbId),
265342
api.getScrapedData(personId).catch(() => null),
266343
api.getAugmentation(personId).catch(() => null),
267344
fetchPhotoStatus(personId),
268345
])
269-
.then(async ([personData, dbData, _scraped, augment, photos]) => {
346+
.then(async ([dbData, _scraped, augment, photos]) => {
270347
if (signal.aborted) return;
271348

272-
setPerson(personData);
273349
setDatabase(dbData);
274350
setPhotoStatus(photos);
275351
setAugmentation(augment);
276352

277-
// Collect all family member IDs for batch photo check
278-
const validParentIds = personData.parents.filter((id): id is string => id != null);
279-
const allFamilyIds: string[] = [
280-
...validParentIds,
281-
...(personData.spouses || []),
282-
...personData.children,
283-
];
284-
285-
// Fetch parent data
286-
if (validParentIds.length > 0) {
287-
const parentResults = await Promise.all(
288-
validParentIds.map((pid: string) => api.getPerson(dbId, pid).catch(() => null))
289-
);
290-
if (signal.aborted) return;
291-
const parents: Record<string, PersonWithId> = {};
292-
parentResults.forEach((p: PersonWithId | null, idx: number) => {
293-
if (p) parents[validParentIds[idx]] = p;
294-
});
295-
setParentData(parents);
296-
}
297-
298-
// Fetch spouse data
299-
if (personData.spouses && personData.spouses.length > 0) {
300-
const spouseResults = await Promise.all(
301-
personData.spouses.map((sid: string) => api.getPerson(dbId, sid).catch(() => null))
302-
);
303-
if (signal.aborted) return;
304-
const spouses: Record<string, PersonWithId> = {};
305-
spouseResults.forEach((s: PersonWithId | null, idx: number) => {
306-
if (s && personData.spouses) spouses[personData.spouses[idx]] = s;
307-
});
308-
setSpouseData(spouses);
309-
}
310-
311-
// Fetch children data
312-
if (personData.children.length > 0) {
313-
const childResults = await Promise.all(
314-
personData.children.map((cid: string) => api.getPerson(dbId, cid).catch(() => null))
315-
);
316-
if (signal.aborted) return;
317-
const children: Record<string, PersonWithId> = {};
318-
childResults.forEach((c: PersonWithId | null, idx: number) => {
319-
if (c) children[personData.children[idx]] = c;
320-
});
321-
setChildData(children);
322-
}
323-
324-
// Check photos for all family members (batch)
325-
if (allFamilyIds.length > 0) {
326-
const photoChecks = await Promise.all(
327-
allFamilyIds.map((id: string) => api.hasPhoto(id).then(r => ({ id, exists: r?.exists ?? false })).catch(() => ({ id, exists: false })))
328-
);
329-
if (signal.aborted) return;
330-
const photos: Record<string, boolean> = {};
331-
photoChecks.forEach(({ id, exists }) => { photos[id] = exists; });
332-
setFamilyPhotos(photos);
333-
}
353+
// Load person + family data via shared helper
354+
await reloadPerson(signal);
355+
if (signal.aborted) return;
334356

335357
// Check for cached lineage
336358
const cached = getCachedLineage(dbId, personId);
@@ -347,7 +369,7 @@ export function PersonDetail() {
347369
});
348370

349371
return () => controller.abort();
350-
}, [dbId, personId]);
372+
}, [dbId, personId, reloadPerson]);
351373

352374
const calculateLineage = async () => {
353375
if (!dbId || !personId || !database?.rootId) return;
@@ -960,9 +982,35 @@ export function PersonDetail() {
960982
<div className="mt-3 pt-3 border-t border-app-border/50 grid grid-cols-1 md:grid-cols-3 gap-3">
961983
{/* Parents */}
962984
<div className="flex flex-wrap items-start gap-2 md:flex-col md:items-start md:gap-2 md:bg-app-bg/30 md:border md:border-app-border/50 md:rounded-lg md:p-2">
963-
<div className="flex items-center gap-1 text-xs text-app-text-muted w-16 shrink-0 pt-2 md:w-full md:pt-0 md:pb-1 md:border-b md:border-app-border/40">
985+
<div className="flex items-center justify-between gap-2 text-xs text-app-text-muted w-16 shrink-0 pt-2 md:w-full md:pt-0 md:pb-1 md:border-b md:border-app-border/40">
986+
<div className="flex items-center gap-1">
964987
<Users size={12} />
965988
Parents
989+
</div>
990+
{person.parents.filter(id => id != null).length < 2 && (
991+
<button
992+
type="button"
993+
className="text-[10px] text-app-accent hover:underline"
994+
title="Add or link a parent"
995+
onClick={() => {
996+
// Determine which parent role is missing by checking the
997+
// *known* genders of existing parents — parents.filter(
998+
// Boolean) on the server collapses sparse positions so
999+
// array index is unreliable. Only infer when we have a
1000+
// definite male/female gender; ignore 'unknown' and
1001+
// un-loaded parents and fall back to 'father'.
1002+
const knownGenders = person.parents
1003+
.filter((id): id is string => id != null)
1004+
.map(id => parentData[id]?.gender)
1005+
.filter((g): g is 'male' | 'female' => g === 'male' || g === 'female');
1006+
const nextRole: RelationshipType =
1007+
knownGenders.includes('male') ? 'mother' : 'father';
1008+
setRelationshipModalType(nextRole);
1009+
}}
1010+
>
1011+
+ Add
1012+
</button>
1013+
)}
9661014
</div>
9671015
{person.parents.some(id => id != null) ? (
9681016
<div className="flex flex-wrap gap-1.5 flex-1">
@@ -993,7 +1041,7 @@ export function PersonDetail() {
9931041
type="button"
9941042
className="text-[10px] text-app-accent hover:underline"
9951043
title="Add or link a spouse"
996-
onClick={() => setShowRelationshipModal(true)}
1044+
onClick={() => setRelationshipModalType('spouse')}
9971045
>
9981046
+ Add
9991047
</button>
@@ -1017,9 +1065,19 @@ export function PersonDetail() {
10171065

10181066
{/* Children */}
10191067
<div className="flex flex-wrap items-start gap-2 md:flex-col md:items-start md:gap-2 md:bg-app-bg/30 md:border md:border-app-border/50 md:rounded-lg md:p-2">
1020-
<div className="flex items-center gap-1 text-xs text-app-text-muted w-16 shrink-0 pt-2 md:w-full md:pt-0 md:pb-1 md:border-b md:border-app-border/40">
1068+
<div className="flex items-center justify-between gap-2 text-xs text-app-text-muted w-16 shrink-0 pt-2 md:w-full md:pt-0 md:pb-1 md:border-b md:border-app-border/40">
1069+
<div className="flex items-center gap-1">
10211070
<Users size={12} />
10221071
Children
1072+
</div>
1073+
<button
1074+
type="button"
1075+
className="text-[10px] text-app-accent hover:underline"
1076+
title="Add or link a child"
1077+
onClick={() => setRelationshipModalType('child')}
1078+
>
1079+
+ Add
1080+
</button>
10231081
</div>
10241082
{person.children.length > 0 ? (
10251083
<div className="flex flex-wrap gap-1.5 flex-1">
@@ -1281,40 +1339,22 @@ export function PersonDetail() {
12811339
loading={linkingLoading}
12821340
/>
12831341

1284-
{/* Relationship placeholder modal */}
1285-
{showRelationshipModal && (
1286-
<div
1287-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
1288-
onClick={(e) => e.target === e.currentTarget && setShowRelationshipModal(false)}
1289-
>
1290-
<div className="bg-app-card rounded-lg border border-app-border shadow-xl max-w-md w-full mx-4">
1291-
<div className="flex items-center justify-between px-4 py-3 border-b border-app-border">
1292-
<h3 className="font-semibold text-app-text">Add Relationship</h3>
1293-
<button
1294-
onClick={() => setShowRelationshipModal(false)}
1295-
className="p-1 text-app-text-muted hover:text-app-text hover:bg-app-hover rounded transition-colors"
1296-
aria-label="Close"
1297-
>
1298-
<X size={18} />
1299-
</button>
1300-
</div>
1301-
<div className="p-4 space-y-3">
1302-
<p className="text-sm text-app-text-muted">
1303-
Coming soon: link existing people or create new profiles for parents, spouses, and children.
1304-
</p>
1305-
<div className="flex justify-end">
1306-
<button
1307-
type="button"
1308-
onClick={() => setShowRelationshipModal(false)}
1309-
className="px-3 py-1.5 text-sm text-app-text-secondary hover:bg-app-hover rounded transition-colors"
1310-
>
1311-
Close
1312-
</button>
1313-
</div>
1314-
</div>
1315-
</div>
1316-
</div>
1317-
)}
1342+
{/* Relationship linking modal */}
1343+
<RelationshipModal
1344+
open={relationshipModalType !== null}
1345+
dbId={dbId!}
1346+
personId={personId!}
1347+
initialType={relationshipModalType ?? undefined}
1348+
onClose={() => setRelationshipModalType(null)}
1349+
onLinked={async () => {
1350+
try {
1351+
await reloadPerson();
1352+
toast.success('Relationship linked');
1353+
} catch (error) {
1354+
toast.error('Failed to refresh person after linking relationship');
1355+
}
1356+
}}
1357+
/>
13181358
</div>
13191359
);
13201360
}

0 commit comments

Comments
 (0)