diff --git a/features/flight/route.fix-fuel-report.feature b/features/flight/route.fix-fuel-report.feature index b8d1dcf7..4b6a29e6 100644 --- a/features/flight/route.fix-fuel-report.feature +++ b/features/flight/route.fix-fuel-report.feature @@ -29,6 +29,72 @@ Feature: Report actual fuel over a fix of the planned route } """ + Scenario: As cabin crew I can report the time I read the fuel over a fix + Given I set database to initial state + And I am signed in as "cabin crew" + When I send a "PUT" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route/fix/4/fuel-report" with body: + """json + { "actualOnBoard": 7900, "reportedAt": "2025-01-01T14:05:00.000Z" } + """ + Then the response status should be 200 + And the response body property "fixes.4.fuelReport" should contain: + """json + { + "actualOnBoard": 7900, + "reportedAt": "2025-01-01T14:05:00.000Z" + } + """ + When I send a "GET" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route" + Then the response status should be 200 + And the response body property "fixes.4.fuelReport" should contain: + """json + { + "actualOnBoard": 7900, + "reportedAt": "2025-01-01T14:05:00.000Z" + } + """ + + Scenario: As cabin crew I cannot report fuel over a fix before the takeoff time + Given I set database to initial state + And I am signed in as "cabin crew" + When I send a "PUT" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route/fix/4/fuel-report" with body: + """json + { "actualOnBoard": 7900, "reportedAt": "2025-01-01T13:24:00.000Z" } + """ + Then the response status should be 422 + And the response body should contain: + """json + { + "statusCode": 422, + "error": "Unprocessable Content", + "message": "Cannot report fuel over a fix before the takeoff time." + } + """ + + Scenario Outline: As cabin crew I cannot report a time that is not in the past + Given I am signed in as "cabin crew" + When I send a "PUT" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route/fix/4/fuel-report" with body: + """json + { "actualOnBoard": 7900, "reportedAt": } + """ + Then the response status should be 400 + And the response body should contain: + """json + { + "statusCode": 400, + "message": "Request validation failed.", + "error": "Bad Request", + "violations": { + "reportedAt": [] + } + } + """ + + Examples: + | value | violations | + | "2999-01-01T00:00:00.000Z" | "reportedAt must not be in the future" | + | "soon" | "reportedAt must not be in the future", "reportedAt must be a Date instance" | + Scenario: As cabin crew I can report fuel over a climb or descent fix while in cruise Given I set database to initial state And I am signed in as "cabin crew" @@ -51,22 +117,25 @@ Feature: Report actual fuel over a fix of the planned route 6400 """ - Scenario: As cabin crew reporting a fix again replaces the earlier figure + Scenario: As cabin crew reporting a fix again replaces the earlier figure and time Given I set database to initial state And I am signed in as "cabin crew" When I send a "PUT" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route/fix/4/fuel-report" with body: """json - { "actualOnBoard": 7900 } + { "actualOnBoard": 7900, "reportedAt": "2025-01-01T14:05:00.000Z" } """ Then the response status should be 200 When I send a "PUT" request to "/api/v1/flight/2d1c92f6-8ed1-4921-9a70-f71b1ed2e72d/route/fix/4/fuel-report" with body: """json - { "actualOnBoard": 7850 } + { "actualOnBoard": 7850, "reportedAt": "2025-01-01T14:10:00.000Z" } """ Then the response status should be 200 - And the response body property "fixes.4.fuelReport.actualOnBoard" should contain: + And the response body property "fixes.4.fuelReport" should contain: """json - 7850 + { + "actualOnBoard": 7850, + "reportedAt": "2025-01-01T14:10:00.000Z" + } """ Scenario: As anyone I can read a reported figure in the briefing diff --git a/package-lock.json b/package-lock.json index e66b083d..de2c868b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flight-tracker-api", - "version": "3.80.5", + "version": "3.81.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flight-tracker-api", - "version": "3.80.5", + "version": "3.81.0", "license": "Unlicense", "dependencies": { "@faker-js/faker": "^10.6.0", diff --git a/package.json b/package.json index 32892606..1497f588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-tracker-api", - "version": "3.80.5", + "version": "3.81.0", "private": true, "homepage": "https://mypreflight.io", "author": "mypreflight", diff --git a/src/modules/flights/application/command/report-fix-fuel.command.ts b/src/modules/flights/application/command/report-fix-fuel.command.ts index fc3befd3..ace440a2 100644 --- a/src/modules/flights/application/command/report-fix-fuel.command.ts +++ b/src/modules/flights/application/command/report-fix-fuel.command.ts @@ -1,17 +1,20 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { FlightsRepository } from '../../infra/database/repository/flights.repository'; import { + FixFuelReportedBeforeTakeoffError, FlightDoesNotExistError, InvalidStatusToReportFixFuelError, RouteFixDoesNotExistError, } from '../../model/error/flight.error'; import { FlightStatus } from '../../model/flight.model'; +import { FullTimesheet } from '../../model/timesheet.model'; export class ReportFixFuelCommand { constructor( public readonly flightId: string, public readonly ordinal: number, public readonly actualOnBoard: number, + public readonly reportedAt: Date, ) {} } @@ -20,7 +23,7 @@ export class ReportFixFuelHandler implements ICommandHandler { - const { flightId, ordinal, actualOnBoard } = command; + const { flightId, ordinal, actualOnBoard, reportedAt } = command; const flight = await this.repository.findOneBy({ id: flightId }); if (!flight) { @@ -31,11 +34,21 @@ export class ReportFixFuelHandler implements ICommandHandler fix.ordinal === ordinal)) { throw new RouteFixDoesNotExistError(); } - await this.repository.saveFixFuelReport(flightId, ordinal, actualOnBoard); + await this.repository.saveFixFuelReport( + flightId, + ordinal, + actualOnBoard, + reportedAt, + ); } } diff --git a/src/modules/flights/infra/database/repository/flights.repository.ts b/src/modules/flights/infra/database/repository/flights.repository.ts index d2ce1d49..f2b91405 100644 --- a/src/modules/flights/infra/database/repository/flights.repository.ts +++ b/src/modules/flights/infra/database/repository/flights.repository.ts @@ -844,10 +844,11 @@ export class FlightsRepository { flightId: string, ordinal: number, actualOnBoard: number, + reportedAt: Date, ): Promise { await this.prisma.flightRouteFix.update({ where: { flightId_ordinal: { flightId, ordinal } }, - data: { fuelActualOnBoard: actualOnBoard, fuelReportedAt: new Date() }, + data: { fuelActualOnBoard: actualOnBoard, fuelReportedAt: reportedAt }, }); } diff --git a/src/modules/flights/infra/http/action/route/report-fix-fuel.action.ts b/src/modules/flights/infra/http/action/route/report-fix-fuel.action.ts index 437d9edc..b9291393 100644 --- a/src/modules/flights/infra/http/action/route/report-fix-fuel.action.ts +++ b/src/modules/flights/infra/http/action/route/report-fix-fuel.action.ts @@ -38,9 +38,10 @@ export class ReportFixFuelAction { @ApiOperation({ summary: 'Report actual fuel over a fix of the planned route', description: - 'Saves the fuel on board the pilot read over a fix and answers the route with it. ' + - 'Reporting the same fix again replaces the earlier figure.
' + + 'Saves the fuel on board the pilot read over a fix, and the time they read it, and answers the route with it. ' + + 'Reporting the same fix again replaces the earlier figure and time.
' + '**NOTE:** This action is only allowed while the flight is in cruise.
' + + '**NOTE:** The time must not be before the actual takeoff.
' + '**NOTE:** This endpoint is only available for users with `cabin crew` role.', }) @ApiBearerAuth('jwt') @@ -56,7 +57,8 @@ export class ReportFixFuelAction { @ApiForbiddenResponse({ type: ForbiddenResponse }) @ApiNotFoundResponse({ type: GenericNotFoundResponse }) @ApiUnprocessableEntityResponse({ - description: 'The flight is not in cruise', + description: + 'The flight is not in cruise, or the time is before the actual takeoff', }) @Put('/:id/route/fix/:ordinal/fuel-report') @Role(UserRole.CabinCrew) @@ -66,7 +68,12 @@ export class ReportFixFuelAction { @Body() body: ReportFixFuelRequest, ): Promise { await this.commandBus.execute( - new ReportFixFuelCommand(id, ordinal, body.actualOnBoard), + new ReportFixFuelCommand( + id, + ordinal, + body.actualOnBoard, + body.reportedAt ?? new Date(), + ), ); return this.queryBus.execute(new GetPlannedRouteQuery(id)); diff --git a/src/modules/flights/infra/http/request/planned-route.dto.ts b/src/modules/flights/infra/http/request/planned-route.dto.ts index f44fa700..96083ea2 100644 --- a/src/modules/flights/infra/http/request/planned-route.dto.ts +++ b/src/modules/flights/infra/http/request/planned-route.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsDate, IsInt, IsOptional, Max, MaxDate, Min } from 'class-validator'; const MAX_REPORTED_FUEL_KG = 999_999; +const CLOCK_DRIFT_ALLOWANCE_MS = 60_000; + +const latestAcceptedReportTime = (): Date => + new Date(Date.now() + CLOCK_DRIFT_ALLOWANCE_MS); export class ReportFixFuelRequest { @ApiProperty({ @@ -14,6 +19,22 @@ export class ReportFixFuelRequest { @Min(0) @Max(MAX_REPORTED_FUEL_KG) actualOnBoard!: number; + + @ApiProperty({ + description: + 'Time the pilot read the fuel over the fix. ' + + 'Must not be in the future or before the actual takeoff. Omit to use the current time.', + example: '2026-11-02T14:05:00.000Z', + type: Date, + required: false, + }) + @IsOptional() + @Type(() => Date) + @IsDate() + @MaxDate(latestAcceptedReportTime, { + message: '$property must not be in the future', + }) + reportedAt?: Date; } export class PlannedRouteFuelReportResponse { @@ -24,7 +45,7 @@ export class PlannedRouteFuelReportResponse { actualOnBoard!: number; @ApiProperty({ - description: 'Time of the latest report for the fix', + description: 'Time the pilot read the fuel over the fix', example: '2026-11-02T14:05:00.000Z', }) reportedAt!: Date; diff --git a/src/modules/flights/model/error/flight.error.ts b/src/modules/flights/model/error/flight.error.ts index 83ca0c62..92e18713 100644 --- a/src/modules/flights/model/error/flight.error.ts +++ b/src/modules/flights/model/error/flight.error.ts @@ -266,6 +266,12 @@ export class InvalidStatusToReportFixFuelError extends UnprocessableError { } } +export class FixFuelReportedBeforeTakeoffError extends UnprocessableError { + constructor() { + super('Cannot report fuel over a fix before the takeoff time.'); + } +} + export class RouteFixDoesNotExistError extends NotFoundError { constructor() { super('Fix with given ordinal does not exist on the planned route.');