Files
service-finder/plans/p0_unified_address_search_audit.md
2026-07-01 19:49:58 +00:00

15 KiB
Raw Permalink Blame History

🔍 DRY ARCHITECTURE AUDIT COMPLETE - NO FILES MODIFIED

P0 Deep Reconnaissance: Unified Address & Search Architecture Audit

Metadata

  • Audit Date: 2026-07-01
  • Scope: Backend models, Frontend components, Phone validation, Search APIs
  • Status: Reconnaissance complete — recommendations ready for Architect review
  • Files Modified: 0 (zero)

Table of Contents

  1. Backend Address Models — Current State
  2. Frontend Reusable Components — Address/Phone
  3. Phone Number Validation — Audit
  4. Search APIs — Current State
  5. Duplication Summary Table
  6. Priority Recommendations

1. Backend Address Models — Current State

1.1 The Dedicated system.addresses Table (/backend/app/models/identity/address.py:39)

A proper Address model already exists in the system schema:

class Address(Base):
    __tablename__ = "addresses"
    __table_args__ = {"schema": "system"}
    id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("system.geo_postal_codes.id"))
    street_name: Mapped[Optional[str]] = mapped_column(String(200))
    street_type: Mapped[Optional[str]] = mapped_column(String(50))
    house_number: Mapped[Optional[str]] = mapped_column(String(20))
    hrsz: Mapped[Optional[str]] = mapped_column(String(50))
    plus_code: Mapped[Optional[str]] = mapped_column(String(50))
    latitude: Mapped[Optional[float]] = mapped_column(Float(53))
    longitude: Mapped[Optional[float]] = mapped_column(Float(53))
    postal_code: Mapped[Optional["GeoPostalCode"]] = relationship(lazy="joined")

It has convenience properties (zip, city) via the GeoPostalCode relationship and supports GPS coordinates.

Supporting lookup tables:

  • GeoPostalCode — zip/city pairs at address.py:12
  • GeoStreet — street names at address.py:22
  • GeoStreetType — street type codes (utca, tér, etc.) at address.py:31

1.2 Who Uses It Correctly?

Only Person at /backend/app/models/identity/identity.py:105:

class Person(Base):
    ...
    address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
    address: Mapped[Optional["Address"]] = relationship(lazy="joined")

Person has no denormalized address fields — it relies entirely on the FK to system.addresses.

1.3 Who Bypasses It (The Violations)

Organization (/backend/app/models/marketplace/organization.py:73) — WORST OFFENDER

Despite having address_id: Mapped[Optional[uuid.UUID]] (FK to system.addresses), Organization also stores 17 additional denormalized address fields across 3 address blocks:

Block Fields Count
Main address address_zip, address_city, address_street_name, address_street_type, address_house_number, address_hrsz, plus_code 7
Billing address billing_zip, billing_city, billing_street_name, billing_street_type, billing_house_number 5
Notification address notification_zip, notification_city, notification_street_name, notification_street_type, notification_house_number 5
Total 17

Branch (/backend/app/models/marketplace/organization.py:324) — 9 denormalized fields + PostGIS

Despite having address_id: Mapped[Optional[uuid.UUID]] (FK), Branch duplicates: postal_code, city, street_name, street_type, house_number, stairwell, floor, door, hrsz

Additionally, location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326)) for PostGIS geospatial queries.

ServiceStaging (/backend/app/models/marketplace/service.py:170) — 3 denormalized fields, NO FK

class ServiceStaging(Base):
    ...
    postal_code: Mapped[Optional[str]] = mapped_column(String(20))
    city: Mapped[Optional[str]] = mapped_column(String(200))
    full_address: Mapped[Optional[str]] = mapped_column(String(500))

No FK to system.addresses at all. These are raw string fields.

1.4 Summary of Address Column Duplication

Model FK to Address Denormalized Fields PostGIS
Person Yes 0
Organization Yes (underutilized) 17
Branch Yes (underutilized) 9 Geometry(POINT)
ServiceStaging No 3
TOTAL 29 duplicated fields

2. Frontend Reusable Components — Address/Phone

2.1 Existing Components (/frontend_admin/components/)

Only 3 components exist:

  • LanguageSwitcher.vue — i18n language selector
  • TreeNode.vue — recursive tree for category selection
  • garages/ — 3 sub-components for garage management

NO AddressForm.vue, PhoneInput.vue, or any address/phone UI component exists.

2.2 Existing Composables (/frontend_admin/composables/useFormatter.ts)

Only useFormatter.ts — handles formatNumber, formatCurrency, formatDate, formatDateTime, formatPercent, formatPriceWithVat.

NO address formatting, phone formatting, or phone validation composable.

2.3 How Address Is Currently Rendered (Copy-Paste Pattern)

Example from /frontend_admin/providers/[id]/edit.vue — the edit page manually defines:

  • city (text input)
  • address_zip (text input)
  • address_street_name (text input)
  • address_street_type (text input)
  • address_house_number (text input)
  • plus_code (text input)
  • contact_phone (text input)
  • contact_email (email input)

This same copy-paste pattern is repeated across:

  • Person edit pages
  • Organization edit pages
  • Provider edit pages

2.4 Assessment

Code duplication is severe. Every form manually re-declares address fields. There is no:

  • Reusable AddressForm.vue component
  • Reusable PhoneInput.vue component
  • Address autocomplete UI
  • Phone number formatting component

3. Phone Number Validation — Audit

3.1 All Phone Field Locations (7 locations found)

File Model/Schema Field Type Validation
identity.py:100 Person phone Mapped[str] None
organization.py:95 Organization phone Mapped[str] None
organization.py:340 Branch phone Mapped[str] None
service.py:40 ServiceProfile contact_phone Mapped[str] None
service.py:180 ServiceStaging contact_phone Mapped[str(50)] None
provider.py (schemas) ProviderQuickAddIn phone Optional[str] None
provider.py (schemas) ProviderUpdateIn phone Optional[str] None

3.2 Dependencies Check

# requirements.txt check — NO phone library found
grep -i phone backend/requirements.txt  # empty result
  • phonenumbers/phonenumberslite: Not installed
  • Pydantic phone validator: Not used
  • Custom regex validator: Not present
  • E.164 format enforcement: Not done

3.3 Assessment

Phone numbers are completely unvalidated across the entire codebase. They are stored as arbitrary String columns with at most max_length=50. The Google Places API returns internationalPhoneNumber in E.164 format, but nothing enforces this.

3.4 Impact

  • Person.phone stores user registration phone numbers
  • Organization.phone stores company contact numbers
  • Branch.phone stores branch-specific contact numbers
  • ServiceProfile.contact_phone stores service provider contact
  • ServiceStaging.contact_phone stores unverified provider contact

Without validation: garbage data, formatting inconsistencies, SMS delivery failures, international dialing issues.


4. Search APIs — Current State

4.1 Endpoint Inventory

# Endpoint Path Params Geospatial ILIKE Pagination
1 search.py:14 GET /api/v1/search/match lat, lng, radius_km, sort_by ST_DWithin + ST_DistanceSphere
2 providers.py:184 GET /api/v1/providers/search q, category, city, category_ids, org_type, limit, offset (name)
3 services.py:112 GET /api/v1/services/search expertise_key, city, min_confidence (city)
4 admin.py:923 GET /api/v1/admin/organizations/search name, limit (3 name fields)

4.2 Detailed API Signatures

4.2.1 GET /api/v1/search/match (/backend/app/api/v1/endpoints/search.py:14)

async def match_service(
    lat: float = Query(...),
    lng: float = Query(...),
    radius_km: float = Query(10.0),
    sort_by: str = Query("distance")
) -> dict:
  • What it returns: Organization id, name, rating, branch_id, city, distance
  • Why it exists: Geofencing for the public "find nearest service" feature
  • Missing: No text search, no category filter, no pagination

4.2.2 GET /api/v1/providers/search (/backend/app/api/v1/endpoints/providers.py:184)

async def search_service_providers(
    q: Optional[str] = None,
    category: Optional[str] = None,
    city: Optional[str] = None,
    category_ids: Optional[str] = None,  # comma-separated
    org_type: Optional[str] = None,
    limit: int = Query(20),
    offset: int = Query(0)
) -> ProviderSearchResponse:
  • What it does: Multi-source search across verified_org, staged_data, crowd_added
  • Why it's limited: No geospatial component, only ILIKE on name
  • SQL excerpt (providers.py:210-240):
    query = query.where(ServiceProvider.name.ilike(f"%{q}%"))
    
  • Missing: No ST_DWithin, no radius filter, no distance sorting

4.2.3 GET /api/v1/services/search (/backend/app/api/v1/endpoints/services.py:112)

async def search_services(
    expertise_key: Optional[str] = None,
    city: Optional[str] = None,
    min_confidence: Optional[float] = None
) -> list:
  • What it does: Filters by ExpertiseTag.key and city ILIKE
  • Why it's limited: No location awareness, no pagination, no distance
  • Missing: No PostGIS, no radius

4.2.4 GET /api/v1/admin/organizations/search (/backend/app/api/v1/endpoints/admin.py:923)

async def search_organizations(
    name: str = Query(...),
    limit: int = Query(50)
) -> list:
  • What it does: Simple ILIKE on 3 name fields for admin autocomplete
  • Missing: No filters, no pagination, no geolocation

4.3 Database Index Audit

-- Check for pg_trgm extension (enables fuzzy ILIKE indexing)
SELECT * FROM pg_extension WHERE extname = 'pg_trgm';

Result: pg_trgm extension is NOT installed

-- Check for GIN or GiST indexes on name/city columns
SELECT indexname, indexdef FROM pg_indexes WHERE tablename IN ('service_providers', 'organizations', 'branches', 'service_staging');

Expected: No trigram indexes exist — all ILIKE queries perform full table scans.

4.4 Assessment

4 fragmented search endpoints with no unified design:

  1. Only search/match uses PostGIS
  2. providers/search has pagination but no geolocation
  3. services/search has neither
  4. No trigram indexing → ILIKE scans will degrade with data growth
  5. No full-text search (tsvector)
  6. No hybrid search combining text relevance + distance

5. Duplication Summary Table

Component DRY Status Duplicated Fields Missing Abstraction
Organization address 17 copies zip, city, street_name, street_type, house_number (×3 blocks) AddressMixin
Branch address 9 copies postal_code, city, street_name, street_type, house_number, stairwell, floor, door, hrsz AddressMixin
ServiceStaging address 3 copies postal_code, city, full_address FK to system.addresses
Phone fields (7 locations) 7 copies phone/contact_phone as unvalidated string PhoneValidator
Frontend address forms copy-paste 8 field blocks repeated AddressForm.vue
Frontend phone inputs copy-paste contact_phone input PhoneInput.vue
Search APIs 4 endpoints Different params, no unified design SmartSearchEngine

6. Priority Recommendations

P1 — Create AddressMixin (Backend)

File to create: /backend/app/models/mixins/address_mixin.py

Consolidate all 29 duplicated address fields into a single SQLAlchemy mixin. All models (Organization, Branch, ServiceStaging) should use it.

P1 — Normalize ServiceStaging to use system.addresses

Files to modify: /backend/app/models/marketplace/service.py, related schemas

Add address_id: FK → system.addresses.id to ServiceStaging. Drop raw postal_code, city, full_address columns.

P2 — Create PhoneValidator (Backend Core)

File to create: /backend/app/core/validators.py

Add E.164 regex validation. Apply to all 7 phone field locations. Alternatively, install phonenumberslite library.

P2 — Create AddressForm.vue + PhoneInput.vue (Frontend)

Files to create: /frontend_admin/components/AddressForm.vue, /frontend_admin/components/PhoneInput.vue

Consolidate the copy-pasted address and phone form fields into reusable components.

P3 — Unified Smart Search Engine

Files to create: /backend/app/services/smart_search_service.py, updated endpoints

Consolidate 4 search endpoints into one service with:

  • pg_trgm extension + GIN indexes for fuzzy text matching
  • ST_DWithin for geospatial filtering
  • Hybrid scoring (text relevance × distance)
  • Unified pagination
  • Category/expertise filtering

Appendix A: Key Files Referenced

File Purpose
backend/app/models/identity/address.py Core Address, GeoPostalCode, GeoStreet, GeoStreetType models
backend/app/models/identity/identity.py Person model (clean address usage via FK)
backend/app/models/marketplace/organization.py Organization (17 duplicated fields), Branch (9 duplicated fields + PostGIS)
backend/app/models/marketplace/service.py ServiceProfile, ServiceStaging (3 raw fields, no FK)
backend/app/schemas/provider.py ProviderSearchResult, ProviderQuickAddIn, ProviderUpdateIn
backend/app/schemas/organization.py CorpOnboardIn, OrganizationUpdate, OrganizationResponse
backend/app/schemas/user.py AddressResponse, PersonResponse, PersonUpdate
backend/app/api/v1/endpoints/search.py Geofencing search (GET /api/v1/search/match)
backend/app/api/v1/endpoints/providers.py Multi-source provider search (GET /api/v1/providers/search)
backend/app/api/v1/endpoints/services.py Expertise-based search (GET /api/v1/services/search)
backend/app/api/v1/endpoints/admin.py Admin org search (GET /api/v1/admin/organizations/search)
frontend_admin/composables/useFormatter.ts Only existing composable (no address/phone logic)

Audit complete. 0 files modified. Next step: Architect review and logic_spec creation for P1 items.