Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/src/asset-locations/dto/assets.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions backend/src/asset-locations/dto/branch.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions backend/src/asset-locations/dto/location.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions backend/src/asset-locations/entities/location.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
57 changes: 57 additions & 0 deletions backend/src/asset-locations/locations.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
13 changes: 13 additions & 0 deletions backend/src/asset-locations/locations.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
118 changes: 118 additions & 0 deletions backend/src/asset-locations/locations.service.ts
Original file line number Diff line number Diff line change
@@ -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<AssetLocation>,
) {}

/**
* Upsert the current location for an asset.
* If the asset already has a row, update it; otherwise insert.
*/
async upsertLocation(dto: UpdateLocationDto): Promise<AssetLocation> {
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<AssetLocation | null> {
return this.repo.findOne({ where: { assetId } });
}

/**
* Query assets currently assigned to a branch.
* Returns AssetLocation[].
*/
async getAssetsByBranch(branchId: string, limit = 100, offset = 0): Promise<AssetLocation[]> {
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),
}));
}
}