feat(server): add unified entity manager with duplicate detection

- Remove author insights and duplicate suggestions panels, composables, DTOs, and all backend support
- Add genre and tag duplicate detection, merge, and dismiss with server-side paginated scan results
- Consolidate per-entity-type tools into a single entity-manager module backed by the Strategy pattern
- Support browse, merge, rename, delete, split, and dismiss across all entity types (author, genre, tag, narrator, publisher, language, series)
- Replace old tools module with unified entity-manager controller, service, and repository; add 85+ unit tests
This commit is contained in:
neon
2026-04-20 19:10:47 -06:00
parent 9ee9d14d94
commit ce6a439062
69 changed files with 5721 additions and 1003 deletions
+13
View File
@@ -64,6 +64,13 @@ export enum AuditAction {
AuthorEnrichmentResume = "author.enrichment.resume",
AuthorEnrichmentCancel = "author.enrichment.cancel",
EntityManagerMerge = "entity_manager.merge",
EntityManagerRename = "entity_manager.rename",
EntityManagerDelete = "entity_manager.delete",
EntityManagerSplit = "entity_manager.split",
EntityManagerDismiss = "entity_manager.dismiss",
EntityManagerUndismiss = "entity_manager.undismiss",
AppSettingsUpdate = "app_settings.update",
KoboDeviceRegister = "kobo.device.register",
@@ -102,11 +109,17 @@ export enum AuditResource {
BookBucketFile = "book_bucket_file",
Author = "author",
AppSettings = "app_settings",
Genre = "genre",
Tag = "tag",
KoboDevice = "kobo_device",
EmailProvider = "email_provider",
EmailTemplate = "email_template",
EmailRecipient = "email_recipient",
EmailRecipientGroup = "email_recipient_group",
Narrator = "narrator",
Publisher = "publisher",
Language = "language",
Series = "series",
OidcIdentity = "oidc_identity",
}
-24
View File
@@ -29,30 +29,6 @@ export type MergeAuthorsResult = {
affectedBookCount: number;
};
export type AuthorDuplicateSuggestion = {
left: AuthorSummary;
right: AuthorSummary;
confidence: number;
reasons: string[];
};
export type AuthorInsightsRow = {
id: number;
name: string;
bookCount: number;
lastAddedAt: string | null;
metric: number;
secondaryMetric?: number | null;
};
export type AuthorInsights = {
generatedAt: string;
windowDays: number;
newAuthors: AuthorInsightsRow[];
mostRead: AuthorInsightsRow[];
unreadBacklog: AuthorInsightsRow[];
};
export const AuthorMetadataProviderKey = {
AUDNEXUS: "audnexus",
} as const;
+131
View File
@@ -0,0 +1,131 @@
export type EntityType = "author" | "genre" | "tag" | "narrator" | "publisher" | "language" | "series";
export type FirstClassEntityType = "author" | "genre" | "tag" | "narrator";
export type InlineEntityType = "publisher" | "language" | "series";
export const FIRST_CLASS_ENTITY_TYPES: readonly FirstClassEntityType[] = ["author", "genre", "tag", "narrator"] as const;
export const INLINE_ENTITY_TYPES: readonly InlineEntityType[] = ["publisher", "language", "series"] as const;
export const ALL_ENTITY_TYPES: readonly EntityType[] = [...FIRST_CLASS_ENTITY_TYPES, ...INLINE_ENTITY_TYPES] as const;
export interface EntityTypeCapabilities {
canSplit: boolean;
hasSoftDelete: boolean;
hasPhoto: boolean;
hasSortName: boolean;
}
export const ENTITY_CAPABILITIES: Record<EntityType, EntityTypeCapabilities> = {
author: { canSplit: true, hasSoftDelete: true, hasPhoto: true, hasSortName: true },
genre: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: false },
tag: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: false },
narrator: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: true },
publisher: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
language: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
series: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
};
// Browse
export interface BrowseEntityItem {
id: number | string;
name: string;
bookCount: number;
sortName?: string | null;
hasPhoto?: boolean;
}
export interface BrowseEntitiesResponse {
items: BrowseEntityItem[];
total: number;
page: number;
pageSize: number;
}
// Duplicate cluster
export interface DuplicateCluster {
clusterId: string;
entities: ClusterEntity[];
averageSimilarity: number;
suggestedTargetId: number | string;
pairDetails: PairDetail[];
}
export interface ClusterEntity {
id: number | string;
name: string;
bookCount: number;
bookTitles: string[];
sortName?: string | null;
hasPhoto?: boolean;
}
export interface PairDetail {
idA: number | string;
idB: number | string;
similarity: number;
reasons: string[];
}
export interface DuplicateScanResponse {
entityType: EntityType;
clusters: DuplicateCluster[];
totalEntities: number;
total: number;
page: number;
pageSize: number;
}
// Operation results
export interface MergeResult {
targetId: number | string;
mergedIds: (number | string)[];
affectedBookCount: number;
imagePromoted?: boolean;
fieldsResolved?: string[];
}
export interface RenameResult {
entityId: number | string;
oldName: string;
newName: string;
affectedBookCount: number;
wasImplicitMerge: boolean;
mergedEntityId?: number | string;
}
export interface DeleteResult {
entityId: number | string;
name: string;
affectedBookCount: number;
mode: "soft" | "hard" | "inline";
}
export interface SplitResult {
originalId: number;
originalName: string;
newEntities: { id: number; name: string }[];
affectedBookCount: number;
}
export interface BulkDeleteResult {
results: DeleteResult[];
errors: { entityId: number | string; error: string }[];
}
// Dismissed pair
export interface DismissedPairInfo {
id: number;
entityType: EntityType;
nameA: string;
nameB: string;
idA: number | string;
idB: number | string;
reason?: string | null;
dismissedAt: string;
}
// Entity info (for modals)
export interface EntityInfo {
id: number | string;
name: string;
bookCount: number;
bookTitles: string[];
}
+1
View File
@@ -29,3 +29,4 @@ export * from "./migration";
export * from "./notification";
export * from "./series";
export * from "./series-collapse";
export * from "./entity-manager";