diff --git a/PLAN.md b/PLAN.md index 686acec..c4f8d6d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ High-level project roadmap. For detailed phase documentation, see [docs/roadmap. | 15.17 | Data integrity + bulk discovery | โœ… | | 15.18 | Separate provider download from auto-apply | โœ… | | 15.19 | Normalize FamilySearch as downstream provider | โœ… | -| 15.20 | Relationship linking (parents, spouses, children) | ๐Ÿ“‹ | +| 15.20 | Relationship linking (parents, spouses, children) | โœ… | | 15.22 | Ancestry free hints automation | โœ… | | 15.23 | Migration Map visualization | โœ… | | 16 | Multi-platform sync architecture | ๐Ÿ“‹ | diff --git a/client/src/components/person/PersonDetail.tsx b/client/src/components/person/PersonDetail.tsx index d2c48e3..9e487e5 100644 --- a/client/src/components/person/PersonDetail.tsx +++ b/client/src/components/person/PersonDetail.tsx @@ -14,6 +14,9 @@ import { UploadToFamilySearchDialog } from './UploadToFamilySearchDialog'; import { UploadToAncestryDialog } from './UploadToAncestryDialog'; import { ProviderDataTable } from './ProviderDataTable'; import { LinkPlatformDialog } from './LinkPlatformDialog'; +import { RelationshipModal } from './RelationshipModal'; +import type { RelationshipType } from './RelationshipModal'; + import { PersonAuditIssues } from './PersonAuditIssues'; interface CachedLineage { @@ -190,7 +193,7 @@ export function PersonDetail() { const [syncLoading, setSyncLoading] = useState(false); const [showUploadDialog, setShowUploadDialog] = useState(false); const [showAncestryUploadDialog, setShowAncestryUploadDialog] = useState(false); - const [showRelationshipModal, setShowRelationshipModal] = useState(false); + const [relationshipModalType, setRelationshipModalType] = useState(null); const [hintsProcessing, setHintsProcessing] = useState(false); // Local overrides state @@ -966,9 +969,24 @@ export function PersonDetail() {
{/* Parents */}
-
+
+
Parents +
+ {person.parents.filter(id => id != null).length < 2 && ( + + )}
{person.parents.some(id => id != null) ? (
@@ -999,7 +1017,7 @@ export function PersonDetail() { type="button" className="text-[10px] text-app-accent hover:underline" title="Add or link a spouse" - onClick={() => setShowRelationshipModal(true)} + onClick={() => setRelationshipModalType('spouse')} > + Add @@ -1023,9 +1041,19 @@ export function PersonDetail() { {/* Children */}
-
+
+
Children +
+
{person.children.length > 0 ? (
@@ -1287,40 +1315,20 @@ export function PersonDetail() { loading={linkingLoading} /> - {/* Relationship placeholder modal */} - {showRelationshipModal && ( -
e.target === e.currentTarget && setShowRelationshipModal(false)} - > -
-
-

Add Relationship

- -
-
-

- Coming soon: link existing people or create new profiles for parents, spouses, and children. -

-
- -
-
-
-
- )} + {/* Relationship linking modal */} + setRelationshipModalType(null)} + onLinked={() => { + api.getPerson(dbId!, personId!).then(updated => { + setPerson(updated); + toast.success('Relationship linked'); + }); + }} + />
); } diff --git a/client/src/components/person/RelationshipModal.tsx b/client/src/components/person/RelationshipModal.tsx new file mode 100644 index 0000000..7af8c09 --- /dev/null +++ b/client/src/components/person/RelationshipModal.tsx @@ -0,0 +1,315 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { X, Loader2, Search, UserPlus, Users, Heart, User } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { api } from '../../services/api'; + +export type RelationshipType = 'father' | 'mother' | 'spouse' | 'child'; + +interface RelationshipModalProps { + open: boolean; + dbId: string; + personId: string; + initialType?: RelationshipType; + onClose: () => void; + onLinked: () => void; +} + +interface QuickSearchResult { + personId: string; + displayName: string; + gender: string; + birthName: string | null; + birthYear: number | null; +} + +const TYPE_CONFIG: Record = { + father: { label: 'Father', icon: User, color: 'text-blue-400' }, + mother: { label: 'Mother', icon: User, color: 'text-pink-400' }, + spouse: { label: 'Spouse', icon: Heart, color: 'text-red-400' }, + child: { label: 'Child', icon: Users, color: 'text-green-400' }, +}; + +export function RelationshipModal({ open, dbId, personId, initialType, onClose, onLinked }: RelationshipModalProps) { + const [relType, setRelType] = useState(initialType ?? 'spouse'); + const [mode, setMode] = useState<'search' | 'create'>('search'); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [linkingId, setLinkingId] = useState(null); + const [newName, setNewName] = useState(''); + const [newGender, setNewGender] = useState<'male' | 'female' | 'unknown'>('unknown'); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + useEffect(() => { + if (open) { + setRelType(initialType ?? 'spouse'); + setMode('search'); + setQuery(''); + setResults([]); + setNewName(''); + setNewGender('unknown'); + setTimeout(() => inputRef.current?.focus(), 100); + } else { + if (debounceRef.current) clearTimeout(debounceRef.current); + } + }, [open, initialType]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + useEffect(() => { + if (relType === 'father') setNewGender('male'); + else if (relType === 'mother') setNewGender('female'); + else setNewGender('unknown'); + }, [relType]); + + const doSearch = useCallback(async (q: string) => { + if (q.length < 2) { + setResults([]); + return; + } + setSearching(true); + const data = await api.quickSearchPersons(dbId, q); + setResults(data.filter(r => r.personId !== personId)); + setSearching(false); + }, [dbId, personId]); + + const handleQueryChange = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(value), 300); + }; + + const handleLinkExisting = async (targetId: string) => { + setLinkingId(targetId); + const result = await api.linkRelationship(dbId, personId, relType, targetId).catch(err => { + toast.error(err.message || 'Failed to link'); + return null; + }); + setLinkingId(null); + if (!result) return; + onLinked(); + onClose(); + }; + + const handleCreateNew = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + setLinkingId('new'); + const result = await api.linkRelationship(dbId, personId, relType, undefined, { name: newName.trim(), gender: newGender }).catch(err => { + toast.error(err.message || 'Failed to create'); + return null; + }); + setLinkingId(null); + if (!result) return; + onLinked(); + onClose(); + }; + + if (!open) return null; + + const linking = linkingId !== null; + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+
+ +

Add Relationship

+
+ +
+ +
+
+ {(Object.keys(TYPE_CONFIG) as RelationshipType[]).map(type => { + const cfg = TYPE_CONFIG[type]; + const Icon = cfg.icon; + return ( + + ); + })} +
+
+ +
+
+ + +
+
+ +
+ {mode === 'search' ? ( +
+
+ + handleQueryChange(e.target.value)} + placeholder="Search by name..." + className="w-full pl-8 pr-3 py-2 bg-app-bg border border-app-border rounded text-app-text text-sm focus:outline-none focus:ring-2 focus:ring-app-accent" + disabled={linking} + /> + {searching && } +
+ + {results.length > 0 && ( +
+ {results.map(r => ( + + ))} +
+ )} + + {query.length >= 2 && !searching && results.length === 0 && ( +

+ No matching people found.{' '} + +

+ )} + + {query.length < 2 && ( +

+ Type at least 2 characters to search +

+ )} +
+ ) : ( +
+
+ + setNewName(e.target.value)} + placeholder="e.g. John Smith" + className="w-full px-3 py-2 bg-app-bg border border-app-border rounded text-app-text text-sm focus:outline-none focus:ring-2 focus:ring-app-accent" + disabled={linking} + /> +
+ +
+ +
+ {(['male', 'female', 'unknown'] as const).map(g => ( + + ))} +
+
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 0c61dad..e2258a7 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -704,6 +704,38 @@ export const api = { } ), + // Relationship linking + quickSearchPersons: (dbId: string, q: string) => + fetchJson>(`/persons/${dbId}/quick-search?q=${encodeURIComponent(q)}`), + + linkRelationship: (dbId: string, personId: string, relationshipType: string, targetId?: string, newPerson?: { name: string; gender?: string }) => + fetchJson<{ + personId: string; + targetId: string; + relationshipType: string; + createdNew: boolean; + }>( + `/persons/${dbId}/${personId}/link-relationship`, + { + method: 'POST', + body: JSON.stringify({ relationshipType, targetId, newPerson }) + } + ), + + unlinkRelationship: (dbId: string, personId: string, relationshipType: string, targetId: string) => + fetchJson<{ personId: string; targetId: string; relationshipType: string }>( + `/persons/${dbId}/${personId}/unlink-relationship`, + { + method: 'DELETE', + body: JSON.stringify({ relationshipType, targetId }) + } + ), // AI Discovery quickDiscovery: (dbId: string, sampleSize = 100, options?: { model?: string; excludeBiblical?: boolean; minBirthYear?: number; maxGenerations?: number; customPrompt?: string }) => diff --git a/server/src/routes/person.routes.ts b/server/src/routes/person.routes.ts index c807bef..d3d83c5 100644 --- a/server/src/routes/person.routes.ts +++ b/server/src/routes/person.routes.ts @@ -14,6 +14,9 @@ import { logger } from '../lib/logger.js'; import type { BuiltInProvider } from '@fsf/shared'; import { PHOTOS_DIR, PROVIDER_CACHE_DIR } from '../utils/paths.js'; import { resolveCanonicalOrFail } from '../utils/resolveCanonical.js'; +import { sanitizeFtsQuery } from '../utils/validation.js'; + +const VALID_RELATIONSHIP_TYPES = ['father', 'mother', 'spouse', 'child'] as const; export const personRoutes = Router(); @@ -25,6 +28,51 @@ personRoutes.get('/:dbId', async (req, res, next) => { if (result) res.json({ success: true, data: result }); }); +// GET /api/persons/:dbId/quick-search?q=name +// Must be registered before /:dbId/:personId to avoid route conflict +personRoutes.get('/:dbId/quick-search', async (req, res, next) => { + const q = (req.query.q as string || '').trim(); + if (!q || q.length < 2) { + return res.json({ success: true, data: [] }); + } + + if (!databaseService.isSqliteEnabled()) { + return res.json({ success: true, data: [] }); + } + + const { dbId } = req.params; + const sanitized = sanitizeFtsQuery(q); + if (!sanitized) return res.json({ success: true, data: [] }); + const ftsQuery = `"${sanitized}"*`; + + const results = sqliteService.queryAll<{ + person_id: string; + display_name: string; + gender: string; + birth_name: string | null; + birth_year: number | null; + }>( + `SELECT p.person_id, p.display_name, p.gender, p.birth_name, ve.date_year AS birth_year + FROM person p + JOIN database_membership dm ON p.person_id = dm.person_id + LEFT JOIN vital_event ve ON ve.person_id = p.person_id AND ve.event_type = 'birth' + WHERE dm.db_id = @dbId + AND p.person_id IN (SELECT person_id FROM person_fts WHERE person_fts MATCH @q) + LIMIT 20`, + { dbId, q: ftsQuery } + ); + + const data = results.map(r => ({ + personId: r.person_id, + displayName: r.display_name, + gender: r.gender, + birthName: r.birth_name, + birthYear: r.birth_year ?? null, + })); + + res.json({ success: true, data }); +}); + // GET /api/persons/:dbId/:personId - Get single person personRoutes.get('/:dbId/:personId', async (req, res, next) => { // Services handle ID resolution internally (accepts both canonical ULID and external IDs) @@ -653,3 +701,170 @@ personRoutes.put('/:dbId/:personId/use-field', async (req, res, next) => { data: override }); }); + +// POST /api/persons/:dbId/:personId/link-relationship +// Link an existing person or create a new stub as parent/spouse/child +// Body: { relationshipType: 'father'|'mother'|'spouse'|'child', targetId?: string, newPerson?: { name: string, gender?: string } } +personRoutes.post('/:dbId/:personId/link-relationship', async (req, res, next) => { + const { personId } = req.params; + const { relationshipType, targetId, newPerson } = req.body; + + if (!relationshipType || !VALID_RELATIONSHIP_TYPES.includes(relationshipType)) { + return res.status(400).json({ success: false, error: `Invalid relationshipType. Must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}` }); + } + + if (!targetId && !newPerson?.name) { + return res.status(400).json({ success: false, error: 'Provide either targetId (existing person) or newPerson.name (to create a stub)' }); + } + + const canonical = resolveCanonicalOrFail(personId, res); + if (!canonical) return; + + if (!databaseService.isSqliteEnabled()) { + return res.status(400).json({ success: false, error: 'SQLite must be enabled for relationship linking' }); + } + + // Resolve or create the target person + let resolvedTargetId = targetId; + let createdNew = false; + + if (targetId) { + // Verify target person exists + const existing = sqliteService.queryOne<{ person_id: string }>( + 'SELECT person_id FROM person WHERE person_id = @id', + { id: targetId } + ); + if (!existing) { + return res.status(404).json({ success: false, error: 'Target person not found' }); + } + } else { + // Create a new person stub + const gender = newPerson.gender || (relationshipType === 'father' ? 'male' : relationshipType === 'mother' ? 'female' : 'unknown'); + resolvedTargetId = idMappingService.createPersonStub(newPerson.name, { gender }); + createdNew = true; + logger.done('link-relationship', `Created person stub: ${newPerson.name} (${resolvedTargetId})`); + } + + // Prevent self-linking + if (resolvedTargetId === canonical) { + return res.status(400).json({ success: false, error: 'Cannot link a person to themselves' }); + } + + // Create the appropriate edge + if (relationshipType === 'father' || relationshipType === 'mother') { + // Check for duplicate + const existing = sqliteService.queryOne<{ id: number }>( + 'SELECT id FROM parent_edge WHERE child_id = @childId AND parent_id = @parentId', + { childId: canonical, parentId: resolvedTargetId } + ); + if (existing) { + return res.status(409).json({ success: false, error: 'This parent relationship already exists' }); + } + + sqliteService.run( + `INSERT INTO parent_edge (child_id, parent_id, parent_role, source, confidence) + VALUES (@childId, @parentId, @role, 'manual', 1.0)`, + { childId: canonical, parentId: resolvedTargetId, role: relationshipType } + ); + } else if (relationshipType === 'spouse') { + // Normalize ordering (smaller ID first) to prevent duplicate pairs + const [p1, p2] = canonical < resolvedTargetId! ? [canonical, resolvedTargetId] : [resolvedTargetId, canonical]; + const existing = sqliteService.queryOne<{ id: number }>( + 'SELECT id FROM spouse_edge WHERE person1_id = @p1 AND person2_id = @p2', + { p1, p2 } + ); + if (existing) { + return res.status(409).json({ success: false, error: 'This spouse relationship already exists' }); + } + + sqliteService.run( + `INSERT INTO spouse_edge (person1_id, person2_id, source, confidence) + VALUES (@p1, @p2, 'manual', 1.0)`, + { p1, p2 } + ); + } else if (relationshipType === 'child') { + // The current person is the parent, target is the child + const existing = sqliteService.queryOne<{ id: number }>( + 'SELECT id FROM parent_edge WHERE child_id = @childId AND parent_id = @parentId', + { childId: resolvedTargetId, parentId: canonical } + ); + if (existing) { + return res.status(409).json({ success: false, error: 'This child relationship already exists' }); + } + + // Determine parent role from current person's gender + const row = sqliteService.queryOne<{ gender: string }>( + 'SELECT gender FROM person WHERE person_id = @id', + { id: canonical } + ); + const parentRole = row?.gender === 'female' ? 'mother' : row?.gender === 'male' ? 'father' : 'parent'; + + sqliteService.run( + `INSERT INTO parent_edge (child_id, parent_id, parent_role, source, confidence) + VALUES (@childId, @parentId, @role, 'manual', 1.0)`, + { childId: resolvedTargetId, parentId: canonical, role: parentRole } + ); + } + + logger.done('link-relationship', `Linked ${relationshipType}: ${canonical} โ†” ${resolvedTargetId}`); + + res.json({ + success: true, + data: { + personId: canonical, + targetId: resolvedTargetId, + relationshipType, + createdNew, + } + }); +}); + +// DELETE /api/persons/:dbId/:personId/unlink-relationship +// Remove a relationship between two people +// Body: { relationshipType: 'father'|'mother'|'spouse'|'child', targetId: string } +personRoutes.delete('/:dbId/:personId/unlink-relationship', async (req, res, next) => { + const { personId } = req.params; + const { relationshipType, targetId } = req.body; + + if (!relationshipType || !VALID_RELATIONSHIP_TYPES.includes(relationshipType) || !targetId) { + return res.status(400).json({ success: false, error: 'relationshipType and targetId are required' }); + } + + const canonical = resolveCanonicalOrFail(personId, res); + if (!canonical) return; + + if (!databaseService.isSqliteEnabled()) { + return res.status(400).json({ success: false, error: 'SQLite must be enabled' }); + } + + let deleted = false; + + if (relationshipType === 'father' || relationshipType === 'mother') { + const result = sqliteService.run( + 'DELETE FROM parent_edge WHERE child_id = @childId AND parent_id = @parentId', + { childId: canonical, parentId: targetId } + ); + deleted = result.changes > 0; + } else if (relationshipType === 'spouse') { + const result = sqliteService.run( + 'DELETE FROM spouse_edge WHERE (person1_id = @a AND person2_id = @b) OR (person1_id = @b AND person2_id = @a)', + { a: canonical, b: targetId } + ); + deleted = result.changes > 0; + } else if (relationshipType === 'child') { + const result = sqliteService.run( + 'DELETE FROM parent_edge WHERE child_id = @childId AND parent_id = @parentId', + { childId: targetId, parentId: canonical } + ); + deleted = result.changes > 0; + } + + if (!deleted) { + return res.status(404).json({ success: false, error: 'Relationship not found' }); + } + + logger.done('unlink-relationship', `Unlinked ${relationshipType}: ${canonical} โ†” ${targetId}`); + + res.json({ success: true, data: { personId: canonical, targetId, relationshipType } }); +}); + diff --git a/server/src/services/id-mapping.service.ts b/server/src/services/id-mapping.service.ts index 876801a..25f610a 100644 --- a/server/src/services/id-mapping.service.ts +++ b/server/src/services/id-mapping.service.ts @@ -162,6 +162,40 @@ function createPerson( return personId; } +/** + * Create a minimal person record (stub) without an external identity. + * Used for manually linking family members who aren't in any provider yet. + */ +function createPersonStub( + displayName: string, + options?: { + birthName?: string; + gender?: 'male' | 'female' | 'unknown'; + living?: boolean; + bio?: string; + } +): string { + const personId = ulid(); + + sqliteService.run( + `INSERT INTO person (person_id, display_name, birth_name, gender, living, bio) + VALUES (@personId, @displayName, @birthName, @gender, @living, @bio)`, + { + personId, + displayName, + birthName: options?.birthName ?? null, + gender: options?.gender ?? 'unknown', + living: options?.living ? 1 : 0, + bio: options?.bio ?? null, + } + ); + + // Update FTS index + sqliteService.updatePersonFts(personId, displayName, options?.birthName); + + return personId; +} + /** * Register an external ID for an existing person */ @@ -353,6 +387,7 @@ export const idMappingService = { getExternalIds, getExternalId, createPerson, + createPersonStub, registerExternalId, removeExternalId, getOrCreateCanonicalId,