# 🔍 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](#1-backend-address-models--current-state) 2. [Frontend Reusable Components — Address/Phone](#2-frontend-reusable-components--addressphone) 3. [Phone Number Validation — Audit](#3-phone-number-validation--audit) 4. [Search APIs — Current State](#4-search-apis--current-state) 5. [Duplication Summary Table](#5-duplication-summary-table) 6. [Priority Recommendations](#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: ```python 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`: ```python 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** ```python 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 ```bash # 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`) ```python 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`) ```python 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):** ```python 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`) ```python 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`) ```python 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 ```sql -- Check for pg_trgm extension (enables fuzzy ILIKE indexing) SELECT * FROM pg_extension WHERE extname = 'pg_trgm'; ``` **Result:** ❌ `pg_trgm` extension is **NOT installed** ```sql -- 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.*