Files
service-finder/docs/p0_address_unification_plan.md
2026-07-08 08:03:57 +00:00

15 KiB

P0 Address Unification Plan

Split-Brain Diagnosis: marketplace.service_providers vs. system.addresses

Date: 2026-07-07
Author: Rendszer-Architect
Status: Discovery Complete — Awaiting Approval for Implementation


1. Executive Summary

The system suffers from a split-brain address architecture. Two models use the centralized system.addresses table via address_id UUID foreign keys correctly (fleet.organizations, identity.persons), but the marketplace.service_providers table still relies entirely on legacy flat columns for address storage.

The ServiceStaging model is in a hybrid state — it has both flat columns and an address_id FK — and the admin API (admin_providers.py) already has partial AddressManager integration. However, this creates a dual-write pattern: data flows to both the flat columns AND the normalized system.addresses table, with complex fallback logic.

The Organization model was already fully refactored — the deprecated flat columns (address_city, address_zip, etc.) were physically removed from the database via cleanup_ghost_columns_2026_07.sql. The ServiceProvider model needs the same treatment.


2. Full System Audit Results

2.1 Model Status Matrix

Model Table Schema Flat Columns address_id FK Status
Address system.addresses system (central) UUID PK (self) Source of Truth
Organization fleet.organizations fleet (removed) UUID FK (3x) Fully Refactored
Person identity.persons identity UUID FK Already correct
Branch fleet.branches fleet UUID FK Already correct
ServiceProfile marketplace.service_profiles marketplace (uses location Geometry) Clean design
ServiceStaging marketplace.service_staging marketplace (city, postal_code, full_address) UUID FK ⚠️ Hybrid — needs cleanup
ServiceProvider marketplace.service_providers marketplace (city, address_zip, address_street_name, address_street_type, address_house_number) NONE 🔴 CORE PROBLEM

2.2 ServiceProvider Model — The Core Problem

File: backend/app/models/identity/social.py

The ServiceProvider model at lines 23-71 defines these flat address columns:

city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)           # line 38
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)      # line 39
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)  # line 40
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)   # line 41
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)  # line 42

There is NO address_id UUID foreign key to system.addresses.

This means:

  • Address data cannot be shared/deduplicated across entities
  • No postal code normalization (no GeoPostalCode relationship)
  • The AddressManager creates system.addresses records but the results are mapped back to flat columns instead of storing the FK
  • The _build_unified_providers_query() in admin_providers.py must use a complex UNION ALL with flat field extraction

2.3 ServiceStaging Model — Hybrid State

File: backend/app/models/marketplace/staged_data.py

The ServiceStaging model at line 81 already has the FK:

address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
    PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
)  # line 81

But it ALSO retains flat columns at lines 25-27:

city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
postal_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
full_address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)

This hybrid state means the API writes to BOTH the address_id FK and the flat columns, creating data duplication.

2.4 Admin API — Partial Fix Complexity

File: backend/app/api/v1/endpoints/admin_providers.py

The update_provider() function at line 1157 has complex branching logic:

  • Lines 1242-1262: For ServiceStaging (which has address_id FK) → uses AddressManager.create_or_update() then sets provider_obj.address_id
  • Lines 1262-1302: For ServiceProvider (NO address_id FK) → uses AddressManager.create_or_update() then maps the result back to 12+ flat columns via addr_mapping (lines 1277-1302)

This dual-write pattern is fragile and error-prone. The addr_mapping dict at lines 1277-1302 maps each AddressIn field to its flat column counterpart.

Similarly, on response:

  • Lines 1507-1518: For ServiceProvider → tries to resolve address_detail from address_id (which may be NULL for existing records)
  • Lines 1548-1560: For ServiceStaging → resolves address_detail from address_id

The _build_unified_providers_query() at line 284 uses UNION ALL across 3 tables with flat field extraction:

-- ServiceProvider branch (lines 306-320): selects flat columns
-- ServiceStaging branch (lines 335-352): selects flat columns  
-- Organization branch (lines 369-390): JOINs with system.addresses

2.5 Frontend Status

Page Path SharedAddressForm address_detail Flat Fallback
Provider Edit providers/[id]/edit.vue (line 343) (lines 810-823) ⚠️ (lines 1133-1134)
Provider Detail providers/[id]/index.vue (lines 144-166) (lines 146, 153)
Provider List providers/index.vue uses provider.address only (line 137)
Garage Edit garages/[id]/index.vue (line 534) (lines 1014-1023)
Garage List garages/index.vue uses garage.city only (line 157)

2.6 Already-Refactored Endpoints (Reference)

These endpoints already use AddressManager correctly and serve as reference implementations:

Endpoint File How
Admin Organizations admin_organizations.py Lines 1097-1145: AddressOut.model_validate(org.address) for 3 address types
User Organizations organizations.py Lines 71-82: AddressManager.create_or_update() on org creation
Admin Persons admin_persons.py Lines 654-661: AddressManager.create_or_update() for person.address
User Profile users.py Lines 355-374: GeoService.get_or_create_full_address()

3. Migration Strategy: 3-Phase Plan

flowchart TD
    subgraph Phase1["Phase 1: Database (Alembic)"]
        A1[Add address_id UUID FK to marketplace.service_providers]
        A2[Create data migration: backfill system.addresses from flat columns]
        A3[Set FK constraint + add relationship to ServiceProvider model]
        A4[Run sync_engine to verify schema]
    end

    subgraph Phase2["Phase 2: Backend API"]
        B1[Simplify update_provider: remove addr_mapping logic]
        B2[Refactor _build_unified_providers_query: JOIN with system.addresses]
        B3[Update ProviderListItem/ProviderDetail schemas]
        B4[Remove flat address field fallback in get_provider_detail]
    end

    subgraph Phase3["Phase 3: Frontend"]
        C1[providers/index.vue: show address_detail instead of flat address]
        C2[providers/[id]/index.vue: remove flat fallback]
        C3[Clean up flat address fields from edit.vue]
        C4[garages/index.vue: show address_detail instead of flat city]
    end

    Phase1 --> Phase2 --> Phase3

Phase 1 — Database Migration (Model + sync_engine)

Goal: Add address_id FK to ServiceProvider and migrate existing data.

Steps:

  1. Add address_id column to ServiceProvider model in social.py:

    address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
        PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
    )
    address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
    
  2. Create data migration script that:

    • Iterates all ServiceProvider records with flat address data and NULL address_id
    • For each record, creates a system.addresses record via AddressManager.create_or_update()
    • Sets service_providers.address_id to the new UUID
    • Logs the migration results
  3. Run sync_engine: docker exec sf_api python /app/backend/app/scripts/sync_engine.py

  4. Verify: Query service_providers to confirm address_id is populated.

  5. Deferred: Drop flat columns in a future migration after Phase 3 is complete and verified.

Phase 2 — Backend API Refactor

Goal: Simplify admin_providers.py to use AddressManager directly without flat column mapping.

Key changes in admin_providers.py:

  1. Simplify update_provider() (lines 1242-1302):

    • Remove the addr_mapping dict (lines 1277-1302) for ServiceProvider
    • Both branches now just call AddressManager.create_or_update() and set provider_obj.address_id
    • Remove flat column updates for address_zip, address_street_name, etc.
  2. Refactor _build_unified_providers_query() (lines 284-407):

    • For ServiceProvider: Add LEFT JOIN to system.addresses and extract fields via relationship
    • Remove flat column SELECT for ServiceProvider
    • Keep flat fallback temporarily for backward compatibility
  3. Update response schemas (lines 59-105, 134-151):

    • ProviderListItem: Add address_detail: Optional[AddressOut]
    • ProviderDetail: Mark flat address and city as DEPRECATED
    • ProviderUpdateInput: Keep address_detail as primary, mark flat fields deprecated
  4. Simplify get_provider_detail() (lines 534-659):

    • Remove flat field fallback (lines 586-587: sp.address or "")
    • Always resolve from address_id via AddressManager.get_normalized()

Phase 3 — Frontend Cleanup

Goal: All provider/garage pages display and edit addresses via address_detail only.

Detailed changes:

  1. providers/index.vue (line 137): Replace provider.address with provider.address_detail?.full_address_text || [provider.address_detail?.zip, ...].filter(Boolean).join(' ')

  2. providers/[id]/index.vue (lines 144-166): Remove || provider.address and || provider.city fallbacks (lines 146, 153)

  3. providers/[id]/edit.vue (lines 1133-1134): Remove data.address fallback from data.address_detail || data.address || {}

  4. garages/index.vue (line 157): Replace garage.city || '—' with garage.address_detail?.city || '—' or similar


4. Risk Assessment

Risk Impact Mitigation
Data loss during migration (flat → normalized) High Migration script must be transactional with rollback. Test on staging first.
Existing API consumers send flat address fields Medium Keep deprecated fields in schemas with [DEPRECATED] tags for one release cycle
Frontend list view breaks if address_detail is missing Medium Add fallback to flat fields in list view during transition period
Service robots (OSM Scout, OCR) write flat columns directly Medium Audit service_robot_1_scout_osm.py — it's in the modified files list
Unified query (3-way UNION ALL) breaks High Refactor query carefully to maintain backward compatibility

5. Acceptance Criteria

Phase 1 — Database Migration

  • marketplace.service_providers has address_id UUID FK → system.addresses.id
  • All existing ServiceProvider records with flat address data have corresponding system.addresses records linked via address_id
  • ServiceProvider model has address relationship with lazy="selectin"
  • sync_engine reports no schema drift

Phase 2 — Backend API

  • update_provider() no longer maps AddressIn fields to flat columns for ServiceProvider
  • get_provider_detail() returns address_detail from address_id relationship for all providers
  • list_providers() includes address_detail in each item
  • _build_unified_providers_query() uses LEFT JOIN for ServiceProvider instead of flat column extraction
  • Flat address fields (address, city) still work in API requests but are marked [DEPRECATED] in schemas

Phase 3 — Frontend

  • Provider list page shows addresses from address_detail (not flat provider.address)
  • Provider detail page has no flat fallback for address fields
  • Provider edit page sends address_detail only (no flat fields)
  • Garage list page shows city from address_detail

6. References