15 KiB
🔍 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
- Backend Address Models — Current State
- Frontend Reusable Components — Address/Phone
- Phone Number Validation — Audit
- Search APIs — Current State
- Duplication Summary Table
- 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 ataddress.py:12GeoStreet— street names ataddress.py:22GeoStreetType— street type codes (utca, tér, etc.) ataddress.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 selectorTreeNode.vue— recursive tree for category selectiongarages/— 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.vuecomponent - Reusable
PhoneInput.vuecomponent - 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.phonestores user registration phone numbersOrganization.phonestores company contact numbersBranch.phonestores branch-specific contact numbersServiceProfile.contact_phonestores service provider contactServiceStaging.contact_phonestores 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.keyand 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:
- Only
search/matchuses PostGIS providers/searchhas pagination but no geolocationservices/searchhas neither- No trigram indexing → ILIKE scans will degrade with data growth
- No full-text search (tsvector)
- 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_trgmextension + GIN indexes for fuzzy text matchingST_DWithinfor 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.