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

357 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔍 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.*