Appearance
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 validationEntities live in
shared/src/entities/(the@gis-data/sharedpackage) so they can be reused by the API and both worker microservices. Import them viaimport { 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 BYpath (live whenever filters or company scope are applied). - Composing the company-scope clauses against both the buckets table and the outer
subsegmentsprojection. - Returning
nullwhen 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)andprivate readonly - Use query builders for complex/filtered queries
- Use
.findOne()withrelationsfor eager loading - Throw
NotFoundExceptionfor missing records (internal error message in English) - Comma-separated filter values: split with
.split(',')and query withIN (:...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-validatordecorators 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
ValidationPipestrips 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 (accidentreasonetc. cap at 500,sectionsat 2000) - Comma-separated codes: combine
@MaxLength()with a@Matches()regex —sectionsaccepts^[A-Za-z0-9_,-]*$,monthaccepts^([1-9]|1[0-2])(,([1-9]|1[0-2]))*$,dayOfWeekaccepts^[1-7](,[1-7])*$, andinitialSpeedaccepts ranges like0-20,80-100,100+ - Pagination
limitis 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/@ManyToOnewith{ cascade: true }on the parent side- Nullable columns:
{ nullable: true }and typed asType | 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
ValidationPipeautomatically 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