Skip to content

API Conventions

Module Structure

Each feature module follows a consistent directory layout:

<module>/
├── <module>.module.ts           # Imports entities, wires controller + service
├── <module>.controller.ts       # HTTP endpoints
├── <module>.service.ts          # Business logic
├── <module>.service.spec.ts     # Unit tests (colocated)
└── dto/
    └── <name>.dto.ts            # Request validation

Entities live in shared/src/entities/ (the @gis-data/shared package) so they can be reused by the API and both worker microservices. Import them via import { MyEntity } from '@gis-data/shared'.

Modules

Register entities with TypeOrmModule.forFeature() and import into app.module.ts:

typescript
@Module({
  imports: [TypeOrmModule.forFeature([Accident, AccidentParticipant])],
  controllers: [AccidentsController],
  providers: [AccidentsService],
})
export class AccidentsModule {}

If other modules need the service, add it to exports: [AccidentsService].

Controllers

Controllers are thin — they validate input and delegate to the service:

typescript
@ApiTags('Accidents')
@Controller('accidents')
export class AccidentsController {
  constructor(private readonly accidentsService: AccidentsService) {}

  @Get('filters')
  @Public()
  @ApiOperation({ summary: 'Vrni možne vrednosti filtrov za nesreče' })
  getFilterOptions() {
    return this.accidentsService.getFilterOptions();
  }

  @Get(':id')
  @Public()
  @ApiOperation({ summary: 'Vrni podrobnosti nesreče z udeleženci' })
  @ApiNotFoundResponse({ description: 'Nesreča ni najdena' })
  findOne(@Param('id', ParseUUIDPipe) id: string) {
    return this.accidentsService.findOne(id);
  }
}

Rules:

  • @ApiTags() for Swagger grouping
  • @ApiOperation({ summary }) on every endpoint — summaries in Slovenian
  • @Query() with a DTO class for query parameters
  • @Param('id', ParseUUIDPipe) for UUID route parameters
  • Return service method directly — controller has no business logic
  • Use @Public() for unauthenticated endpoints
  • Use @Roles('admin') for restricted endpoints

Shared building blocks (src/common/)

Cross-cutting helpers that more than one module needs live in src/common/. Reach for them instead of duplicating SQL or boilerplate:

common/auth/company-scope.ts

Encapsulates the EXISTS-against-analytics company-scope decision used by every endpoint that returns event-level data (accidents, breaking events).

typescript
import { applyCompanyScope } from '../common/auth/company-scope';

const qb = this.accidentRepository.createQueryBuilder('a');
applyCompanyScope(qb, currentUser, {
  alias: 'a',
  existsTable: 'analytics.accidents_by_segment',
  existsKey: 'accident_id',
});

The helper short-circuits with 1 = 0 when the user is scoped but their company has no allowed roads, so callers don't need to handle that case manually. Both AccidentsService and BreakingService consume it; if you add another scoped domain, do the same instead of inlining the EXISTS subquery.

common/map-query/map-query-builder.ts

Shared builder for the density / markers / zoom-band logic that powers the /accidents/map and /breaking/map endpoints. The caller still owns its own filter-condition list and parameter array; the builder takes care of:

  • Picking between the pre-aggregated rollup table and the live GROUP BY path (live whenever filters or company scope are applied).
  • Composing the company-scope clauses against both the buckets table and the outer subsegments projection.
  • Returning null when the user's company has zero allowed roads (signal to the caller to short-circuit without a query).

Use MapQueryBuilder.runDensity({...}). The same density code path used to be copied into both services; if you find yourself reaching for a third copy, extend this builder instead.

Services

Services contain all business logic and database queries:

typescript
@Injectable()
export class AccidentsService {
  constructor(
    @InjectRepository(Accident)
    private readonly accidentRepository: Repository<Accident>,
  ) {}

  async findAll(filters?: AccidentFiltersQuery) {
    const qb = this.accidentRepository
      .createQueryBuilder('a')
      .select(['a.id', 'a.latitude', 'a.longitude']);

    if (filters?.accidentreason) {
      const values = filters.accidentreason.split(',');
      qb.andWhere('a.accidentreason IN (:...accidentreasons)', {
        accidentreasons: values,
      });
    }

    if (filters?.startDate) {
      qb.andWhere('a.date >= :startDate', { startDate: filters.startDate });
    }

    return qb.getMany();
  }

  async findOne(id: string) {
    const accident = await this.accidentRepository.findOne({
      where: { id },
      relations: ['participants'],
    });
    if (!accident) {
      throw new NotFoundException(`Accident with id ${id} not found`);
    }
    return accident;
  }
}

Rules:

  • @Injectable() decorator
  • Inject repositories with @InjectRepository(Entity) and private readonly
  • Use query builders for complex/filtered queries
  • Use .findOne() with relations for eager loading
  • Throw NotFoundException for missing records (internal error message in English)
  • Comma-separated filter values: split with .split(',') and query with IN (:...param)
  • Use Promise.all() for parallel independent queries

DTOs

DTOs define request validation using class-validator:

typescript
import { IsOptional, IsString, IsDateString, IsNumber, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';

export class AccidentFiltersQuery {
  @ApiPropertyOptional({ description: 'Vzrok nesreče', example: 'Neprilagojena hitrost' })
  @IsOptional()
  @IsString()
  accidentreason?: string;

  @ApiPropertyOptional({ description: 'Začetni datum (ISO 8601)', example: '2023-01-01' })
  @IsOptional()
  @IsDateString()
  startDate?: string;

  @ApiPropertyOptional({ description: 'Stopnja povečave zemljevida', example: 12 })
  @IsNumber()
  @Min(1)
  @Max(22)
  @Type(() => Number)
  zoom: number;
}

Rules:

  • Use class-validator decorators for validation
  • Use @ApiPropertyOptional() for Swagger docs — descriptions in Slovenian
  • All query parameters are @IsOptional() unless required
  • Use @Type(() => Number) for numeric query parameters (they arrive as strings)
  • The global ValidationPipe strips unknown properties (whitelist) and auto-converts types (transform)

Validation patterns to follow (the accident/breaking filter and pagination DTOs are the reference implementations):

  • Date strings: @IsDateString()
  • Integers: @IsInt() + @Min() / @Max() (e.g. zoom is @Min(1) @Max(22), hour is @Min(0) @Max(23))
  • Free-text fields: always cap with @MaxLength() so an attacker can't ship multi-MB query strings (accidentreason etc. cap at 500, sections at 2000)
  • Comma-separated codes: combine @MaxLength() with a @Matches() regex — sections accepts ^[A-Za-z0-9_,-]*$, month accepts ^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$, dayOfWeek accepts ^[1-7](,[1-7])*$, and initialSpeed accepts ranges like 0-20,80-100,100+
  • Pagination limit is capped at @Max(1000) everywhere

Entities

typescript
interface GeoPoint {
  type: 'Point';
  coordinates: [number, number]; // [longitude, latitude]
}

@Entity('accidents')
export class Accident {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('double precision')
  latitude: number;

  @Column('double precision')
  longitude: number;

  @Index({ spatial: true })
  @Column({
    type: 'geometry',
    spatialFeatureType: 'Point',
    srid: 4326,
  })
  geom: GeoPoint;

  @Column({ type: 'date' })
  date: string;

  @Column({ type: 'varchar', nullable: true })
  accidentreason: string | null;

  @Column('int')
  participantscount: number;

  @OneToMany(() => AccidentParticipant, (p) => p.accident, { cascade: true })
  participants: AccidentParticipant[];
}

Rules:

  • UUID primary keys: @PrimaryGeneratedColumn('uuid')
  • Explicit table name: @Entity('table_name')
  • Explicit column types: @Column('double precision'), @Column('int')
  • PostGIS geometry: type: 'geometry', spatialFeatureType, srid: 4326
  • Always add @Index({ spatial: true }) on geometry columns
  • Define GeoJSON interfaces (GeoPoint, GeoMultiLineString) for type safety
  • @OneToMany / @ManyToOne with { cascade: true } on the parent side
  • Nullable columns: { nullable: true } and typed as Type | null

Error Handling

  • Throw NestJS built-in exceptions: NotFoundException, BadRequestException, UnauthorizedException, ForbiddenException
  • Internal error messages in English
  • User-facing error messages (returned in responses) in Slovenian where applicable
  • The global ValidationPipe automatically returns 400 with validation error details

Pagination Pattern

List endpoints return paginated responses:

typescript
async findAll(query: UsersQueryDto) {
  const page = query.page || 1;
  const limit = query.limit || 20;

  const [data, total] = await this.userRepository.findAndCount({
    skip: (page - 1) * limit,
    take: limit,
    order: { createdAt: 'DESC' },
  });

  return { data, total, page, limit };
}

Export Pattern

CSV exports use streaming:

typescript
@Get('export')
@Header('Content-Type', 'text/csv')
@Header('Content-Disposition', 'attachment; filename="export.csv"')
async export(@Query() query: FiltersDto) {
  return this.service.findAllForExport(query);
}

Swagger

API docs are auto-generated at /docs (note: not under the /api prefix). The @nestjs/swagger plugin in nest-cli.json handles automatic DTO documentation.

Key decorators:

  • @ApiTags('Group') — groups endpoints in the UI
  • @ApiOperation({ summary: '...' }) — endpoint description (Slovenian)
  • @ApiNotFoundResponse({ description: '...' }) — error response docs
  • @ApiPropertyOptional() — optional DTO fields