🎯 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:
2025-07-20 19:52:16 +07:00
parent 3203463a6a
commit 07a93d44b4
113 changed files with 28421 additions and 1831 deletions

View 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);
}
}

View 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 {}

View 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,
};
}
}

View 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();
}
}

View 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 {}

View 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',
};
}
}

View 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';
}

View 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>;
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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;
}

View 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);
}
}

View 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 {}

View 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[];
}
}

View 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;
}

View 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();
}
}

View 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 {}

View 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',
};
}
}
}

View 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;
}
}

View 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();
}
}

View 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 {}

View 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);
}
}