refaktor címtár

This commit is contained in:
Roo
2026-07-01 19:49:58 +00:00
parent 6e627d0ebe
commit 7654913d21
198 changed files with 3485 additions and 1609 deletions

View 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.*