Task 5.23: Deduplication / Merge — ContactMergeHistory model, dedup service, API routes (duplicates/merge/merge-history), DedupDialog frontend, i18n, 5 tests

This commit is contained in:
Agent Zero
2026-07-23 23:58:45 +02:00
parent a914280a4e
commit d54a87cf84
11 changed files with 1051 additions and 3 deletions
+70
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import csv
import io
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
@@ -19,10 +20,30 @@ from app.schemas.contact import (
ContactPersonUpdate,
)
from app.services import contact_service
from app.services import dedup_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
# ── Deduplication / Merge (Task 5.23) ──────────────────────────────────────────
from pydantic import BaseModel, Field
class DuplicateCheckRequest(BaseModel):
"""Request body for duplicate detection."""
threshold: float = Field(default=0.7, ge=0.0, le=1.0)
limit: int = Field(default=50, ge=1, le=200)
class MergeRequest(BaseModel):
"""Request body for merging two contacts."""
source_contact_id: str
target_contact_id: str
field_overrides: dict[str, Any] | None = Field(default=None)
note: str | None = Field(default=None)
@router.get("")
async def list_contacts(
page: int = Query(1, ge=1),
@@ -77,6 +98,18 @@ async def create_contact(
return await contact_service.create_contact(db, tenant_id, user_id, data)
@router.get("/merge-history")
async def get_contact_merge_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Get paginated merge history for the tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await dedup_service.get_merge_history(db, tenant_id, page=page, page_size=page_size)
@router.get("/{contact_id}")
async def get_contact(
contact_id: str,
@@ -188,3 +221,40 @@ async def delete_contact_person(
await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ── Deduplication / Merge endpoints (Task 5.23) ───────────────────────────────
@router.post("/duplicates")
async def find_duplicate_contacts(
body: DuplicateCheckRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Find potential duplicate contacts within the tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await dedup_service.find_duplicates(
db, tenant_id, threshold=body.threshold, limit=body.limit
)
@router.post("/merge")
async def merge_duplicate_contacts(
body: MergeRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Merge two contacts (source → target)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
return await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=body.source_contact_id,
target_id=body.target_contact_id,
field_overrides=body.field_overrides,
note=body.note,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))