Skip to content
Merged
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
79 changes: 74 additions & 5 deletions features/flight/route.fix-fuel-report.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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": <value> }
"""
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": [<violations>]
}
}
"""

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"
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flight-tracker-api",
"version": "3.80.5",
"version": "3.81.0",
"private": true,
"homepage": "https://mypreflight.io",
"author": "mypreflight",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
) {}
}

Expand All @@ -20,7 +23,7 @@ export class ReportFixFuelHandler implements ICommandHandler<ReportFixFuelComman
constructor(private readonly repository: FlightsRepository) {}

async execute(command: ReportFixFuelCommand): Promise<void> {
const { flightId, ordinal, actualOnBoard } = command;
const { flightId, ordinal, actualOnBoard, reportedAt } = command;

const flight = await this.repository.findOneBy({ id: flightId });
if (!flight) {
Expand All @@ -31,11 +34,21 @@ export class ReportFixFuelHandler implements ICommandHandler<ReportFixFuelComman
throw new InvalidStatusToReportFixFuelError();
}

const takeoffTime = (flight.timesheet as FullTimesheet).actual?.takeoffTime;
if (takeoffTime && reportedAt < new Date(takeoffTime)) {
throw new FixFuelReportedBeforeTakeoffError();
}

const route = await this.repository.findPlannedRoute(flightId);
if (!route?.fixes.some((fix) => fix.ordinal === ordinal)) {
throw new RouteFixDoesNotExistError();
}

await this.repository.saveFixFuelReport(flightId, ordinal, actualOnBoard);
await this.repository.saveFixFuelReport(
flightId,
ordinal,
actualOnBoard,
reportedAt,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -844,10 +844,11 @@ export class FlightsRepository {
flightId: string,
ordinal: number,
actualOnBoard: number,
reportedAt: Date,
): Promise<void> {
await this.prisma.flightRouteFix.update({
where: { flightId_ordinal: { flightId, ordinal } },
data: { fuelActualOnBoard: actualOnBoard, fuelReportedAt: new Date() },
data: { fuelActualOnBoard: actualOnBoard, fuelReportedAt: reportedAt },
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br />' +
'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. <br />' +
'**NOTE:** This action is only allowed while the flight is in cruise. <br />' +
'**NOTE:** The time must not be before the actual takeoff. <br />' +
'**NOTE:** This endpoint is only available for users with `cabin crew` role.',
})
@ApiBearerAuth('jwt')
Expand All @@ -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)
Expand All @@ -66,7 +68,12 @@ export class ReportFixFuelAction {
@Body() body: ReportFixFuelRequest,
): Promise<PlannedRouteResponse> {
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));
Expand Down
25 changes: 23 additions & 2 deletions src/modules/flights/infra/http/request/planned-route.dto.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/modules/flights/model/error/flight.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading