Skip to content

Commit 364e0d2

Browse files
committed
feat: ancestry chain-aware unlinked provider checks and audited/unaudited tree overlay
1 parent 3e92209 commit 364e0d2

5 files changed

Lines changed: 119 additions & 34 deletions

File tree

.changelog/NEXT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
- Renamed `coverage_gap` audit check to `unlinked_provider` for clarity
1515
- Split provider linkage check into primary (FamilySearch, Ancestry) at `info` severity and optional (WikiTree, 23andMe) at `hint` severity
1616
- Removed `unlinked_provider` from default enabled checks (opt-in only)
17+
- Ancestry unlinked_provider check now only flags when child in BFS chain already has ancestry link (chain-aware)
18+
- Audit tree overlay distinguishes "Clean" (audited, no issues) from "Unaudited" persons
19+
- Issue overlay API returns audited person IDs alongside issue data
20+
- Unlinked provider issue detail shows linked providers with checkmarks and missing provider as addition
1721

1822
## Fixed
1923

client/src/components/audit/AuditTreeView.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ function getGenerationLabel(level: number): { main: string; sub?: string } {
126126
export function AuditTreeView({ dbId, onPersonIssuesClick }: AuditTreeViewProps) {
127127
const [treeData, setTreeData] = useState<AncestryTreeResult | null>(null);
128128
const [overlay, setOverlay] = useState<IssueOverlay>({});
129+
const [auditedSet, setAuditedSet] = useState<Set<string>>(new Set());
129130
const [loading, setLoading] = useState(true);
130131
const [expandingNodes, setExpandingNodes] = useState<Set<string>>(new Set());
131132

@@ -145,16 +146,20 @@ export function AuditTreeView({ dbId, onPersonIssuesClick }: AuditTreeViewProps)
145146
),
146147
api.getAuditIssueOverlay(dbId),
147148
])
148-
.then(([tree, issueOverlay]) => {
149+
.then(([tree, overlayData]) => {
149150
setTreeData(tree);
150-
setOverlay(issueOverlay);
151+
setOverlay(overlayData.issues);
152+
setAuditedSet(new Set(overlayData.auditedPersonIds));
151153
})
152154
.catch(err => toast.error(`Failed to load tree: ${err.message}`))
153155
.finally(() => setLoading(false));
154156
}, [dbId]);
155157

156158
const refreshOverlay = useCallback(() => {
157-
api.getAuditIssueOverlay(dbId).then(setOverlay).catch(() => {});
159+
api.getAuditIssueOverlay(dbId).then(data => {
160+
setOverlay(data.issues);
161+
setAuditedSet(new Set(data.auditedPersonIds));
162+
}).catch(() => {});
158163
}, [dbId]);
159164

160165
const handleExpand = useCallback((request: ExpandAncestryRequest, nodeId: string) => {
@@ -308,6 +313,7 @@ export function AuditTreeView({ dbId, onPersonIssuesClick }: AuditTreeViewProps)
308313
person={item.person}
309314
dbId={dbId}
310315
issueData={overlay[item.person.id]}
316+
isAudited={auditedSet.has(item.person.id)}
311317
isOnPath={pathHighlight.has(item.person.id)}
312318
isExpanding={expandingNodes.has(item.person.id)}
313319
onExpand={item.person.hasMoreAncestors ? () => {
@@ -380,6 +386,7 @@ function AuditPersonNode({
380386
person,
381387
dbId,
382388
issueData,
389+
isAudited,
383390
isOnPath,
384391
isExpanding,
385392
onExpand,
@@ -388,6 +395,7 @@ function AuditPersonNode({
388395
person: AncestryPersonCard;
389396
dbId: string;
390397
issueData?: { count: number; maxSeverity: string; types: string[] };
398+
isAudited: boolean;
391399
isOnPath: boolean;
392400
isExpanding: boolean;
393401
onExpand?: () => void;
@@ -443,6 +451,7 @@ function AuditPersonNode({
443451
className={`w-full px-2 py-1.5 text-left border-t ${
444452
severity === 'error' ? 'bg-red-500/10 border-red-500/20' :
445453
severity === 'warning' ? 'bg-yellow-500/10 border-yellow-500/20' :
454+
severity === 'hint' ? 'bg-gray-500/10 border-gray-500/20' :
446455
'bg-blue-400/10 border-blue-400/20'
447456
}`}
448457
>
@@ -455,6 +464,7 @@ function AuditPersonNode({
455464
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[9px] font-medium ${
456465
severity === 'error' ? 'bg-red-500/20 text-red-400' :
457466
severity === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
467+
severity === 'hint' ? 'bg-gray-500/20 text-gray-400' :
458468
'bg-blue-400/20 text-blue-400'
459469
}`}
460470
>
@@ -467,12 +477,17 @@ function AuditPersonNode({
467477
</button>
468478
)}
469479

470-
{/* Clean indicator */}
471-
{!hasIssues && (
480+
{/* Audited clean vs unaudited */}
481+
{!hasIssues && isAudited && (
472482
<div className="px-2 py-1 border-t border-app-border/50 bg-green-500/5">
473483
<span className="text-[9px] text-green-500/70">Clean</span>
474484
</div>
475485
)}
486+
{!hasIssues && !isAudited && (
487+
<div className="px-2 py-1 border-t border-app-border/50 bg-app-bg-secondary">
488+
<span className="text-[9px] text-app-text-subtle">Unaudited</span>
489+
</div>
490+
)}
476491
</div>
477492
);
478493
}

client/src/components/person/PersonAuditIssues.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,17 @@ export function PersonAuditIssues({ dbId, personId }: PersonAuditIssuesProps) {
155155
</span>
156156
</div>
157157
<p className="text-xs text-app-text leading-relaxed">{issue.description}</p>
158-
{issue.suggestedValue && (
158+
{issue.suggestedValue && issue.issueType === 'unlinked_provider' ? (
159+
<div className="mt-1.5 flex items-center gap-2 text-xs flex-wrap">
160+
{issue.currentValue?.split(',').map(p => (
161+
<span key={p} className="flex items-center gap-0.5 text-green-400">
162+
<Check size={10} /> {p}
163+
</span>
164+
))}
165+
<span className="text-app-text-muted">|</span>
166+
<span className="text-yellow-400">+ {issue.suggestedValue}</span>
167+
</div>
168+
) : issue.suggestedValue ? (
159169
<div className="mt-1.5 flex items-center gap-2 text-xs">
160170
<span className="text-app-text-muted">Current:</span>
161171
<span className="text-red-400 line-through">{issue.currentValue}</span>
@@ -165,7 +175,7 @@ export function PersonAuditIssues({ dbId, personId }: PersonAuditIssuesProps) {
165175
<span className="text-app-text-subtle">({issue.suggestedSource})</span>
166176
)}
167177
</div>
168-
)}
178+
) : null}
169179
<div className="mt-2 flex items-center gap-2">
170180
{issue.suggestedValue ? (
171181
<button

client/src/services/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,10 @@ export const api = {
897897
}),
898898

899899
getAuditIssueOverlay: (dbId: string) =>
900-
fetchJson<Record<string, { count: number; maxSeverity: string; types: string[] }>>(`/audit/${dbId}/issue-overlay`),
900+
fetchJson<{
901+
issues: Record<string, { count: number; maxSeverity: string; types: string[] }>;
902+
auditedPersonIds: string[];
903+
}>(`/audit/${dbId}/issue-overlay`),
901904

902905
// Ancestry Hints Automation
903906
processAncestryHints: (dbId: string, personId: string) =>

server/src/services/auditor-agent.service.ts

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,14 @@ function checkOrphanedEdges(runId: string, personId: string): AuditIssue[] {
475475
o.parent_id, null));
476476
}
477477

478-
function checkUnlinkedProviders(runId: string, personId: string, displayName: string): AuditIssue[] {
478+
/**
479+
* Check if person is missing links to providers they could be linked to.
480+
* For ancestry: only flag if a child in the BFS chain already has an ancestry link
481+
* (ancestry requires a connected chain from the root).
482+
*/
483+
function checkUnlinkedProviders(
484+
runId: string, personId: string, displayName: string, childHasAncestry: boolean,
485+
): { issues: AuditIssue[]; linkedSources: Set<string> } {
479486
const linked = sqliteService.queryAll<{ source: string }>(
480487
'SELECT DISTINCT source FROM external_identity WHERE person_id = @personId',
481488
{ personId }
@@ -484,28 +491,31 @@ function checkUnlinkedProviders(runId: string, personId: string, displayName: st
484491
const linkedSources = new Set(linked.map(l => l.source));
485492

486493
// Only flag if person has at least one provider link (otherwise they're likely too old/mythological)
487-
if (linkedSources.size === 0) return [];
494+
if (linkedSources.size === 0) return { issues: [], linkedSources };
488495

489496
const issues: AuditIssue[] = [];
490-
const missingPrimary = PRIMARY_PROVIDERS.filter(p => !linkedSources.has(p));
491-
const missingOptional = OPTIONAL_PROVIDERS.filter(p => !linkedSources.has(p));
497+
const currentStr = [...linkedSources].join(',');
492498

493-
for (const provider of missingPrimary) {
499+
for (const provider of PRIMARY_PROVIDERS) {
500+
if (linkedSources.has(provider)) continue;
501+
// Only flag ancestry if the child in the chain is already linked to ancestry
502+
if (provider === 'ancestry' && !childHasAncestry) continue;
494503
issues.push(makeIssue(
495504
runId, personId, 'unlinked_provider', 'info',
496-
`${displayName} is linked to ${[...linkedSources].join(', ')} but not ${provider}`,
497-
[...linkedSources].join(','), provider, provider,
505+
`${displayName} is linked to ${currentStr} but not ${provider}`,
506+
currentStr, provider, provider,
498507
));
499508
}
500-
for (const provider of missingOptional) {
509+
for (const provider of OPTIONAL_PROVIDERS) {
510+
if (linkedSources.has(provider)) continue;
501511
issues.push(makeIssue(
502512
runId, personId, 'unlinked_provider', 'hint',
503-
`${displayName} is linked to ${[...linkedSources].join(', ')} but not ${provider}`,
504-
[...linkedSources].join(','), provider, provider,
513+
`${displayName} is linked to ${currentStr} but not ${provider}`,
514+
currentStr, provider, provider,
505515
));
506516
}
507517

508-
return issues;
518+
return { issues, linkedSources };
509519
}
510520

511521
function checkDateMismatches(runId: string, personId: string, displayName: string): AuditIssue[] {
@@ -790,13 +800,17 @@ const DEFAULT_CONFIG: AuditRunConfig = {
790800

791801
/**
792802
* Run structural checks on a single person.
793-
* Returns all issues found and the person's display name.
803+
* Returns all issues found, the person's display name, and their linked providers.
804+
* childHasAncestry: whether this person's child (closer to root) has an ancestry link.
794805
*/
795-
function auditPerson(runId: string, personId: string, checksEnabled: AuditIssueType[]): { issues: AuditIssue[]; displayName?: string } {
806+
function auditPerson(
807+
runId: string, personId: string, checksEnabled: AuditIssueType[], childHasAncestry: boolean,
808+
): { issues: AuditIssue[]; displayName?: string; linkedSources: Set<string> } {
796809
const vitals = getPersonVitals(personId);
797-
if (!vitals) return { issues: [] };
810+
if (!vitals) return { issues: [], linkedSources: new Set() };
798811

799812
const issues: AuditIssue[] = [];
813+
let linkedSources = new Set<string>();
800814

801815
if (checksEnabled.includes('impossible_date')) {
802816
issues.push(...checkImpossibleDates(runId, vitals));
@@ -814,13 +828,22 @@ function auditPerson(runId: string, personId: string, checksEnabled: AuditIssueT
814828
issues.push(...checkOrphanedEdges(runId, vitals.personId));
815829
}
816830
if (checksEnabled.includes('unlinked_provider')) {
817-
issues.push(...checkUnlinkedProviders(runId, vitals.personId, vitals.displayName));
831+
const result = checkUnlinkedProviders(runId, vitals.personId, vitals.displayName, childHasAncestry);
832+
issues.push(...result.issues);
833+
linkedSources = result.linkedSources;
834+
} else {
835+
// Still need linked sources for ancestry chain tracking even if check is disabled
836+
const linked = sqliteService.queryAll<{ source: string }>(
837+
'SELECT DISTINCT source FROM external_identity WHERE person_id = @personId',
838+
{ personId }
839+
);
840+
linkedSources = new Set(linked.map(l => l.source));
818841
}
819842
if (checksEnabled.includes('date_mismatch')) {
820843
issues.push(...checkDateMismatches(runId, vitals.personId, vitals.displayName));
821844
}
822845

823-
return { issues, displayName: vitals.displayName };
846+
return { issues, displayName: vitals.displayName, linkedSources };
824847
}
825848

826849
/**
@@ -904,6 +927,8 @@ async function* runAudit(
904927
};
905928

906929
const checkedSet = new Set(cursor.checkedPersonIds);
930+
// Persons whose child (closer to root) has an ancestry link — ancestry chain is reachable
931+
const childAncestryReachable = new Set<string>();
907932
let { personsChecked, issuesFound, fixesApplied } = run;
908933
let batchCount = 0;
909934

@@ -951,8 +976,14 @@ async function* runAudit(
951976

952977
if (checkedSet.has(personId)) continue;
953978

954-
// Run checks (also returns display name, avoiding redundant query)
955-
const { issues, displayName } = auditPerson(run.runId, personId, run.config.checksEnabled);
979+
// For root person (gen 0), ancestry chain is always reachable.
980+
// For others, only if their child already has ancestry.
981+
const childHasAncestry = currentGen === 0 || childAncestryReachable.has(personId);
982+
983+
// Run checks
984+
const { issues, displayName, linkedSources } = auditPerson(
985+
run.runId, personId, run.config.checksEnabled, childHasAncestry,
986+
);
956987

957988
// Persist issues
958989
if (issues.length > 0) {
@@ -986,13 +1017,17 @@ async function* runAudit(
9861017
}
9871018

9881019
// Queue parents for next generation
1020+
// If this person has ancestry, mark parents so they know the chain is reachable
9891021
const parents = sqliteService.queryAll<{ parent_id: string }>(
9901022
'SELECT parent_id FROM parent_edge WHERE child_id = @personId',
9911023
{ personId }
9921024
);
9931025
for (const p of parents) {
9941026
if (!checkedSet.has(p.parent_id)) {
9951027
nextGenPersonIds.push(p.parent_id);
1028+
if (linkedSources.has('ancestry')) {
1029+
childAncestryReachable.add(p.parent_id);
1030+
}
9961031
}
9971032
}
9981033
}
@@ -1086,9 +1121,12 @@ function auditPath(
10861121

10871122
const allIssues: AuditIssue[] = [];
10881123

1124+
// Path is ordered root → target; track ancestry chain along the path
1125+
let childHasAncestry = true; // root always starts the chain
10891126
sqliteService.transaction(() => {
10901127
for (const personId of personIds) {
1091-
const { issues } = auditPerson(run.runId, personId, checksEnabled);
1128+
const { issues, linkedSources } = auditPerson(run.runId, personId, checksEnabled, childHasAncestry);
1129+
childHasAncestry = linkedSources.has('ancestry');
10921130
for (const issue of issues) {
10931131
insertIssue(issue);
10941132
}
@@ -1104,11 +1142,14 @@ function auditPath(
11041142

11051143
/**
11061144
* Get issues grouped by person ID for tree overlay.
1107-
* Returns a map of personId -> issue count and max severity.
1145+
* Returns issue counts per person + set of all audited person IDs.
11081146
*/
11091147
function getIssueOverlay(
11101148
dbId: string,
1111-
): Record<string, { count: number; maxSeverity: AuditIssueSeverity; types: AuditIssueType[] }> {
1149+
): {
1150+
issues: Record<string, { count: number; maxSeverity: AuditIssueSeverity; types: AuditIssueType[] }>;
1151+
auditedPersonIds: string[];
1152+
} {
11121153
const rows = sqliteService.queryAll<{
11131154
person_id: string;
11141155
count: number;
@@ -1118,7 +1159,7 @@ function getIssueOverlay(
11181159
`SELECT
11191160
ai.person_id,
11201161
COUNT(*) as count,
1121-
MIN(CASE ai.severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END) as max_severity,
1162+
MIN(CASE ai.severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 WHEN 'info' THEN 2 ELSE 3 END) as max_severity,
11221163
GROUP_CONCAT(DISTINCT ai.issue_type) as types
11231164
FROM audit_issue ai
11241165
JOIN audit_run ar ON ai.run_id = ar.run_id
@@ -1127,16 +1168,28 @@ function getIssueOverlay(
11271168
{ dbId }
11281169
);
11291170

1130-
const result: Record<string, { count: number; maxSeverity: AuditIssueSeverity; types: AuditIssueType[] }> = {};
1131-
const severityMap = ['error', 'warning', 'info'] as const;
1171+
const issues: Record<string, { count: number; maxSeverity: AuditIssueSeverity; types: AuditIssueType[] }> = {};
1172+
const severityMap = ['error', 'warning', 'info', 'hint'] as const;
11321173
for (const row of rows) {
1133-
result[row.person_id] = {
1174+
issues[row.person_id] = {
11341175
count: row.count,
11351176
maxSeverity: severityMap[row.max_severity as unknown as number] ?? 'info',
11361177
types: (row.types?.split(',') ?? []) as AuditIssueType[],
11371178
};
11381179
}
1139-
return result;
1180+
1181+
// Get audited person IDs from the latest completed or running run's cursor
1182+
const latestRun = sqliteService.queryOne<{ cursor: string | null }>(
1183+
`SELECT cursor FROM audit_run
1184+
WHERE db_id = @dbId AND status IN ('completed', 'running', 'paused')
1185+
ORDER BY started_at DESC LIMIT 1`,
1186+
{ dbId }
1187+
);
1188+
const auditedPersonIds: string[] = latestRun?.cursor
1189+
? (JSON.parse(latestRun.cursor) as AuditCursor).checkedPersonIds
1190+
: [];
1191+
1192+
return { issues, auditedPersonIds };
11401193
}
11411194

11421195
// ============================================================================

0 commit comments

Comments
 (0)