File

src/tracking/geofence.controller.ts

Prefix

geofences

Index

Methods

Methods

Async create
create(orgId: string, body: CreateGeofenceBody)
Decorators :
@Post()
@Roles(undefined)
@ApiOperation({summary: 'Create a geofence'})
Parameters :
Name Type Optional
orgId string No
body CreateGeofenceBody No
Returns : unknown
Async delete
delete(orgId: string, id: string)
Decorators :
@Delete(':id')
@Roles(undefined)
@ApiOperation({summary: 'Delete a geofence (org-scoped)'})
Parameters :
Name Type Optional
orgId string No
id string No
Returns : unknown
Async get
get(orgId: string, id: string)
Decorators :
@Get(':id')
@ApiOperation({summary: 'Get geofence by ID (org-scoped)'})
Parameters :
Name Type Optional
orgId string No
id string No
Returns : unknown
Async list
list(orgId: string)
Decorators :
@Get()
@ApiOperation({summary: 'List all geofences (org-scoped)'})
Parameters :
Name Type Optional
orgId string No
Returns : unknown
Async update
update(orgId: string, id: string, body: CreateGeofenceBody)
Decorators :
@Patch(':id')
@Roles(undefined)
@ApiOperation({summary: 'Update a geofence (org-scoped)'})
Parameters :
Name Type Optional
orgId string No
id string No
body CreateGeofenceBody No
Returns : unknown
import {
  BadRequestException,
  Body,
  Controller,
  Delete,
  Get,
  NotFoundException,
  Param,
  Patch,
  Post,
  UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CombinedAuthGuard } from '../auth/guards/combined-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';

/**
 * Geofence CRUD. Reads are open to all authenticated staff (dispatchers
 * and drivers need to know the operating envelope) but only operations
 * leadership can edit the geofence fabric.
 */
const GEOFENCE_WRITERS = ['SUPER_ADMIN', 'ADMIN', 'OPERATIONS_MANAGER'] as const;

interface CreateGeofenceBody {
  name?: string;
  type?: string;
  centerLat?: number;
  centerLng?: number;
  radiusMeters?: number;
  polygon?: any;
  geofenceType?: string;
}

@ApiTags('Geofences')
@ApiBearerAuth()
@UseGuards(CombinedAuthGuard, RolesGuard)
@Controller('geofences')
export class GeofenceController {
  constructor(private readonly prisma: PrismaService) {}

  @Post()
  @Roles(...GEOFENCE_WRITERS)
  @ApiOperation({ summary: 'Create a geofence' })
  async create(
    @CurrentUser('organizationId') orgId: string,
    @Body() body: CreateGeofenceBody,
  ) {
    if (!body?.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
      throw new BadRequestException('Geofence name is required');
    }
    if (body.name.length > 120) {
      throw new BadRequestException('Geofence name must be 120 characters or fewer');
    }
    // Either circular (centerLat + centerLng + radius) or polygon
    const isCircle = body.centerLat != null && body.centerLng != null && body.radiusMeters != null;
    const isPolygon = body.polygon && Array.isArray(body.polygon) && body.polygon.length >= 3;
    if (!isCircle && !isPolygon) {
      throw new BadRequestException(
        'Geofence must have either centerLat/centerLng/radiusMeters (circle) or a polygon array of at least 3 points',
      );
    }
    if (isCircle) {
      if (body.centerLat! < -90 || body.centerLat! > 90) {
        throw new BadRequestException('centerLat must be between -90 and 90');
      }
      if (body.centerLng! < -180 || body.centerLng! > 180) {
        throw new BadRequestException('centerLng must be between -180 and 180');
      }
      if (body.radiusMeters! <= 0 || body.radiusMeters! > 1_000_000) {
        throw new BadRequestException('radiusMeters must be between 1 and 1,000,000');
      }
    }

    return this.prisma.geofence.create({
      data: {
        organizationId: orgId,
        name: body.name.trim(),
        type: body.type || 'circle',
        centerLat: body.centerLat,
        centerLng: body.centerLng,
        radiusMeters: body.radiusMeters,
        polygon: body.polygon,
        geofenceType: body.geofenceType,
      },
    });
  }

  @Get()
  @ApiOperation({ summary: 'List all geofences (org-scoped)' })
  async list(@CurrentUser('organizationId') orgId: string) {
    return this.prisma.geofence.findMany({
      where: { organizationId: orgId },
      orderBy: { createdAt: 'desc' },
    });
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get geofence by ID (org-scoped)' })
  async get(
    @CurrentUser('organizationId') orgId: string,
    @Param('id') id: string,
  ) {
    const geofence = await this.prisma.geofence.findFirst({
      where: { id, organizationId: orgId },
    });
    if (!geofence) throw new NotFoundException('Geofence not found');
    return geofence;
  }

  @Patch(':id')
  @Roles(...GEOFENCE_WRITERS)
  @ApiOperation({ summary: 'Update a geofence (org-scoped)' })
  async update(
    @CurrentUser('organizationId') orgId: string,
    @Param('id') id: string,
    @Body() body: CreateGeofenceBody,
  ) {
    // Org-scoped existence check before update
    const existing = await this.prisma.geofence.findFirst({
      where: { id, organizationId: orgId },
    });
    if (!existing) throw new NotFoundException('Geofence not found');

    if (body.centerLat !== undefined && (body.centerLat < -90 || body.centerLat > 90)) {
      throw new BadRequestException('centerLat must be between -90 and 90');
    }
    if (body.centerLng !== undefined && (body.centerLng < -180 || body.centerLng > 180)) {
      throw new BadRequestException('centerLng must be between -180 and 180');
    }
    if (body.radiusMeters !== undefined && (body.radiusMeters <= 0 || body.radiusMeters > 1_000_000)) {
      throw new BadRequestException('radiusMeters must be between 1 and 1,000,000');
    }
    if (body.name !== undefined && body.name.length > 120) {
      throw new BadRequestException('Geofence name must be 120 characters or fewer');
    }

    return this.prisma.geofence.update({ where: { id }, data: body });
  }

  @Delete(':id')
  @Roles(...GEOFENCE_WRITERS)
  @ApiOperation({ summary: 'Delete a geofence (org-scoped)' })
  async delete(
    @CurrentUser('organizationId') orgId: string,
    @Param('id') id: string,
  ) {
    const existing = await this.prisma.geofence.findFirst({
      where: { id, organizationId: orgId },
    });
    if (!existing) throw new NotFoundException('Geofence not found');
    await this.prisma.geofence.delete({ where: { id } });
    return { deleted: true };
  }
}

results matching ""

    No results matching ""