diff --git a/backend/src/asset-locations/dto/assets.dto.ts b/backend/src/asset-locations/dto/assets.dto.ts new file mode 100644 index 0000000..628a5d0 --- /dev/null +++ b/backend/src/asset-locations/dto/assets.dto.ts @@ -0,0 +1,21 @@ +import { IsLatitude, IsLongitude, IsNumber, IsOptional, Min } from 'class-validator'; + +export class GetAssetsNearbyDto { + @IsLatitude() + lat!: number; + + @IsLongitude() + lng!: number; + + /** + * Radius in meters (default 1000m) + */ + @IsOptional() + @IsNumber() + @Min(0) + radius?: number; + + @IsOptional() + @IsNumber() + limit?: number; +} diff --git a/backend/src/asset-locations/dto/branch.dto.ts b/backend/src/asset-locations/dto/branch.dto.ts new file mode 100644 index 0000000..24f4f88 --- /dev/null +++ b/backend/src/asset-locations/dto/branch.dto.ts @@ -0,0 +1,15 @@ +import { IsUUID, IsOptional, IsNumberString } from 'class-validator'; + +export class GetAssetsByBranchDto { + // path param sometimes; we keep DTO for validation when using query + @IsUUID() + branchId!: string; + + @IsOptional() + @IsNumberString() + limit?: string; // parsed to number in controller + + @IsOptional() + @IsNumberString() + offset?: string; +} diff --git a/backend/src/asset-locations/dto/location.dto.ts b/backend/src/asset-locations/dto/location.dto.ts new file mode 100644 index 0000000..52aeebc --- /dev/null +++ b/backend/src/asset-locations/dto/location.dto.ts @@ -0,0 +1,23 @@ +import { IsUUID, IsOptional, IsNumber, IsString, IsLatitude, IsLongitude, Min, Max } from 'class-validator'; + +export class UpdateLocationDto { + @IsUUID() + assetId!: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + // Allow either branchId OR GPS coordinates (or both). + @IsOptional() + @IsLatitude() + latitude?: number; + + @IsOptional() + @IsLongitude() + longitude?: number; + + @IsOptional() + @IsString() + locationNote?: string; +} diff --git a/backend/src/asset-locations/entities/location.entity.ts b/backend/src/asset-locations/entities/location.entity.ts new file mode 100644 index 0000000..e936514 --- /dev/null +++ b/backend/src/asset-locations/entities/location.entity.ts @@ -0,0 +1,33 @@ +import { Entity, Column, PrimaryGeneratedColumn, UpdateDateColumn, CreateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'asset_locations' }) +@Index(['assetId'], { unique: true }) +export class AssetLocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Reference to the asset (assumes assets are tracked elsewhere) + @Column({ type: 'uuid' }) + assetId: string; + + // Reference to a branch or warehouse (optional) + @Column({ type: 'uuid', nullable: true }) + branchId?: string | null; + + // GPS coordinates stored as separate columns for portability + @Column({ type: 'double precision', nullable: true }) + latitude?: number | null; + + @Column({ type: 'double precision', nullable: true }) + longitude?: number | null; + + // Optionally store freeform location note + @Column({ type: 'text', nullable: true }) + locationNote?: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/asset-locations/locations.controller.ts b/backend/src/asset-locations/locations.controller.ts new file mode 100644 index 0000000..4255a23 --- /dev/null +++ b/backend/src/asset-locations/locations.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Post, Body, Get, Param, Query, ParseUUIDPipe, DefaultValuePipe, ParseIntPipe } from '@nestjs/common'; +import { AssetLocationsService } from './asset-locations.service'; +import { UpdateLocationDto } from './dto/update-location.dto'; +import { GetAssetsNearbyDto } from './dto/get-assets-nearby.dto'; + +@Controller('asset-locations') +export class AssetLocationsController { + constructor(private readonly svc: AssetLocationsService) {} + + /** + * Upsert current location for an asset. + * POST /asset-locations + */ + @Post() + async upsertLocation(@Body() dto: UpdateLocationDto) { + const updated = await this.svc.upsertLocation(dto); + return { success: true, data: updated }; + } + + /** + * Get current location for a given asset + * GET /asset-locations/asset/:assetId + */ + @Get('asset/:assetId') + async getByAsset(@Param('assetId', ParseUUIDPipe) assetId: string) { + const loc = await this.svc.getLocationByAsset(assetId); + return { success: true, data: loc }; + } + + /** + * Get assets assigned to a branch + * GET /asset-locations/branch/:branchId?limit=50&offset=0 + */ + @Get('branch/:branchId') + async getByBranch( + @Param('branchId', ParseUUIDPipe) branchId: string, + @Query('limit', new DefaultValuePipe('100'), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe('0'), ParseIntPipe) offset: number, + ) { + const rows = await this.svc.getAssetsByBranch(branchId, limit, offset); + return { success: true, total: rows.length, data: rows }; + } + + /** + * Query nearby assets + * GET /asset-locations/nearby?lat=..&lng=..&radius=1000&limit=50 + */ + @Get('nearby') + async getNearby(@Query() q: GetAssetsNearbyDto) { + // class-validator won't run automatically for query objects without ValidationPipe configured globally, + // but in typical Nest projects a global ValidationPipe is set. We'll assume that. + const radius = q.radius ?? 1000; + const limit = q.limit ?? 100; + const rows = await this.svc.getAssetsNearby(q.lat, q.lng, radius, limit); + return { success: true, total: rows.length, data: rows }; + } +} diff --git a/backend/src/asset-locations/locations.module.ts b/backend/src/asset-locations/locations.module.ts new file mode 100644 index 0000000..4a46a2e --- /dev/null +++ b/backend/src/asset-locations/locations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetLocation } from './asset-location.entity'; +import { AssetLocationsService } from './asset-locations.service'; +import { AssetLocationsController } from './asset-locations.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetLocation])], + providers: [AssetLocationsService], + controllers: [AssetLocationsController], + exports: [AssetLocationsService], +}) +export class AssetLocationsModule {} diff --git a/backend/src/asset-locations/locations.service.ts b/backend/src/asset-locations/locations.service.ts new file mode 100644 index 0000000..2a265b0 --- /dev/null +++ b/backend/src/asset-locations/locations.service.ts @@ -0,0 +1,118 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetLocation } from './asset-location.entity'; +import { UpdateLocationDto } from './dto/update-location.dto'; + +@Injectable() +export class AssetLocationsService { + constructor( + @InjectRepository(AssetLocation) + private readonly repo: Repository, + ) {} + + /** + * Upsert the current location for an asset. + * If the asset already has a row, update it; otherwise insert. + */ + async upsertLocation(dto: UpdateLocationDto): Promise { + const { assetId, branchId, latitude, longitude, locationNote } = dto; + + // Basic sanity: either branchId or both coordinates or at least something must be present + if (!branchId && (latitude === undefined || longitude === undefined)) { + throw new BadRequestException('Provide branchId or both latitude and longitude.'); + } + + // Try find existing + let existing = await this.repo.findOne({ where: { assetId } }); + + if (!existing) { + existing = this.repo.create({ + assetId, + branchId: branchId ?? null, + latitude: latitude ?? null, + longitude: longitude ?? null, + locationNote: locationNote ?? null, + }); + + return this.repo.save(existing); + } + + // update fields + existing.branchId = branchId ?? existing.branchId ?? null; + existing.latitude = latitude ?? existing.latitude ?? null; + existing.longitude = longitude ?? existing.longitude ?? null; + existing.locationNote = locationNote ?? existing.locationNote ?? null; + + return this.repo.save(existing); + } + + /** + * Get the latest location for a given asset + */ + async getLocationByAsset(assetId: string): Promise { + return this.repo.findOne({ where: { assetId } }); + } + + /** + * Query assets currently assigned to a branch. + * Returns AssetLocation[]. + */ + async getAssetsByBranch(branchId: string, limit = 100, offset = 0): Promise { + return this.repo.find({ + where: { branchId }, + take: limit, + skip: offset, + order: { updatedAt: 'DESC' }, + }); + } + + /** + * Query assets near a latitude/longitude within radius (meters). + * Uses Haversine formula in SQL to calculate distance on the fly. + * + * Returns array of objects: { assetId, latitude, longitude, distance } + */ + async getAssetsNearby(lat: number, lng: number, radiusMeters = 1000, limit = 100) { + // Earth's radius in meters + const earthRadius = 6371000; + + // We'll compute the Haversine distance using SQL formula. Filter out null coordinates. + // Note: This works without PostGIS. Performance: if you need heavy usage consider PostGIS spatial index. + + const qb = this.repo.createQueryBuilder('al') + .select([ + 'al.assetId AS "assetId"', + 'al.latitude AS "latitude"', + 'al.longitude AS "longitude"', + // Haversine distance: + `(${earthRadius} * acos( + cos(radians(:lat)) * cos(radians(al.latitude)) * cos(radians(al.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(al.latitude)) + ))` + + ' AS "distance"' + ]) + .where('al.latitude IS NOT NULL') + .andWhere('al.longitude IS NOT NULL') + .setParameters({ lat, lng }) + .having(`(${earthRadius} * acos( + cos(radians(:lat)) * cos(radians(al.latitude)) * cos(radians(al.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(al.latitude)) + )) <= :radius`) + .setParameter('radius', radiusMeters) + .orderBy('"distance"', 'ASC') + .limit(limit); + + // Note: some DB drivers require a groupBy if using having; however when selecting raw values and no aggregates, + // PostgreSQL allows this pattern. If your DB errors, consider switching `having` to `andWhere` with the same expression. + const raw = await qb.getRawMany(); + + // raw rows have assetId, latitude, longitude, distance + return raw.map((r) => ({ + assetId: r.assetId, + latitude: parseFloat(r.latitude), + longitude: parseFloat(r.longitude), + distance: parseFloat(r.distance), + })); + } +}