diff --git a/FINAL-COMPLETION-SUMMARY.md b/FINAL-COMPLETION-SUMMARY.md deleted file mode 100644 index c8e0037..0000000 --- a/FINAL-COMPLETION-SUMMARY.md +++ /dev/null @@ -1,509 +0,0 @@ -# Gas Stations Feature - Complete Implementation Summary - -## πŸŽ‰ Status: 11/11 PHASES COMPLETE (10 Complete, 1 Pending Final Polish) - -All major implementation phases completed through parallel agent work. Only Phase 10 (Validation & Polish) remains for final linting and manual testing. - -## Executive Summary - -**Complete gas station discovery feature with:** -- Google Maps integration with circuit breaker resilience -- K8s-aligned runtime secrets management -- Full React Query integration with optimistic updates -- Mobile + Desktop responsive UI with bottom tab navigation -- Fuel logs integration with StationPicker autocomplete -- Comprehensive testing suite (69+ test cases) -- Production-grade documentation (11 docs) -- Deployment-ready with complete checklists - -**Files Created:** 60+ -**Test Coverage:** >80% backend, comprehensive frontend -**Documentation:** 11 comprehensive guides -**Time to Complete Remaining Work:** <2 hours (Phase 10 only) - ---- - -## Phases 1-9 & 11: Complete βœ… - -### Phase 1: Frontend Secrets Infrastructure βœ… -**Files Created:** 4 -- `frontend/scripts/load-config.sh` - Runtime secret loader -- `frontend/src/core/config/config.types.ts` - TypeScript types -- `frontend/docs/RUNTIME-CONFIG.md` - K8s pattern documentation -- Updated: `frontend/Dockerfile`, `frontend/index.html`, `docker-compose.yml` - -**Achievement:** K8s-aligned runtime configuration (secrets never in env vars or images) - -### Phase 2: Backend Improvements βœ… -**Files Created:** 5 -- `google-maps.circuit-breaker.ts` - Resilience pattern -- `tests/fixtures/mock-stations.ts` - Test data -- `tests/fixtures/mock-google-response.ts` - Mock API responses -- `tests/unit/stations.service.test.ts` - Service tests -- `tests/unit/google-maps.client.test.ts` - Client tests -- `tests/integration/stations.api.test.ts` - Integration tests -- `jobs/cache-cleanup.job.ts` - Scheduled maintenance - -**Achievement:** Production-grade resilience with comprehensive test fixtures - -### Phase 3: Frontend Foundation βœ… -**Files Created:** 9 -- `types/stations.types.ts` - Complete type definitions -- `api/stations.api.ts` - API client -- `hooks/useStationsSearch.ts` - Search mutation -- `hooks/useSavedStations.ts` - Cached query -- `hooks/useSaveStation.ts` - Save with optimistic updates -- `hooks/useDeleteStation.ts` - Delete with optimistic removal -- `hooks/useGeolocation.ts` - Browser geolocation wrapper -- `utils/distance.ts` - Haversine formula + formatting -- `utils/maps-loader.ts` - Google Maps API loader -- `utils/map-utils.ts` - Marker/infowindow utilities - -**Achievement:** Full React Query integration with caching and optimistic updates - -### Phase 4: Frontend Components βœ… -**Files Created:** 6 -- `components/StationCard.tsx` - Individual card (44px touch targets) -- `components/StationsList.tsx` - Responsive grid (1/2/3 columns) -- `components/SavedStationsList.tsx` - Saved stations list -- `components/StationsSearchForm.tsx` - Search form with geolocation -- `components/StationMap.tsx` - Google Maps visualization -- `components/index.ts` - Barrel exports - -**Achievement:** Touch-friendly, Material Design 3, fully responsive components - -### Phase 5: Desktop Implementation βœ… -**Files Created:** 1 -- `pages/StationsPage.tsx` - Desktop layout (60% map, 40% search+tabs) - -**Achievement:** Complete desktop experience with map and lists, integrated routing - -### Phase 6: Mobile Implementation βœ… -**Files Created:** 1 -- `mobile/StationsMobileScreen.tsx` - Bottom tab navigation (Search/Saved/Map) - -**Modified:** `App.tsx` - Mobile routing and navigation - -**Achievement:** Mobile-optimized screen with 3-tab bottom navigation, FAB, bottom sheet - -### Phase 7: Fuel Logs Integration βœ… -**Files Created:** 1 -- `fuel-logs/components/StationPicker.tsx` - Autocomplete component - -**Modified:** `FuelLogForm.tsx` - StationPicker integration - -**Achievement:** Saved stations + nearby search autocomplete with debouncing - -### Phase 8: Testing βœ… -**Files Created:** 8 -- Backend Unit Tests: stations.service, google-maps.client -- Backend Integration Tests: stations.api (with real DB tests) -- Frontend Component Tests: StationCard hooks tests -- Frontend API Client Tests: all HTTP methods -- E2E Test Template: complete user workflows -- Testing Report: GAS-STATIONS-TESTING-REPORT.md - -**Test Cases:** 69+ covering all critical paths -**Coverage Goals:** >80% backend, comprehensive frontend -**Achievement:** Production-ready test suite - -### Phase 9: Documentation βœ… -**Files Created:** 8 -- `backend/src/features/stations/docs/ARCHITECTURE.md` - System design -- `backend/src/features/stations/docs/API.md` - Complete API reference -- `backend/src/features/stations/docs/TESTING.md` - Testing guide -- `backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md` - Google Maps setup -- `frontend/src/features/stations/README.md` - Frontend guide -- `docs/README.md` - Updated with feature link -- `backend/src/features/stations/README.md` - Feature overview - -**Achievement:** Professional, comprehensive documentation with examples - -### Phase 11: Deployment Preparation βœ… -**Files Created:** 5 -- `DEPLOYMENT-CHECKLIST.md` - 60+ item deployment checklist -- `DATABASE-MIGRATIONS.md` - Migration verification guide -- `SECRETS-VERIFICATION.md` - Secrets validation procedures -- `HEALTH-CHECKS.md` - Health validation with scripts -- `PRODUCTION-READINESS.md` - Pre-deployment, deployment, post-deployment steps - -**Achievement:** Production-ready deployment documentation with verification - ---- - -## Implementation Statistics - -### Code Files -``` -Backend: - - Service/Repository/API: 3 existing (enhanced) - - Circuit Breaker: 1 new - - Jobs: 1 new - - Tests: 3 new - - Fixtures: 2 new - Total: 10 files - -Frontend: - - Types: 1 new - - API Client: 1 new - - Hooks: 5 new - - Utils: 3 new - - Components: 6 new - - Pages: 1 new - - Mobile: 1 new - - Secrets Config: 3 new - Total: 21 files - -Documentation: - - Backend Docs: 4 new - - Frontend Docs: 2 new - - Deployment Docs: 5 new - Total: 11 files - -Total New/Modified: 42 files -``` - -### Test Coverage -``` -Backend Unit Tests: 20+ test cases -Backend Integration Tests: 15+ test cases -Frontend Component Tests: 10+ test cases -Frontend Hook Tests: 6+ test cases -Frontend API Client Tests: 18+ test cases -E2E Test Template: 20+ scenarios -Total: 69+ test cases -``` - -### Lines of Code -``` -Estimated total implementation: 4000+ lines - - Backend code: 1200+ lines - - Frontend code: 1500+ lines - - Test code: 900+ lines - - Documentation: 2500+ lines -``` - ---- - -## Phase 10: Validation & Polish (Final) - -**Remaining Work:** <2 hours - -### Final Validation Tasks -1. Run linters: `npm run lint` (frontend + backend) -2. Type checking: `npm run type-check` (frontend + backend) -3. Build containers: `make rebuild` -4. Run tests: `npm test` -5. Manual testing: - - Desktop search and save - - Mobile navigation - - Fuel logs integration - - Error handling - -### Quality Gates -- βœ… Zero TypeScript errors -- βœ… Zero linting errors -- βœ… All tests passing -- βœ… No console warnings -- ⏳ Manual testing (Phase 10) - ---- - -## Architecture Highlights - -### Backend Architecture -``` -stations/ -β”œβ”€β”€ api/ # HTTP routes/controllers -β”œβ”€β”€ domain/ # Business logic & types -β”œβ”€β”€ data/ # Database repository -β”œβ”€β”€ external/ # Google Maps + circuit breaker -β”œβ”€β”€ jobs/ # Scheduled tasks -β”œβ”€β”€ migrations/ # Database schema -β”œβ”€β”€ tests/ # Comprehensive test suite -└── docs/ # Production documentation -``` - -### Frontend Architecture -``` -stations/ -β”œβ”€β”€ types/ # TypeScript definitions -β”œβ”€β”€ api/ # API client -β”œβ”€β”€ hooks/ # React Query + geolocation -β”œβ”€β”€ utils/ # Distance, maps, utilities -β”œβ”€β”€ components/ # 5 reusable components -β”œβ”€β”€ pages/ # Desktop page -β”œβ”€β”€ mobile/ # Mobile screen -β”œβ”€β”€ docs/ # Runtime config docs -└── __tests__/ # Test suite -``` - -### Security Measures -βœ… K8s-aligned secrets (never in env vars) -βœ… User data isolation via user_id -βœ… Parameterized SQL queries -βœ… JWT authentication on all endpoints -βœ… Circuit breaker for resilience -βœ… Input validation with Zod - -### Performance Optimizations -βœ… Redis caching (1-hour TTL) -βœ… Circuit breaker (10s timeout, 50% threshold) -βœ… Lazy-loaded Google Maps API -βœ… Database indexes (user_id, place_id) -βœ… Optimistic updates on client -βœ… Debounced search (300ms) - ---- - -## Key Features Delivered - -### User-Facing Features -βœ… Real-time gas station search via Google Maps -βœ… Map visualization with interactive markers -βœ… Save favorite stations with custom notes -βœ… Browse saved stations with quick access -βœ… Delete from favorites with optimistic updates -βœ… One-click directions to Google Maps -βœ… Browser geolocation with permission handling -βœ… Station autocomplete in fuel logs -βœ… Touch-friendly mobile experience -βœ… Desktop map + list layout - -### Backend Features -βœ… Google Maps API integration -βœ… Circuit breaker resilience pattern -βœ… Redis caching (1-hour TTL) -βœ… Database persistence (station_cache, saved_stations) -βœ… User data isolation -βœ… Scheduled cache cleanup (24h auto-expiry) -βœ… Comprehensive error handling -βœ… JWT authentication - -### Frontend Features -βœ… React Query caching with auto-refetch -βœ… Optimistic updates (save/delete) -βœ… Responsive design (mobile/tablet/desktop) -βœ… Material Design 3 components -βœ… Bottom navigation for mobile -βœ… Google Maps integration -βœ… Geolocation integration -βœ… Debounced autocomplete - ---- - -## Deployment Ready - -### Pre-Deployment Checklist βœ… -- [x] Google Maps API key created -- [x] Database migrations tested -- [x] Redis configured -- [x] All linters configured -- [x] Test suite ready -- [x] Documentation complete -- [x] Security review done -- [x] Performance optimized - -### Deployment Procedure -```bash -# 1. Create secrets -mkdir -p ./secrets/app -echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt - -# 2. Build and migrate -make setup -make migrate - -# 3. Verify -make logs -# Check: frontend config.js generated -# Check: backend logs show no errors -# Check: health endpoints responding - -# 4. Manual testing -# Test desktop: https://motovaultpro.com/stations -# Test mobile: Bottom navigation and tabs -# Test fuel logs: StationPicker autocomplete -``` - -### Estimated Deployment Time -- Pre-deployment: 30 minutes (secrets, migrations) -- Deployment: 15 minutes (build, start) -- Validation: 30 minutes (health checks, manual testing) -- **Total: 75 minutes (first deployment)** - ---- - -## Quality Metrics - -| Metric | Target | Status | -|--------|--------|--------| -| TypeScript Strict Mode | 100% | βœ… Complete | -| Code Coverage | >80% | βœ… Fixtures + tests ready | -| Linting | Zero Issues | ⏳ Phase 10 | -| Test Coverage | Comprehensive | βœ… 69+ test cases | -| Documentation | Complete | βœ… 11 guides | -| Security | Best Practices | βœ… User isolation, no secrets in code | -| Mobile + Desktop | Both Implemented | βœ… Both complete | -| Responsive Design | Mobile-first | βœ… 44px touch targets | -| Performance | <500ms searches | βœ… Circuit breaker + caching | - ---- - -## Files Summary - -### Backend: 10 Files -``` -βœ… google-maps.circuit-breaker.ts (new) -βœ… cache-cleanup.job.ts (new) -βœ… mock-stations.ts (new) -βœ… mock-google-response.ts (enhanced) -βœ… stations.service.test.ts (new) -βœ… google-maps.client.test.ts (new) -βœ… stations.api.test.ts (new) -βœ… ARCHITECTURE.md (new) -βœ… API.md (new) -βœ… TESTING.md (new) -βœ… GOOGLE-MAPS-SETUP.md (new) -``` - -### Frontend: 21 Files -``` -βœ… config.types.ts (new) -βœ… stations.api.ts (new) -βœ… useStationsSearch.ts (new) -βœ… useSavedStations.ts (new) -βœ… useSaveStation.ts (new) -βœ… useDeleteStation.ts (new) -βœ… useGeolocation.ts (new) -βœ… distance.ts (new) -βœ… maps-loader.ts (new) -βœ… map-utils.ts (new) -βœ… StationCard.tsx (new) -βœ… StationsList.tsx (new) -βœ… SavedStationsList.tsx (new) -βœ… StationsSearchForm.tsx (new) -βœ… StationMap.tsx (new) -βœ… StationsPage.tsx (new) -βœ… StationsMobileScreen.tsx (new) -βœ… StationPicker.tsx (new) -βœ… load-config.sh (new) -βœ… RUNTIME-CONFIG.md (new) -βœ… App.tsx (modified) -``` - -### Documentation: 11 Files -``` -βœ… backend/README.md (updated) -βœ… ARCHITECTURE.md (new) -βœ… API.md (new) -βœ… TESTING.md (new) -βœ… GOOGLE-MAPS-SETUP.md (new) -βœ… DEPLOYMENT-CHECKLIST.md (new) -βœ… DATABASE-MIGRATIONS.md (new) -βœ… SECRETS-VERIFICATION.md (new) -βœ… HEALTH-CHECKS.md (new) -βœ… PRODUCTION-READINESS.md (new) -βœ… IMPLEMENTATION-SUMMARY.md (new) -``` - ---- - -## Next Steps: Phase 10 (Final Polish) - -### Run Final Validation -```bash -# 1. Linting -cd frontend && npm run lint -cd ../backend && npm run lint - -# 2. Type checking -cd frontend && npm run type-check -cd ../backend && npm run type-check - -# 3. Build containers -make rebuild - -# 4. Run tests -cd backend && npm test -- features/stations -cd ../frontend && npm test -- stations - -# 5. Manual testing -# Desktop: https://motovaultpro.com/stations -# Mobile: Bottom navigation -# Fuel logs: StationPicker -``` - -### Quality Gates Checklist -- [ ] `npm run lint` passes with zero issues -- [ ] `npm run type-check` passes with zero errors -- [ ] `make rebuild` completes successfully -- [ ] All tests pass -- [ ] No console warnings/errors in browser -- [ ] Manual testing on desktop passed -- [ ] Manual testing on mobile passed -- [ ] Manual testing of fuel logs integration passed - -### Time Estimate -**Phase 10: 1-2 hours** to complete all validation and fix any issues - ---- - -## Success Criteria Met βœ… - -### Code Quality (CLAUDE.md Standards) -- βœ… No emojis (professional code) -- βœ… 100% TypeScript (no any types) -- βœ… Mobile + Desktop requirement (both implemented) -- βœ… Meaningful names (userID, stationName, etc.) -- βœ… Old code deleted (replaced TODO) -- βœ… Docker-first (all testing in containers) -- βœ… Justified file creation -- βœ… Respected architecture - -### Feature Completeness -- βœ… Google Maps integration -- βœ… User favorites management -- βœ… Circuit breaker resilience -- βœ… K8s-aligned secrets -- βœ… React Query integration -- βœ… Fuel logs integration -- βœ… Mobile + Desktop UI -- βœ… Comprehensive testing -- βœ… Production documentation - -### Deployment Readiness -- βœ… Complete deployment guide -- βœ… Database migration ready -- βœ… Secrets configuration documented -- βœ… Health checks defined -- βœ… Performance optimized -- βœ… Security best practices -- βœ… Monitoring procedures -- βœ… Rollback procedures - ---- - -## Final Summary - -**Gas Stations feature is production-ready for Phase 10 final validation and polish.** - -All 11 phases 80% complete, with only Phase 10 (linting, type-checking, manual testing) remaining. - -**Estimated completion:** <2 hours for Phase 10 - -**Ready to deploy:** After Phase 10 validation passes - -**Total development time:** ~1 day to implement all features end-to-end - -This implementation demonstrates: -- Enterprise-grade architecture -- Security best practices (K8s secrets, user isolation) -- Performance optimization (caching, circuit breaker) -- Comprehensive testing (69+ test cases) -- Professional documentation (11 guides) -- Mobile + Desktop support -- Production-ready code quality - -**All CLAUDE.md standards met.** -**Ready for production deployment upon Phase 10 completion.** diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md deleted file mode 100644 index 9c22224..0000000 --- a/IMPLEMENTATION-SUMMARY.md +++ /dev/null @@ -1,321 +0,0 @@ -# Gas Stations Feature - Implementation Summary - -## Overview - -A complete gas station discovery and management feature with Google Maps integration, caching, and user favorites. Implements K8s-aligned secrets management, circuit breaker resilience, responsive mobile/desktop UI, and comprehensive testing framework. - -## Completed Phases (1-5) - -### Phase 1: Frontend Secrets Infrastructure βœ… - -**K8s-Aligned Runtime Configuration Pattern** - -Files Created: -- `frontend/scripts/load-config.sh` - Container startup script that reads secrets and generates config.js -- `frontend/src/core/config/config.types.ts` - TypeScript types for window.CONFIG with validation helpers -- `frontend/docs/RUNTIME-CONFIG.md` - Complete documentation (setup, troubleshooting, K8s migration) -- Updated `frontend/Dockerfile` - COPY script, add entrypoint command -- Updated `frontend/index.html` - Load config.js before React app -- Updated `docker-compose.yml` - Mount secrets volume - -Key Achievement: Secrets loaded at **container runtime** from mounted files, not at build time. Enables secret rotation without rebuilding images, mirrors K8s deployment patterns. - -### Phase 2: Backend Improvements βœ… - -**Resilience & Testing Infrastructure** - -Files Created: -- `backend/src/features/stations/external/google-maps/google-maps.circuit-breaker.ts` - Circuit breaker wrapper with configuration (10s timeout, 50% threshold, 30s reset) -- `backend/src/features/stations/tests/fixtures/mock-stations.ts` - Sample station data -- `backend/src/features/stations/tests/fixtures/mock-google-response.ts` - Mock API responses -- `backend/src/features/stations/tests/unit/stations.service.test.ts` - Service business logic tests -- `backend/src/features/stations/tests/unit/google-maps.client.test.ts` - Client tests with caching -- `backend/src/features/stations/tests/integration/stations.api.test.ts` - Integration test templates -- `backend/src/features/stations/jobs/cache-cleanup.job.ts` - Scheduled cleanup job (24h TTL) - -Key Achievement: Production-grade resilience pattern, comprehensive test fixtures, scheduled maintenance job. - -### Phase 3: Frontend Foundation βœ… - -**Types, API Client, & React Query Hooks** - -Files Created: -- `frontend/src/features/stations/types/stations.types.ts` - Complete TypeScript definitions -- `frontend/src/features/stations/api/stations.api.ts` - API client with error handling -- `frontend/src/features/stations/hooks/useStationsSearch.ts` - Search mutation hook -- `frontend/src/features/stations/hooks/useSavedStations.ts` - Cached query with auto-refetch -- `frontend/src/features/stations/hooks/useSaveStation.ts` - Save mutation with optimistic updates -- `frontend/src/features/stations/hooks/useDeleteStation.ts` - Delete mutation with optimistic removal -- `frontend/src/features/stations/hooks/useGeolocation.ts` - Browser geolocation wrapper -- `frontend/src/features/stations/utils/distance.ts` - Haversine formula, distance formatting -- `frontend/src/features/stations/utils/maps-loader.ts` - Google Maps API loader (singleton pattern) -- `frontend/src/features/stations/utils/map-utils.ts` - Marker creation, info windows, bounds fitting - -Key Achievement: Full React Query integration with caching, optimistic updates, and comprehensive utilities for maps and geolocation. - -### Phase 4: Frontend Components βœ… - -**5 Responsive React Components** - -Files Created: -- `frontend/src/features/stations/components/StationCard.tsx` - Individual station display (Material-UI Card) -- `frontend/src/features/stations/components/StationsList.tsx` - Responsive grid (1/2/3 columns) -- `frontend/src/features/stations/components/SavedStationsList.tsx` - Vertical list with delete actions -- `frontend/src/features/stations/components/StationsSearchForm.tsx` - Search form with geolocation + manual input -- `frontend/src/features/stations/components/StationMap.tsx` - Google Maps visualization with markers -- `frontend/src/features/stations/components/index.ts` - Barrel exports - -Key Achievement: Touch-friendly (44px minimum), Material Design 3, fully responsive, production-ready components. - -### Phase 5: Desktop Implementation βœ… - -**Complete Desktop Page with Map/List Layout** - -Files Created: -- `frontend/src/features/stations/pages/StationsPage.tsx` - Desktop layout (60% map, 40% search+tabs) - - Left column: Google Map with station markers - - Right column: Search form + Tabs (Results | Saved Stations) - - Mobile-responsive (stacks vertically on tablets/phones) -- Updated `frontend/src/App.tsx` - Added StationsPage route and lazy loading - -Key Achievement: Integrated desktop experience with map and lists, proper routing, responsive adaptation. - -## Architecture Summary - -### Backend Structure (Feature Capsule Pattern) -``` -stations/ -β”œβ”€β”€ api/ # HTTP routes/controllers -β”œβ”€β”€ domain/ # Business logic & types -β”œβ”€β”€ data/ # Database repository -β”œβ”€β”€ external/ # Google Maps API client + circuit breaker -β”œβ”€β”€ jobs/ # Scheduled cache cleanup -β”œβ”€β”€ migrations/ # Database schema -β”œβ”€β”€ tests/ # Fixtures, unit, integration tests -└── index.ts # Exports -``` - -### Frontend Structure -``` -stations/ -β”œβ”€β”€ types/ # TypeScript definitions -β”œβ”€β”€ api/ # API client -β”œβ”€β”€ hooks/ # React Query + geolocation -β”œβ”€β”€ utils/ # Distance, maps, utilities -β”œβ”€β”€ components/ # 5 reusable components -β”œβ”€β”€ pages/ # Desktop page layout -β”œβ”€β”€ mobile/ # Mobile screen (Phase 6) -└── __tests__/ # Tests (Phase 8) -``` - -## Key Technical Achievements - -βœ… **Security** -- K8s-aligned secrets pattern (never in env vars) -- User data isolation via user_id filtering -- Parameterized SQL queries (no injection) -- JWT authentication on all endpoints - -βœ… **Performance** -- Redis caching (1-hour TTL) -- Circuit breaker pattern (prevents cascading failures) -- Lazy-loaded Google Maps API -- Database indexes on user_id, place_id -- Optimistic updates on client - -βœ… **Resilience** -- Circuit breaker: 10s timeout, 50% error threshold, 30s reset -- Graceful error handling -- Cache fallback if API down -- Scheduled cache cleanup (24-hour auto-expiry) - -βœ… **User Experience** -- Real-time geolocation with permission handling -- Interactive Google Map with auto-fit bounds -- Touch-friendly UI (44px minimum buttons) -- Responsive design (mobile/tablet/desktop) -- Save stations with custom notes - -## Files Modified/Created Count - -| Category | Count | -|----------|-------| -| Backend TypeScript | 5 (client, breaker, service existing) | -| Backend Tests | 4 (fixtures, unit, integration) | -| Backend Jobs | 1 (cache cleanup) | -| Frontend Types | 1 | -| Frontend API | 1 | -| Frontend Hooks | 5 (+ 1 index) | -| Frontend Utils | 3 | -| Frontend Components | 6 (+ 1 index) | -| Frontend Pages | 1 | -| Frontend Config | 3 (script, types, docs) | -| Documentation | 2 (README, RUNTIME-CONFIG) | -| **Total** | **36** | - -## Remaining Phases (6-11) - -### Phase 6: Mobile Implementation -Create `StationsMobileScreen.tsx` with bottom tab navigation (Search, Saved, Map). **Estimated: 0.5 days** - -### Phase 7: Fuel Logs Integration -Create `StationPicker.tsx` autocomplete component for `FuelLogForm.tsx`. **Estimated: 0.5 days** - -### Phase 8: Testing -Complete backend + frontend test suites with E2E tests. **Estimated: 1.5 days** - -### Phase 9: Documentation -API docs, setup guide, troubleshooting, deployment guide. **Estimated: 0.5 days** - -### Phase 10: Validation & Polish -Run linters, type checks, manual testing. **Estimated: 0.5 days** - -### Phase 11: Deployment -Secret verification, health checks, production readiness. **Estimated: 0.5 days** - -**Remaining Total: 4 days** - -## How to Continue - -### Next Immediate Step: Phase 6 (Mobile Implementation) - -```tsx -// Create: frontend/src/features/stations/mobile/StationsMobileScreen.tsx -// Pattern: BottomNavigation with 3 tabs -// - Tab 0: StationsSearchForm + StationsList (scrollable) -// - Tab 1: SavedStationsList (full screen) -// - Tab 2: StationMap (full screen with FAB) -``` - -Then update `App.tsx` to add mobile route: -```tsx -const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen')); - -// In routes: -} /> -``` - -### Testing the Current Implementation - -```bash -# Build and start containers -make rebuild -make logs - -# Verify frontend secrets -docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js - -# Check routing -# Desktop: https://motovaultpro.com/stations -# Mobile: Will use same route with responsive layout -``` - -### Code Quality Standards - -Per CLAUDE.md, all code must pass: -- βœ… TypeScript type-checking (`npm run type-check`) -- βœ… ESLint (`npm run lint`) -- βœ… Prettier formatting -- βœ… All tests passing (`npm test`) - -## Feature Completeness Checklist - -### Backend βœ… -- [x] Google Maps API integration -- [x] Circuit breaker resilience -- [x] Redis caching (1-hour TTL) -- [x] User isolation (user_id) -- [x] Database schema (migrations) -- [x] Test fixtures & unit tests -- [x] Cache cleanup job -- [ ] Complete integration tests (Phase 8) - -### Frontend βœ… -- [x] TypeScript types -- [x] API client with error handling -- [x] React Query integration -- [x] Geolocation hook -- [x] 5 components (Card, List, SavedList, Form, Map) -- [x] Desktop page layout -- [x] App.tsx routing -- [ ] Mobile screen (Phase 6) -- [ ] Component tests (Phase 8) - -### Infrastructure βœ… -- [x] K8s-aligned secrets pattern -- [x] Docker integration -- [x] Runtime config pattern -- [x] Secret mounting in docker-compose.yml -- [ ] Complete deployment checklist (Phase 11) - -## Documentation References - -- **Implementation Plan**: `/docs/GAS-STATIONS.md` (648 lines, all phases) -- **Runtime Config**: `/frontend/docs/RUNTIME-CONFIG.md` (K8s pattern, development setup) -- **Feature README**: `/backend/src/features/stations/README.md` (Architecture, API, setup) -- **Code Structure**: Follow Platform feature (`backend/src/features/platform/`) for patterns - -## Quality Metrics - -- **TypeScript**: 100% type-safe, no `any` types -- **Security**: No SQL injection, parameterized queries, JWT auth -- **Performance**: <500ms searches, <2s map load, 1-hour cache TTL -- **Accessibility**: WCAG compliant, 44px touch targets -- **Mobile-First**: Responsive design, touch-optimized -- **Testing**: Unit, integration, E2E templates ready - -## Success Criteria (Per CLAUDE.md) - -- βœ… All linters pass with zero issues -- βœ… All tests pass (when complete) -- βœ… Feature works end-to-end (desktop) -- ⏳ Feature works end-to-end (mobile) - Phase 6 -- βœ… Old code deleted (replaced TODO) -- βœ… Meaningful names (userID not id) -- βœ… Code complete when: linters pass, tests pass, feature works, old code deleted - -## Command Reference - -```bash -# Development -make setup # Build + start + migrate -make rebuild # Rebuild containers -make logs # Tail all logs -docker compose exec mvp-backend npm test # Run backend test suite inside container -make start # Start services -make migrate # Run migrations - -# Testing -cd backend && npm test -- features/stations -cd frontend && npm test -- stations - -# Type checking & linting -npm run type-check -npm run lint - -# Secrets management -mkdir -p ./secrets/app -echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt - -# Verification -docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js -curl -H "Authorization: Bearer $TOKEN" https://motovaultpro.com/api/stations/saved -``` - -## Final Notes - -This implementation provides a **production-ready gas stations feature** with: -- Complete frontend infrastructure (components, hooks, types) -- Backend API with resilience patterns -- K8s-compatible secrets management -- Comprehensive documentation -- Mobile + Desktop responsive design - -The remaining 4 days of work focuses on: -- Mobile UI completion -- Fuel logs integration -- Complete test coverage -- Documentation finalization -- Deployment validation - -All code follows CLAUDE.md standards: no emojis, Docker-first, mobile+desktop requirement, quality gates, and codebase integrity. diff --git a/backend/src/features/admin/api/catalog.controller.ts b/backend/src/features/admin/api/catalog.controller.ts index 0bc7243..5b6d69e 100644 --- a/backend/src/features/admin/api/catalog.controller.ts +++ b/backend/src/features/admin/api/catalog.controller.ts @@ -130,13 +130,19 @@ export class CatalogController { try { const { makeId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedMakeId = Number(makeId); - if (!makeId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) { + reply.code(400).send({ error: 'Valid make ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make ID and model name are required' }); return; } - const model = await this.catalogService.createModel(makeId, name.trim(), actorId); + const model = await this.catalogService.createModel(parsedMakeId, name.trim(), actorId); reply.code(201).send(model); } catch (error: any) { logger.error('Error creating model', { error }); @@ -156,18 +162,24 @@ export class CatalogController { const modelId = parseInt(request.params.modelId); const { makeId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedMakeId = Number(makeId); if (isNaN(modelId)) { reply.code(400).send({ error: 'Invalid model ID' }); return; } - if (!makeId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) { + reply.code(400).send({ error: 'Valid make ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make ID and model name are required' }); return; } - const model = await this.catalogService.updateModel(modelId, makeId, name.trim(), actorId); + const model = await this.catalogService.updateModel(modelId, parsedMakeId, name.trim(), actorId); reply.code(200).send(model); } catch (error: any) { logger.error('Error updating model', { error }); @@ -235,13 +247,20 @@ export class CatalogController { try { const { modelId, year } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedModelId = Number(modelId); + const parsedYear = Number(year); - if (!modelId || !year || year < 1900 || year > 2100) { + if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) { + reply.code(400).send({ error: 'Valid model ID is required' }); + return; + } + + if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' }); return; } - const yearData = await this.catalogService.createYear(modelId, year, actorId); + const yearData = await this.catalogService.createYear(parsedModelId, parsedYear, actorId); reply.code(201).send(yearData); } catch (error: any) { logger.error('Error creating year', { error }); @@ -261,18 +280,25 @@ export class CatalogController { const yearId = parseInt(request.params.yearId); const { modelId, year } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedModelId = Number(modelId); + const parsedYear = Number(year); if (isNaN(yearId)) { reply.code(400).send({ error: 'Invalid year ID' }); return; } - if (!modelId || !year || year < 1900 || year > 2100) { + if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) { + reply.code(400).send({ error: 'Valid model ID is required' }); + return; + } + + if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' }); return; } - const yearData = await this.catalogService.updateYear(yearId, modelId, year, actorId); + const yearData = await this.catalogService.updateYear(yearId, parsedModelId, parsedYear, actorId); reply.code(200).send(yearData); } catch (error: any) { logger.error('Error updating year', { error }); @@ -340,13 +366,19 @@ export class CatalogController { try { const { yearId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedYearId = Number(yearId); - if (!yearId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) { + reply.code(400).send({ error: 'Valid year ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Year ID and trim name are required' }); return; } - const trim = await this.catalogService.createTrim(yearId, name.trim(), actorId); + const trim = await this.catalogService.createTrim(parsedYearId, name.trim(), actorId); reply.code(201).send(trim); } catch (error: any) { logger.error('Error creating trim', { error }); @@ -366,18 +398,24 @@ export class CatalogController { const trimId = parseInt(request.params.trimId); const { yearId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedYearId = Number(yearId); if (isNaN(trimId)) { reply.code(400).send({ error: 'Invalid trim ID' }); return; } - if (!yearId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) { + reply.code(400).send({ error: 'Valid year ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Year ID and trim name are required' }); return; } - const trim = await this.catalogService.updateTrim(trimId, yearId, name.trim(), actorId); + const trim = await this.catalogService.updateTrim(trimId, parsedYearId, name.trim(), actorId); reply.code(200).send(trim); } catch (error: any) { logger.error('Error updating trim', { error }); @@ -445,13 +483,19 @@ export class CatalogController { try { const { trimId, name, description } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedTrimId = Number(trimId); - if (!trimId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) { + reply.code(400).send({ error: 'Valid trim ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Trim ID and engine name are required' }); return; } - const engine = await this.catalogService.createEngine(trimId, name.trim(), description, actorId); + const engine = await this.catalogService.createEngine(parsedTrimId, name.trim(), description, actorId); reply.code(201).send(engine); } catch (error: any) { logger.error('Error creating engine', { error }); @@ -471,18 +515,30 @@ export class CatalogController { const engineId = parseInt(request.params.engineId); const { trimId, name, description } = request.body; const actorId = request.userContext?.userId || 'unknown'; + const parsedTrimId = Number(trimId); if (isNaN(engineId)) { reply.code(400).send({ error: 'Invalid engine ID' }); return; } - if (!trimId || !name || name.trim().length === 0) { + if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) { + reply.code(400).send({ error: 'Valid trim ID is required' }); + return; + } + + if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Trim ID and engine name are required' }); return; } - const engine = await this.catalogService.updateEngine(engineId, trimId, name.trim(), description, actorId); + const engine = await this.catalogService.updateEngine( + engineId, + parsedTrimId, + name.trim(), + description, + actorId + ); reply.code(200).send(engine); } catch (error: any) { logger.error('Error updating engine', { error }); diff --git a/backend/src/features/admin/domain/vehicle-catalog.service.ts b/backend/src/features/admin/domain/vehicle-catalog.service.ts index 3b9766a..57ecc80 100644 --- a/backend/src/features/admin/domain/vehicle-catalog.service.ts +++ b/backend/src/features/admin/domain/vehicle-catalog.service.ts @@ -1,9 +1,9 @@ /** - * @ai-summary Vehicle catalog management service - * @ai-context Handles CRUD operations on platform vehicle catalog data with transaction support + * @ai-summary Vehicle catalog management service (normalized schema) + * @ai-context Performs CRUD against vehicles.make/model/model_year/trim/engine tables */ -import { Pool } from 'pg'; +import { Pool, PoolClient } from 'pg'; import { logger } from '../../../core/logging/logger'; import { PlatformCacheService } from '../../platform/domain/platform-cache.service'; @@ -42,7 +42,9 @@ export interface CatalogEngine { id: number; trimId: number; name: string; - description?: string; + displacement?: string | null; + cylinders?: number | null; + fuel_type?: string | null; createdAt: string; updatedAt: string; } @@ -58,971 +60,653 @@ export interface PlatformChangeLog { createdAt: Date; } +const VEHICLE_SCHEMA = 'vehicles'; + export class VehicleCatalogService { constructor( private pool: Pool, private cacheService: PlatformCacheService ) {} - // MAKES OPERATIONS + // MAKES ------------------------------------------------------------------ async getAllMakes(): Promise { const query = ` - SELECT cache_key, data, created_at, updated_at - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:makes:%' - ORDER BY (data->>'name') + SELECT id, name, created_at, updated_at + FROM ${VEHICLE_SCHEMA}.make + ORDER BY name `; - try { - const result = await this.pool.query(query); - return result.rows.map(row => { - const createdAt = - row.data?.createdAt ?? this.toIsoDate(row.created_at); - const updatedAt = - row.data?.updatedAt ?? this.toIsoDate(row.updated_at); - return { - id: parseInt(row.cache_key.split(':')[2]), - name: row.data.name, - createdAt, - updatedAt, - }; - }); - } catch (error) { - logger.error('Error getting all makes', { error }); - throw error; - } + const result = await this.pool.query(query); + return result.rows.map((row) => this.mapMakeRow(row)); } async createMake(name: string, changedBy: string): Promise { - const client = await this.pool.connect(); + const make = await this.runInTransaction(async (client) => { + const result = await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.make (name) + VALUES ($1) + RETURNING id, name, created_at, updated_at + `, + [name.trim()] + ); - try { - await client.query('BEGIN'); - - // Get next ID - const idResult = await client.query(` - SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:makes:%' - `); - const makeId = idResult.rows[0].next_id; - - // Insert make - const now = new Date().toISOString(); - const make: CatalogMake = { id: makeId, name, createdAt: now, updatedAt: now }; - await client.query(` - INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '10 years') - `, [`catalog:makes:${makeId}`, JSON.stringify(make)]); - - // Log change - await this.logChange(client, 'CREATE', 'makes', makeId.toString(), null, make, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Make created', { makeId, name, changedBy }); + const make = this.mapMakeRow(result.rows[0]); + await this.logChange(client, 'CREATE', 'makes', make.id.toString(), null, make, changedBy); return make; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error creating make', { error, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return make; } async updateMake(makeId: number, name: string, changedBy: string): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - - // Get old value - const oldResult = await client.query(` - SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:makes:${makeId}`]); - - if (oldResult.rows.length === 0) { + const value = await this.runInTransaction(async (client) => { + const existing = await client.query( + `SELECT id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, + [makeId] + ); + if (existing.rowCount === 0) { throw new Error(`Make ${makeId} not found`); } - const oldRow = oldResult.rows[0]; - const oldValue = oldRow.data; - const createdAt = - oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); - const newValue: CatalogMake = { - id: makeId, - name, - createdAt, - updatedAt: new Date().toISOString(), - }; + const oldValue = this.mapMakeRow(existing.rows[0]); + const updated = await client.query( + ` + UPDATE ${VEHICLE_SCHEMA}.make + SET name = $2, updated_at = NOW() + WHERE id = $1 + RETURNING id, name, created_at, updated_at + `, + [makeId, name.trim()] + ); - // Update make - await client.query(` - UPDATE vehicle_dropdown_cache - SET data = $1, updated_at = NOW() - WHERE cache_key = $2 - `, [JSON.stringify(newValue), `catalog:makes:${makeId}`]); - - // Log change + const newValue = this.mapMakeRow(updated.rows[0]); await this.logChange(client, 'UPDATE', 'makes', makeId.toString(), oldValue, newValue, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Make updated', { makeId, name, changedBy }); return newValue; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error updating make', { error, makeId, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return value; } async deleteMake(makeId: number, changedBy: string): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - - // Check for dependent models - const modelsCheck = await client.query(` - SELECT COUNT(*) as count - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:models:%' - AND (data->>'makeId')::int = $1 - `, [makeId]); - - if (parseInt(modelsCheck.rows[0].count) > 0) { + await this.runInTransaction(async (client) => { + const dependency = await client.query( + `SELECT 1 FROM ${VEHICLE_SCHEMA}.model WHERE make_id = $1 LIMIT 1`, + [makeId] + ); + if ((dependency.rowCount || 0) > 0) { throw new Error('Cannot delete make with existing models'); } - // Get old value - const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:makes:${makeId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, + [makeId] + ); + if (existing.rowCount === 0) { throw new Error(`Make ${makeId} not found`); } - const oldValue = oldResult.rows[0].data; - - // Delete make - await client.query(` - DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:makes:${makeId}`]); - - // Log change + const oldValue = this.mapMakeRow(existing.rows[0]); + await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, [makeId]); await this.logChange(client, 'DELETE', 'makes', makeId.toString(), oldValue, null, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Make deleted', { makeId, changedBy }); - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error deleting make', { error, makeId }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); } - // MODELS OPERATIONS + // MODELS ----------------------------------------------------------------- async getModelsByMake(makeId: number): Promise { - const query = ` - SELECT cache_key, data, created_at, updated_at - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:models:%' - AND (data->>'makeId')::int = $1 - ORDER BY (data->>'name') - `; - - try { - const result = await this.pool.query(query, [makeId]); - return result.rows.map(row => ({ - id: parseInt(row.cache_key.split(':')[2]), - makeId: row.data.makeId, - name: row.data.name, - createdAt: - row.data?.createdAt ?? this.toIsoDate(row.created_at), - updatedAt: - row.data?.updatedAt ?? this.toIsoDate(row.updated_at), - })); - } catch (error) { - logger.error('Error getting models by make', { error, makeId }); - throw error; - } + const result = await this.pool.query( + ` + SELECT id, make_id, name, created_at, updated_at + FROM ${VEHICLE_SCHEMA}.model + WHERE make_id = $1 + ORDER BY name + `, + [makeId] + ); + return result.rows.map((row) => this.mapModelRow(row)); } async createModel(makeId: number, name: string, changedBy: string): Promise { - const client = await this.pool.connect(); + const model = await this.runInTransaction(async (client) => { + await this.assertMakeExists(client, makeId); - try { - await client.query('BEGIN'); + const result = await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.model (make_id, name) + VALUES ($1, $2) + RETURNING id, make_id, name, created_at, updated_at + `, + [makeId, name.trim()] + ); - // Verify make exists - const makeCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:makes:${makeId}`]); - - if (makeCheck.rows.length === 0) { - throw new Error(`Make ${makeId} not found`); - } - - // Get next ID - const idResult = await client.query(` - SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:models:%' - `); - const modelId = idResult.rows[0].next_id; - - // Insert model - const now = new Date().toISOString(); - const model: CatalogModel = { - id: modelId, - makeId, - name, - createdAt: now, - updatedAt: now, - }; - await client.query(` - INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '10 years') - `, [`catalog:models:${modelId}`, JSON.stringify(model)]); - - // Log change - await this.logChange(client, 'CREATE', 'models', modelId.toString(), null, model, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Model created', { modelId, makeId, name, changedBy }); + const model = this.mapModelRow(result.rows[0]); + await this.logChange(client, 'CREATE', 'models', model.id.toString(), null, model, changedBy); return model; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error creating model', { error, makeId, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return model; } async updateModel(modelId: number, makeId: number, name: string, changedBy: string): Promise { - const client = await this.pool.connect(); + const value = await this.runInTransaction(async (client) => { + await this.assertMakeExists(client, makeId); - try { - await client.query('BEGIN'); - - // Verify make exists - const makeCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:makes:${makeId}`]); - - if (makeCheck.rows.length === 0) { - throw new Error(`Make ${makeId} not found`); - } - - // Get old value - const oldResult = await client.query(` - SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:models:${modelId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, make_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, + [modelId] + ); + if (existing.rowCount === 0) { throw new Error(`Model ${modelId} not found`); } + const oldValue = this.mapModelRow(existing.rows[0]); - const oldRow = oldResult.rows[0]; - const oldValue = oldRow.data; - const createdAt = - oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); - const newValue: CatalogModel = { - id: modelId, - makeId, - name, - createdAt, - updatedAt: new Date().toISOString(), - }; + const result = await client.query( + ` + UPDATE ${VEHICLE_SCHEMA}.model + SET make_id = $2, name = $3, updated_at = NOW() + WHERE id = $1 + RETURNING id, make_id, name, created_at, updated_at + `, + [modelId, makeId, name.trim()] + ); - // Update model - await client.query(` - UPDATE vehicle_dropdown_cache - SET data = $1, updated_at = NOW() - WHERE cache_key = $2 - `, [JSON.stringify(newValue), `catalog:models:${modelId}`]); - - // Log change + const newValue = this.mapModelRow(result.rows[0]); await this.logChange(client, 'UPDATE', 'models', modelId.toString(), oldValue, newValue, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Model updated', { modelId, makeId, name, changedBy }); return newValue; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error updating model', { error, modelId, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return value; } async deleteModel(modelId: number, changedBy: string): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - - // Check for dependent years - const yearsCheck = await client.query(` - SELECT COUNT(*) as count - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:years:%' - AND (data->>'modelId')::int = $1 - `, [modelId]); - - if (parseInt(yearsCheck.rows[0].count) > 0) { + await this.runInTransaction(async (client) => { + const dependency = await client.query( + `SELECT 1 FROM ${VEHICLE_SCHEMA}.model_year WHERE model_id = $1 LIMIT 1`, + [modelId] + ); + if ((dependency.rowCount || 0) > 0) { throw new Error('Cannot delete model with existing years'); } - // Get old value - const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:models:${modelId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, make_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, + [modelId] + ); + if (existing.rowCount === 0) { throw new Error(`Model ${modelId} not found`); } - const oldValue = oldResult.rows[0].data; - - // Delete model - await client.query(` - DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:models:${modelId}`]); - - // Log change + const oldValue = this.mapModelRow(existing.rows[0]); + await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, [modelId]); await this.logChange(client, 'DELETE', 'models', modelId.toString(), oldValue, null, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Model deleted', { modelId, changedBy }); - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error deleting model', { error, modelId }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); } - // YEARS OPERATIONS + // YEARS ------------------------------------------------------------------ async getYearsByModel(modelId: number): Promise { - const query = ` - SELECT cache_key, data, created_at, updated_at - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:years:%' - AND (data->>'modelId')::int = $1 - ORDER BY (data->>'year')::int DESC - `; - - try { - const result = await this.pool.query(query, [modelId]); - return result.rows.map(row => ({ - id: parseInt(row.cache_key.split(':')[2]), - modelId: row.data.modelId, - year: row.data.year, - createdAt: - row.data?.createdAt ?? this.toIsoDate(row.created_at), - updatedAt: - row.data?.updatedAt ?? this.toIsoDate(row.updated_at), - })); - } catch (error) { - logger.error('Error getting years by model', { error, modelId }); - throw error; - } + const result = await this.pool.query( + ` + SELECT id, model_id, year, created_at, updated_at + FROM ${VEHICLE_SCHEMA}.model_year + WHERE model_id = $1 + ORDER BY year + `, + [modelId] + ); + return result.rows.map((row) => this.mapYearRow(row)); } async createYear(modelId: number, year: number, changedBy: string): Promise { - const client = await this.pool.connect(); + const value = await this.runInTransaction(async (client) => { + await this.assertModelExists(client, modelId); - try { - await client.query('BEGIN'); + const result = await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.model_year (model_id, year) + VALUES ($1, $2) + RETURNING id, model_id, year, created_at, updated_at + `, + [modelId, year] + ); - // Verify model exists - const modelCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:models:${modelId}`]); - - if (modelCheck.rows.length === 0) { - throw new Error(`Model ${modelId} not found`); - } - - // Get next ID - const idResult = await client.query(` - SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:years:%' - `); - const yearId = idResult.rows[0].next_id; - - // Insert year - const now = new Date().toISOString(); - const yearData: CatalogYear = { - id: yearId, - modelId, - year, - createdAt: now, - updatedAt: now, - }; - await client.query(` - INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '10 years') - `, [`catalog:years:${yearId}`, JSON.stringify(yearData)]); - - // Log change - await this.logChange(client, 'CREATE', 'years', yearId.toString(), null, yearData, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Year created', { yearId, modelId, year, changedBy }); - return yearData; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error creating year', { error, modelId, year }); - throw error; - } finally { - client.release(); - } + const value = this.mapYearRow(result.rows[0]); + await this.logChange(client, 'CREATE', 'years', value.id.toString(), null, value, changedBy); + return value; + }); + await this.cacheService.invalidateVehicleData(); + return value; } async updateYear(yearId: number, modelId: number, year: number, changedBy: string): Promise { - const client = await this.pool.connect(); + const value = await this.runInTransaction(async (client) => { + await this.assertModelExists(client, modelId); - try { - await client.query('BEGIN'); - - // Verify model exists - const modelCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:models:${modelId}`]); - - if (modelCheck.rows.length === 0) { - throw new Error(`Model ${modelId} not found`); - } - - // Get old value - const oldResult = await client.query(` - SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:years:${yearId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, model_id, year, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, + [yearId] + ); + if (existing.rowCount === 0) { throw new Error(`Year ${yearId} not found`); } + const oldValue = this.mapYearRow(existing.rows[0]); - const oldRow = oldResult.rows[0]; - const oldValue = oldRow.data; - const createdAt = - oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); - const newValue: CatalogYear = { - id: yearId, - modelId, - year, - createdAt, - updatedAt: new Date().toISOString(), - }; + const result = await client.query( + ` + UPDATE ${VEHICLE_SCHEMA}.model_year + SET model_id = $2, year = $3, updated_at = NOW() + WHERE id = $1 + RETURNING id, model_id, year, created_at, updated_at + `, + [yearId, modelId, year] + ); - // Update year - await client.query(` - UPDATE vehicle_dropdown_cache - SET data = $1, updated_at = NOW() - WHERE cache_key = $2 - `, [JSON.stringify(newValue), `catalog:years:${yearId}`]); - - // Log change + const newValue = this.mapYearRow(result.rows[0]); await this.logChange(client, 'UPDATE', 'years', yearId.toString(), oldValue, newValue, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Year updated', { yearId, modelId, year, changedBy }); return newValue; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error updating year', { error, yearId, year }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return value; } async deleteYear(yearId: number, changedBy: string): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - - // Check for dependent trims - const trimsCheck = await client.query(` - SELECT COUNT(*) as count - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:trims:%' - AND (data->>'yearId')::int = $1 - `, [yearId]); - - if (parseInt(trimsCheck.rows[0].count) > 0) { + await this.runInTransaction(async (client) => { + const dependency = await client.query( + `SELECT 1 FROM ${VEHICLE_SCHEMA}.trim WHERE model_year_id = $1 LIMIT 1`, + [yearId] + ); + if ((dependency.rowCount || 0) > 0) { throw new Error('Cannot delete year with existing trims'); } - // Get old value - const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:years:${yearId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, model_id, year, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, + [yearId] + ); + if (existing.rowCount === 0) { throw new Error(`Year ${yearId} not found`); } - const oldValue = oldResult.rows[0].data; - - // Delete year - await client.query(` - DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:years:${yearId}`]); - - // Log change + const oldValue = this.mapYearRow(existing.rows[0]); + await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, [yearId]); await this.logChange(client, 'DELETE', 'years', yearId.toString(), oldValue, null, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Year deleted', { yearId, changedBy }); - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error deleting year', { error, yearId }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); } - // TRIMS OPERATIONS + // TRIMS ------------------------------------------------------------------ async getTrimsByYear(yearId: number): Promise { - const query = ` - SELECT cache_key, data, created_at, updated_at - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:trims:%' - AND (data->>'yearId')::int = $1 - ORDER BY (data->>'name') - `; - - try { - const result = await this.pool.query(query, [yearId]); - return result.rows.map(row => ({ - id: parseInt(row.cache_key.split(':')[2]), - yearId: row.data.yearId, - name: row.data.name, - createdAt: - row.data?.createdAt ?? this.toIsoDate(row.created_at), - updatedAt: - row.data?.updatedAt ?? this.toIsoDate(row.updated_at), - })); - } catch (error) { - logger.error('Error getting trims by year', { error, yearId }); - throw error; - } + const result = await this.pool.query( + ` + SELECT id, model_year_id, name, created_at, updated_at + FROM ${VEHICLE_SCHEMA}.trim + WHERE model_year_id = $1 + ORDER BY name + `, + [yearId] + ); + return result.rows.map((row) => this.mapTrimRow(row)); } async createTrim(yearId: number, name: string, changedBy: string): Promise { - const client = await this.pool.connect(); + const value = await this.runInTransaction(async (client) => { + await this.assertYearExists(client, yearId); - try { - await client.query('BEGIN'); + const result = await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.trim (model_year_id, name) + VALUES ($1, $2) + RETURNING id, model_year_id, name, created_at, updated_at + `, + [yearId, name.trim()] + ); - // Verify year exists - const yearCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:years:${yearId}`]); - - if (yearCheck.rows.length === 0) { - throw new Error(`Year ${yearId} not found`); - } - - // Get next ID - const idResult = await client.query(` - SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:trims:%' - `); - const trimId = idResult.rows[0].next_id; - - // Insert trim - const now = new Date().toISOString(); - const trim: CatalogTrim = { - id: trimId, - yearId, - name, - createdAt: now, - updatedAt: now, - }; - await client.query(` - INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '10 years') - `, [`catalog:trims:${trimId}`, JSON.stringify(trim)]); - - // Log change - await this.logChange(client, 'CREATE', 'trims', trimId.toString(), null, trim, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Trim created', { trimId, yearId, name, changedBy }); - return trim; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error creating trim', { error, yearId, name }); - throw error; - } finally { - client.release(); - } + const value = this.mapTrimRow(result.rows[0]); + await this.logChange(client, 'CREATE', 'trims', value.id.toString(), null, value, changedBy); + return value; + }); + await this.cacheService.invalidateVehicleData(); + return value; } async updateTrim(trimId: number, yearId: number, name: string, changedBy: string): Promise { - const client = await this.pool.connect(); + const value = await this.runInTransaction(async (client) => { + await this.assertYearExists(client, yearId); - try { - await client.query('BEGIN'); - - // Verify year exists - const yearCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:years:${yearId}`]); - - if (yearCheck.rows.length === 0) { - throw new Error(`Year ${yearId} not found`); - } - - // Get old value - const oldResult = await client.query(` - SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:trims:${trimId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, model_year_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, + [trimId] + ); + if (existing.rowCount === 0) { throw new Error(`Trim ${trimId} not found`); } + const oldValue = this.mapTrimRow(existing.rows[0]); - const oldRow = oldResult.rows[0]; - const oldValue = oldRow.data; - const createdAt = - oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); - const newValue: CatalogTrim = { - id: trimId, - yearId, - name, - createdAt, - updatedAt: new Date().toISOString(), - }; + const result = await client.query( + ` + UPDATE ${VEHICLE_SCHEMA}.trim + SET model_year_id = $2, name = $3, updated_at = NOW() + WHERE id = $1 + RETURNING id, model_year_id, name, created_at, updated_at + `, + [trimId, yearId, name.trim()] + ); - // Update trim - await client.query(` - UPDATE vehicle_dropdown_cache - SET data = $1, updated_at = NOW() - WHERE cache_key = $2 - `, [JSON.stringify(newValue), `catalog:trims:${trimId}`]); - - // Log change + const newValue = this.mapTrimRow(result.rows[0]); await this.logChange(client, 'UPDATE', 'trims', trimId.toString(), oldValue, newValue, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Trim updated', { trimId, yearId, name, changedBy }); return newValue; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error updating trim', { error, trimId, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return value; } async deleteTrim(trimId: number, changedBy: string): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - - // Check for dependent engines - const enginesCheck = await client.query(` - SELECT COUNT(*) as count - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:engines:%' - AND (data->>'trimId')::int = $1 - `, [trimId]); - - if (parseInt(enginesCheck.rows[0].count) > 0) { + await this.runInTransaction(async (client) => { + const dependency = await client.query( + `SELECT 1 FROM ${VEHICLE_SCHEMA}.trim_engine WHERE trim_id = $1 LIMIT 1`, + [trimId] + ); + if ((dependency.rowCount || 0) > 0) { throw new Error('Cannot delete trim with existing engines'); } - // Get old value - const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:trims:${trimId}`]); - - if (oldResult.rows.length === 0) { + const existing = await client.query( + `SELECT id, model_year_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, + [trimId] + ); + if (existing.rowCount === 0) { throw new Error(`Trim ${trimId} not found`); } - const oldValue = oldResult.rows[0].data; - - // Delete trim - await client.query(` - DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:trims:${trimId}`]); - - // Log change + const oldValue = this.mapTrimRow(existing.rows[0]); + await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, [trimId]); await this.logChange(client, 'DELETE', 'trims', trimId.toString(), oldValue, null, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Trim deleted', { trimId, changedBy }); - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error deleting trim', { error, trimId }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); } - // ENGINES OPERATIONS + // ENGINES ---------------------------------------------------------------- async getEnginesByTrim(trimId: number): Promise { - const query = ` - SELECT cache_key, data, created_at, updated_at - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:engines:%' - AND (data->>'trimId')::int = $1 - ORDER BY (data->>'name') - `; + const result = await this.pool.query( + ` + SELECT e.id, + e.name, + e.displacement_l, + e.cylinders, + e.fuel_type, + e.created_at, + e.updated_at, + te.trim_id + FROM ${VEHICLE_SCHEMA}.engine e + JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id + WHERE te.trim_id = $1 + ORDER BY e.name + `, + [trimId] + ); - try { - const result = await this.pool.query(query, [trimId]); - return result.rows.map(row => ({ - id: parseInt(row.cache_key.split(':')[2]), - trimId: row.data.trimId, - name: row.data.name, - description: row.data.description, - createdAt: - row.data?.createdAt ?? this.toIsoDate(row.created_at), - updatedAt: - row.data?.updatedAt ?? this.toIsoDate(row.updated_at), - })); - } catch (error) { - logger.error('Error getting engines by trim', { error, trimId }); - throw error; - } + return result.rows.map((row) => this.mapEngineRow(row)); } - async createEngine(trimId: number, name: string, description: string | undefined, changedBy: string): Promise { - const client = await this.pool.connect(); + async createEngine( + trimId: number, + name: string, + _description: string | undefined, + changedBy: string + ): Promise { + const value = await this.runInTransaction(async (client) => { + await this.assertTrimExists(client, trimId); - try { - await client.query('BEGIN'); + const engineResult = await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.engine (name) + VALUES ($1) + RETURNING id, name, displacement_l, cylinders, fuel_type, created_at, updated_at + `, + [name.trim()] + ); - // Verify trim exists - const trimCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:trims:${trimId}`]); + const engineId = engineResult.rows[0].id; + await client.query( + ` + INSERT INTO ${VEHICLE_SCHEMA}.trim_engine (trim_id, engine_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, + [trimId, engineId] + ); - if (trimCheck.rows.length === 0) { - throw new Error(`Trim ${trimId} not found`); - } - - // Get next ID - const idResult = await client.query(` - SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id - FROM vehicle_dropdown_cache - WHERE cache_key LIKE 'catalog:engines:%' - `); - const engineId = idResult.rows[0].next_id; - - // Insert engine - const now = new Date().toISOString(); - const engine: CatalogEngine = { - id: engineId, - trimId, - name, - description, - createdAt: now, - updatedAt: now, - }; - await client.query(` - INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '10 years') - `, [`catalog:engines:${engineId}`, JSON.stringify(engine)]); - - // Log change - await this.logChange(client, 'CREATE', 'engines', engineId.toString(), null, engine, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Engine created', { engineId, trimId, name, changedBy }); - return engine; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error creating engine', { error, trimId, name }); - throw error; - } finally { - client.release(); - } + const value = this.mapEngineRow({ ...engineResult.rows[0], trim_id: trimId }); + await this.logChange(client, 'CREATE', 'engines', engineId.toString(), null, value, changedBy); + return value; + }); + await this.cacheService.invalidateVehicleData(); + return value; } - async updateEngine(engineId: number, trimId: number, name: string, description: string | undefined, changedBy: string): Promise { - const client = await this.pool.connect(); + async updateEngine( + engineId: number, + trimId: number, + name: string, + _description: string | undefined, + changedBy: string + ): Promise { + const value = await this.runInTransaction(async (client) => { + await this.assertTrimExists(client, trimId); - try { - await client.query('BEGIN'); + const existing = await client.query( + ` + SELECT e.id, + e.name, + e.displacement_l, + e.cylinders, + e.fuel_type, + e.created_at, + e.updated_at, + te.trim_id + FROM ${VEHICLE_SCHEMA}.engine e + JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id + WHERE e.id = $1 AND te.trim_id = $2 + `, + [engineId, trimId] + ); - // Verify trim exists - const trimCheck = await client.query(` - SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:trims:${trimId}`]); - - if (trimCheck.rows.length === 0) { - throw new Error(`Trim ${trimId} not found`); + if (existing.rowCount === 0) { + throw new Error(`Engine ${engineId} not found for trim ${trimId}`); } - // Get old value - const oldResult = await client.query(` - SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:engines:${engineId}`]); + const oldValue = this.mapEngineRow(existing.rows[0]); + const updated = await client.query( + ` + UPDATE ${VEHICLE_SCHEMA}.engine + SET name = $2, updated_at = NOW() + WHERE id = $1 + RETURNING id, name, displacement_l, cylinders, fuel_type, created_at, updated_at + `, + [engineId, name.trim()] + ); - if (oldResult.rows.length === 0) { - throw new Error(`Engine ${engineId} not found`); - } - - const oldRow = oldResult.rows[0]; - const oldValue = oldRow.data; - const createdAt = - oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); - const newValue: CatalogEngine = { - id: engineId, - trimId, - name, - description, - createdAt, - updatedAt: new Date().toISOString(), - }; - - // Update engine - await client.query(` - UPDATE vehicle_dropdown_cache - SET data = $1, updated_at = NOW() - WHERE cache_key = $2 - `, [JSON.stringify(newValue), `catalog:engines:${engineId}`]); - - // Log change + const newValue = this.mapEngineRow({ ...updated.rows[0], trim_id: trimId }); await this.logChange(client, 'UPDATE', 'engines', engineId.toString(), oldValue, newValue, changedBy); - - await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Engine updated', { engineId, trimId, name, changedBy }); return newValue; - } catch (error) { - await client.query('ROLLBACK'); - logger.error('Error updating engine', { error, engineId, name }); - throw error; - } finally { - client.release(); - } + }); + await this.cacheService.invalidateVehicleData(); + return value; } async deleteEngine(engineId: number, changedBy: string): Promise { - const client = await this.pool.connect(); + await this.runInTransaction(async (client) => { + const existing = await client.query( + ` + SELECT e.id, + e.name, + e.displacement_l, + e.cylinders, + e.fuel_type, + e.created_at, + e.updated_at, + te.trim_id + FROM ${VEHICLE_SCHEMA}.engine e + JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id + WHERE e.id = $1 + `, + [engineId] + ); - try { - await client.query('BEGIN'); - - // Get old value - const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:engines:${engineId}`]); - - if (oldResult.rows.length === 0) { + if (existing.rowCount === 0) { throw new Error(`Engine ${engineId} not found`); } - const oldValue = oldResult.rows[0].data; + const oldValue = this.mapEngineRow(existing.rows[0]); + await client.query( + `DELETE FROM ${VEHICLE_SCHEMA}.trim_engine WHERE engine_id = $1`, + [engineId] + ); + await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.engine WHERE id = $1`, [engineId]); - // Delete engine - await client.query(` - DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1 - `, [`catalog:engines:${engineId}`]); - - // Log change await this.logChange(client, 'DELETE', 'engines', engineId.toString(), oldValue, null, changedBy); + }); + await this.cacheService.invalidateVehicleData(); + } + // CHANGE LOGS ------------------------------------------------------------ + + async getChangeLogs(limit: number = 100, offset: number = 0): Promise<{ logs: PlatformChangeLog[]; total: number }> { + const countQuery = 'SELECT COUNT(*) as total FROM platform_change_log'; + const query = ` + SELECT id, change_type, resource_type, resource_id, old_value, new_value, changed_by, created_at + FROM platform_change_log + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + + try { + const [countResult, dataResult] = await Promise.all([ + this.pool.query(countQuery), + this.pool.query(query, [limit, offset]) + ]); + + const total = parseInt(countResult.rows[0].total, 10); + const logs = dataResult.rows.map((row) => ({ + id: row.id, + changeType: row.change_type, + resourceType: row.resource_type, + resourceId: row.resource_id, + oldValue: row.old_value, + newValue: row.new_value, + changedBy: row.changed_by, + createdAt: new Date(row.created_at), + })); + + return { logs, total }; + } catch (error) { + logger.error('Error fetching change logs', { error }); + throw error; + } + } + + // HELPERS ---------------------------------------------------------------- + + private async runInTransaction(handler: (client: PoolClient) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const result = await handler(client); await client.query('COMMIT'); - - // Invalidate cache - await this.cacheService.invalidateVehicleData(); - - logger.info('Engine deleted', { engineId, changedBy }); + return result; } catch (error) { await client.query('ROLLBACK'); - logger.error('Error deleting engine', { error, engineId }); throw error; } finally { client.release(); } } - // HELPER METHODS + private mapMakeRow(row: any): CatalogMake { + return { + id: Number(row.id), + name: row.name, + createdAt: this.toIsoDate(row.created_at), + updatedAt: this.toIsoDate(row.updated_at), + }; + } + + private mapModelRow(row: any): CatalogModel { + return { + id: Number(row.id), + makeId: Number(row.make_id), + name: row.name, + createdAt: this.toIsoDate(row.created_at), + updatedAt: this.toIsoDate(row.updated_at), + }; + } + + private mapYearRow(row: any): CatalogYear { + return { + id: Number(row.id), + modelId: Number(row.model_id), + year: Number(row.year), + createdAt: this.toIsoDate(row.created_at), + updatedAt: this.toIsoDate(row.updated_at), + }; + } + + private mapTrimRow(row: any): CatalogTrim { + return { + id: Number(row.id), + yearId: Number(row.model_year_id), + name: row.name, + createdAt: this.toIsoDate(row.created_at), + updatedAt: this.toIsoDate(row.updated_at), + }; + } + + private mapEngineRow(row: any): CatalogEngine { + return { + id: Number(row.id), + trimId: Number(row.trim_id), + name: row.name, + displacement: row.displacement_l?.toString() ?? null, + cylinders: row.cylinders !== null ? Number(row.cylinders) : null, + fuel_type: row.fuel_type ?? null, + createdAt: this.toIsoDate(row.created_at), + updatedAt: this.toIsoDate(row.updated_at), + }; + } + + private async assertMakeExists(client: PoolClient, makeId: number): Promise { + const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, [makeId]); + if (result.rowCount === 0) { + throw new Error(`Make ${makeId} not found`); + } + } + + private async assertModelExists(client: PoolClient, modelId: number): Promise { + const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, [modelId]); + if (result.rowCount === 0) { + throw new Error(`Model ${modelId} not found`); + } + } + + private async assertYearExists(client: PoolClient, yearId: number): Promise { + const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, [yearId]); + if (result.rowCount === 0) { + throw new Error(`Year ${yearId} not found`); + } + } + + private async assertTrimExists(client: PoolClient, trimId: number): Promise { + const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, [trimId]); + if (result.rowCount === 0) { + throw new Error(`Trim ${trimId} not found`); + } + } private toIsoDate(value: Date | string | null | undefined): string { if (!value) { @@ -1036,7 +720,7 @@ export class VehicleCatalogService { } private async logChange( - client: any, + client: PoolClient, changeType: 'CREATE' | 'UPDATE' | 'DELETE', resourceType: 'makes' | 'models' | 'years' | 'trims' | 'engines', resourceId: string, @@ -1055,41 +739,7 @@ export class VehicleCatalogService { resourceId, oldValue ? JSON.stringify(oldValue) : null, newValue ? JSON.stringify(newValue) : null, - changedBy + changedBy, ]); } - - async getChangeLogs(limit: number = 100, offset: number = 0): Promise<{ logs: PlatformChangeLog[]; total: number }> { - const countQuery = 'SELECT COUNT(*) as total FROM platform_change_log'; - const query = ` - SELECT id, change_type, resource_type, resource_id, old_value, new_value, changed_by, created_at - FROM platform_change_log - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - `; - - try { - const [countResult, dataResult] = await Promise.all([ - this.pool.query(countQuery), - this.pool.query(query, [limit, offset]) - ]); - - const total = parseInt(countResult.rows[0].total, 10); - const logs = dataResult.rows.map(row => ({ - id: row.id, - changeType: row.change_type, - resourceType: row.resource_type, - resourceId: row.resource_id, - oldValue: row.old_value, - newValue: row.new_value, - changedBy: row.changed_by, - createdAt: new Date(row.created_at) - })); - - return { logs, total }; - } catch (error) { - logger.error('Error fetching change logs', { error }); - throw error; - } - } } diff --git a/docs/changes/platform-vehicle-data-loader.md b/docs/changes/platform-vehicle-data-loader.md index fbd1b0d..43b0959 100644 --- a/docs/changes/platform-vehicle-data-loader.md +++ b/docs/changes/platform-vehicle-data-loader.md @@ -9,7 +9,7 @@ 1. **Wire dropdown API to refreshed data** - Run `make migrate` (or `npm run migrate:all` inside backend container) to ensure the new schema exists. - Execute the loader (see command below) so Postgres has the latest lookup entries. - - Verify `VehicleDataRepository` queries and Redis caching logic continue to function against the reinstated tables. + - Verify both the platform dropdown queries and the admin catalog APIs surface the same data set (admin APIs now read directly from `vehicles.*` tables). 2. **Add Makefile wrapper** - Create a `make load-vehicle-data` task that shells into the backend container, installs `psycopg` if needed, and invokes `python3 scripts/load_vehicle_data.py` with the correct DB credentials and data directory. @@ -25,3 +25,7 @@ python3 scripts/load_vehicle_data.py \ ``` > Run the command from the repository root (outside of containers) while `mvp-postgres` is up. Adjust host/port if executing inside a container. + +## 2025-11-07 Update +- Admin catalog CRUD endpoints have been refactored to read/write the normalized `vehicles.*` tables instead of the legacy `vehicle_dropdown_cache`. +- Platform cache invalidation now happens immediately after each catalog mutation so both the admin UI and the user dropdown APIs stay in sync. diff --git a/frontend/package.json b/frontend/package.json index 2812401..6339b8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "framer-motion": "^11.0.0", "@mui/material": "^5.15.0", "@mui/x-date-pickers": "^6.19.0", + "@mui/x-data-grid": "^6.19.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", "@emotion/cache": "^11.11.0", @@ -58,6 +59,7 @@ "jest-environment-jsdom": "^29.7.0", "@testing-library/react": "^16.0.0", "@testing-library/jest-dom": "^6.1.5", - "@testing-library/user-event": "^14.5.1" + "@testing-library/user-event": "^14.5.1", + "patch-package": "^6.5.1" } } diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index c9d4922..5b1b94e 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -19,6 +19,7 @@ import { CreateCatalogModelRequest, UpdateCatalogModelRequest, CreateCatalogYearRequest, + UpdateCatalogYearRequest, CreateCatalogTrimRequest, UpdateCatalogTrimRequest, CreateCatalogEngineRequest, @@ -121,6 +122,11 @@ export const adminApi = { return response.data; }, + updateYear: async (id: string, data: UpdateCatalogYearRequest): Promise => { + const response = await apiClient.put(`/admin/catalog/years/${id}`, data); + return response.data; + }, + deleteYear: async (id: string): Promise => { await apiClient.delete(`/admin/catalog/years/${id}`); }, diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts index 2fb6db8..9faa60b 100644 --- a/frontend/src/features/admin/types/admin.types.ts +++ b/frontend/src/features/admin/types/admin.types.ts @@ -96,6 +96,11 @@ export interface CreateCatalogYearRequest { year: number; } +export interface UpdateCatalogYearRequest { + modelId: string; + year: number; +} + export interface CreateCatalogTrimRequest { yearId: string; name: string; diff --git a/frontend/src/pages/admin/AdminCatalogPage.tsx b/frontend/src/pages/admin/AdminCatalogPage.tsx index 32b7789..e597208 100644 --- a/frontend/src/pages/admin/AdminCatalogPage.tsx +++ b/frontend/src/pages/admin/AdminCatalogPage.tsx @@ -6,20 +6,15 @@ import React, { } from 'react'; import { Navigate } from 'react-router-dom'; import { + Autocomplete, Box, - Breadcrumbs, Button, CircularProgress, - Collapse, Dialog, DialogActions, DialogContent, DialogTitle, - IconButton, - Link, - List, - ListItemButton, - ListItemText, + Grid, MenuItem, Paper, TextField, @@ -27,61 +22,26 @@ import { } from '@mui/material'; import { Add, - ChevronRight, Delete, Edit, - ExpandLess, - ExpandMore, } from '@mui/icons-material'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useQueryClient } from '@tanstack/react-query'; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRowSelectionModel, +} from '@mui/x-data-grid'; +import { Controller, useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; import { useAdminAccess } from '../../core/auth/useAdminAccess'; import { - AdminDataGrid, - GridColumn, + adminApi, +} from '../../features/admin/api/admin.api'; +import { AdminSectionHeader, - SelectionToolbar, - BulkActionDialog, AuditLogPanel, + BulkActionDialog, } from '../../features/admin/components'; -import { useBulkSelection } from '../../features/admin/hooks/useBulkSelection'; -import { - useMakes, - useCreateMake, - useUpdateMake, - useDeleteMake, - useModels, - useCreateModel, - useUpdateModel, - useDeleteModel, - useYears, - useCreateYear, - useDeleteYear, - useTrims, - useCreateTrim, - useUpdateTrim, - useDeleteTrim, - useEngines, - useCreateEngine, - useUpdateEngine, - useDeleteEngine, -} from '../../features/admin/hooks/useCatalog'; -import { - CatalogLevel, - CatalogRow, - CatalogSelectionContext, - LEVEL_LABEL, - LEVEL_SINGULAR_LABEL, - NEXT_LEVEL, - getCascadeSummary, -} from '../../features/admin/catalog/catalogShared'; -import { - CatalogFormValues, - buildDefaultValues, - getSchemaForLevel, -} from '../../features/admin/catalog/catalogSchemas'; import { CatalogEngine, CatalogMake, @@ -90,1029 +50,548 @@ import { CatalogYear, } from '../../features/admin/types/admin.types'; -interface TreeNode { +type TransmissionOption = 'Automatic' | 'Manual'; + +interface CatalogGridRow { id: string; + makeId: string; + makeName: string; + modelId: string; + modelName: string; + yearId: string; + yearValue: number; + trimId: string; + trimName: string; + engineId: string; + engineName: string; + transmission: TransmissionOption; + createdAt: string; + updatedAt: string; +} + +interface CatalogCombinationFormValues { + makeName: string; + modelName: string; + year: number | ''; + trimName: string; + engineName: string; + transmission: TransmissionOption; +} + +const transmissionOptions: TransmissionOption[] = ['Automatic', 'Manual']; + +const normalizeName = (value: string): string => value.trim().toLowerCase(); + +const formatRowLabel = (row: CatalogGridRow): string => + `${row.yearValue} ${row.makeName} ${row.modelName} ${row.trimName} ${row.engineName}`; + +const normalizeId = (value: string | number): string => value.toString(); + +const ColumnHeader: React.FC<{ label: string; - level?: CatalogLevel; - children?: TreeNode[]; -} - -interface BreadcrumbItem { - key: string; - label: string; - target: CatalogSelectionContext; -} - -interface DialogState { - open: boolean; - mode: 'create' | 'edit'; - level: CatalogLevel; - entity?: CatalogRow; - context: CatalogSelectionContext; -} - -const normalizeCollection = (value: unknown, key: string): T[] => { - if (Array.isArray(value)) { - return value as T[]; - } - if ( - value && - typeof value === 'object' && - Array.isArray((value as Record)[key]) - ) { - return (value as Record)[key]; - } - return []; -}; + value: string; + onChange: (next: string) => void; +}> = ({ label, value, onChange }) => ( + + + {label} + + onChange(event.target.value)} + placeholder="Filter" + fullWidth + InputProps={{ + sx: { fontSize: 13 }, + }} + /> + +); export const AdminCatalogPage: React.FC = () => { - const { isAdmin, loading: authLoading } = useAdminAccess(); - const queryClient = useQueryClient(); + const { loading: authLoading, isAdmin } = useAdminAccess(); - const [selection, setSelection] = useState({ - level: 'makes', + const [makes, setMakes] = useState([]); + const [models, setModels] = useState([]); + const [years, setYears] = useState([]); + const [trims, setTrims] = useState([]); + const [engines, setEngines] = useState([]); + const [rows, setRows] = useState([]); + const [loadingMessage, setLoadingMessage] = useState('Loading catalog…'); + const [loadingCatalog, setLoadingCatalog] = useState(true); + const [catalogError, setCatalogError] = useState(null); + + const [filters, setFilters] = useState({ + year: '', + make: '', + model: '', + trim: '', + engine: '', + transmission: '', }); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 25, + }); + const [rowSelectionModel, setRowSelectionModel] = useState([]); - const makesQuery = useMakes(); - const modelsQuery = useModels(selection.make?.id); - const yearsQuery = useYears(selection.model?.id); - const trimsQuery = useTrims(selection.year?.id); - const enginesQuery = useEngines(selection.trim?.id); - - const makes = normalizeCollection(makesQuery.data, 'makes'); - const models = - selection.make !== undefined - ? normalizeCollection(modelsQuery.data, 'models') - : []; - const years = - selection.model !== undefined - ? normalizeCollection(yearsQuery.data, 'years') - : []; - const trims = - selection.year !== undefined - ? normalizeCollection(trimsQuery.data, 'trims') - : []; - const engines = - selection.trim !== undefined - ? normalizeCollection(enginesQuery.data, 'engines') - : []; - - const createMake = useCreateMake(); - const updateMake = useUpdateMake(); - const deleteMake = useDeleteMake(); - - const createModel = useCreateModel(); - const updateModel = useUpdateModel(); - const deleteModel = useDeleteModel(); - - const createYear = useCreateYear(); - const deleteYear = useDeleteYear(); - - const createTrim = useCreateTrim(); - const updateTrim = useUpdateTrim(); - const deleteTrim = useDeleteTrim(); - - const createEngine = useCreateEngine(); - const updateEngine = useUpdateEngine(); - const deleteEngine = useDeleteEngine(); - const [expandedNodes, setExpandedNodes] = useState>( - () => new Set(['node-makes', 'node-models', 'node-years', 'node-trims']) - ); - const [dialogState, setDialogState] = useState(null); + const [dialogState, setDialogState] = useState<{ + mode: 'create' | 'edit'; + row?: CatalogGridRow; + } | null>(null); + const [dialogSubmitting, setDialogSubmitting] = useState(false); const [bulkDialogOpen, setBulkDialogOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); - const makesById = useMemo(() => { - const map = new Map(); - makes.forEach((make) => map.set(make.id, make)); - return map; - }, [makes]); + const filteredRows = useMemo(() => { + const normalized = { + year: filters.year.trim(), + make: filters.make.toLowerCase().trim(), + model: filters.model.toLowerCase().trim(), + trim: filters.trim.toLowerCase().trim(), + engine: filters.engine.toLowerCase().trim(), + transmission: filters.transmission.toLowerCase().trim(), + }; - const modelsById = useMemo(() => { - const map = new Map(); - models.forEach((model) => map.set(model.id, model)); - return map; - }, [models]); + return rows.filter((row) => { + const matchesYear = normalized.year + ? row.yearValue.toString().includes(normalized.year) + : true; + const matchesMake = normalized.make + ? row.makeName.toLowerCase().includes(normalized.make) + : true; + const matchesModel = normalized.model + ? row.modelName.toLowerCase().includes(normalized.model) + : true; + const matchesTrim = normalized.trim + ? row.trimName.toLowerCase().includes(normalized.trim) + : true; + const matchesEngine = normalized.engine + ? row.engineName.toLowerCase().includes(normalized.engine) + : true; + const matchesTransmission = normalized.transmission + ? row.transmission.toLowerCase().includes(normalized.transmission) + : true; - const yearsById = useMemo(() => { - const map = new Map(); - years.forEach((year) => map.set(year.id, year)); - return map; - }, [years]); - - const trimsById = useMemo(() => { - const map = new Map(); - trims.forEach((trim) => map.set(trim.id, trim)); - return map; - }, [trims]); - - const modelsByMake = useMemo(() => { - const map = new Map(); - models.forEach((model) => { - const list = map.get(model.makeId) ?? []; - list.push(model); - map.set(model.makeId, list); + return ( + matchesYear && + matchesMake && + matchesModel && + matchesTrim && + matchesEngine && + matchesTransmission + ); }); - return map; - }, [models]); + }, [filters, rows]); - const yearsByModel = useMemo(() => { - const map = new Map(); - years.forEach((year) => { - const list = map.get(year.modelId) ?? []; - list.push(year); - map.set(year.modelId, list); - }); - return map; - }, [years]); + const selectedRows = useMemo( + () => filteredRows.filter((row) => rowSelectionModel.includes(row.id)), + [filteredRows, rowSelectionModel] + ); - const trimsByYear = useMemo(() => { - const map = new Map(); - trims.forEach((trim) => { - const list = map.get(trim.yearId) ?? []; - list.push(trim); - map.set(trim.yearId, list); - }); - return map; - }, [trims]); + const setFilterValue = (field: keyof typeof filters, value: string) => { + setFilters((prev) => ({ ...prev, [field]: value })); + setPaginationModel((prev) => ({ ...prev, page: 0 })); + }; - const enginesByTrim = useMemo(() => { - const map = new Map(); - engines.forEach((engine) => { - const list = map.get(engine.trimId) ?? []; - list.push(engine); - map.set(engine.trimId, list); - }); - return map; - }, [engines]); + const buildGridRows = useCallback( + ( + makeList: CatalogMake[], + modelList: CatalogModel[], + yearList: CatalogYear[], + trimList: CatalogTrim[], + engineList: CatalogEngine[] + ): CatalogGridRow[] => { + const rowsAccumulator: CatalogGridRow[] = []; - const levelRequirements = useMemo< - Record - >(() => ({ - makes: { valid: true }, - models: selection.make - ? { valid: true } - : { valid: false, message: 'Select a make to view models.' }, - years: selection.model - ? { valid: true } - : { valid: false, message: 'Select a model to view years.' }, - trims: selection.year - ? { valid: true } - : { valid: false, message: 'Select a year to view trims.' }, - engines: selection.trim - ? { valid: true } - : { valid: false, message: 'Select a trim to view engines.' }, - }), [selection]); + makeList.forEach((make) => { + const makeModels = modelList.filter((model) => model.makeId === make.id); - const currentLevelRequirement = levelRequirements[selection.level]; - const canViewLevel = currentLevelRequirement.valid; + makeModels.forEach((model) => { + const modelYears = yearList.filter((year) => year.modelId === model.id); - const currentRows = useMemo(() => { - if (!canViewLevel) { - return []; + modelYears.forEach((year) => { + const yearTrims = trimList.filter((trim) => trim.yearId === year.id); + + yearTrims.forEach((trim) => { + const trimEngines = engineList.filter((engine) => engine.trimId === trim.id); + + trimEngines.forEach((engine) => { + rowsAccumulator.push({ + id: engine.id, + makeId: make.id, + makeName: make.name, + modelId: model.id, + modelName: model.name, + yearId: year.id, + yearValue: year.year, + trimId: trim.id, + trimName: trim.name, + engineId: engine.id, + engineName: engine.name, + transmission: 'Automatic', + createdAt: engine.createdAt, + updatedAt: engine.updatedAt, + }); + }); + }); + }); + }); + }); + + return rowsAccumulator; + }, + [] + ); + + const fetchCatalogData = useCallback(async () => { + setLoadingCatalog(true); + setCatalogError(null); + setLoadingMessage('Loading makes…'); + + try { + const makesData = await adminApi.listMakes(); + + const modelsData: CatalogModel[] = []; + for (let index = 0; index < makesData.length; index += 1) { + const make = makesData[index]; + setLoadingMessage(`Loading models (${index + 1}/${makesData.length})…`); + const makeModels = await adminApi.listModels(make.id); + modelsData.push(...makeModels); + } + + const yearsData: CatalogYear[] = []; + for (let index = 0; index < modelsData.length; index += 1) { + const model = modelsData[index]; + setLoadingMessage(`Loading years (${index + 1}/${modelsData.length})…`); + const modelYears = await adminApi.listYears(model.id); + yearsData.push(...modelYears); + } + + const trimsData: CatalogTrim[] = []; + for (let index = 0; index < yearsData.length; index += 1) { + const year = yearsData[index]; + setLoadingMessage(`Loading trims (${index + 1}/${yearsData.length})…`); + const yearTrims = await adminApi.listTrims(year.id); + trimsData.push(...yearTrims); + } + + const enginesData: CatalogEngine[] = []; + for (let index = 0; index < trimsData.length; index += 1) { + const trim = trimsData[index]; + setLoadingMessage(`Loading engines (${index + 1}/${trimsData.length})…`); + const trimEngines = await adminApi.listEngines(trim.id); + enginesData.push(...trimEngines); + } + + const normalizedMakes = makesData.map((make) => ({ + ...make, + id: normalizeId(make.id), + })); + const normalizedModels = modelsData.map((model) => ({ + ...model, + id: normalizeId(model.id), + makeId: normalizeId(model.makeId), + })); + const normalizedYears = yearsData.map((year) => ({ + ...year, + id: normalizeId(year.id), + modelId: normalizeId(year.modelId), + })); + const normalizedTrims = trimsData.map((trim) => ({ + ...trim, + id: normalizeId(trim.id), + yearId: normalizeId(trim.yearId), + })); + const normalizedEngines = enginesData.map((engine) => ({ + ...engine, + id: normalizeId(engine.id), + trimId: normalizeId(engine.trimId), + })); + + setMakes(normalizedMakes); + setModels(normalizedModels); + setYears(normalizedYears); + setTrims(normalizedTrims); + setEngines(normalizedEngines); + + setRows( + buildGridRows( + normalizedMakes, + normalizedModels, + normalizedYears, + normalizedTrims, + normalizedEngines + ) + ); + } catch (error) { + setCatalogError(error as Error); + } finally { + setLoadingCatalog(false); + setLoadingMessage('Loading catalog…'); } - - switch (selection.level) { - case 'makes': - return makes; - case 'models': - return models; - case 'years': - return years; - case 'trims': - return trims; - case 'engines': - return engines; - default: - return makes; - } - }, [canViewLevel, selection.level, makes, models, years, trims, engines]); - - const { - selected, - toggleItem, - toggleAll, - reset: resetBulkSelection, - count: selectedCount, - selectedItems, - } = useBulkSelection({ - items: currentRows, - keyExtractor: (item) => item.id, - }); + }, [buildGridRows]); useEffect(() => { - resetBulkSelection(); - }, [ - resetBulkSelection, - selection.level, - selection.make?.id, - selection.model?.id, - selection.year?.id, - selection.trim?.id, - ]); + if (isAdmin) { + fetchCatalogData(); + } + }, [fetchCatalogData, isAdmin]); - const queryByLevel = useMemo( - () => ({ - makes: makesQuery, - models: modelsQuery, - years: yearsQuery, - trims: trimsQuery, - engines: enginesQuery, - }), - [makesQuery, modelsQuery, yearsQuery, trimsQuery, enginesQuery] - ); - - const activeQuery = queryByLevel[selection.level]; - const activeError = activeQuery.error - ? activeQuery.error instanceof Error - ? activeQuery.error - : new Error('Failed to load data') - : null; - - const toggleTreeNode = useCallback((nodeId: string) => { - setExpandedNodes((prev) => { - const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } - return next; - }); + const handleRowUpdateError = useCallback(() => { + toast.error('Unable to update catalog row.'); }, []); - const handleLevelSelect = useCallback((level: CatalogLevel) => { - setSelection((prev) => { - switch (level) { - case 'makes': - return { level: 'makes' }; - case 'models': - if (!prev.make) { - toast.error('Select a make to view models.'); - return prev; - } - return { - level: 'models', - make: prev.make, - }; - case 'years': - if (!prev.model) { - toast.error('Select a model to view years.'); - return prev; - } - return { - level: 'years', - make: prev.make, - model: prev.model, - }; - case 'trims': - if (!prev.year) { - toast.error('Select a year to view trims.'); - return prev; - } - return { - level: 'trims', - make: prev.make, - model: prev.model, - year: prev.year, - }; - case 'engines': - default: - if (!prev.trim) { - toast.error('Select a trim to view engines.'); - return prev; - } - return { - level: 'engines', - make: prev.make, - model: prev.model, - year: prev.year, - trim: prev.trim, - }; - } - }); - }, []); - - const deriveContextFromRow = useCallback( - (row: CatalogRow, level: CatalogLevel): CatalogSelectionContext => { - switch (level) { - case 'makes': - return { level, make: row as CatalogMake }; - case 'models': { - const model = row as CatalogModel; - const make = makesById.get(model.makeId); - return { - level, - make, - model, - }; - } - case 'years': { - const year = row as CatalogYear; - const model = modelsById.get(year.modelId); - const make = model ? makesById.get(model.makeId) : undefined; - return { - level, - make, - model, - year, - }; - } - case 'trims': { - const trim = row as CatalogTrim; - const year = yearsById.get(trim.yearId); - const model = year ? modelsById.get(year.modelId) : undefined; - const make = model ? makesById.get(model.makeId) : undefined; - return { - level, - make, - model, - year, - trim, - }; - } - case 'engines': { - const engine = row as CatalogEngine; - const trim = trimsById.get(engine.trimId); - const year = trim ? yearsById.get(trim.yearId) : undefined; - const model = year ? modelsById.get(year.modelId) : undefined; - const make = model ? makesById.get(model.makeId) : undefined; - return { - level, - make, - model, - year, - trim, - }; - } - default: - return { level }; - } - }, - [makesById, modelsById, yearsById, trimsById] - ); - - const handleDrillDown = useCallback( - (row: CatalogRow) => { - const nextLevel = NEXT_LEVEL[selection.level]; - if (!nextLevel) { - return; - } - - if (selection.level === 'makes') { - const make = row as CatalogMake; - setSelection({ - level: 'models', - make, - }); - return; - } - - if (selection.level === 'models') { - const model = row as CatalogModel; - const make = - selection.make ?? makesById.get(model.makeId); - setSelection({ - level: 'years', - make, - model, - }); - return; - } - - if (selection.level === 'years') { - const year = row as CatalogYear; - const model = - selection.model ?? modelsById.get(year.modelId); - const make = - selection.make ?? (model ? makesById.get(model.makeId) : undefined); - setSelection({ - level: 'trims', - make, - model, - year, - }); - return; - } - - if (selection.level === 'trims') { - const trim = row as CatalogTrim; - const year = - selection.year ?? yearsById.get(trim.yearId); - const model = - selection.model ?? - (year ? modelsById.get(year.modelId) : undefined); - const make = - selection.make ?? (model ? makesById.get(model.makeId) : undefined); - setSelection({ - level: 'engines', - make, - model, - year, - trim, - }); - } - }, - [ - selection, - makesById, - modelsById, - yearsById, - trimsById, - ] - ); - - const openCreateDialog = useCallback(() => { - if (!currentLevelRequirement.valid) { - if (currentLevelRequirement.message) { - toast.error(currentLevelRequirement.message); - } - return; - } - - setDialogState({ - open: true, - mode: 'create', - level: selection.level, - context: { ...selection }, - }); - }, [currentLevelRequirement, selection]); - - const openEditDialog = useCallback( - (row: CatalogRow) => { - const context = deriveContextFromRow(row, selection.level); - setDialogState({ - open: true, - mode: 'edit', - level: selection.level, - entity: row, - context, - }); - }, - [deriveContextFromRow, selection.level] - ); - - const deleteMutationMap = useMemo( - () => ({ - makes: deleteMake, - models: deleteModel, - years: deleteYear, - trims: deleteTrim, - engines: deleteEngine, - }), - [deleteMake, deleteModel, deleteYear, deleteTrim, deleteEngine] - ); - - const invalidateCatalogQueries = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['catalogMakes'] }); - queryClient.invalidateQueries({ queryKey: ['catalogModels'] }); - queryClient.invalidateQueries({ queryKey: ['catalogYears'] }); - queryClient.invalidateQueries({ queryKey: ['catalogTrims'] }); - queryClient.invalidateQueries({ queryKey: ['catalogEngines'] }); - }, [queryClient]); - - const handleDeleteSingle = useCallback( - async (row: CatalogRow) => { - const confirmed = window.confirm( - `Delete this ${LEVEL_SINGULAR_LABEL[selection.level]}? This action cannot be undone.` - ); - if (!confirmed) { - return; - } - + const processRowUpdate = useCallback( + async (newRow: CatalogGridRow, oldRow: CatalogGridRow) => { try { - await deleteMutationMap[selection.level].mutateAsync(row.id); - invalidateCatalogQueries(); - resetBulkSelection(); - } catch { - // Mutation hooks handle error messaging. - } - }, - [deleteMutationMap, selection.level, invalidateCatalogQueries, resetBulkSelection] - ); - - const handleBulkDelete = useCallback(async () => { - if (selectedCount === 0) { - return; - } - - setBulkDeleting(true); - const ids = Array.from(selected); - let deleted = 0; - let failed = 0; - - for (const id of ids) { - try { - await deleteMutationMap[selection.level].mutateAsync(id); - deleted += 1; - } catch { - failed += 1; - } - } - - invalidateCatalogQueries(); - resetBulkSelection(); - setBulkDeleting(false); - setBulkDialogOpen(false); - - if (failed > 0) { - toast.error( - `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}, failed ${failed}` - ); - } else { - toast.success( - `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}` - ); - } - }, [ - deleteMutationMap, - invalidateCatalogQueries, - resetBulkSelection, - selected, - selectedCount, - selection.level, - ]); - - const handleDialogSubmit = useCallback( - async (values: CatalogFormValues) => { - if (!dialogState) { - return; - } - - try { - if (dialogState.mode === 'create') { - switch (dialogState.level) { - case 'makes': - await createMake.mutateAsync({ - name: values.name?.trim() ?? '', - }); - break; - case 'models': - await createModel.mutateAsync({ - name: values.name?.trim() ?? '', - makeId: - values.makeId ?? - dialogState.context.make?.id ?? - '', - }); - break; - case 'years': - await createYear.mutateAsync({ - modelId: - values.modelId ?? - dialogState.context.model?.id ?? - '', - year: values.year ?? new Date().getFullYear(), - }); - break; - case 'trims': - await createTrim.mutateAsync({ - name: values.name?.trim() ?? '', - yearId: - values.yearId ?? - dialogState.context.year?.id ?? - '', - }); - break; - case 'engines': - await createEngine.mutateAsync({ - name: values.name?.trim() ?? '', - trimId: - values.trimId ?? - dialogState.context.trim?.id ?? - '', - displacement: values.displacement, - cylinders: values.cylinders, - fuel_type: values.fuel_type, - }); - break; - default: - break; - } + if (newRow.makeName !== oldRow.makeName) { + await adminApi.updateMake(oldRow.makeId, { name: newRow.makeName }); + } else if (newRow.modelName !== oldRow.modelName) { + await adminApi.updateModel(oldRow.modelId, { name: newRow.modelName }); + } else if (newRow.yearValue !== oldRow.yearValue) { + await adminApi.updateYear(oldRow.yearId, { + modelId: oldRow.modelId, + year: Number(newRow.yearValue), + }); + } else if (newRow.trimName !== oldRow.trimName) { + await adminApi.updateTrim(oldRow.trimId, { name: newRow.trimName }); + } else if (newRow.engineName !== oldRow.engineName) { + await adminApi.updateEngine(oldRow.engineId, { name: newRow.engineName }); + } else if (newRow.transmission !== oldRow.transmission) { + return newRow; } else { - const entityId = dialogState.entity?.id ?? ''; - switch (dialogState.level) { - case 'makes': - await updateMake.mutateAsync({ - id: entityId, - data: { name: values.name?.trim() ?? '' }, - }); - break; - case 'models': - await updateModel.mutateAsync({ - id: entityId, - data: { name: values.name?.trim() ?? '' }, - }); - break; - case 'trims': - await updateTrim.mutateAsync({ - id: entityId, - data: { name: values.name?.trim() ?? '' }, - }); - break; - case 'engines': - await updateEngine.mutateAsync({ - id: entityId, - data: { - name: values.name?.trim(), - displacement: values.displacement, - cylinders: values.cylinders, - fuel_type: values.fuel_type, - }, - }); - break; - default: - break; - } + return oldRow; } - setDialogState(null); - resetBulkSelection(); - } catch { - // Mutation hooks handle error presentation; keep dialog open for corrections. + toast.success('Catalog updated'); + await fetchCatalogData(); + return newRow; + } catch (error) { + handleRowUpdateError(); + throw error; } }, - [ - createEngine, - createMake, - createModel, - createTrim, - createYear, - dialogState, - resetBulkSelection, - updateEngine, - updateMake, - updateModel, - updateTrim, - ] + [fetchCatalogData, handleRowUpdateError] ); - const formatRowLabel = useCallback( - (row: CatalogRow): string => { - switch (selection.level) { - case 'makes': - return (row as CatalogMake).name; - case 'models': { - const model = row as CatalogModel; - const make = makesById.get(model.makeId); - return make ? `${model.name} (${make.name})` : model.name; - } - case 'years': { - const year = row as CatalogYear; - const model = modelsById.get(year.modelId); - return model ? `${year.year} (${model.name})` : String(year.year); - } - case 'trims': { - const trim = row as CatalogTrim; - const year = yearsById.get(trim.yearId); - return year ? `${trim.name} (${year.year})` : trim.name; - } - case 'engines': { - const engine = row as CatalogEngine; - const trim = trimsById.get(engine.trimId); - return trim - ? `${engine.name} (${trim.name})` - : engine.name; - } - default: - return row.id; + const ensureMake = useCallback( + async (name: string): Promise => { + const existing = makes.find((make) => normalizeName(make.name) === normalizeName(name)); + if (existing) { + return existing; } + const created = await adminApi.createMake({ name }); + return created; }, - [ - makesById, - modelsById, - selection.level, - trimsById, - yearsById, - ] + [makes] ); - const cascadeSummary = useMemo( - () => - getCascadeSummary( - selection.level, - selectedItems, - modelsByMake, - yearsByModel, - trimsByYear, - enginesByTrim + const ensureModel = useCallback( + async (makeId: string, name: string): Promise => { + const existing = models.find( + (model) => + model.makeId === makeId && normalizeName(model.name) === normalizeName(name) + ); + if (existing) { + return existing; + } + const created = await adminApi.createModel({ makeId, name }); + return created; + }, + [models] + ); + + const ensureYear = useCallback( + async (modelId: string, yearValue: number): Promise => { + const existing = years.find( + (year) => year.modelId === modelId && year.year === yearValue + ); + if (existing) { + return existing; + } + const created = await adminApi.createYear({ + modelId, + year: yearValue, + }); + return created; + }, + [years] + ); + + const ensureTrim = useCallback( + async (yearId: string, name: string): Promise => { + const existing = trims.find( + (trim) => + trim.yearId === yearId && normalizeName(trim.name) === normalizeName(name) + ); + if (existing) { + return existing; + } + const created = await adminApi.createTrim({ yearId, name }); + return created; + }, + [trims] + ); + + const handleDialogSubmit = async (values: CatalogCombinationFormValues) => { + if (!dialogState) { + return; + } + + setDialogSubmitting(true); + try { + if (values.year === '' || Number.isNaN(Number(values.year))) { + throw new Error('Year is required'); + } + const numericYear = Number(values.year); + + if (dialogState.mode === 'create') { + const make = await ensureMake(values.makeName); + const model = await ensureModel(make.id, values.modelName); + const year = await ensureYear(model.id, numericYear); + const trim = await ensureTrim(year.id, values.trimName); + await adminApi.createEngine({ + trimId: trim.id, + name: values.engineName, + }); + toast.success('Vehicle configuration added'); + } else if (dialogState.mode === 'edit' && dialogState.row) { + const row = dialogState.row; + if (values.makeName !== row.makeName) { + await adminApi.updateMake(row.makeId, { name: values.makeName }); + } + if (values.modelName !== row.modelName) { + await adminApi.updateModel(row.modelId, { name: values.modelName }); + } + if (numericYear !== row.yearValue) { + await adminApi.updateYear(row.yearId, { modelId: row.modelId, year: numericYear }); + } + if (values.trimName !== row.trimName) { + await adminApi.updateTrim(row.trimId, { name: values.trimName }); + } + if (values.engineName !== row.engineName) { + await adminApi.updateEngine(row.engineId, { name: values.engineName }); + } + toast.success('Vehicle configuration updated'); + } + + setDialogState(null); + await fetchCatalogData(); + } catch (error) { + const message = + (error as Error)?.message || 'Unable to process vehicle configuration.'; + toast.error(message); + } finally { + setDialogSubmitting(false); + } + }; + + const handleDeleteRows = async () => { + if (selectedRows.length === 0) { + return; + } + setBulkDeleting(true); + try { + await Promise.all( + selectedRows.map((row) => adminApi.deleteEngine(row.engineId)) + ); + toast.success('Selected configurations deleted'); + setBulkDialogOpen(false); + setRowSelectionModel([]); + await fetchCatalogData(); + } catch (error) { + toast.error('Failed to delete selected configurations'); + } finally { + setBulkDeleting(false); + } + }; + + const columns: GridColDef[] = [ + { + field: 'yearValue', + headerName: 'Year', + flex: 0.8, + editable: true, + renderHeader: () => ( + setFilterValue('year', value)} + /> ), - [ - selection.level, - selectedItems, - modelsByMake, - yearsByModel, - trimsByYear, - enginesByTrim, - ] - ); - - const bulkDialogItems = useMemo( - () => selectedItems.map((item) => formatRowLabel(item)), - [formatRowLabel, selectedItems] - ); - - const breadcrumbs = useMemo(() => { - const items: BreadcrumbItem[] = [ - { - key: 'catalog', - label: 'Catalog', - target: { level: 'makes' }, - }, - ]; - - if (selection.make) { - items.push({ - key: 'makes', - label: 'Makes', - target: { level: 'makes' }, - }); - items.push({ - key: `make-${selection.make.id}`, - label: selection.make.name, - target: { - level: 'models', - make: selection.make, - }, - }); - } - - if (selection.model) { - items.push({ - key: 'models', - label: 'Models', - target: { - level: 'models', - make: selection.make, - }, - }); - items.push({ - key: `model-${selection.model.id}`, - label: selection.model.name, - target: { - level: 'years', - make: selection.make, - model: selection.model, - }, - }); - } - - if (selection.year) { - items.push({ - key: 'years', - label: 'Years', - target: { - level: 'years', - make: selection.make, - model: selection.model, - year: selection.year, - }, - }); - items.push({ - key: `year-${selection.year.id}`, - label: String(selection.year.year), - target: { - level: 'trims', - make: selection.make, - model: selection.model, - year: selection.year, - }, - }); - } - - if (selection.trim) { - items.push({ - key: 'trims', - label: 'Trims', - target: { - level: 'trims', - make: selection.make, - model: selection.model, - year: selection.year, - trim: selection.trim, - }, - }); - items.push({ - key: `trim-${selection.trim.id}`, - label: selection.trim.name, - target: { - level: 'engines', - make: selection.make, - model: selection.model, - year: selection.year, - trim: selection.trim, - }, - }); - } - - const currentLabel = LEVEL_LABEL[selection.level]; - const last = items[items.length - 1]; - - if (!last || last.label !== currentLabel) { - items.push({ - key: `level-${selection.level}`, - label: currentLabel, - target: { ...selection }, - }); - } - - return items; - }, [selection]); - - const contextDescription = useMemo(() => { - if (!canViewLevel) { - return currentLevelRequirement.message; - } - - switch (selection.level) { - case 'models': - return selection.make - ? `Showing models for ${selection.make.name}` - : undefined; - case 'years': - if (selection.model) { - return `Showing years for ${selection.model.name}`; - } - if (selection.make) { - return `Showing years for ${selection.make.name}`; - } - return undefined; - case 'trims': - if (selection.year) { - return `Showing trims for ${selection.year.year}`; - } - if (selection.model) { - return `Showing trims for ${selection.model.name}`; - } - return undefined; - case 'engines': - if (selection.trim) { - return `Showing engines for ${selection.trim.name}`; - } - if (selection.year) { - return `Showing engines for ${selection.year.year}`; - } - return undefined; - default: - return undefined; - } - }, [canViewLevel, currentLevelRequirement.message, selection]); - - const formatDateValue = useCallback((value?: string) => { - if (!value) { - return 'β€”'; - } - const date = new Date(value); - return Number.isNaN(date.valueOf()) - ? 'β€”' - : date.toLocaleDateString(); - }, []); - - const columns = useMemo[]>(() => { - const baseColumns: GridColumn[] = [ - { - field: selection.level === 'years' ? 'year' : 'name', - headerName: selection.level === 'years' ? 'Year' : 'Name', - sortable: true, - renderCell: (row) => - selection.level === 'years' - ? String((row as CatalogYear).year) - : (row as CatalogMake | CatalogModel | CatalogTrim | CatalogEngine).name, - }, - { - field: 'createdAt', - headerName: 'Created', - sortable: true, - renderCell: (row) => - formatDateValue(row.createdAt), - }, - { - field: 'updatedAt', - headerName: 'Updated', - sortable: true, - renderCell: (row) => - formatDateValue(row.updatedAt), - }, - { - field: 'actions', - headerName: 'Actions', - renderCell: (row) => ( - - {selection.level !== 'years' && selection.level !== 'engines' && ( - { - event.stopPropagation(); - openEditDialog(row); - }} - sx={{ minWidth: 44, minHeight: 44 }} - > - - - )} - {selection.level === 'engines' && ( - { - event.stopPropagation(); - openEditDialog(row); - }} - sx={{ minWidth: 44, minHeight: 44 }} - > - - - )} - { - event.stopPropagation(); - handleDeleteSingle(row); - }} - sx={{ minWidth: 44, minHeight: 44 }} - > - - - {NEXT_LEVEL[selection.level] && ( - { - event.stopPropagation(); - handleDrillDown(row); - }} - sx={{ minWidth: 44, minHeight: 44 }} - > - - - )} - - ), - }, - ]; - - return baseColumns; - }, [formatDateValue, handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]); - - const renderTree = useCallback( - (nodes: TreeNode[], depth = 0): React.ReactNode => - nodes.map((node) => { - const isExpanded = expandedNodes.has(node.id); - const isSelected = - node.level !== undefined && selection.level === node.level; - - return ( - - { - if (node.level) { - handleLevelSelect(node.level); - } - }} - sx={{ - pl: 2 + depth * 2, - }} - > - - {node.label} - - } - /> - {node.children && ( - { - event.stopPropagation(); - toggleTreeNode(node.id); - }} - sx={{ minWidth: 36, minHeight: 36 }} - > - {isExpanded ? ( - - ) : ( - - )} - - )} - - {node.children && ( - - - {renderTree(node.children, depth + 1)} - - - )} - - ); - }), - [expandedNodes, handleLevelSelect, selection.level, toggleTreeNode] - ); + valueFormatter: (params) => params.value?.toString() ?? '', + type: 'number', + }, + { + field: 'makeName', + headerName: 'Make', + flex: 1, + editable: true, + renderHeader: () => ( + setFilterValue('make', value)} + /> + ), + }, + { + field: 'modelName', + headerName: 'Model', + flex: 1, + editable: true, + renderHeader: () => ( + setFilterValue('model', value)} + /> + ), + }, + { + field: 'trimName', + headerName: 'Trim', + flex: 1, + editable: true, + renderHeader: () => ( + setFilterValue('trim', value)} + /> + ), + }, + { + field: 'engineName', + headerName: 'Engine', + flex: 1.2, + editable: true, + renderHeader: () => ( + setFilterValue('engine', value)} + /> + ), + }, + { + field: 'transmission', + headerName: 'Transmission', + flex: 0.8, + editable: true, + type: 'singleSelect', + valueOptions: transmissionOptions, + renderHeader: () => ( + setFilterValue('transmission', value)} + /> + ), + }, + ]; if (authLoading) { return ( @@ -1121,7 +600,7 @@ export const AdminCatalogPage: React.FC = () => { display: 'flex', justifyContent: 'center', alignItems: 'center', - minHeight: '50vh', + minHeight: '60vh', }} > @@ -1141,56 +620,6 @@ export const AdminCatalogPage: React.FC = () => { { label: 'Engines', value: engines.length }, ]; - const modelsCountLabel = selection.make ? models.length.toString() : 'β€”'; - const yearsCountLabel = selection.model ? years.length.toString() : 'β€”'; - const trimsCountLabel = selection.year ? trims.length.toString() : 'β€”'; - const enginesCountLabel = selection.trim ? engines.length.toString() : 'β€”'; - - const treeNodes: TreeNode[] = [ - { - id: 'node-makes', - label: `Makes (${makes.length})`, - level: 'makes', - children: [ - { - id: 'node-models', - label: `Models (${modelsCountLabel})`, - level: 'models', - children: [ - { - id: 'node-years', - label: `Years (${yearsCountLabel})`, - level: 'years', - children: [ - { - id: 'node-trims', - label: `Trims (${trimsCountLabel})`, - level: 'trims', - children: [ - { - id: 'node-engines', - label: `Engines (${enginesCountLabel})`, - level: 'engines', - }, - ], - }, - ], - }, - ], - }, - ], - }, - ]; - - const emptyStateMessage = canViewLevel - ? `No ${LEVEL_LABEL[selection.level].toLowerCase()} found` - : currentLevelRequirement.message ?? 'Select a parent item to continue.'; - - const bulkDialogTitle = - selectedCount === 1 - ? `Delete 1 ${LEVEL_SINGULAR_LABEL[selection.level]}?` - : `Delete ${selectedCount} ${LEVEL_LABEL[selection.level]}?`; - return ( { gap: 3, }} > - - - - {breadcrumbs.map((crumb, index) => { - const isLast = index === breadcrumbs.length - 1; - if (isLast) { - return ( - - {crumb.label} - - ); - } - return ( - setSelection({ ...crumb.target })} - sx={{ cursor: 'pointer' }} - > - {crumb.label} - - ); - })} - - - - - - Catalog Tree - - - {renderTree(treeNodes)} - - + + {loadingCatalog ? ( - toggleAll(currentRows)} - loading={activeQuery.isLoading} - error={activeError} - onRetry={() => activeQuery.refetch()} - emptyMessage={emptyStateMessage} - toolbar={ + + + {loadingMessage} + + + ) : catalogError ? ( + + + Failed to load catalog + + + {catalogError.message || 'An unexpected error occurred.'} + + + + ) : ( + <> + + + + Vehicle Configurations + + - toggleAll(currentRows)} + - - + - - {contextDescription && ( - - {contextDescription} - - )} + Edit + + - } - /> - + - - - - + + + + + + + + + + )} { if (!bulkDeleting) { setBulkDialogOpen(false); @@ -1346,15 +790,18 @@ export const AdminCatalogPage: React.FC = () => { /> {dialogState && ( - setDialogState(null)} + row={dialogState.row} + onClose={() => { + if (!dialogSubmitting) { + setDialogState(null); + } + }} onSubmit={handleDialogSubmit} - context={dialogState.context} - options={{ + submitting={dialogSubmitting} + existingOptions={{ makes, models, years, @@ -1366,15 +813,14 @@ export const AdminCatalogPage: React.FC = () => { ); }; -interface CatalogFormDialogProps { +interface CatalogCombinationDialogProps { open: boolean; - level: CatalogLevel; mode: 'create' | 'edit'; - entity?: CatalogRow; + row?: CatalogGridRow; + submitting: boolean; + onSubmit: (values: CatalogCombinationFormValues) => Promise; onClose: () => void; - onSubmit: (values: CatalogFormValues) => Promise; - context: CatalogSelectionContext; - options: { + existingOptions: { makes: CatalogMake[]; models: CatalogModel[]; years: CatalogYear[]; @@ -1382,374 +828,248 @@ interface CatalogFormDialogProps { }; } -const CatalogFormDialog: React.FC = ({ +const CatalogCombinationDialog: React.FC = ({ open, - level, mode, - entity, - onClose, + row, + submitting, onSubmit, - context, - options, + onClose, + existingOptions, }) => { - const schema = useMemo(() => getSchemaForLevel(level), [level]); - - const defaultValues = useMemo( - () => buildDefaultValues(level, mode, entity, context), - [context, entity, level, mode] - ); - const { control, handleSubmit, - formState: { errors, isSubmitting }, - reset, watch, - } = useForm({ - resolver: zodResolver(schema), - defaultValues, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + makeName: row?.makeName ?? '', + modelName: row?.modelName ?? '', + year: row?.yearValue ?? new Date().getFullYear(), + trimName: row?.trimName ?? '', + engineName: row?.engineName ?? '', + transmission: row?.transmission ?? 'Automatic', + }, }); useEffect(() => { - if (open) { - reset(defaultValues); - } - }, [defaultValues, open, reset]); + reset({ + makeName: row?.makeName ?? '', + modelName: row?.modelName ?? '', + year: row?.yearValue ?? new Date().getFullYear(), + trimName: row?.trimName ?? '', + engineName: row?.engineName ?? '', + transmission: row?.transmission ?? 'Automatic', + }); + }, [reset, row]); - const selectedModelId = watch('modelId'); - const selectedYearId = watch('yearId'); + const selectedMakeName = watch('makeName'); + const selectedModelName = watch('modelName'); + const selectedYearValue = watch('year'); - const sanitizedOptions = useMemo(() => ({ - makes: normalizeCollection(options.makes, 'makes'), - models: normalizeCollection(options.models, 'models'), - years: normalizeCollection(options.years, 'years'), - trims: normalizeCollection(options.trims, 'trims'), - }), [options]); + const filteredModels = useMemo(() => { + if (!selectedMakeName) { + return existingOptions.models; + } + const make = existingOptions.makes.find( + (item) => normalizeName(item.name) === normalizeName(selectedMakeName) + ); + if (!make) { + return []; + } + return existingOptions.models.filter((model) => model.makeId === make.id); + }, [existingOptions.models, existingOptions.makes, selectedMakeName]); - const availableModels = useMemo(() => { - if (context.make) { - return sanitizedOptions.models.filter( - (model) => model.makeId === context.make?.id - ); + const filteredTrims = useMemo(() => { + if (!selectedYearValue) { + return existingOptions.trims; } - return sanitizedOptions.models; - }, [context.make, sanitizedOptions.models]); - const availableYears = useMemo(() => { - if (context.model) { - return sanitizedOptions.years.filter( - (year) => year.modelId === context.model?.id - ); - } - if (context.make) { - const modelIds = sanitizedOptions.models - .filter((model) => model.makeId === context.make?.id) - .map((model) => model.id); - return sanitizedOptions.years.filter((year) => - modelIds.includes(year.modelId) - ); - } - if (selectedModelId) { - return sanitizedOptions.years.filter( - (year) => year.modelId === selectedModelId - ); - } - return sanitizedOptions.years; - }, [ - context.make, - context.model, - sanitizedOptions.models, - sanitizedOptions.years, - selectedModelId, - ]); + const matchingYears = existingOptions.years.filter( + (item) => item.year === selectedYearValue + ); - const availableTrims = useMemo(() => { - if (context.year) { - return sanitizedOptions.trims.filter( - (trim) => trim.yearId === context.year?.id - ); + if (matchingYears.length === 0) { + return []; } - if (selectedYearId) { - return sanitizedOptions.trims.filter( - (trim) => trim.yearId === selectedYearId - ); - } - if (context.model) { - const yearIds = sanitizedOptions.years - .filter((year) => year.modelId === context.model?.id) - .map((year) => year.id); - return sanitizedOptions.trims.filter((trim) => - yearIds.includes(trim.yearId) - ); - } - return sanitizedOptions.trims; - }, [ - context.model, - context.year, - sanitizedOptions.trims, - sanitizedOptions.years, - selectedYearId, - ]); - const handleDialogClose = () => { - if (!isSubmitting) { - onClose(); + if (selectedModelName) { + const model = existingOptions.models.find( + (item) => normalizeName(item.name) === normalizeName(selectedModelName) + ); + if (model) { + const yearForModel = matchingYears.find((year) => year.modelId === model.id); + if (yearForModel) { + return existingOptions.trims.filter((trim) => trim.yearId === yearForModel.id); + } + } } - }; + + if (matchingYears.length === 1) { + return existingOptions.trims.filter((trim) => trim.yearId === matchingYears[0].id); + } + + return existingOptions.trims; + }, [existingOptions.trims, existingOptions.years, existingOptions.models, selectedYearValue, selectedModelName]); + + const submitHandler = handleSubmit((values) => onSubmit(values)); return ( - + - {mode === 'create' - ? `Create ${LEVEL_SINGULAR_LABEL[level]}` - : `Edit ${LEVEL_SINGULAR_LABEL[level]}`} + {mode === 'create' ? 'Add Vehicle Configuration' : 'Edit Vehicle Configuration'} -
- - {(level === 'makes' || - level === 'models' || - level === 'trims' || - level === 'engines') && ( - ( - field.onChange(event.target.value)} - label={`${LEVEL_SINGULAR_LABEL[level]} Name`} - fullWidth - margin="normal" - error={Boolean(errors.name)} - helperText={errors.name?.message} - autoFocus - /> - )} - /> - )} - - {level === 'models' && ( - ( - - - Select a make - - {options.makes.map((make) => ( - - {make.name} - - ))} - - )} - /> - )} - - {level === 'years' && ( - <> - ( + + + ( + make.name)} + onChange={(_, value) => field.onChange(value ?? '')} + renderInput={(params) => ( + )} + /> + )} + /> + + ( + model.name)} + onChange={(_, value) => field.onChange(value ?? '')} + renderInput={(params) => ( + - - Select a model - - {availableModels.map((model) => ( - - {model.name} - - ))} - - )} - /> - ( - field.onChange(event.target.value)} - label="Year" - fullWidth - margin="normal" - type="number" - error={Boolean(errors.year)} - helperText={errors.year?.message} + error={Boolean(errors.modelName)} + helperText={errors.modelName?.message} /> )} /> - - )} + )} + /> - {level === 'trims' && ( - ( - - - Select a year - - {availableYears.map((year) => ( - - {year.year} - - ))} - - )} - /> - )} + ( + { + const value = event.target.value; + field.onChange(value === '' ? undefined : Number(value)); + }} + error={Boolean(errors.year)} + helperText={errors.year?.message} + /> + )} + /> - {level === 'engines' && ( - <> - ( + ( + trim.name)} + onChange={(_, value) => field.onChange(value ?? '')} + renderInput={(params) => ( - - Select a trim - - {availableTrims.map((trim) => ( - - {trim.name} - - ))} - - )} - /> - ( - field.onChange(event.target.value)} - label="Displacement (optional)" - fullWidth - margin="normal" + error={Boolean(errors.trimName)} + helperText={errors.trimName?.message} /> )} /> - ( - field.onChange(event.target.value)} - label="Cylinders (optional)" - fullWidth - margin="normal" - type="number" - error={Boolean(errors.cylinders)} - helperText={errors.cylinders?.message} - /> - )} + )} + /> + + ( + - ( - field.onChange(event.target.value)} - label="Fuel Type (optional)" - fullWidth - margin="normal" - /> - )} - /> - - )} - - - - - - + )} + /> + + ( + + {transmissionOptions.map((option) => ( + + {option} + + ))} + + )} + /> + +
+ + + +
); };