"""Custom fields routes — merge plugin definitions with stored values.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Body from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_permission from app.models.contact import Contact from app.plugins.registry import get_registry router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"]) class CustomFieldUpdateRequest(BaseModel): """Request body for updating custom field values.""" values: dict[str, Any] = {} def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]: """Collect all custom field definitions from active plugin manifests.""" definitions: list[dict[str, Any]] = [] seen_names: set[str] = set() registry = get_registry() for plugin in registry._plugins.values(): manifest = plugin.manifest for cf in manifest.custom_fields: if cf.entity != entity: continue if cf.name in seen_names: continue seen_names.add(cf.name) definitions.append( { "name": cf.name, "label": cf.label, "label_key": cf.label_key, "field_type": cf.field_type, "options": cf.options, "default_value": cf.default_value, "required": cf.required, "entity": cf.entity, "plugin": manifest.name, } ) return definitions def _merge_definitions_with_values( definitions: list[dict[str, Any]], stored: dict[str, Any] | None ) -> list[dict[str, Any]]: """Merge field definitions with stored values, applying defaults.""" stored = stored or {} result: list[dict[str, Any]] = [] for d in definitions: name = d["name"] value = stored.get(name, d.get("default_value")) entry = {**d, "value": value} result.append(entry) return result @router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))]) async def get_custom_fields( contact_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get all custom fields for a contact (merged definitions + stored values).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) definitions = _collect_custom_field_definitions("contact") merged = _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged} @router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))]) async def update_custom_fields( contact_id: str, body: CustomFieldUpdateRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update custom field values for a contact (stored in contacts.custom JSONB).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) # Validate against definitions definitions = _collect_custom_field_definitions("contact") def_map = {d["name"]: d for d in definitions} current_custom = dict(contact.custom or {}) for name, value in body.values.items(): if name not in def_map: raise HTTPException( 400, detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"}, ) field_def = def_map[name] # Validate required if field_def["required"] and (value is None or value == ""): raise HTTPException( 400, detail={"detail": f"Field '{name}' is required", "code": "required_field"}, ) # Validate select/multiselect options if field_def["field_type"] == "select" and value is not None: if value not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"}, ) if field_def["field_type"] == "multiselect" and value is not None: if not isinstance(value, list): raise HTTPException( 400, detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"}, ) for v in value: if v not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"}, ) current_custom[name] = value contact.custom = current_custom await db.flush() merged = _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged}