refaktor címtár
This commit is contained in:
356
plans/p0_unified_address_search_audit.md
Normal file
356
plans/p0_unified_address_search_audit.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# 🔍 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.*
|
||||
Reference in New Issue
Block a user