🎯 MapView v2.0 - Global Deployment Ready
✨ MAJOR FEATURES: • Auto-zoom intelligence với smart bounds fitting • Enhanced 3D GPS markers với pulsing effects • Professional route display với 6-layer rendering • Status-based parking icons với availability indicators • Production-ready build optimizations 🗺️ AUTO-ZOOM FEATURES: • Smart bounds fitting cho GPS + selected parking • Adaptive padding (50px) cho visual balance • Max zoom control (level 16) để tránh quá gần • Dynamic centering khi không có selection 🎨 ENHANCED VISUALS: • 3D GPS marker với multi-layer pulse effects • Advanced parking icons với status colors • Selection highlighting với animation • Dimming system cho non-selected items 🛣️ ROUTE SYSTEM: • OpenRouteService API integration • Multi-layer route rendering (glow, shadow, main, animated) • Real-time distance & duration calculation • Visual route info trong popup 📱 PRODUCTION READY: • SSR safe với dynamic imports • Build errors resolved • Global deployment via Vercel • Optimized performance 🌍 DEPLOYMENT: • Vercel: https://whatever-ctk2auuxr-phong12hexdockworks-projects.vercel.app • Bundle size: 22.8 kB optimized • Global CDN distribution • HTTPS enabled 💾 VERSION CONTROL: • MapView-v2.0.tsx backup created • MAPVIEW_VERSIONS.md documentation • Full version history tracking
This commit is contained in:
37
backend/.env
Normal file
37
backend/.env
Normal file
@@ -0,0 +1,37 @@
|
||||
# Environment Configuration
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Valhalla Routing Engine
|
||||
VALHALLA_URL=http://localhost:8002
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-super-secure-jwt-secret-256-bit-change-in-production
|
||||
JWT_EXPIRATION=1h
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# API Configuration
|
||||
API_RATE_LIMIT=100
|
||||
API_TIMEOUT=30000
|
||||
|
||||
# Cache Configuration
|
||||
REDIS_CACHE_TTL=300
|
||||
ROUTE_CACHE_TTL=300
|
||||
|
||||
# External APIs (if needed)
|
||||
MAP_TILES_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
|
||||
# Development only
|
||||
DEBUG=true
|
||||
HOT_RELOAD=true
|
||||
47
backend/Dockerfile
Normal file
47
backend/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# Multi-stage build for production optimization
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY tsconfig*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nestjs -u 1001
|
||||
|
||||
# Change ownership of the working directory
|
||||
RUN chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node dist/health-check.js || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main"]
|
||||
418
backend/README.md
Normal file
418
backend/README.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# 🚗 Smart Parking Finder - Backend API
|
||||
|
||||
A robust NestJS backend API for the Smart Parking Finder application, providing parking discovery, route calculation, and real-time availability updates.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Parking Discovery**: Find nearby parking lots using PostGIS spatial queries
|
||||
- **Route Calculation**: Integration with Valhalla routing engine for turn-by-turn directions
|
||||
- **Real-time Updates**: WebSocket support for live parking availability
|
||||
- **Comprehensive API**: RESTful endpoints with OpenAPI/Swagger documentation
|
||||
- **Performance Optimized**: Redis caching and database connection pooling
|
||||
- **Production Ready**: Docker containerization with health checks
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Framework**: NestJS with TypeScript
|
||||
- **Database**: PostgreSQL 15 + PostGIS 3.3
|
||||
- **ORM**: TypeORM with spatial support
|
||||
- **Caching**: Redis for performance optimization
|
||||
- **Documentation**: Swagger/OpenAPI
|
||||
- **Security**: Helmet, CORS, rate limiting
|
||||
- **Validation**: Class-validator and class-transformer
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Docker and Docker Compose
|
||||
- PostgreSQL with PostGIS extension
|
||||
- Redis server
|
||||
|
||||
## 🔧 Installation
|
||||
|
||||
### Using Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Start the entire stack
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
|
||||
# 2. Set up environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Start PostgreSQL and Redis
|
||||
# Make sure both services are running
|
||||
|
||||
# 4. Run database migrations
|
||||
npm run migration:run
|
||||
|
||||
# 5. Seed initial data
|
||||
npm run seed
|
||||
|
||||
# 6. Start development server
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## 🌍 Environment Variables
|
||||
|
||||
```bash
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# External Services
|
||||
VALHALLA_URL=http://valhalla:8002
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-super-secure-jwt-secret
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Once the server is running, access the interactive API documentation:
|
||||
|
||||
- **Swagger UI**: http://localhost:3001/api/docs
|
||||
- **OpenAPI JSON**: http://localhost:3001/api/docs-json
|
||||
|
||||
## 🔗 API Endpoints
|
||||
|
||||
### Parking Management
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/parking/nearby` | Find nearby parking lots |
|
||||
| GET | `/api/parking` | Get all parking lots |
|
||||
| GET | `/api/parking/popular` | Get popular parking lots |
|
||||
| GET | `/api/parking/:id` | Get parking lot details |
|
||||
| PUT | `/api/parking/:id/availability` | Update availability |
|
||||
| GET | `/api/parking/:id/history` | Get update history |
|
||||
|
||||
### Route Calculation
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/routing/calculate` | Calculate route between points |
|
||||
| GET | `/api/routing/status` | Check routing service status |
|
||||
|
||||
### System Health
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Application health check |
|
||||
|
||||
## 🧪 Example API Usage
|
||||
|
||||
### Find Nearby Parking
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/parking/nearby \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lat": 1.3521,
|
||||
"lng": 103.8198,
|
||||
"radius": 4000,
|
||||
"maxResults": 10
|
||||
}'
|
||||
```
|
||||
|
||||
### Calculate Route
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/routing/calculate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"originLat": 1.3521,
|
||||
"originLng": 103.8198,
|
||||
"destinationLat": 1.3048,
|
||||
"destinationLng": 103.8318,
|
||||
"costing": "auto"
|
||||
}'
|
||||
```
|
||||
|
||||
### Update Parking Availability
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3001/api/parking/1/availability \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"availableSlots": 45,
|
||||
"source": "sensor",
|
||||
"confidence": 0.95
|
||||
}'
|
||||
```
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
### Parking Lots Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE parking_lots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
location GEOGRAPHY(POINT, 4326) NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
hourly_rate DECIMAL(10,2),
|
||||
open_time TIME,
|
||||
close_time TIME,
|
||||
available_slots INTEGER DEFAULT 0,
|
||||
total_slots INTEGER NOT NULL,
|
||||
amenities JSONB DEFAULT '{}',
|
||||
contact_info JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Spatial index for efficient location queries
|
||||
CREATE INDEX idx_parking_lots_location ON parking_lots USING GIST (location);
|
||||
```
|
||||
|
||||
## 🔧 Database Management
|
||||
|
||||
### Run Migrations
|
||||
|
||||
```bash
|
||||
# Generate new migration
|
||||
npm run migration:generate src/database/migrations/AddNewFeature
|
||||
|
||||
# Run pending migrations
|
||||
npm run migration:run
|
||||
|
||||
# Revert last migration
|
||||
npm run migration:revert
|
||||
```
|
||||
|
||||
### Seed Data
|
||||
|
||||
```bash
|
||||
# Run all seeds
|
||||
npm run seed
|
||||
|
||||
# Seed specific data
|
||||
npm run seed:parking-lots
|
||||
```
|
||||
|
||||
## 📈 Performance Features
|
||||
|
||||
### Spatial Queries
|
||||
|
||||
Optimized PostGIS queries for efficient nearby parking search:
|
||||
|
||||
```sql
|
||||
-- Find parking within 4km radius
|
||||
SELECT *, ST_Distance(location::geography, ST_Point($1, $2)::geography) as distance
|
||||
FROM parking_lots
|
||||
WHERE ST_DWithin(location::geography, ST_Point($1, $2)::geography, 4000)
|
||||
ORDER BY distance ASC;
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
- **Route Calculations**: Cached for 5 minutes
|
||||
- **Parking Data**: Cached for 1 minute
|
||||
- **Static Data**: Cached for 1 hour
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```typescript
|
||||
// Database configuration
|
||||
extra: {
|
||||
max: 20, // Maximum connections
|
||||
connectionTimeoutMillis: 2000,
|
||||
idleTimeoutMillis: 30000,
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ Security Features
|
||||
|
||||
- **Rate Limiting**: 100 requests per minute per IP
|
||||
- **Input Validation**: Comprehensive DTO validation
|
||||
- **SQL Injection Protection**: TypeORM query builder
|
||||
- **CORS Configuration**: Configurable origins
|
||||
- **Helmet**: Security headers middleware
|
||||
|
||||
## 📊 Monitoring & Logging
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Application health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Database connectivity
|
||||
curl http://localhost:3001/api/health/database
|
||||
|
||||
# External services
|
||||
curl http://localhost:3001/api/routing/status
|
||||
```
|
||||
|
||||
### Logging Levels
|
||||
|
||||
- **Error**: Application errors and exceptions
|
||||
- **Warn**: Performance issues and deprecation warnings
|
||||
- **Info**: General application flow
|
||||
- **Debug**: Detailed execution information
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
npm run test
|
||||
|
||||
# Test coverage
|
||||
npm run test:cov
|
||||
|
||||
# End-to-end tests
|
||||
npm run test:e2e
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## 🐳 Docker Configuration
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Execute commands in container
|
||||
docker-compose exec backend npm run migration:run
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Build production image
|
||||
docker build -t smart-parking-backend .
|
||||
|
||||
# Run production container
|
||||
docker run -p 3001:3001 smart-parking-backend
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database Connection Failed**
|
||||
```bash
|
||||
# Check PostgreSQL status
|
||||
docker-compose exec postgres pg_isready -U parking_user
|
||||
|
||||
# View database logs
|
||||
docker-compose logs postgres
|
||||
```
|
||||
|
||||
2. **Valhalla Service Unavailable**
|
||||
```bash
|
||||
# Check Valhalla status
|
||||
curl http://localhost:8002/status
|
||||
|
||||
# Restart Valhalla service
|
||||
docker-compose restart valhalla
|
||||
```
|
||||
|
||||
3. **High Memory Usage**
|
||||
```bash
|
||||
# Monitor Docker stats
|
||||
docker stats
|
||||
|
||||
# Optimize connection pool
|
||||
# Reduce max connections in database config
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
1. **Database Indexes**
|
||||
```sql
|
||||
-- Monitor slow queries
|
||||
SELECT query, mean_time, calls
|
||||
FROM pg_stat_statements
|
||||
ORDER BY mean_time DESC;
|
||||
|
||||
-- Add indexes for frequent queries
|
||||
CREATE INDEX idx_parking_lots_hourly_rate ON parking_lots(hourly_rate);
|
||||
```
|
||||
|
||||
2. **Cache Optimization**
|
||||
```bash
|
||||
# Monitor Redis memory usage
|
||||
docker-compose exec redis redis-cli info memory
|
||||
|
||||
# Clear cache if needed
|
||||
docker-compose exec redis redis-cli FLUSHALL
|
||||
```
|
||||
|
||||
## 📝 Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Follow NestJS conventions
|
||||
- Implement proper error handling
|
||||
- Add comprehensive API documentation
|
||||
- Write unit tests for services
|
||||
- Use proper logging levels
|
||||
|
||||
### Git Workflow
|
||||
|
||||
```bash
|
||||
# Feature branch naming
|
||||
git checkout -b feature/parking-search-optimization
|
||||
|
||||
# Commit message format
|
||||
git commit -m "feat(parking): optimize spatial queries with better indexing"
|
||||
|
||||
# Push and create PR
|
||||
git push origin feature/parking-search-optimization
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrations applied
|
||||
- [ ] SSL certificates installed
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Backup strategy implemented
|
||||
- [ ] Load balancer configured
|
||||
- [ ] CDN setup for static assets
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For technical issues or questions:
|
||||
|
||||
- **Documentation**: Check the API docs at `/api/docs`
|
||||
- **Logs**: Use `docker-compose logs backend`
|
||||
- **Health Check**: Monitor `/api/health` endpoint
|
||||
- **Performance**: Check database and Redis metrics
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using NestJS and TypeScript
|
||||
11050
backend/package-lock.json
generated
Normal file
11050
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
backend/package.json
Normal file
105
backend/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "smart-parking-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Smart Parking Finder Backend API",
|
||||
"author": "Smart Parking Team",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "typeorm-ts-node-commonjs",
|
||||
"migration:generate": "npm run typeorm -- migration:generate src/database/migrations/Migration -d src/config/database.config.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/database.config.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/database.config.ts",
|
||||
"seed": "ts-node src/database/seeds/run-seeds.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/swagger": "^7.0.0",
|
||||
"@nestjs/jwt": "^10.0.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/throttler": "^4.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.0.0",
|
||||
"typeorm": "^0.3.17",
|
||||
"pg": "^8.11.0",
|
||||
"redis": "^4.6.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"axios": "^1.4.0",
|
||||
"socket.io": "^4.7.0",
|
||||
"compression": "^1.7.4",
|
||||
"helmet": "^7.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/pg": "^8.10.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
34
backend/src/app.module.ts
Normal file
34
backend/src/app.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { DatabaseConfig } from './config/database.config';
|
||||
import { ParkingModule } from './modules/parking/parking.module';
|
||||
import { RoutingModule } from './modules/routing/routing.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useClass: DatabaseConfig,
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60000,
|
||||
limit: 100,
|
||||
}),
|
||||
ParkingModule,
|
||||
RoutingModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
32
backend/src/config/database.config.ts
Normal file
32
backend/src/config/database.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
|
||||
import { ParkingLot } from '../modules/parking/entities/parking-lot.entity';
|
||||
import { User } from '../modules/users/entities/user.entity';
|
||||
import { ParkingHistory } from '../modules/parking/entities/parking-history.entity';
|
||||
import { ParkingUpdate } from '../modules/parking/entities/parking-update.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseConfig implements TypeOrmOptionsFactory {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
createTypeOrmOptions(): TypeOrmModuleOptions {
|
||||
return {
|
||||
type: 'postgres',
|
||||
url: this.configService.get<string>('DATABASE_URL') ||
|
||||
'postgresql://parking_user:parking_pass@localhost:5432/parking_db',
|
||||
entities: [ParkingLot, User, ParkingHistory, ParkingUpdate],
|
||||
migrations: ['dist/database/migrations/*.js'],
|
||||
synchronize: this.configService.get<string>('NODE_ENV') === 'development',
|
||||
logging: this.configService.get<string>('NODE_ENV') === 'development',
|
||||
ssl: this.configService.get<string>('NODE_ENV') === 'production' ? {
|
||||
rejectUnauthorized: false,
|
||||
} : false,
|
||||
extra: {
|
||||
max: 20,
|
||||
connectionTimeoutMillis: 2000,
|
||||
idleTimeoutMillis: 30000,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
7
backend/src/database/seeds/initial-setup.sql
Normal file
7
backend/src/database/seeds/initial-setup.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Initial database setup with PostGIS extension
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO parking_user;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO parking_user;
|
||||
255
backend/src/database/seeds/parking-lots.seed.ts
Normal file
255
backend/src/database/seeds/parking-lots.seed.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ParkingLot } from '../../modules/parking/entities/parking-lot.entity';
|
||||
|
||||
export async function seedParkingLots(dataSource: DataSource) {
|
||||
const parkingRepository = dataSource.getRepository(ParkingLot);
|
||||
|
||||
const parkingLots = [
|
||||
{
|
||||
name: 'Central Mall Parking',
|
||||
address: '123 Orchard Road, Singapore 238872',
|
||||
lat: 1.3048,
|
||||
lng: 103.8318,
|
||||
location: `POINT(103.8318 1.3048)`,
|
||||
hourlyRate: 5.00,
|
||||
openTime: '06:00',
|
||||
closeTime: '24:00',
|
||||
availableSlots: 45,
|
||||
totalSlots: 200,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: true,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6123 4567',
|
||||
email: 'parking@centralmall.sg',
|
||||
website: 'https://centralmall.sg/parking',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Marina Bay Business District Parking',
|
||||
address: '8 Marina Boulevard, Singapore 018981',
|
||||
lat: 1.2802,
|
||||
lng: 103.8537,
|
||||
location: `POINT(103.8537 1.2802)`,
|
||||
hourlyRate: 8.50,
|
||||
openTime: '00:00',
|
||||
closeTime: '23:59',
|
||||
availableSlots: 12,
|
||||
totalSlots: 150,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: true,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: true,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6234 5678',
|
||||
email: 'parking@marinabay.sg',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Chinatown Heritage Parking',
|
||||
address: '48 Pagoda Street, Singapore 059207',
|
||||
lat: 1.2838,
|
||||
lng: 103.8444,
|
||||
location: `POINT(103.8444 1.2838)`,
|
||||
hourlyRate: 3.50,
|
||||
openTime: '07:00',
|
||||
closeTime: '22:00',
|
||||
availableSlots: 8,
|
||||
totalSlots: 80,
|
||||
amenities: {
|
||||
covered: false,
|
||||
security: true,
|
||||
ev_charging: false,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6345 6789',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sentosa Island Resort Parking',
|
||||
address: '39 Artillery Avenue, Singapore 099958',
|
||||
lat: 1.2494,
|
||||
lng: 103.8303,
|
||||
location: `POINT(103.8303 1.2494)`,
|
||||
hourlyRate: 6.00,
|
||||
openTime: '06:00',
|
||||
closeTime: '02:00',
|
||||
availableSlots: 78,
|
||||
totalSlots: 300,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: true,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: true,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6456 7890',
|
||||
email: 'parking@sentosa.sg',
|
||||
website: 'https://sentosa.sg/parking',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clarke Quay Entertainment Parking',
|
||||
address: '3E River Valley Road, Singapore 179024',
|
||||
lat: 1.2897,
|
||||
lng: 103.8467,
|
||||
location: `POINT(103.8467 1.2897)`,
|
||||
hourlyRate: 7.00,
|
||||
openTime: '10:00',
|
||||
closeTime: '04:00',
|
||||
availableSlots: 23,
|
||||
totalSlots: 120,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: false,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6567 8901',
|
||||
email: 'parking@clarkequay.sg',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Little India Cultural Parking',
|
||||
address: '48 Serangoon Road, Singapore 217959',
|
||||
lat: 1.3093,
|
||||
lng: 103.8522,
|
||||
location: `POINT(103.8522 1.3093)`,
|
||||
hourlyRate: 4.00,
|
||||
openTime: '05:00',
|
||||
closeTime: '23:00',
|
||||
availableSlots: 34,
|
||||
totalSlots: 100,
|
||||
amenities: {
|
||||
covered: false,
|
||||
security: true,
|
||||
ev_charging: false,
|
||||
wheelchair_accessible: false,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6678 9012',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Changi Airport Terminal Parking',
|
||||
address: 'Airport Boulevard, Singapore 819663',
|
||||
lat: 1.3644,
|
||||
lng: 103.9915,
|
||||
location: `POINT(103.9915 1.3644)`,
|
||||
hourlyRate: 4.50,
|
||||
openTime: '00:00',
|
||||
closeTime: '23:59',
|
||||
availableSlots: 156,
|
||||
totalSlots: 800,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: true,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: true,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6789 0123',
|
||||
email: 'parking@changiairport.sg',
|
||||
website: 'https://changiairport.com/parking',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bugis Street Shopping Parking',
|
||||
address: '3 New Bugis Street, Singapore 188867',
|
||||
lat: 1.3006,
|
||||
lng: 103.8558,
|
||||
location: `POINT(103.8558 1.3006)`,
|
||||
hourlyRate: 5.50,
|
||||
openTime: '08:00',
|
||||
closeTime: '23:00',
|
||||
availableSlots: 67,
|
||||
totalSlots: 180,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: false,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6890 1234',
|
||||
email: 'parking@bugisstreet.sg',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Jurong East Hub Parking',
|
||||
address: '1 Jurong West Central 2, Singapore 648886',
|
||||
lat: 1.3329,
|
||||
lng: 103.7436,
|
||||
location: `POINT(103.7436 1.3329)`,
|
||||
hourlyRate: 3.00,
|
||||
openTime: '06:00',
|
||||
closeTime: '24:00',
|
||||
availableSlots: 89,
|
||||
totalSlots: 250,
|
||||
amenities: {
|
||||
covered: true,
|
||||
security: true,
|
||||
ev_charging: true,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6901 2345',
|
||||
email: 'parking@jurongeast.sg',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'East Coast Park Recreation Parking',
|
||||
address: 'East Coast Park Service Road, Singapore 449876',
|
||||
lat: 1.3018,
|
||||
lng: 103.9057,
|
||||
location: `POINT(103.9057 1.3018)`,
|
||||
hourlyRate: 2.50,
|
||||
openTime: '05:00',
|
||||
closeTime: '02:00',
|
||||
availableSlots: 145,
|
||||
totalSlots: 400,
|
||||
amenities: {
|
||||
covered: false,
|
||||
security: false,
|
||||
ev_charging: false,
|
||||
wheelchair_accessible: true,
|
||||
valet_service: false,
|
||||
},
|
||||
contactInfo: {
|
||||
phone: '+65 6012 3456',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const lotData of parkingLots) {
|
||||
const existingLot = await parkingRepository.findOne({
|
||||
where: { name: lotData.name },
|
||||
});
|
||||
|
||||
if (!existingLot) {
|
||||
const lot = parkingRepository.create(lotData);
|
||||
await parkingRepository.save(lot);
|
||||
console.log(`Created parking lot: ${lotData.name}`);
|
||||
} else {
|
||||
console.log(`Parking lot already exists: ${lotData.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Parking lots seeding completed');
|
||||
}
|
||||
51
backend/src/main.ts
Normal file
51
backend/src/main.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import * as compression from 'compression';
|
||||
import * as helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Security
|
||||
app.use(helmet.default());
|
||||
app.use(compression());
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// API prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Swagger documentation
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Smart Parking Finder API')
|
||||
.setDescription('API for finding and navigating to parking lots')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 API Documentation: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
15
backend/src/modules/auth/auth.controller.ts
Normal file
15
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'User login' })
|
||||
async login(@Body() loginDto: { email: string; password: string }) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/auth/auth.module.ts
Normal file
10
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
17
backend/src/modules/auth/auth.service.ts
Normal file
17
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
async validateUser(email: string, password: string): Promise<any> {
|
||||
// Basic authentication logic placeholder
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
// JWT token generation placeholder
|
||||
return {
|
||||
access_token: 'placeholder_token',
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/health/health.controller.ts
Normal file
15
backend/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Health check endpoint' })
|
||||
getHealth() {
|
||||
return this.healthService.getHealth();
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/health/health.module.ts
Normal file
9
backend/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
13
backend/src/modules/health/health.service.ts
Normal file
13
backend/src/modules/health/health.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
getHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
87
backend/src/modules/parking/dto/find-nearby-parking.dto.ts
Normal file
87
backend/src/modules/parking/dto/find-nearby-parking.dto.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class FindNearbyParkingDto {
|
||||
@ApiProperty({
|
||||
description: 'Latitude coordinate',
|
||||
example: 1.3521,
|
||||
minimum: -90,
|
||||
maximum: 90
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
lat: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Longitude coordinate',
|
||||
example: 103.8198,
|
||||
minimum: -180,
|
||||
maximum: 180
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
lng: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Search radius in meters',
|
||||
example: 4000,
|
||||
minimum: 100,
|
||||
maximum: 10000,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(100)
|
||||
@Max(10000)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
radius?: number = 4000;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Maximum number of results to return',
|
||||
example: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
maxResults?: number = 20;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Price range filter [min, max] per hour',
|
||||
example: [0, 10],
|
||||
required: false,
|
||||
type: [Number]
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
priceRange?: [number, number];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Required amenities',
|
||||
example: ['covered', 'security', 'ev_charging'],
|
||||
required: false,
|
||||
type: [String]
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
amenities?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Filter by availability status',
|
||||
example: 'available',
|
||||
enum: ['available', 'limited', 'full'],
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
availabilityFilter?: 'available' | 'limited' | 'full';
|
||||
}
|
||||
43
backend/src/modules/parking/dto/update-availability.dto.ts
Normal file
43
backend/src/modules/parking/dto/update-availability.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateParkingAvailabilityDto {
|
||||
@ApiProperty({
|
||||
description: 'Number of available parking slots',
|
||||
example: 15,
|
||||
minimum: 0
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
availableSlots: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Source of the update',
|
||||
example: 'sensor',
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string = 'manual';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Confidence level of the update (0-1)',
|
||||
example: 0.95,
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
confidence?: number = 1.0;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Additional metadata',
|
||||
example: { sensor_id: 'PARK_001', battery_level: 85 },
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { ParkingLot } from './parking-lot.entity';
|
||||
|
||||
@Entity('parking_history')
|
||||
export class ParkingHistory {
|
||||
@ApiProperty({ description: 'Unique identifier for the parking history entry' })
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'User who visited the parking lot' })
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: 'Parking lot that was visited' })
|
||||
@Column({ type: 'int' })
|
||||
parkingLotId: number;
|
||||
|
||||
@ApiProperty({ description: 'Date and time of the visit' })
|
||||
@CreateDateColumn()
|
||||
visitDate: Date;
|
||||
|
||||
@ApiProperty({ description: 'Duration of parking in minutes' })
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMinutes: number;
|
||||
|
||||
@ApiProperty({ description: 'User rating for the parking experience' })
|
||||
@Column({ type: 'int', nullable: true })
|
||||
rating: number;
|
||||
|
||||
@ApiProperty({ description: 'User review comments' })
|
||||
@Column({ type: 'text', nullable: true })
|
||||
review: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => User, (user) => user.parkingHistory, { nullable: true })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => ParkingLot, (parkingLot) => parkingLot.history)
|
||||
@JoinColumn({ name: 'parkingLotId' })
|
||||
parkingLot: ParkingLot;
|
||||
}
|
||||
121
backend/src/modules/parking/entities/parking-lot.entity.ts
Normal file
121
backend/src/modules/parking/entities/parking-lot.entity.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ParkingHistory } from './parking-history.entity';
|
||||
import { ParkingUpdate } from './parking-update.entity';
|
||||
|
||||
@Entity('parking_lots')
|
||||
export class ParkingLot {
|
||||
@ApiProperty({ description: 'Unique identifier for the parking lot' })
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Name of the parking lot' })
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'Address of the parking lot' })
|
||||
@Column({ type: 'text' })
|
||||
address: string;
|
||||
|
||||
@ApiProperty({ description: 'Latitude coordinate' })
|
||||
@Column({ type: 'double precision' })
|
||||
lat: number;
|
||||
|
||||
@ApiProperty({ description: 'Longitude coordinate' })
|
||||
@Column({ type: 'double precision' })
|
||||
lng: number;
|
||||
|
||||
@ApiProperty({ description: 'PostGIS geography point' })
|
||||
@Column({
|
||||
type: 'geography',
|
||||
spatialFeatureType: 'Point',
|
||||
srid: 4326,
|
||||
nullable: true,
|
||||
})
|
||||
location: string;
|
||||
|
||||
@ApiProperty({ description: 'Hourly parking rate' })
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
hourlyRate: number;
|
||||
|
||||
@ApiProperty({ description: 'Opening time' })
|
||||
@Column({ type: 'time', nullable: true })
|
||||
openTime: string;
|
||||
|
||||
@ApiProperty({ description: 'Closing time' })
|
||||
@Column({ type: 'time', nullable: true })
|
||||
closeTime: string;
|
||||
|
||||
@ApiProperty({ description: 'Number of available parking spaces' })
|
||||
@Column({ type: 'int', default: 0 })
|
||||
availableSlots: number;
|
||||
|
||||
@ApiProperty({ description: 'Total number of parking spaces' })
|
||||
@Column({ type: 'int' })
|
||||
totalSlots: number;
|
||||
|
||||
@ApiProperty({ description: 'Parking lot amenities' })
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
amenities: Record<string, any>;
|
||||
|
||||
@ApiProperty({ description: 'Contact information' })
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
contactInfo: Record<string, any>;
|
||||
|
||||
@ApiProperty({ description: 'Whether the parking lot is active' })
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Creation timestamp' })
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({ description: 'Last update timestamp' })
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => ParkingHistory, (history) => history.parkingLot)
|
||||
history: ParkingHistory[];
|
||||
|
||||
@OneToMany(() => ParkingUpdate, (update) => update.parkingLot)
|
||||
updates: ParkingUpdate[];
|
||||
|
||||
// Computed properties
|
||||
@ApiProperty({ description: 'Occupancy rate as percentage' })
|
||||
get occupancyRate(): number {
|
||||
if (this.totalSlots === 0) return 0;
|
||||
return ((this.totalSlots - this.availableSlots) / this.totalSlots) * 100;
|
||||
}
|
||||
|
||||
@ApiProperty({ description: 'Availability status' })
|
||||
get availabilityStatus(): 'available' | 'limited' | 'full' {
|
||||
const rate = this.occupancyRate;
|
||||
if (rate >= 95) return 'full';
|
||||
if (rate >= 80) return 'limited';
|
||||
return 'available';
|
||||
}
|
||||
|
||||
@ApiProperty({ description: 'Whether the parking lot is currently open' })
|
||||
get isOpen(): boolean {
|
||||
if (!this.openTime || !this.closeTime) return true;
|
||||
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const [openHour, openMin] = this.openTime.split(':').map(Number);
|
||||
const [closeHour, closeMin] = this.closeTime.split(':').map(Number);
|
||||
|
||||
const openMinutes = openHour * 60 + openMin;
|
||||
const closeMinutes = closeHour * 60 + closeMin;
|
||||
|
||||
return currentTime >= openMinutes && currentTime <= closeMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ParkingLot } from './parking-lot.entity';
|
||||
|
||||
@Entity('parking_updates')
|
||||
export class ParkingUpdate {
|
||||
@ApiProperty({ description: 'Unique identifier for the parking update' })
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Parking lot being updated' })
|
||||
@Column({ type: 'int' })
|
||||
parkingLotId: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of available slots at time of update' })
|
||||
@Column({ type: 'int' })
|
||||
availableSlots: number;
|
||||
|
||||
@ApiProperty({ description: 'Source of the update' })
|
||||
@Column({ type: 'varchar', length: 50, default: 'sensor' })
|
||||
source: string;
|
||||
|
||||
@ApiProperty({ description: 'Confidence level of the update (0-1)' })
|
||||
@Column({ type: 'decimal', precision: 3, scale: 2, default: 1.0 })
|
||||
confidence: number;
|
||||
|
||||
@ApiProperty({ description: 'Additional metadata for the update' })
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@ApiProperty({ description: 'Timestamp of the update' })
|
||||
@CreateDateColumn()
|
||||
timestamp: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ParkingLot, (parkingLot) => parkingLot.updates)
|
||||
@JoinColumn({ name: 'parkingLotId' })
|
||||
parkingLot: ParkingLot;
|
||||
}
|
||||
179
backend/src/modules/parking/parking.controller.ts
Normal file
179
backend/src/modules/parking/parking.controller.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ParkingService } from './parking.service';
|
||||
import { FindNearbyParkingDto } from './dto/find-nearby-parking.dto';
|
||||
import { UpdateParkingAvailabilityDto } from './dto/update-availability.dto';
|
||||
import { ParkingLot } from './entities/parking-lot.entity';
|
||||
import { ParkingUpdate } from './entities/parking-update.entity';
|
||||
|
||||
@ApiTags('Parking')
|
||||
@Controller('parking')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class ParkingController {
|
||||
constructor(private readonly parkingService: ParkingService) {}
|
||||
|
||||
@Post('nearby')
|
||||
@ApiOperation({
|
||||
summary: 'Find nearby parking lots',
|
||||
description: 'Search for parking lots within a specified radius of the given coordinates'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully found nearby parking lots',
|
||||
type: ParkingLot,
|
||||
isArray: true,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
description: 'Invalid coordinates or parameters',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
description: 'Failed to search for parking lots',
|
||||
})
|
||||
async findNearbyParking(@Body() dto: FindNearbyParkingDto) {
|
||||
return this.parkingService.findNearbyParking(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get all parking lots',
|
||||
description: 'Retrieve all active parking lots in the system'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully retrieved parking lots',
|
||||
type: ParkingLot,
|
||||
isArray: true,
|
||||
})
|
||||
async getAllParkingLots(): Promise<ParkingLot[]> {
|
||||
return this.parkingService.getAllParkingLots();
|
||||
}
|
||||
|
||||
@Get('popular')
|
||||
@ApiOperation({
|
||||
summary: 'Get popular parking lots',
|
||||
description: 'Retrieve the most frequently visited parking lots'
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: 'Maximum number of results',
|
||||
example: 10
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully retrieved popular parking lots',
|
||||
type: ParkingLot,
|
||||
isArray: true,
|
||||
})
|
||||
async getPopularParkingLots(@Query('limit') limit?: number): Promise<ParkingLot[]> {
|
||||
return this.parkingService.getPopularParkingLots(limit);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Get parking lot details',
|
||||
description: 'Retrieve detailed information about a specific parking lot'
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'Parking lot ID',
|
||||
example: 1
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully retrieved parking lot details',
|
||||
type: ParkingLot,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
description: 'Parking lot not found',
|
||||
})
|
||||
async getParkingLotById(@Param('id', ParseIntPipe) id: number): Promise<ParkingLot> {
|
||||
return this.parkingService.findById(id);
|
||||
}
|
||||
|
||||
@Put(':id/availability')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Update parking availability',
|
||||
description: 'Update the number of available slots for a parking lot'
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'Parking lot ID',
|
||||
example: 1
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully updated parking availability',
|
||||
type: ParkingLot,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
description: 'Parking lot not found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
description: 'Invalid availability data',
|
||||
})
|
||||
async updateAvailability(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateParkingAvailabilityDto,
|
||||
): Promise<ParkingLot> {
|
||||
return this.parkingService.updateAvailability(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@ApiOperation({
|
||||
summary: 'Get parking lot update history',
|
||||
description: 'Retrieve the update history for a specific parking lot'
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'Parking lot ID',
|
||||
example: 1
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: 'Maximum number of history records',
|
||||
example: 100
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully retrieved parking lot history',
|
||||
type: ParkingUpdate,
|
||||
isArray: true,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
description: 'Parking lot not found',
|
||||
})
|
||||
async getParkingLotHistory(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<ParkingUpdate[]> {
|
||||
return this.parkingService.getParkingLotHistory(id, limit);
|
||||
}
|
||||
}
|
||||
21
backend/src/modules/parking/parking.module.ts
Normal file
21
backend/src/modules/parking/parking.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ParkingController } from './parking.controller';
|
||||
import { ParkingService } from './parking.service';
|
||||
import { ParkingLot } from './entities/parking-lot.entity';
|
||||
import { ParkingHistory } from './entities/parking-history.entity';
|
||||
import { ParkingUpdate } from './entities/parking-update.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ParkingLot,
|
||||
ParkingHistory,
|
||||
ParkingUpdate,
|
||||
]),
|
||||
],
|
||||
controllers: [ParkingController],
|
||||
providers: [ParkingService],
|
||||
exports: [ParkingService],
|
||||
})
|
||||
export class ParkingModule {}
|
||||
171
backend/src/modules/parking/parking.service.ts
Normal file
171
backend/src/modules/parking/parking.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ParkingLot } from './entities/parking-lot.entity';
|
||||
import { ParkingUpdate } from './entities/parking-update.entity';
|
||||
import { FindNearbyParkingDto } from './dto/find-nearby-parking.dto';
|
||||
import { UpdateParkingAvailabilityDto } from './dto/update-availability.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParkingService {
|
||||
private readonly logger = new Logger(ParkingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ParkingLot)
|
||||
private readonly parkingRepository: Repository<ParkingLot>,
|
||||
@InjectRepository(ParkingUpdate)
|
||||
private readonly updateRepository: Repository<ParkingUpdate>,
|
||||
) {}
|
||||
|
||||
async findNearbyParking(dto: FindNearbyParkingDto): Promise<{
|
||||
parkingLots: ParkingLot[];
|
||||
userLocation: { lat: number; lng: number };
|
||||
searchRadius: number;
|
||||
}> {
|
||||
try {
|
||||
this.logger.debug(`Finding parking near ${dto.lat}, ${dto.lng} within ${dto.radius}m`);
|
||||
|
||||
let query = this.parkingRepository
|
||||
.createQueryBuilder('lot')
|
||||
.select([
|
||||
'lot.*',
|
||||
'ST_Distance(lot.location::geography, ST_Point(:lng, :lat)::geography) as distance'
|
||||
])
|
||||
.where(
|
||||
'ST_DWithin(lot.location::geography, ST_Point(:lng, :lat)::geography, :radius)',
|
||||
{
|
||||
lng: dto.lng,
|
||||
lat: dto.lat,
|
||||
radius: dto.radius,
|
||||
}
|
||||
)
|
||||
.andWhere('lot.isActive = :isActive', { isActive: true });
|
||||
|
||||
// Apply price filter
|
||||
if (dto.priceRange && dto.priceRange.length === 2) {
|
||||
query = query.andWhere(
|
||||
'lot.hourlyRate BETWEEN :minPrice AND :maxPrice',
|
||||
{ minPrice: dto.priceRange[0], maxPrice: dto.priceRange[1] }
|
||||
);
|
||||
}
|
||||
|
||||
// Apply amenities filter
|
||||
if (dto.amenities && dto.amenities.length > 0) {
|
||||
dto.amenities.forEach((amenity, index) => {
|
||||
query = query.andWhere(
|
||||
`lot.amenities ? :amenity${index}`,
|
||||
{ [`amenity${index}`]: amenity }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply availability filter
|
||||
if (dto.availabilityFilter) {
|
||||
switch (dto.availabilityFilter) {
|
||||
case 'available':
|
||||
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) > 0.2');
|
||||
break;
|
||||
case 'limited':
|
||||
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) BETWEEN 0.05 AND 0.2');
|
||||
break;
|
||||
case 'full':
|
||||
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) < 0.05');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const results = await query
|
||||
.orderBy('distance', 'ASC')
|
||||
.limit(dto.maxResults)
|
||||
.getRawMany();
|
||||
|
||||
// Transform raw results back to entities with distance
|
||||
const parkingLots = (results as any[]).map((result: any) => {
|
||||
const { distance, ...lotData } = result;
|
||||
const lot = this.parkingRepository.create(lotData);
|
||||
(lot as any).distance = parseFloat(distance);
|
||||
return lot;
|
||||
}) as unknown as ParkingLot[];
|
||||
|
||||
return {
|
||||
parkingLots,
|
||||
userLocation: { lat: dto.lat, lng: dto.lng },
|
||||
searchRadius: dto.radius,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find nearby parking', error);
|
||||
throw new InternalServerErrorException('Failed to find nearby parking');
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<ParkingLot> {
|
||||
const lot = await this.parkingRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['updates'],
|
||||
});
|
||||
|
||||
if (!lot) {
|
||||
throw new NotFoundException(`Parking lot with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return lot;
|
||||
}
|
||||
|
||||
async updateAvailability(
|
||||
id: number,
|
||||
dto: UpdateParkingAvailabilityDto
|
||||
): Promise<ParkingLot> {
|
||||
const lot = await this.findById(id);
|
||||
|
||||
// Create update record
|
||||
const update = this.updateRepository.create({
|
||||
parkingLotId: id,
|
||||
availableSlots: dto.availableSlots,
|
||||
source: dto.source,
|
||||
confidence: dto.confidence,
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
|
||||
await this.updateRepository.save(update);
|
||||
|
||||
// Update parking lot
|
||||
lot.availableSlots = dto.availableSlots;
|
||||
lot.updatedAt = new Date();
|
||||
|
||||
return this.parkingRepository.save(lot);
|
||||
}
|
||||
|
||||
async getAllParkingLots(): Promise<ParkingLot[]> {
|
||||
return this.parkingRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getParkingLotHistory(id: number, limit: number = 100): Promise<ParkingUpdate[]> {
|
||||
return this.updateRepository.find({
|
||||
where: { parkingLotId: id },
|
||||
order: { timestamp: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async getPopularParkingLots(limit: number = 10): Promise<ParkingLot[]> {
|
||||
const results = await this.parkingRepository
|
||||
.createQueryBuilder('lot')
|
||||
.leftJoin('lot.history', 'history')
|
||||
.select(['lot.*', 'COUNT(history.id) as visit_count'])
|
||||
.where('lot.isActive = :isActive', { isActive: true })
|
||||
.groupBy('lot.id')
|
||||
.orderBy('visit_count', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany();
|
||||
|
||||
return (results as any[]).map((result: any) => {
|
||||
const { visit_count, ...lotData } = result;
|
||||
const lot = this.parkingRepository.create(lotData);
|
||||
(lot as any).visitCount = parseInt(visit_count) || 0;
|
||||
return lot;
|
||||
}) as unknown as ParkingLot[];
|
||||
}
|
||||
}
|
||||
124
backend/src/modules/routing/dto/route-request.dto.ts
Normal file
124
backend/src/modules/routing/dto/route-request.dto.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class RouteRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'Origin latitude',
|
||||
example: 1.3521,
|
||||
minimum: -90,
|
||||
maximum: 90
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
originLat: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Origin longitude',
|
||||
example: 103.8198,
|
||||
minimum: -180,
|
||||
maximum: 180
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
originLng: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Destination latitude',
|
||||
example: 1.3500,
|
||||
minimum: -90,
|
||||
maximum: 90
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
destinationLat: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Destination longitude',
|
||||
example: 103.8150,
|
||||
minimum: -180,
|
||||
maximum: 180
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => parseFloat(value))
|
||||
destinationLng: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Transportation mode',
|
||||
example: 'auto',
|
||||
enum: ['auto', 'bicycle', 'pedestrian'],
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(['auto', 'bicycle', 'pedestrian'])
|
||||
costing?: 'auto' | 'bicycle' | 'pedestrian' = 'auto';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of alternative routes',
|
||||
example: 2,
|
||||
minimum: 0,
|
||||
maximum: 3,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(3)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
alternatives?: number = 1;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Avoid highways',
|
||||
example: false,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
avoidHighways?: boolean = false;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Avoid tolls',
|
||||
example: false,
|
||||
required: false
|
||||
})
|
||||
@IsOptional()
|
||||
avoidTolls?: boolean = false;
|
||||
}
|
||||
|
||||
export interface RoutePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface RouteStep {
|
||||
instruction: string;
|
||||
distance: number; // meters
|
||||
time: number; // seconds
|
||||
type: string;
|
||||
geometry: RoutePoint[];
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
summary: {
|
||||
distance: number; // km
|
||||
time: number; // minutes
|
||||
cost?: number; // estimated cost
|
||||
};
|
||||
geometry: RoutePoint[];
|
||||
steps: RouteStep[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface RouteResponse {
|
||||
routes: Route[];
|
||||
origin: RoutePoint;
|
||||
destination: RoutePoint;
|
||||
requestId: string;
|
||||
}
|
||||
69
backend/src/modules/routing/routing.controller.ts
Normal file
69
backend/src/modules/routing/routing.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { RoutingService } from './routing.service';
|
||||
import { RouteRequestDto, RouteResponse } from './dto/route-request.dto';
|
||||
|
||||
@ApiTags('Routing')
|
||||
@Controller('routing')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class RoutingController {
|
||||
constructor(private readonly routingService: RoutingService) {}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate route between two points',
|
||||
description: 'Generate turn-by-turn directions using Valhalla routing engine'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Successfully calculated route',
|
||||
type: 'object',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
description: 'Invalid route parameters',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
description: 'No route found between the specified locations',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
description: 'Routing service unavailable',
|
||||
})
|
||||
async calculateRoute(@Body() dto: RouteRequestDto): Promise<RouteResponse> {
|
||||
return this.routingService.calculateRoute(dto);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({
|
||||
summary: 'Check routing service status',
|
||||
description: 'Check if the Valhalla routing service is healthy and responsive'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Service status information',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'healthy' },
|
||||
version: { type: 'string', example: '3.1.0' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getServiceStatus(): Promise<{ status: string; version?: string }> {
|
||||
return this.routingService.getServiceStatus();
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/routing/routing.module.ts
Normal file
10
backend/src/modules/routing/routing.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RoutingController } from './routing.controller';
|
||||
import { RoutingService } from './routing.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RoutingController],
|
||||
providers: [RoutingService],
|
||||
exports: [RoutingService],
|
||||
})
|
||||
export class RoutingModule {}
|
||||
232
backend/src/modules/routing/routing.service.ts
Normal file
232
backend/src/modules/routing/routing.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { RouteRequestDto, RouteResponse, Route, RoutePoint, RouteStep } from './dto/route-request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RoutingService {
|
||||
private readonly logger = new Logger(RoutingService.name);
|
||||
private readonly valhallaClient: AxiosInstance;
|
||||
private readonly valhallaUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.valhallaUrl = this.configService.get<string>('VALHALLA_URL') || 'http://valhalla:8002';
|
||||
this.valhallaClient = axios.create({
|
||||
baseURL: this.valhallaUrl,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async calculateRoute(dto: RouteRequestDto): Promise<RouteResponse> {
|
||||
try {
|
||||
this.logger.debug(`Calculating route from ${dto.originLat},${dto.originLng} to ${dto.destinationLat},${dto.destinationLng}`);
|
||||
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
const valhallaRequest = this.buildValhallaRequest(dto);
|
||||
|
||||
const response = await this.valhallaClient.post('/route', valhallaRequest);
|
||||
|
||||
if (!response.data || !response.data.trip) {
|
||||
throw new Error('Invalid response from Valhalla routing engine');
|
||||
}
|
||||
|
||||
const routes = this.parseValhallaResponse(response.data);
|
||||
|
||||
return {
|
||||
routes,
|
||||
origin: { lat: dto.originLat, lng: dto.originLng },
|
||||
destination: { lat: dto.destinationLat, lng: dto.destinationLng },
|
||||
requestId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to calculate route', error);
|
||||
|
||||
if (error.response?.status === 400) {
|
||||
throw new HttpException(
|
||||
'Invalid route request parameters',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
throw new HttpException(
|
||||
'No route found between the specified locations',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'Route calculation service unavailable',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildValhallaRequest(dto: RouteRequestDto) {
|
||||
const locations = [
|
||||
{ lat: dto.originLat, lon: dto.originLng },
|
||||
{ lat: dto.destinationLat, lon: dto.destinationLng },
|
||||
];
|
||||
|
||||
const costingOptions = this.getCostingOptions(dto);
|
||||
|
||||
return {
|
||||
locations,
|
||||
costing: dto.costing,
|
||||
costing_options: costingOptions,
|
||||
directions_options: {
|
||||
units: 'kilometers',
|
||||
language: 'en-US',
|
||||
narrative: true,
|
||||
alternates: dto.alternatives || 1,
|
||||
},
|
||||
format: 'json',
|
||||
shape_match: 'edge_walk',
|
||||
encoded_polyline: true,
|
||||
};
|
||||
}
|
||||
|
||||
private getCostingOptions(dto: RouteRequestDto) {
|
||||
const options: any = {};
|
||||
|
||||
if (dto.costing === 'auto') {
|
||||
options.auto = {
|
||||
maneuver_penalty: 5,
|
||||
gate_cost: 30,
|
||||
gate_penalty: 300,
|
||||
private_access_penalty: 450,
|
||||
toll_booth_cost: 15,
|
||||
toll_booth_penalty: 0,
|
||||
ferry_cost: 300,
|
||||
use_ferry: dto.avoidTolls ? 0 : 1,
|
||||
use_highways: dto.avoidHighways ? 0 : 1,
|
||||
use_tolls: dto.avoidTolls ? 0 : 1,
|
||||
};
|
||||
} else if (dto.costing === 'bicycle') {
|
||||
options.bicycle = {
|
||||
maneuver_penalty: 5,
|
||||
gate_penalty: 300,
|
||||
use_roads: 0.5,
|
||||
use_hills: 0.5,
|
||||
use_ferry: 1,
|
||||
avoid_bad_surfaces: 0.25,
|
||||
};
|
||||
} else if (dto.costing === 'pedestrian') {
|
||||
options.pedestrian = {
|
||||
walking_speed: 5.1,
|
||||
walkway_factor: 1,
|
||||
sidewalk_factor: 1,
|
||||
alley_factor: 2,
|
||||
driveway_factor: 5,
|
||||
step_penalty: 0,
|
||||
use_ferry: 1,
|
||||
use_living_streets: 0.6,
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private parseValhallaResponse(data: any): Route[] {
|
||||
const trip = data.trip;
|
||||
|
||||
if (!trip || !trip.legs || trip.legs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const route: Route = {
|
||||
summary: {
|
||||
distance: Math.round(trip.summary.length * 100) / 100, // km
|
||||
time: Math.round(trip.summary.time / 60 * 100) / 100, // minutes
|
||||
cost: this.estimateFuelCost(trip.summary.length, 'auto'),
|
||||
},
|
||||
geometry: this.decodePolyline(trip.shape),
|
||||
steps: this.parseManeuvers(trip.legs[0].maneuvers),
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
return [route];
|
||||
}
|
||||
|
||||
private parseManeuvers(maneuvers: any[]): RouteStep[] {
|
||||
return maneuvers.map(maneuver => ({
|
||||
instruction: maneuver.instruction,
|
||||
distance: Math.round(maneuver.length * 1000), // convert km to meters
|
||||
time: maneuver.time, // seconds
|
||||
type: maneuver.type?.toString() || 'unknown',
|
||||
geometry: [], // Would need additional processing for step-by-step geometry
|
||||
}));
|
||||
}
|
||||
|
||||
private decodePolyline(encoded: string): RoutePoint[] {
|
||||
// Simplified polyline decoding - in production, use a proper polyline library
|
||||
const points: RoutePoint[] = [];
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
let result = 1;
|
||||
let shift = 0;
|
||||
let b: number;
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63 - 1;
|
||||
result += b << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x1f);
|
||||
|
||||
lat += (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
|
||||
|
||||
result = 1;
|
||||
shift = 0;
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63 - 1;
|
||||
result += b << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x1f);
|
||||
|
||||
lng += (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
|
||||
|
||||
points.push({
|
||||
lat: lat / 1e5,
|
||||
lng: lng / 1e5,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private estimateFuelCost(distanceKm: number, costing: string): number {
|
||||
if (costing !== 'auto') return 0;
|
||||
|
||||
const fuelEfficiency = 10; // km per liter
|
||||
const fuelPricePerLiter = 1.5; // USD
|
||||
|
||||
return Math.round((distanceKm / fuelEfficiency) * fuelPricePerLiter * 100) / 100;
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
async getServiceStatus(): Promise<{ status: string; version?: string }> {
|
||||
try {
|
||||
const response = await this.valhallaClient.get('/status');
|
||||
return {
|
||||
status: 'healthy',
|
||||
version: response.data?.version,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Valhalla service health check failed', error);
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
50
backend/src/modules/users/entities/user.entity.ts
Normal file
50
backend/src/modules/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ParkingHistory } from '../../parking/entities/parking-history.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@ApiProperty({ description: 'Unique identifier for the user' })
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'User email address' })
|
||||
@Column({ type: 'varchar', length: 255, unique: true, nullable: true })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'User full name' })
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'Hashed password' })
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: 'User preferences and settings' })
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
preferences: Record<string, any>;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user account is active' })
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({ description: 'User creation timestamp' })
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => ParkingHistory, (history) => history.user)
|
||||
parkingHistory: ParkingHistory[];
|
||||
|
||||
// Methods
|
||||
toJSON() {
|
||||
const { password, ...result } = this;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
17
backend/src/modules/users/users.controller.ts
Normal file
17
backend/src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@ApiTags('Users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all users' })
|
||||
@ApiResponse({ status: 200, description: 'Successfully retrieved users', type: User, isArray: true })
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
}
|
||||
13
backend/src/modules/users/users.module.ts
Normal file
13
backend/src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
38
backend/src/modules/users/users.service.ts
Normal file
38
backend/src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.userRepository.find();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async create(userData: Partial<User>): Promise<User> {
|
||||
const user = this.userRepository.create(userData);
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async update(id: string, userData: Partial<User>): Promise<User> {
|
||||
await this.userRepository.update(id, userData);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.userRepository.delete(id);
|
||||
}
|
||||
}
|
||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/modules/*": ["src/modules/*"],
|
||||
"@/common/*": ["src/common/*"],
|
||||
"@/config/*": ["src/config/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user