Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MessagesController } from './messages/messages.controller';
import { FlaggedSchoolService } from './flagged-school/flagged-school.service';
import { FlaggedSchoolController } from './flagged-school/flagged-school.controller';
import { SchoolController } from './school/school.controller';
import { StagingSchoolController } from './school/staging-school.controller';
import { SchoolService } from './school/school.service';
import { CountryController } from './country/country.controller';
import { CountryService } from './country/country.service';
Expand Down Expand Up @@ -72,6 +73,7 @@ import { SchedulerService } from './scheduler/scheduler.service';
MessagesController,
FlaggedSchoolController,
SchoolController,
StagingSchoolController,
SchoolMasterController,
CountryController,
MeasurementController,
Expand Down
19 changes: 19 additions & 0 deletions src/common/staging.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CanActivate, Injectable, NotFoundException } from '@nestjs/common';

/**
* Guard that only lets a route through when the service is running in the
* staging environment (NODE_ENV === 'staging').
*
* Outside staging it throws a NotFoundException so the endpoint is completely
* invisible in production (same response as an unknown route), rather than
* advertising its existence with a 403.
*/
@Injectable()
export class StagingGuard implements CanActivate {
canActivate(): boolean {
if (process.env.NODE_ENV !== 'staging') {
throw new NotFoundException();
}
return true;
}
}
149 changes: 149 additions & 0 deletions src/school/create-school.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';

/**
* Input payload for creating a record in the canonical `school` table.
*
* Only used by the staging-only school creation endpoint. `name` is the only
* required field; everything else is optional and mirrors the columns of the
* `school` model. `latitude`/`longitude` are translated into the PostGIS
* `geopoint` column server-side.
*/
export class CreateSchoolDto {
@ApiProperty({ description: 'School name' })
@IsString()
name: string;

@ApiPropertyOptional({ description: 'IANA timezone, eg: Asia/Kolkata' })
@IsOptional()
@IsString()
timezone?: string;

@ApiPropertyOptional({
description: 'Latitude of the school location (WGS84). Stored in geopoint.',
})
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;

@ApiPropertyOptional({
description:
'Longitude of the school location (WGS84). Stored in geopoint.',
})
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;

@ApiPropertyOptional({ description: 'Confidence of the GPS coordinates' })
@IsOptional()
@IsNumber()
gps_confidence?: number;

@ApiPropertyOptional({ description: 'Altitude in meters' })
@IsOptional()
@IsInt()
altitude?: number;

@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
postal_code?: string;

@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
education_level?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
environment?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
school_type?: string;

@ApiPropertyOptional()
@IsOptional()
@IsInt()
country_id?: number;

@ApiPropertyOptional()
@IsOptional()
@IsInt()
location_id?: number;

@ApiPropertyOptional({ description: 'ISO2 country code, eg: IN' })
@IsOptional()
@IsString()
country_code?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
admin_1_name?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
admin_2_name?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
admin_3_name?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
admin_4_name?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
external_id?: string;

@ApiPropertyOptional({ description: 'The GIGA id of the school' })
@IsOptional()
@IsString()
giga_id_school?: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
education_level_regional?: string;

@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
feature_flags?: Record<string, unknown>;
}

export class CreateSchoolResponseDto {
@ApiProperty({ description: 'Id of the created school record' })
id: string;
}
59 changes: 59 additions & 0 deletions src/school/school.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { dailycheckapp_school as School } from '@prisma/client';
import { SchoolDto } from './school.dto';
import { CreateSchoolDto } from './create-school.dto';
import { v4 as uuidv4 } from 'uuid';
import { GeolocationUtility } from '../geolocation/geolocation.utility';
import {
Expand Down Expand Up @@ -403,6 +405,63 @@ export class SchoolService {
};
}

/**
* Creates a record in the canonical `school` table.
*
* NOTE: this writes to the master `school` table (not `dailycheckapp_school`)
* and is only exposed through the staging-only endpoint. The PostGIS
* `geopoint` column is unsupported by Prisma's typed client, so when
* latitude/longitude are provided it is populated with a follow-up raw query
* inside the same transaction.
*/
async createSchoolRecord(dto: CreateSchoolDto): Promise<string> {
const now = new Date();

const data: Prisma.schoolUncheckedCreateInput = {
name: dto.name,
name_lower: dto.name?.toLowerCase(),
timezone: dto.timezone,
gps_confidence: dto.gps_confidence,
altitude: dto.altitude,
address: dto.address,
postal_code: dto.postal_code,
email: dto.email,
education_level: dto.education_level,
environment: dto.environment,
school_type: dto.school_type,
country_id: dto.country_id,
location_id: dto.location_id,
admin_1_name: dto.admin_1_name,
admin_2_name: dto.admin_2_name,
admin_3_name: dto.admin_3_name,
admin_4_name: dto.admin_4_name,
external_id: dto.external_id,
giga_id_school: dto.giga_id_school?.toLowerCase().trim(),
education_level_regional: dto.education_level_regional,
feature_flags: dto.feature_flags as Prisma.InputJsonValue,
country_code: dto.country_code,
created: now,
modified: now,
created_at: now,
};

const id = await this.prisma.$transaction(async (tx) => {
const school = await tx.school.create({ data });

if (dto.latitude != null && dto.longitude != null) {
await tx.$executeRaw`
UPDATE school
SET geopoint = ST_SetSRID(ST_MakePoint(${dto.longitude}, ${dto.latitude}), 4326)::geography
WHERE id = ${school.id}
`;
}

return school.id;
});

return id.toString();
}

private toDto(school: School): SchoolDto {
return {
id: school.id.toString(),
Expand Down
62 changes: 62 additions & 0 deletions src/school/staging-school.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
Body,
Controller,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import {
ApiExcludeController,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { SchoolService } from './school.service';
import { StagingGuard } from '../common/staging.guard';
import { CreateSchoolDto, CreateSchoolResponseDto } from './create-school.dto';
import { ApiSuccessResponseDto } from '../common/common.dto';

/**
* Staging-only endpoints for seeding the canonical `school` table.
*
* The whole controller is gated by {@link StagingGuard}: outside the staging
* environment (NODE_ENV !== 'staging') every route responds with 404, so this
* is never reachable in production. Excluded from Swagger for the same reason.
*/
@ApiTags('Schools (staging)')
@ApiExcludeController()
@Controller('api/v1/staging/schools')
@UseGuards(StagingGuard)
export class StagingSchoolController {
constructor(private readonly schoolService: SchoolService) {}

@Post()
@UsePipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
)
@ApiOperation({
summary: 'Create a school in the canonical school table (staging only)',
})
@ApiResponse({
status: 201,
description: 'Returns the id of the created school',
type: CreateSchoolResponseDto,
})
@ApiResponse({
status: 404,
description: 'Not found; endpoint is only available in staging',
})
async createSchool(
@Body() dto: CreateSchoolDto,
): Promise<ApiSuccessResponseDto<CreateSchoolResponseDto>> {
const id = await this.schoolService.createSchoolRecord(dto);

return {
success: true,
data: { id },
timestamp: new Date().toISOString(),
message: 'success',
};
}
}