Compare commits

..

27 Commits

Author SHA1 Message Date
20b910b015 adding heroui 3 2025-12-01 20:26:37 -06:00
8d166983f1 adding heroui 3 2025-12-01 20:25:37 -06:00
4ad53f99c1 misc fixes 2025-11-29 13:22:32 -06:00
819e632d8e misc fixes 2025-11-29 13:09:56 -06:00
5100a93659 misc fixes 2025-11-29 12:59:56 -06:00
a4fdd93ede updating version number 2025-11-23 11:50:49 -06:00
1e1b1f7c97 updating version number 2025-11-23 11:48:38 -06:00
96b6754f29 updating version number 2025-11-23 11:21:48 -06:00
952b9a806b 96 switch from azure table storage to sqlite (#100)
* adding typeorm to api

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure
2025-11-23 11:18:01 -06:00
42bc48c1df 96 switch from azure table storage to sqlite (#99)
* adding typeorm to api

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure
2025-11-23 11:07:08 -06:00
999f4c1a6a 96 switch from azure table storage to sqlite (#98)
* adding typeorm to api

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure
2025-11-23 10:51:01 -06:00
f98a2ab127 96 switch from azure table storage to sqlite (#97)
* adding typeorm to api

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure
2025-11-23 10:42:31 -06:00
f94d0f7ca9 fixing page totals (#93) 2025-05-25 08:36:03 -05:00
fc5811c3b6 updating version number to 1.4.0 2025-05-16 12:50:26 -05:00
9833bc6593 adding skeleton loader to flights page 2025-05-16 11:23:20 -05:00
8d1842efa7 adding skeleton loader to flights page (#91) 2025-05-16 11:16:07 -05:00
294766b8da add totals to logbook table (#90)
* adding logbook page totals

* adding logbook page totals

* adding logbook page totals

* adding logbook page totals
2025-05-16 10:24:27 -05:00
c5f9dcce73 adding logbook page totals (#89)
* adding logbook page totals

* adding logbook page totals

* adding logbook page totals
2025-05-16 10:14:28 -05:00
a185abf6d8 display multiple kml files on same leaflet map component (#88)
* display multiple kml files on same leaflet map component

* display multiple kml files on same leaflet map component
2025-05-12 19:18:03 -05:00
9f13cc20a5 Upating version number to 1.3.1 2025-05-06 13:06:57 -05:00
3ab9024152 fixing track parsing error for new log entries (#85) 2025-05-06 12:58:00 -05:00
f15008a8db Feature/73 add flights page (#78)
* adding flights page

* adding flights page

* adding flights page

* adding flights page

* removing unused dependencies

* adding flights page
2025-03-23 11:48:41 -05:00
514f970233 Feature/73 add flights page (#77)
* adding flights page

* adding flights page

* adding flights page

* adding flights page

* removing unused dependencies
2025-03-23 11:30:32 -05:00
db94838249 Feature/73 add flights page (#76)
* adding flights page

* adding flights page

* adding flights page

* adding flights page
2025-03-23 11:24:31 -05:00
f76244d00c Feature/73 add flights page (#75)
* adding flights page

* adding flights page

* adding flights page
2025-03-23 11:09:25 -05:00
98f50641c8 adding flights page (#74)
* adding flights page

* adding flights page
2025-03-23 10:29:38 -05:00
0f1e303c27 Feature/71 add leaflet to view tracks drawer (#72)
* adding leaflet

* adding tracks swiper

* adding tracks swiper

* adding tracks swiper

* adding tracks swiper

* adding tracks swiper

* adding tracks swiper
2025-03-22 23:42:08 -05:00
212 changed files with 26248 additions and 16580 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,70 +0,0 @@
name: 'App Build'
on:
workflow_call:
inputs:
environment_name:
required: true
type: string
version_number:
required: true
type: string
env:
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_CLIENT_ID: ${{ vars.VITE_CLIENT_ID }}
VITE_TENANT_ID: ${{ vars.VITE_TENANT_ID }}
VITE_REDIRECT_URL: ${{ vars.VITE_REDIRECT_URL }}
jobs:
build:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
app
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: 'Setup Node'
uses: actions/setup-node@v4
with:
node-version: ${{ vars.NODE_VERSION }}
- name: Install
run: |
pnpm install
- name: Build
run: |
pnpm --filter app build
- name: Deploy
if: ${{ github.event_name != 'pull_request' }}
run: |
pnpm --filter app --prod deploy ./.prod/app
- name: Setup Docker Buildx
if: ${{ github.event_name != 'pull_request' }}
uses: docker/setup-buildx-action@v3
- name: Log into Docker Hub
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push to registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/build-push-action@v6
with:
push: true
tags: noahspan/flying-app:${{ inputs.version_number }}
context: .
target: app

View File

@@ -15,16 +15,11 @@ jobs:
environment: ${{ inputs.environment_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
sparse-checkout: |
api
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
client
- name: Install Nest CLI
run: |
@@ -37,16 +32,15 @@ jobs:
- name: Install dependencies
run: |
pnpm install
npm ci
- name: Build
- name: Build API
run: |
pnpm --filter api build
npm run build -w api
- name: Deploy
if: ${{ github.event_name != 'pull_request' }}
- name: Build Client
run: |
pnpm --filter api --prod deploy ./.prod/api
npm run build -w client
- name: Log into Docker Hub
if: ${{ github.event_name != 'pull_request' }}
@@ -64,6 +58,5 @@ jobs:
uses: docker/build-push-action@v6
with:
push: true
tags: noahspan/flying-api:${{ inputs.version_number }}
tags: noahspan/flying:${{ inputs.version_number }}
context: .
target: api

View File

@@ -4,8 +4,8 @@ on:
outputs:
api:
value: ${{ jobs.changes.outputs.api }}
app:
value: ${{ jobs.changes.outputs.app }}
client:
value: ${{ jobs.changes.outputs.client }}
jobs:
changes:
@@ -13,16 +13,15 @@ jobs:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
app: ${{ steps.filter.outputs.app }}
client: ${{ steps.filter.outputs.client }}
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v3
id: filter
with:
initial-fetch-depth: 2
filters: |
api:
- 'api/**'
app:
- 'app/**'
client:
- 'client/**'

View File

@@ -23,13 +23,13 @@ jobs:
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ vars.VITE_CLIENT_ID }}
client-id: ${{ vars.AZURE_CLIENT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant-id: ${{ vars.VITE_TENANT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
- name: Azure CLI script
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --resource-group noahspan-flying --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --container-name ${{ inputs.app_name }} --resource-group noahspan --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}

View File

@@ -8,48 +8,25 @@ jobs:
changes:
uses: ./.github/workflows/changes.yaml
build-api:
if: ${{ needs.changes.outputs.api == 'true' }}
name: build-api
build:
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.client == 'true' }}
name: build
needs:
- changes
uses: ./.github/workflows/api_build.yaml
uses: ./.github/workflows/build.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
deploy-api:
name: deploy-api
deploy:
name: deploy
needs:
- changes
- build-api
- build
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-api
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
build-app:
if: ${{ needs.changes.outputs.app == 'true' }}
name: build-app
needs:
- changes
uses: ./.github/workflows/app_build.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
deploy-app:
name: deploy-app
needs:
- changes
- build-app
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-app
app_name: flying
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit

View File

@@ -7,23 +7,12 @@ jobs:
changes:
uses: ./.github/workflows/changes.yaml
build-api:
if: ${{ needs.changes.outputs.api == 'true' }}
name: build-api
build:
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.client == 'true'}}
name: build
needs:
- changes
uses: ./.github/workflows/api_build.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}
secrets: inherit
build-app:
if: ${{ needs.changes.outputs.app == 'true' }}
name: build-app
needs:
- changes
uses: ./.github/workflows/app_build.yaml
uses: ./.github/workflows/build.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}

View File

@@ -5,40 +5,21 @@ on:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
build-api:
name: build-api
uses: ./.github/workflows/api_build.yaml
build:
name: build
uses: ./.github/workflows/build.yaml
with:
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit
deploy-api:
name: deploy-api
deploy:
name: deploy
needs:
- build-api
- build
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-api
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit
build-app:
name: build-app
uses: ./.github/workflows/app_build.yaml
with:
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit
deploy-app:
name: deploy-app
needs:
- build-app
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-app
app_name: flying
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit

View File

@@ -1,39 +1,18 @@
# FROM --platform=linux/amd64 node:18-alpine AS base
# ENV PNPM_HOME="/pnpm"
# ENV PATH="$PNPM_HOME:$PATH"
# RUN corepack enable
FROM --platform=linux/amd64 node:22-slim
# FROM base AS api
# COPY /api/dist /app/dist
# COPY /api/node_modules /app/node_modules
# WORKDIR /app
# EXPOSE 3000
# # CMD ["node", "dist/main.js"]
# ENTRYPOINT ["tail", "-f", "/dev/null"]
WORKDIR app
COPY ./api/dist ./api/dist
COPY ./api/package.json package-lock.json ./api
COPY ./client/dist ./client/dist
# FROM base AS app
# COPY /app/dist /app/dist
# WORKDIR /app
# RUN npm i -g serve
# EXPOSE 8080
# CMD [ "serve", "-s", "/dist", "-p", "8080" ]
FROM node:18-slim AS base
FROM base AS api
WORKDIR api
COPY ./.prod/api .
EXPOSE 3000
CMD ["node", "dist/main.js"]
# ENTRYPOINT ["tail", "-f", "/dev/null"]
RUN npm ci
FROM base AS app
WORKDIR /app
COPY ./.prod/app .
RUN npm i -g serve
EXPOSE 8080
CMD [ "serve", "-s", "dist", "-p", "8080" ]
WORKDIR /
COPY ./api/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
# ENTRYPOINT ["tail", "-f", "/dev/null"]

3
api/.gitignore vendored
View File

@@ -57,3 +57,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
local.settings.json
/src/database/*.db

3
api/entrypoint.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
npx typeorm migration:run -d ./app/api/dist/database/data-source.js
node ./app/api/dist/main.js

View File

@@ -1,6 +1,6 @@
{
"name": "api",
"version": "1.2.0",
"version": "2.0.0-alpha-3",
"description": "",
"author": "",
"private": true,
@@ -16,24 +16,40 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "npm run build && npx typeorm -d dist/database/data-source.js",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert"
},
"dependencies": {
"@azure/storage-blob": "^12.27.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/axios": "^3.0.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.1.6",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.6",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/typeorm": "^11.0.0",
"@noahspan/azure-database": "^3.1.2",
"@noahspan/noahspan-modules": "^1.1.5",
"@noahspan/noahspan-modules": "^1.2.9",
"@schematics/angular": "^17.3.7",
"@types/multer": "^1.4.12",
"dotenv": "^16.4.7",
"better-sqlite3": "^12.2.0",
"dotenv": "^16.6.1",
"express-session": "^1.18.2",
"jwks-rsa": "^3.2.0",
"jwt-decode": "^4.0.0",
"node-gyp": "^11.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.25",
"uuid": "^10.0.0",
"uuidv4": "^6.2.13"
},
@@ -41,11 +57,13 @@
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@nestjs/testing": "^11.1.6",
"@types/express": "^4.17.17",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-azure-ad": "^4.3.6",
"@types/passport-openidconnect": "^0.1.3",
"@types/supertest": "^6.0.0",
"jest": "^29.5.0",
"source-map-support": "^0.5.21",

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService]
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,12 +1,17 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from './featureFlag/feature-flag.module'
import { HealthModule } from './health/health.module';
import { LogModule } from './log/log.module';
import { PilotModule } from './pilot/pilot.module';
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthGuard, AuthModule, UserModule } from '@noahspan/noahspan-modules';
import { AuthModule, MsGraphModule } from '@noahspan/noahspan-modules';
import configuration from './config/configuration';
import { TypeOrmModule } from '@nestjs/typeorm';
import { dataSourceOptions } from './database/data-source';
import { TrackModule } from './track/track.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
@@ -15,19 +20,19 @@ import configuration from './config/configuration';
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
clientId: configService.get<string>('clientId'),
clientSecret: configService.get<string>('clientSecret'),
tenantId: configService.get<string>('tenantId')
audience: configService.get<string>('audience'),
issuerUrl: configService.get<string>('issuer'),
jwksUri: configService.get<string>('jwksUri')
};
},
}),
ConfigModule.forRoot({
isGlobal: true,
load: [configuration]
}),
FeatureFlagModule,
HealthModule,
LogModule,
PilotModule,
UserModule.registerAsync({
MsGraphModule.registerAsync({
inject: [ConfigService],
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
@@ -37,13 +42,19 @@ import configuration from './config/configuration';
tenantId: configService.get<string>('tenantId')
}
}
})
}),
PilotModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../..', 'client', 'dist')
}),
TrackModule,
TypeOrmModule.forRoot(dataSourceOptions)
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
}
]
})
export class AppModule {}

View File

@@ -1,5 +0,0 @@
export interface AuthModuleOptions {
tenantId: string;
clientId: string;
clientSecret: string;
}

View File

@@ -1,4 +0,0 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { AuthModuleOptions } from './auth.interface';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<AuthModuleOptions>().build()

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AzureAdStrategy } from './auth.strategy';
import { ConfigurableModuleClass } from './auth.module-definition';
@Module({
imports: [
PassportModule.register({
defaultStrategy: 'azure-ad'
})
],
providers: [AzureAdStrategy]
})
export class AuthModule extends ConfigurableModuleClass {}

View File

@@ -1,25 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { AuthModuleOptions } from './auth.interface'
import { MODULE_OPTIONS_TOKEN } from "./auth.module-definition";
import { BearerStrategy } from 'passport-azure-ad'
@Injectable()
export class AzureAdStrategy extends PassportStrategy(
BearerStrategy,
'azure-ad'
) {
constructor(@Inject(MODULE_OPTIONS_TOKEN) authModuleOptions: AuthModuleOptions) {
super({
identityMetadata: `https://login.microsoftonline.com/${authModuleOptions.tenantId}/.well-known/openid-configuration`,
clientID: authModuleOptions.clientId,
audience: `api://${authModuleOptions.clientId}`,
loggingLevel: 'info',
loggingNoPII: false
})
}
async validate(data: any): Promise<any> {
return data;
}
}

View File

View File

@@ -0,0 +1,21 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { PilotEntity } from '../pilot/pilot.entity';
@Entity({ name: 'certificates'})
export class CertificateEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
type: string;
@Column()
number: string;
@Column()
issueDate: Date
@ManyToOne(() => PilotEntity, (pilot: PilotEntity) => pilot.certificates, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'pilotId' })
pilot: PilotEntity;
}

View File

@@ -1,6 +1,9 @@
export default () => ({
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
audience: process.env.AUDIENCE,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
issuer: process.env.ISSUER_URL,
jwksUri: process.env.JWKS_URI,
tenantId: process.env.TENANT_ID
})

View File

@@ -0,0 +1,19 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
import { ConfigService } from '@nestjs/config';
config();
const configService = new ConfigService();
export const dataSourceOptions: DataSourceOptions = {
type: 'better-sqlite3',
database: configService.get<string>('DB_PATH'),
entities: ['../**/*.entity.js'],
migrations: ['./migrations/*.js'],
synchronize: configService.get<boolean>('DB_SYNC')
}
const dataSource = new DataSource(dataSourceOptions);
export default dataSource;

View File

@@ -0,0 +1,4 @@
dbs:
- path: /var/lib/data/flying.db
replicas:
- path: /mnt/data/backup

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialMigration1758802917932 implements MigrationInterface {
name = 'InitialMigration1758802917932'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "endorsements" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "medical" ("id" varchar PRIMARY KEY NOT NULL, "class" varchar NOT NULL, "expirationDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "pilots" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "address" varchar NOT NULL, "city" varchar NOT NULL, "state" varchar NOT NULL, "postalCode" varchar NOT NULL, "email" varchar NOT NULL, "phone" varchar NOT NULL, "userId" varchar NOT NULL)`);
await queryRunner.query(`CREATE TABLE "logs" ("id" varchar PRIMARY KEY NOT NULL, "date" datetime NOT NULL, "aircraftMakeModel" varchar NOT NULL, "aircraftIdentity" varchar NOT NULL, "routeFrom" varchar NOT NULL, "routeTo" varchar NOT NULL, "durationOfFlight" integer NOT NULL, "singleEngineLand" integer, "simulatorAtd" integer, "landingsDay" integer, "landingsNight" integer, "groundTrainingReceived" integer, "flightTrainingReceived" integer, "crossCountry" integer, "night" integer, "solo" integer, "pilotInCommand" integer, "instrumentActual" integer, "instrumentSimulated" integer, "instrumentApproaches" integer, "instrumentHolds" integer, "instrumentNavTrack" integer, "notes" varchar, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "tracks" ("id" varchar PRIMARY KEY NOT NULL, "url" varchar NOT NULL, "order" integer NOT NULL, "logId" varchar)`);
await queryRunner.query(`CREATE TABLE "temporary_certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar, CONSTRAINT "FK_05a68997dc2d27dfcc642a4cf51" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_certificates"("id", "type", "number", "issueDate", "pilotId") SELECT "id", "type", "number", "issueDate", "pilotId" FROM "certificates"`);
await queryRunner.query(`DROP TABLE "certificates"`);
await queryRunner.query(`ALTER TABLE "temporary_certificates" RENAME TO "certificates"`);
await queryRunner.query(`CREATE TABLE "temporary_endorsements" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar, CONSTRAINT "FK_96a69a0bfbdeccce6bffe34002d" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_endorsements"("id", "type", "issueDate", "pilotId") SELECT "id", "type", "issueDate", "pilotId" FROM "endorsements"`);
await queryRunner.query(`DROP TABLE "endorsements"`);
await queryRunner.query(`ALTER TABLE "temporary_endorsements" RENAME TO "endorsements"`);
await queryRunner.query(`CREATE TABLE "temporary_medical" ("id" varchar PRIMARY KEY NOT NULL, "class" varchar NOT NULL, "expirationDate" datetime NOT NULL, "pilotId" varchar, CONSTRAINT "FK_cb1f5d88fa2b105cc77513e9082" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_medical"("id", "class", "expirationDate", "pilotId") SELECT "id", "class", "expirationDate", "pilotId" FROM "medical"`);
await queryRunner.query(`DROP TABLE "medical"`);
await queryRunner.query(`ALTER TABLE "temporary_medical" RENAME TO "medical"`);
await queryRunner.query(`CREATE TABLE "temporary_logs" ("id" varchar PRIMARY KEY NOT NULL, "date" datetime NOT NULL, "aircraftMakeModel" varchar NOT NULL, "aircraftIdentity" varchar NOT NULL, "routeFrom" varchar NOT NULL, "routeTo" varchar NOT NULL, "durationOfFlight" integer NOT NULL, "singleEngineLand" integer, "simulatorAtd" integer, "landingsDay" integer, "landingsNight" integer, "groundTrainingReceived" integer, "flightTrainingReceived" integer, "crossCountry" integer, "night" integer, "solo" integer, "pilotInCommand" integer, "instrumentActual" integer, "instrumentSimulated" integer, "instrumentApproaches" integer, "instrumentHolds" integer, "instrumentNavTrack" integer, "notes" varchar, "pilotId" varchar, CONSTRAINT "FK_19598551658f7625a82c7f029c8" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_logs"("id", "date", "aircraftMakeModel", "aircraftIdentity", "routeFrom", "routeTo", "durationOfFlight", "singleEngineLand", "simulatorAtd", "landingsDay", "landingsNight", "groundTrainingReceived", "flightTrainingReceived", "crossCountry", "night", "solo", "pilotInCommand", "instrumentActual", "instrumentSimulated", "instrumentApproaches", "instrumentHolds", "instrumentNavTrack", "notes", "pilotId") SELECT "id", "date", "aircraftMakeModel", "aircraftIdentity", "routeFrom", "routeTo", "durationOfFlight", "singleEngineLand", "simulatorAtd", "landingsDay", "landingsNight", "groundTrainingReceived", "flightTrainingReceived", "crossCountry", "night", "solo", "pilotInCommand", "instrumentActual", "instrumentSimulated", "instrumentApproaches", "instrumentHolds", "instrumentNavTrack", "notes", "pilotId" FROM "logs"`);
await queryRunner.query(`DROP TABLE "logs"`);
await queryRunner.query(`ALTER TABLE "temporary_logs" RENAME TO "logs"`);
await queryRunner.query(`CREATE TABLE "temporary_tracks" ("id" varchar PRIMARY KEY NOT NULL, "url" varchar NOT NULL, "order" integer NOT NULL, "logId" varchar, CONSTRAINT "FK_71881df31cfff2362e39accc4b2" FOREIGN KEY ("logId") REFERENCES "logs" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_tracks"("id", "url", "order", "logId") SELECT "id", "url", "order", "logId" FROM "tracks"`);
await queryRunner.query(`DROP TABLE "tracks"`);
await queryRunner.query(`ALTER TABLE "temporary_tracks" RENAME TO "tracks"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tracks" RENAME TO "temporary_tracks"`);
await queryRunner.query(`CREATE TABLE "tracks" ("id" varchar PRIMARY KEY NOT NULL, "url" varchar NOT NULL, "order" integer NOT NULL, "logId" varchar)`);
await queryRunner.query(`INSERT INTO "tracks"("id", "url", "order", "logId") SELECT "id", "url", "order", "logId" FROM "temporary_tracks"`);
await queryRunner.query(`DROP TABLE "temporary_tracks"`);
await queryRunner.query(`ALTER TABLE "logs" RENAME TO "temporary_logs"`);
await queryRunner.query(`CREATE TABLE "logs" ("id" varchar PRIMARY KEY NOT NULL, "date" datetime NOT NULL, "aircraftMakeModel" varchar NOT NULL, "aircraftIdentity" varchar NOT NULL, "routeFrom" varchar NOT NULL, "routeTo" varchar NOT NULL, "durationOfFlight" integer NOT NULL, "singleEngineLand" integer, "simulatorAtd" integer, "landingsDay" integer, "landingsNight" integer, "groundTrainingReceived" integer, "flightTrainingReceived" integer, "crossCountry" integer, "night" integer, "solo" integer, "pilotInCommand" integer, "instrumentActual" integer, "instrumentSimulated" integer, "instrumentApproaches" integer, "instrumentHolds" integer, "instrumentNavTrack" integer, "notes" varchar, "pilotId" varchar)`);
await queryRunner.query(`INSERT INTO "logs"("id", "date", "aircraftMakeModel", "aircraftIdentity", "routeFrom", "routeTo", "durationOfFlight", "singleEngineLand", "simulatorAtd", "landingsDay", "landingsNight", "groundTrainingReceived", "flightTrainingReceived", "crossCountry", "night", "solo", "pilotInCommand", "instrumentActual", "instrumentSimulated", "instrumentApproaches", "instrumentHolds", "instrumentNavTrack", "notes", "pilotId") SELECT "id", "date", "aircraftMakeModel", "aircraftIdentity", "routeFrom", "routeTo", "durationOfFlight", "singleEngineLand", "simulatorAtd", "landingsDay", "landingsNight", "groundTrainingReceived", "flightTrainingReceived", "crossCountry", "night", "solo", "pilotInCommand", "instrumentActual", "instrumentSimulated", "instrumentApproaches", "instrumentHolds", "instrumentNavTrack", "notes", "pilotId" FROM "temporary_logs"`);
await queryRunner.query(`DROP TABLE "temporary_logs"`);
await queryRunner.query(`ALTER TABLE "medical" RENAME TO "temporary_medical"`);
await queryRunner.query(`CREATE TABLE "medical" ("id" varchar PRIMARY KEY NOT NULL, "class" varchar NOT NULL, "expirationDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`INSERT INTO "medical"("id", "class", "expirationDate", "pilotId") SELECT "id", "class", "expirationDate", "pilotId" FROM "temporary_medical"`);
await queryRunner.query(`DROP TABLE "temporary_medical"`);
await queryRunner.query(`ALTER TABLE "endorsements" RENAME TO "temporary_endorsements"`);
await queryRunner.query(`CREATE TABLE "endorsements" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`INSERT INTO "endorsements"("id", "type", "issueDate", "pilotId") SELECT "id", "type", "issueDate", "pilotId" FROM "temporary_endorsements"`);
await queryRunner.query(`DROP TABLE "temporary_endorsements"`);
await queryRunner.query(`ALTER TABLE "certificates" RENAME TO "temporary_certificates"`);
await queryRunner.query(`CREATE TABLE "certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`INSERT INTO "certificates"("id", "type", "number", "issueDate", "pilotId") SELECT "id", "type", "number", "issueDate", "pilotId" FROM "temporary_certificates"`);
await queryRunner.query(`DROP TABLE "temporary_certificates"`);
await queryRunner.query(`DROP TABLE "tracks"`);
await queryRunner.query(`DROP TABLE "logs"`);
await queryRunner.query(`DROP TABLE "pilots"`);
await queryRunner.query(`DROP TABLE "medical"`);
await queryRunner.query(`DROP TABLE "endorsements"`);
await queryRunner.query(`DROP TABLE "certificates"`);
}
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Delete, Get, HttpException, Param, Post, Put } from "@nestjs/common";
import { EndorsementService } from "./endorsement.service";
import { CustomError } from "@noahspan/noahspan-modules";
import { EndorsementDto } from "./endorsement.dto";
@Controller('endorsements')
export class EndorsementController {
constructor(private readonly endorsementService: EndorsementService) {}
@Get(':id')
async find(@Param('id') id: string) {
try {
return await this.endorsementService.find(id);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get()
async findAll() {
try {
return await this.endorsementService.findAll();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Post()
async create(@Body() endorsementDto: EndorsementDto) {
try {
return await this.endorsementService.create(endorsementDto);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Put(':id')
async update(@Param('id') id: string, @Body() endorsementDto: EndorsementDto) {
try {
return await this.endorsementService.update(id, endorsementDto);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode)
}
}
@Delete(':id')
async delete(@Param('id') id: string) {
try {
return await this.endorsementService.delete(id);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode)
}
}
}

View File

@@ -0,0 +1,3 @@
export class EndorsementDto {
}

View File

@@ -0,0 +1,18 @@
import { PilotEntity } from 'src/pilot/pilot.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'endorsements' })
export class EndorsementEntity {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
type: string;
@Column()
issueDate: Date;
@ManyToOne(() => PilotEntity, (pilot: PilotEntity) => pilot.endorsements, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'pilotId' })
pilot: PilotEntity
}

View File

@@ -0,0 +1,32 @@
import { InjectRepository } from "@nestjs/typeorm";
import { EndorsementEntity } from "./endorsement.entity";
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { Injectable } from "@nestjs/common";
import { EndorsementDto } from "./endorsement.dto";
@Injectable()
export class EndorsementService {
constructor(
@InjectRepository(EndorsementEntity) private readonly endorsementRepository: Repository<EndorsementEntity>
) {}
async find(id: string): Promise<EndorsementEntity> {
return await this.endorsementRepository.findOneBy({ id });
}
async findAll(): Promise<EndorsementEntity[]> {
return await this.endorsementRepository.find();
}
async create(endorsement: EndorsementDto): Promise<InsertResult> {
return await this.endorsementRepository.insert(endorsement);
}
async update(id: string, endorsement: EndorsementDto): Promise<UpdateResult> {
return await this.endorsementRepository.update(id, endorsement);
}
async delete(id: string): Promise<DeleteResult> {
return await this.endorsementRepository.delete({ id });
}
}

View File

@@ -1,41 +0,0 @@
import {
Controller,
Get,
HttpException,
Param,
UseGuards,
} from '@nestjs/common';
import { FeatureFlagService } from './feature-flag.service';
import { CustomError } from '../error/customError';
import { AuthGuard } from '@nestjs/passport';
@Controller('featureFlags')
@UseGuards(AuthGuard('azure-ad'))
export class FeatureFlagController {
constructor(private readonly featureFlagService: FeatureFlagService) {}
@Get(':partitionKey/:rowKey')
async find(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
) {
try {
return await this.featureFlagService.find(partitionKey, rowKey);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get()
async findAll() {
try {
return await this.featureFlagService.findAll();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -1,5 +0,0 @@
export class FeatureFlagDto {
partitionKey: string;
rowKey: string;
active: string;
}

View File

@@ -1,7 +0,0 @@
import { EntityString } from '@noahspan/azure-database';
export class FeatureFlag {
@EntityString() partitionKey: string;
@EntityString() rowKey: string;
@EntityString() active: string;
}

View File

@@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { FeatureFlagController } from './feature-flag.controller';
import { FeatureFlagService } from './feature-flag.service';
import { AzureTableStorageModule } from '@noahspan/azure-database';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FeatureFlag } from './feature-flag.entity';
@Module({
imports: [
AzureTableStorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
connectionString: configService.get<string>('azureStorageConnectionString')
};
},
inject: [ConfigService]
}),
AzureTableStorageModule.forFeature(FeatureFlag, {
createTableIfNotExists: false,
table: 'featureFlags'
}),
],
controllers: [FeatureFlagController],
providers: [FeatureFlagService]
})
export class FeatureFlagModule {}

View File

@@ -1,18 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository, Repository } from '@noahspan/azure-database';
import { FeatureFlag } from './feature-flag.entity';
@Injectable()
export class FeatureFlagService {
constructor(
@InjectRepository(FeatureFlag) private readonly featureFlagRepository: Repository<FeatureFlag>
) {}
async find(partitionKey: string, rowKey: string): Promise<FeatureFlag> {
return await this.featureFlagRepository.find(partitionKey, rowKey);
}
async findAll(): Promise<FeatureFlag[]> {
return await this.featureFlagRepository.findAll();
}
}

View File

@@ -37,10 +37,10 @@ import { ConfigService } from '@nestjs/config';
return blockBlobClient;
}
async uploadFile(file: Express.Multer.File, containerName: string, rowKey: string): Promise<string> {
async uploadFile(file: Express.Multer.File, containerName: string, logId: string): Promise<string> {
this.containerName = containerName;
const blockBlobClient = await this.getBlobClient(`${rowKey}/${file.originalname}`);
const blockBlobClient = await this.getBlobClient(`${logId}/${file.originalname}`);
const fileUrl = blockBlobClient.url;
await blockBlobClient.uploadData(file.buffer);
@@ -58,11 +58,32 @@ import { ConfigService } from '@nestjs/config';
return downloaded
}
async deleteFile(containerName: string, rowKey:string, fileName: string): Promise<void> {
async deleteFile(containerName: string, logId: string, fileName: string): Promise<void> {
this.containerName = containerName;
const blockBlobClient = await this.getBlobClient(`${rowKey}/${fileName}`);
const blockBlobClient = await this.getBlobClient(`${logId}/${fileName}`);
await blockBlobClient.deleteIfExists();
}
async deleteFolder(containerName: string, logId: string): Promise<void> {
const blobService = await this.getBlobServiceInstance();
this.containerName = containerName;
const containerClient = blobService.getContainerClient(containerName);
const blobsToDelete = []
for await (const blob of containerClient.listBlobsFlat({ prefix: logId })) {
blobsToDelete.push(blob.name)
}
for (const blobName of blobsToDelete) {
const blockBlobClient = containerClient.getBlockBlobClient(blobName);
await blockBlobClient.delete();
}
return;
}
}

View File

@@ -0,0 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
describe('HealthController', () => {
let controller; HealthController;
const mockHealthService = {
isDatabaseConnected: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [HealthService]
}).compile();
controller = module.get<HealthController>(HealthController);
})
it('isHealthy => should return true', () => {
expect(controller).toBeDefined();
});
it('should return database connected', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(true);
const result = await controller.isHealthy();
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(true);
})
it('isHealthy => should return error', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(false);
const result = await controller.isHealthy();
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(false);
})
})

View File

@@ -0,0 +1,27 @@
import {
Controller,
Get,
HttpException,
} from '@nestjs/common';
import { HealthService } from './health.service';
import { CustomError } from 'src/error/customError';
@Controller('health')
export class HealthController {
constructor(
private readonly healthService: HealthService
) {}
@Get()
async isHealthy(): Promise<boolean> {
try {
return await this.healthService.isDatabaseConnected();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [
HealthService
]
})
export class HealthModule {}

View File

@@ -0,0 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthService } from './health.service';
describe('HealthService', () => {
let service: HealthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HealthService]
}).compile();
service = module.get<HealthService>(HealthService);
});
});

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { CustomError } from '../error/customError';
import { DataSource } from 'typeorm';
@Injectable()
export class HealthService {
constructor(private dataSource: DataSource) {}
async isDatabaseConnected(): Promise<boolean> {
try {
const isDatabaseConnected: boolean = this.dataSource.isInitialized;
if (isDatabaseConnected) {
return isDatabaseConnected;
} else {
throw new CustomError('Database not connected', 'Database not connected', 400)
}
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,5 @@
import { JwtPayload } from "jwt-decode";
export interface CustomJwtPayload extends JwtPayload {
roles: string[];
}

View File

@@ -1,41 +0,0 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
export class LogInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
console.log(token)
if (!token) {
return handler.handle().pipe(
map((data) => {
if (data.length) {
const logs = data.map((log) => {
return {
partitionKey: log.partitionKey,
rowKey: log.rowKey,
pilotId: log.pilotId,
pilotName: log.pilotName,
date: log.date,
aircraftMakeModel: log.aircraftMakeModel,
routeFrom: log.routeFrom,
routeTo: log.routeTo,
durationOfFlight: log.durationOfFlight,
tracks: log.tracks,
notes: log.notes
};
});
return logs;
} else {
return data;
}
})
);
}
return handler.handle().pipe(map((data) => data));
}
}

View File

@@ -7,22 +7,23 @@ import {
Param,
Post,
Put,
Query,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { LogDto } from './log.dto';
import { Log } from './log.entity';
import { LogEntity } from './log.entity';
import { LogService } from './log.service';
import { CustomError } from '../error/customError';
import { AuthGuard } from '@noahspan/noahspan-modules';
import { LogInterceptor } from './interceptors/log.interceptor';
import { AuthGuard, Public } from '@noahspan/noahspan-modules';
import { LogInterceptor } from './log.interceptor';
import { FileService } from '../file/file.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { DeleteResult, InsertResult, UpdateResult } from 'typeorm';
import { Reflector } from '@nestjs/core';
const reflector = new Reflector();
@Controller('logs')
@UseInterceptors(new LogInterceptor(reflector))
export class LogController {
constructor(
private readonly fileService: FileService,
@@ -30,14 +31,13 @@ export class LogController {
) {}
@Get(':partitionKey/:rowKey')
@UseInterceptors(new LogInterceptor())
@Get(':id')
@Public()
async find(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
): Promise<Log> {
@Param('id') id: string,
): Promise<LogEntity> {
try {
return await this.logService.find(partitionKey, rowKey);
return await this.logService.find(id);
} catch (error) {
const customError = error as CustomError;
@@ -46,8 +46,8 @@ export class LogController {
}
@Get()
@UseInterceptors(new LogInterceptor())
async findAll(): Promise<Log[]> {
@Public()
async findAll(): Promise<LogEntity[]> {
try {
return await this.logService.findAll();
} catch (error) {
@@ -57,66 +57,12 @@ export class LogController {
}
}
@UseGuards(AuthGuard)
@Post()
async create(@Body() logDto: LogDto): Promise<Log> {
try {
const log = new Log();
Object.assign(log, logDto);
return await this.logService.create(log);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Put(':partitionKey/:rowKey')
async update(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string,
@Body() logDto: LogDto
): Promise<Log> {
try {
const log = new Log();
Object.assign(log, logDto);
return await this.logService.update(partitionKey, rowKey, log);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Delete(':partitionKey/:rowKey')
async delete(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
): Promise<void> {
try {
return await this.logService.delete(partitionKey, rowKey);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Post(':partitionKey/:rowKey/track')
@UseInterceptors(FileInterceptor('file'))
async createTrack(@Param('rowKey') rowKey: string, @UploadedFile() file: Express.Multer.File) {
try {
const containerName = 'tracks';
const url = await this.fileService.uploadFile(file, containerName, rowKey);
return { url }
@Post()
@UseGuards(AuthGuard)
async create(@Body() logDto: LogDto): Promise<InsertResult> {
try {
return await this.logService.create(logDto);
} catch (error) {
const customError = error as CustomError;
@@ -124,21 +70,28 @@ export class LogController {
}
}
@Get(':partitionKey/:rowKey/track')
async downloadTrack(@Param('rowKey') rowKey: string, @Query('fileName') fileName: string): Promise<string> {
const containerName = 'tracks';
const downloadedFile: string = await this.fileService.downloadFile(containerName, rowKey, fileName)
@Put(':id')
@UseGuards(AuthGuard)
async update(
@Param('id') id: string,
@Body() logDto: LogDto
): Promise<UpdateResult> {
try {
return await this.logService.update(id, logDto);
} catch (error) {
const customError = error as CustomError;
return downloadedFile;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Delete(':id')
@UseGuards(AuthGuard)
@Delete(':partitionKey/:rowKey/track')
async deleteTrack(@Param('rowKey') rowKey: string, @Query('fileName') fileName: string): Promise<void> {
async delete(
@Param('id') id: string,
): Promise<DeleteResult> {
try {
const containerName = 'tracks';
return await this.fileService.deleteFile(containerName, rowKey, fileName)
return await this.logService.delete(id);
} catch (error) {
const customError = error as CustomError;

View File

@@ -1,27 +1,28 @@
import { PilotEntity } from "src/pilot/pilot.entity";
export class LogDto {
pilotId: string;
pilotName: string;
date: string;
date: Date;
aircraftMakeModel: string;
aircraftIdentity: string;
routeFrom: string;
routeTo: string;
durationOfFlight: number;
singleEngineLand: string;
simulatorAtd: number;
landingsDay: number;
landingsNight: number;
instrumentActual: number;
instrumentSimulated: number;
instrumentApproaches: number;
instrumentHolds: number;
instrumentNavTrack: number;
groundTrainingReceived: number;
flightTrainingReceived: number;
crossCountry: number;
night: number;
solo: number;
pilotInCommand: number;
tracks: string[];
notes: string;
durationOfFlight?: number;
singleEngineLand?: number;
simulatorAtd?: number;
landingsDay?: number;
landingsNight?: number;
instrumentActual?: number;
instrumentSimulated?: number;
instrumentApproaches?: number;
instrumentHolds?: number;
instrumentNavTrack?: number;
groundTrainingReceived?: number;
flightTrainingReceived?: number;
crossCountry?: number;
night?: number;
solo?: number;
pilotInCommand?: number;
notes?: string;
tracks?: []
}

View File

@@ -1,29 +1,85 @@
export class Log {
partitionKey: string;
rowKey: string;
import { PilotEntity } from 'src/pilot/pilot.entity';
import { TrackEntity } from 'src/track/track.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
@Entity({ name: 'logs' })
export class LogEntity {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
pilotId: string;
pilotName: string;
date: string;
@Column()
date: Date;
@Column()
aircraftMakeModel: string;
@Column()
aircraftIdentity: string;
@Column()
routeFrom: string;
@Column()
routeTo: string;
durationOfFlight: number | null;
@Column()
durationOfFlight: number;
@Column({ nullable: true })
singleEngineLand: number | null;
simulatorAtd?: number | null;
landingsDay?: number | null;
landingsNight?: number | null;
groundTrainingReceived?: number;
flightTrainingReceived?: number;
crossCountry?: number | null;
night?: number | null;
solo?: number | null;
pilotInCommand?: number | null;
instrumentActual?: number | null;
instrumentSimulated?: number | null;
instrumentApproaches?: number | null;
instrumentHolds?: number | null;
instrumentNavTrack?: number | null;
tracks?: string[];
notes?: string;
}
@Column({ nullable: true })
simulatorAtd: number | null;
@Column({ nullable: true })
landingsDay: number | null;
@Column({ nullable: true })
landingsNight: number | null;
@Column({ nullable: true })
groundTrainingReceived: number | null;
@Column({ nullable: true })
flightTrainingReceived: number | null;
@Column({ nullable: true })
crossCountry: number | null;
@Column({ nullable: true })
night: number | null;
@Column({ nullable: true })
solo: number | null;
@Column({ nullable: true })
pilotInCommand: number | null;
@Column({ nullable: true })
instrumentActual: number | null;
@Column({ nullable: true })
instrumentSimulated: number | null;
@Column({ nullable: true })
instrumentApproaches: number | null;
@Column({ nullable: true })
instrumentHolds: number | null;
@Column({ nullable: true })
instrumentNavTrack: number | null;
@Column({ nullable: true })
notes: string | null;
@OneToMany(() => TrackEntity, (track: TrackEntity) => track.log)
tracks: TrackEntity[]
@ManyToOne(() => PilotEntity, (pilot: PilotEntity) => pilot.logs, {onDelete: 'CASCADE', onUpdate: 'CASCADE'})
@JoinColumn({ name: 'pilotId' })
pilot: PilotEntity;
}

View File

@@ -0,0 +1,59 @@
import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { LogEntity } from './log.entity';
import { jwtDecode } from 'jwt-decode';
import { CustomJwtPayload } from 'src/interfaces/customJwtPayload.interface';
import { IS_PUBLIC_KEY } from '@noahspan/noahspan-modules';
import { Reflector } from '@nestjs/core';
export class LogInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return handler.handle().pipe(
map((data: LogEntity[]) => {
const req = context.switchToHttp().getRequest();
const limitData = (data) => {
return data.map((log: LogEntity) => {
return {
id: log.id,
pilot: {
name: log.pilot.name
},
date: log.date,
aircraftMakeModel: log.aircraftMakeModel,
routeFrom: log.routeFrom,
routeTo: log.routeTo,
durationOfFlight: log.durationOfFlight,
tracks: log.tracks,
};
});
}
if (req.headers.authorization) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const jwtPayload: CustomJwtPayload = jwtDecode(token);
if (jwtPayload.roles.includes('Flying.Read')) {
const logs = limitData(data);
return logs;
} else {
return data;
}
} else if (!req.headers.authorization && isPublic) {
const publicData = limitData(data)
const logs = publicData.slice(0, 5)
return logs;
}
})
);
}
}

View File

@@ -1,28 +1,19 @@
import { Module } from '@nestjs/common';
import { LogController } from './log.controller';
import { LogService } from './log.service';
import { AzureTableStorageModule } from '@noahspan/azure-database';
import { Log } from './log.entity';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LogEntity } from './log.entity';
import { ConfigService } from '@nestjs/config';
import { FileService } from '../file/file.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PilotModule } from 'src/pilot/pilot.module';
@Module({
imports: [
AzureTableStorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
connectionString: configService.get<string>('azureStorageConnectionString')
};
},
inject: [ConfigService]
}),
AzureTableStorageModule.forFeature(Log, {
createTableIfNotExists: false,
table: 'logs'
}),
PilotModule,
TypeOrmModule.forFeature([LogEntity])
],
controllers: [LogController],
exports: [LogService],
providers: [
ConfigService,
FileService,

View File

@@ -1,34 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository, Repository } from '@noahspan/azure-database';
import { Log } from './log.entity';
import { v4 as uuidv4 } from 'uuid';
import { InjectRepository } from '@nestjs/typeorm';
import { LogEntity } from './log.entity';
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { LogDto } from './log.dto';
import { PilotService } from 'src/pilot/pilot.service';
import { PilotEntity } from 'src/pilot/pilot.entity';
import { CustomError } from 'src/error/customError';
import { FileService } from 'src/file/file.service';
@Injectable()
export class LogService {
constructor(
@InjectRepository(Log) private readonly logRepository: Repository<Log>
@InjectRepository(LogEntity) private readonly logRepository: Repository<LogEntity>,
private readonly fileService: FileService,
private readonly pilotService: PilotService,
) {}
async find(partitionKey: string, rowKey: string): Promise<Log> {
return await this.logRepository.find(partitionKey, rowKey);
async find(id: string): Promise<LogEntity> {
const logEntity: LogEntity = await this.logRepository.findOne({
where: { id: id },
relations: ['pilot', 'tracks']
});
return logEntity;
}
async findAll(): Promise<Log[]> {
return await this.logRepository.findAll();
async findAll(): Promise<LogEntity[]> {
return await this.logRepository.find({
relations: ['pilot', 'tracks']
});
}
async create(log: Log): Promise<Log> {
log.partitionKey = 'log';
log.rowKey = uuidv4();
async create(logDto: LogDto): Promise<InsertResult> {
try{
const pilotEntity: PilotEntity = await this.pilotService.find(logDto.pilotId);
return await this.logRepository.create(log);
if (pilotEntity) {
const { pilotId, ...newLogDto } = logDto;
const log = this.logRepository.create({
...newLogDto,
pilot: pilotEntity
})
return this.logRepository.insert(log);
} else {
throw new CustomError('Pilot not found', 'Not found', 404);
}
} catch (error) {
throw error
}
}
async update(partitionKey: string, rowKey: string, log: Log): Promise<Log> {
return await this.logRepository.update(partitionKey, rowKey, log);
async update(id: string, log: LogDto): Promise<UpdateResult> {
return await this.logRepository.update(id, log);
}
async delete(partitionKey: string, rowKey: string): Promise<void> {
await this.logRepository.delete(partitionKey, rowKey);
async delete(id: string): Promise<DeleteResult> {
await this.fileService.deleteFolder('tracks', id);
return await this.logRepository.delete({ id });
}
}

View File

@@ -3,15 +3,26 @@ import { AppModule } from './app.module';
import { HttpService } from '@nestjs/axios';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { InternalServerErrorException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import * as session from 'express-session';
async function bootstrap() {
const httpService = new HttpService();
const app = await NestFactory.create(AppModule);
app.enableCors();
app.enableCors({
origin: 'http://localhost:8080', // Allow requests from your frontend's origin
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // If you need to send cookies or authorization headers
});
app.setGlobalPrefix('api');
app.useGlobalFilters(new HttpExceptionFilter());
app.use(
session({
secret: 'blah',
resave: false,
saveUninitialized: false
})
)
httpService.axiosRef.interceptors.response.use(
(response) => {

View File

@@ -0,0 +1,71 @@
import { Body, Controller, Delete, Get, HttpException, Param, Post, Put, UseGuards } from "@nestjs/common";
import { MedicalService } from "./medical.service";
import { CustomError } from "src/error/customError";
import { MedicalDto } from "./medical.dto";
import { AuthGuard } from "@noahspan/noahspan-modules";
@Controller('medical')
export class MedicalController {
constructor(private readonly medicalService: MedicalService) {}
@UseGuards(AuthGuard)
@Get(':id')
async find(@Param('id') id: string) {
try {
return await this.medicalService.find(id)
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Get()
async findAll() {
try {
return await this.medicalService.findAll();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Post()
async create(@Body() medicalDto: MedicalDto) {
try {
return await this.medicalService.create(medicalDto);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Put(':id')
async update(@Param('id') id: string, @Body() medicalDto: MedicalDto) {
try {
return await this.medicalService.update(id, medicalDto);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Delete(':id')
async delete(@Param('id') id: string) {
try {
return await this.medicalService.delete(id);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -0,0 +1,6 @@
export class MedicalDto {
id: string;
class: string;
expirationDate: string;
pilotId: string;
}

View File

@@ -0,0 +1,18 @@
import { PilotEntity } from 'src/pilot/pilot.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'medical' })
export class MedicalEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
class: string;
@Column()
expirationDate: Date;
@ManyToOne(() => PilotEntity, (pilot: PilotEntity) => pilot.medical, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'pilotId' })
pilot: PilotEntity;
}

View File

View File

@@ -0,0 +1,33 @@
import { MedicalEntity } from "./medical.entity";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { MedicalDto } from "./medical.dto";
@Injectable()
export class MedicalService {
constructor(
@InjectRepository(MedicalEntity) private readonly medicalRepository: Repository<MedicalEntity>
) {}
async find(id: string): Promise<MedicalEntity> {
return await this.medicalRepository.findOneBy({ id });
}
async findAll(): Promise<MedicalEntity[]> {
return await this.medicalRepository.find();
}
async create(medical: MedicalDto): Promise<InsertResult> {
return await this.medicalRepository.insert(medical)
}
async update(id: string, medical: MedicalDto): Promise<UpdateResult> {
return await this.medicalRepository.update(id, medical);
}
async delete(id: string): Promise<DeleteResult> {
return await this.medicalRepository.delete({ id });
}
}

View File

@@ -1,5 +0,0 @@
export class Certificate {
type: string;
issueDate: string;
number: string;
}

View File

@@ -1,4 +0,0 @@
export class Endorsement {
type: string;
issueDate: Date;
}

View File

@@ -1,35 +0,0 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
export class PilotInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return handler.handle().pipe(
map((data) => {
if (data.length) {
const pilots = data.map((pilot) => {
return {
partitionKey: pilot.partitionKey,
rowKey: pilot.rowKey,
id: pilot.id,
name: pilot.name,
certificates: pilot.certificates,
endorsements: pilot.endorsements
};
});
return pilots;
} else {
return data;
}
})
);
}
return handler.handle().pipe(map((data) => data));
}
}

View File

@@ -11,24 +11,24 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { PilotDto } from './pilot.dto';
import { Pilot } from './pilot.entity';
import { PilotService } from './pilot.service';
import { CustomError } from '../error/customError';
import { AuthGuard } from '@noahspan/noahspan-modules'
import { PilotInterceptor } from './interceptors/pilot.interceptor';
import { PilotInterceptor } from './pilot.interceptor';
import { AuthGuard, Public } from '@noahspan/noahspan-modules';
import { Reflector } from '@nestjs/core';
const reflector = new Reflector();
@Controller('pilots')
@UseInterceptors(new PilotInterceptor())
@UseInterceptors(new PilotInterceptor(reflector))
export class PilotController {
constructor(private readonly pilotService: PilotService) {}
@Get(':partitionKey/:rowKey')
async find(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
) {
@Get(':id')
@Public()
async find(@Param('id') id: string) {
try {
return await this.pilotService.find(partitionKey, rowKey);
return await this.pilotService.find(id);
} catch (error) {
const customError = error as CustomError;
@@ -37,6 +37,7 @@ export class PilotController {
}
@Get()
@Public()
async findAll() {
try {
return await this.pilotService.findAll();
@@ -47,30 +48,12 @@ export class PilotController {
}
}
@UseGuards(AuthGuard)
@Post()
@UseGuards(AuthGuard)
async create(@Body() pilotDto: PilotDto) {
try {
let pilot = new Pilot();
pilot = {
partitionKey: pilotDto.partitionKey,
rowKey: pilotDto.rowKey,
id: pilotDto.id,
name: pilotDto.name,
address: pilotDto.address,
city: pilotDto.city,
state: pilotDto.state,
postalCode: pilotDto.postalCode,
email: pilotDto.email,
phone: pilotDto.phone,
medicalClass: pilotDto.medicalClass,
medicalExpiration: pilotDto.medicalExpiration,
certificates: JSON.stringify(pilotDto.certificates),
endorsements: JSON.stringify(pilotDto.endorsements)
}
return await this.pilotService.create(pilot);
return await this.pilotService.create(pilotDto);
} catch (error) {
const customError = error as CustomError;
@@ -78,34 +61,14 @@ export class PilotController {
}
}
@Put(':id')
@UseGuards(AuthGuard)
@Put(':partitionKey/:rowKey')
async update(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string,
@Param('id') id: string,
@Body() pilotDto: PilotDto
) {
try {
let pilot = new Pilot();
pilot = {
partitionKey: pilotDto.partitionKey,
rowKey: pilotDto.rowKey,
id: pilotDto.id,
name: pilotDto.name,
address: pilotDto.address,
city: pilotDto.city,
state: pilotDto.state,
postalCode: pilotDto.postalCode,
email: pilotDto.email,
phone: pilotDto.phone,
medicalClass: pilotDto.medicalClass,
medicalExpiration: pilotDto.medicalExpiration,
certificates: JSON.stringify(pilotDto.certificates),
endorsements: JSON.stringify(pilotDto.endorsements)
}
return await this.pilotService.update(partitionKey, rowKey, pilot);
return await this.pilotService.update(id, pilotDto);
} catch (error) {
const customError = error as CustomError;
@@ -113,14 +76,13 @@ export class PilotController {
}
}
@Delete(':id')
@UseGuards(AuthGuard)
@Delete(':partitionKey/:rowKey')
async delete(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
@Param('id') id: string,
) {
try {
return await this.pilotService.delete(partitionKey, rowKey);
return await this.pilotService.delete(id);
} catch (error) {
const customError = error as CustomError;

View File

@@ -1,10 +1,6 @@
import { Certificate } from "./certificate/certificate.entity";
import { Endorsement } from "./endorsement/endorsement.entity";
import { LogEntity } from "src/log/log.entity";
export class PilotDto {
partitionKey: string;
rowKey: string;
id: string;
name: string;
address: string;
city: string;
@@ -12,8 +8,4 @@ export class PilotDto {
postalCode: string;
email?: string;
phone?: string;
medicalClass?: string;
medicalExpiration?: string;
certificates: Certificate;
endorsements: Endorsement
}

View File

@@ -1,18 +1,47 @@
import { EntityString } from '@noahspan/azure-database';
import { LogEntity } from 'src/log/log.entity';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { CertificateEntity } from '../certificate/certificate.entity';
import { EndorsementEntity } from 'src/endorsement/endorsement.entity';
import { MedicalEntity } from 'src/medical/medical.entity';
export class Pilot {
@EntityString() partitionKey: string;
@EntityString() rowKey: string;
@EntityString() id: string;
@EntityString() name: string;
@EntityString() address?: string;
@EntityString() city?: string;
@EntityString() state?: string;
@EntityString() postalCode?: string;
@EntityString() email?: string;
@EntityString() phone?: string;
@EntityString() medicalClass?: string;
@EntityString() medicalExpiration: string;
@EntityString() certificates: string;
@EntityString() endorsements: string;
@Entity({ name: 'pilots' })
export class PilotEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
address: string
@Column()
city: string;
@Column()
state: string;
@Column()
postalCode: string;
@Column()
email: string;
@Column()
phone: string;
@Column()
userId: string | null;
@OneToMany(() => LogEntity, (log: LogEntity) => log.pilot)
logs: LogEntity[];
@OneToMany(() => CertificateEntity, (certificate: CertificateEntity) => certificate.pilot)
certificates: CertificateEntity[];
@OneToMany(() => EndorsementEntity, (endorsement: EndorsementEntity) => endorsement.pilot)
endorsements: EndorsementEntity[];
@OneToMany(() => MedicalEntity, (medical: MedicalEntity) => medical.pilot)
medical: MedicalEntity[];
}

View File

@@ -0,0 +1,51 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { jwtDecode } from 'jwt-decode';
import { Observable, map } from 'rxjs';
import { CustomJwtPayload } from 'src/interfaces/customJwtPayload.interface';
import { PilotEntity } from './pilot.entity';
import { IS_PUBLIC_KEY } from '@noahspan/noahspan-modules';
import { Reflector } from '@nestjs/core';
export class PilotInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return handler.handle().pipe(
map((data: PilotEntity[]) => {
const req = context.switchToHttp().getRequest();
const limitData = (data) => {
return data.map((pilot: PilotEntity) => {
return {
id: pilot.id,
name: pilot.name
};
})
}
if (req.headers.authorization) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const jwtPayload: CustomJwtPayload = jwtDecode(token);
if (jwtPayload.roles.includes('Flying.Read')) {
const pilots = limitData(data)
return pilots;
} else {
return data;
}
} else if (!req.headers.authorization && isPublic) {
const publicData = limitData(data);
const logs = publicData.slice(0,5)
return logs;
}
})
);
}
}

View File

@@ -1,41 +1,15 @@
import { Module } from '@nestjs/common';
import { PilotController } from './pilot.controller';
import { PilotService } from './pilot.service';
import { AzureTableStorageModule } from '@noahspan/azure-database';
import { Pilot } from './pilot.entity';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Log } from 'src/log/log.entity';
import { PilotEntity } from './pilot.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
AzureTableStorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
connectionString: configService.get<string>('azureStorageConnectionString')
};
},
inject: [ConfigService]
}),
AzureTableStorageModule.forFeature(Log, {
createTableIfNotExists: false,
table: 'logs'
}),
AzureTableStorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
connectionString: configService.get<string>('azureStorageConnectionString')
};
},
inject: [ConfigService]
}),
AzureTableStorageModule.forFeature(Pilot, {
createTableIfNotExists: false,
table: 'pilots'
})
TypeOrmModule.forFeature([PilotEntity])
],
controllers: [PilotController],
exports: [PilotService],
providers: [PilotService]
})
export class PilotModule {}

View File

@@ -1,54 +1,46 @@
import { InjectRepository, Repository } from '@noahspan/azure-database';
import { Inject, Injectable } from '@nestjs/common';
import { Pilot } from './pilot.entity';
import { Log } from 'src/log/log.entity';
import { LogService } from 'src/log/log.service';
import { Injectable } from '@nestjs/common';
import { PilotEntity } from './pilot.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { PilotDto } from './pilot.dto';
import { CustomError } from 'src/error/customError';
@Injectable()
export class PilotService {
constructor(
@InjectRepository(Pilot) private readonly pilotRepository: Repository<Pilot>,
@InjectRepository(Log) private readonly logRepository: Repository<Log>
@InjectRepository(PilotEntity) private readonly pilotRepository: Repository<PilotEntity>
) {}
async find(partitionKey: string, rowKey: string): Promise<Pilot> {
return await this.pilotRepository.find(partitionKey, rowKey);
async find(id: string): Promise<PilotEntity> {
try {
const pilotEntity = await this.pilotRepository.findOneBy({ id });
if (pilotEntity) {
return pilotEntity
} else {
throw new CustomError('Pilot not found', 'Not found', 404)
}
} catch (error) {
throw error
}
}
async findAll(): Promise<Pilot[]> {
return await this.pilotRepository.findAll();
async findAll(): Promise<PilotEntity[]> {
return await this.pilotRepository.find();
}
async create(pilot: Pilot): Promise<Pilot> {
// try {
// return await this.pilotRepository.create(pilot);
// } catch (error) {
// throw new Error(error);
// }
return await this.pilotRepository.create(pilot);
async create(pilot: PilotDto): Promise<InsertResult> {
return await this.pilotRepository.insert(pilot);
}
async update(
partitionKey: string,
rowKey: string,
pilot: Pilot
): Promise<Pilot> {
return await this.pilotRepository.update(partitionKey, rowKey, pilot);
id: string,
pilot: PilotDto
): Promise<UpdateResult> {
return await this.pilotRepository.update(id, pilot);
}
async delete(partitionKey: string, rowKey: string): Promise<void> {
const pilotLogs: Log[] = await this.logRepository.findAll({
queryOptions: {
filter: `pilotId eq '${rowKey}'`
}
})
for (const pilotLog of pilotLogs) {
await this.logRepository.delete(pilotLog.partitionKey, pilotLog.rowKey);
}
await this.pilotRepository.delete(partitionKey, rowKey);
return
async delete(id: string): Promise<DeleteResult> {
return await this.pilotRepository.delete({ id });
}
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Delete,
Get,
HttpException,
Param,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { AuthGuard } from '@noahspan/noahspan-modules';
import { CustomError } from '../error/customError';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileService } from '../file/file.service';
import { TrackService } from './track.service';
import { TrackEntity } from './track.entity';
import { DeleteResult, InsertResult } from 'typeorm';
@Controller('tracks')
export class TrackController {
constructor(
private readonly fileService: FileService,
private readonly trackService: TrackService
) {}
@UseGuards(AuthGuard)
@Get(':logId')
async findAll(@Param('logId') logId: string): Promise<TrackEntity[]> {
try {
return await this.trackService.findAll(logId);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Post(':logId/:order')
@UseInterceptors(FileInterceptor('file'))
async create(@Param('logId') logId: string, @Param('order') order: number, @UploadedFile() file: Express.Multer.File): Promise<InsertResult> {
try {
return await this.trackService.create(logId, order, file);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Delete(':id/:filename/:logId')
async delete(@Param('id') id: string, @Query('fileName') filename: string, @Query('logId') logId: string): Promise<DeleteResult> {
try {
return await this.trackService.delete(id, logId, filename);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get(':logId/:fileName')
async downloadTrack(@Param('logId') logId: string, @Param('fileName') fileName: string): Promise<string> {
try {
return await this.trackService.downloadTrackFile(logId, fileName);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -0,0 +1,5 @@
export class TrackDto {
logId: string;
order: number;
url?: string;
}

View File

@@ -0,0 +1,18 @@
import { LogEntity } from 'src/log/log.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'tracks' })
export class TrackEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
url: string;
@Column()
order: number;
@ManyToOne(() => LogEntity, (log: LogEntity) => log.tracks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'logId' })
log: LogEntity;
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from "@nestjs/typeorm";
import { TrackEntity } from "./track.entity";
import { TrackController } from "./track.controller";
import { FileService } from "../file/file.service";
import { TrackService } from './track.service';
import { LogModule } from 'src/log/log.module';
@Module({
imports: [
LogModule,
TypeOrmModule.forFeature([TrackEntity])
],
controllers: [TrackController],
providers: [
FileService,
TrackService
]
})
export class TrackModule {}

View File

@@ -0,0 +1,98 @@
import { DeleteResult, InsertResult, Repository, UpdateResult } from "typeorm";
import { TrackDto } from "./track.dto";
import { TrackEntity } from "./track.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Injectable } from "@nestjs/common";
import { LogService } from "src/log/log.service";
import { LogEntity } from "src/log/log.entity";
import { CustomError } from "src/error/customError";
import { FileService } from "src/file/file.service";
@Injectable()
export class TrackService {
private readonly containerName: string = 'tracks'
constructor(
@InjectRepository(TrackEntity) private readonly trackRepository: Repository<TrackEntity>,
private readonly fileService: FileService,
private readonly logService: LogService
) {}
async find(id: string): Promise<TrackEntity> {
try {
return await this.trackRepository.findOneBy({ id });
} catch (error) {
throw new CustomError('Track not found', 'Not found', 404)
}
}
async findAll(logId: string): Promise<TrackEntity[]> {
try {
const logEntity: LogEntity = await this.logService.find(logId);
if (logEntity) {
const tracks = await this.trackRepository.find({
where: { log:
{
id: logEntity.id
}
},
})
return tracks;
}
} catch (error) {
console.log(error)
throw new CustomError('Tracks not found', 'Not found', 404);
}
}
async create(logId: string, order: number, file: Express.Multer.File): Promise<InsertResult> {
try {
const logEntity: LogEntity = await this.logService.find(logId);
if (logEntity) {
const url = await this.fileService.uploadFile(file, this.containerName, logId);
const track = this.trackRepository.create({
log: logEntity,
order: order,
url: url
});
return await this.trackRepository.insert(track);
} else {
throw new CustomError('Log not found', 'Not found', 404)
}
} catch (error) {
throw error;
}
}
async update(id: string, track: TrackDto): Promise<UpdateResult> {
try {
return await this.trackRepository.update(id, track);
} catch(error) {
throw error
}
}
async downloadTrackFile(logId: string, fileName: string): Promise<string> {
try {
const downloadedFile: string = await this.fileService.downloadFile(this.containerName, logId, fileName);
return downloadedFile;
} catch (error) {
throw error
}
}
async delete(id: string, logId: string, fileName: string): Promise<DeleteResult> {
try {
await this.fileService.deleteFile(this.containerName, logId, fileName);
return await this.trackRepository.delete({ id });
} catch (error) {
throw error
}
}
}

View File

@@ -1,45 +0,0 @@
{
"name": "app",
"private": true,
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"serve": "serve -s dist"
},
"dependencies": {
"@azure/msal-browser": "^4.0.1",
"@azure/msal-react": "^3.0.1",
"@noahspan/noahspan-components": "1.6.0",
"axios": "^1.7.2",
"dotenv": "^16.4.7",
"install": "^0.13.0",
"leaflet": "^1.9.4",
"pure-react-carousel": "^1.32.0",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"react-hook-form": "^7.51.4",
"react-leaflet": "^5.0.0",
"react-leaflet-kml": "^2.1.2",
"react-router-dom": "^6.23.0",
"swiper": "^11.2.6"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0",
"@types/leaflet": "^1.9.16",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"engines": {
"node": ">=18.0.0"
},
"files": [
"dist"
]
}

View File

@@ -1,70 +0,0 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { LogLevel } from '@azure/msal-browser';
/**
* Configuration object to be passed to MSAL instance on creation.
* For a full list of MSAL.js configuration parameters, visit:
* https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md
*/
export const msalConfig = {
auth: {
clientId: import.meta.env.VITE_CLIENT_ID, // This is the ONLY mandatory field that you need to supply.
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}`, // Replace the placeholder with your tenant subdomain
redirectUri: import.meta.env.VITE_REDIRECT_URL, // Points to window.location.origin. You must register this URI on Microsoft Entra admin center/App Registration.
postLogoutRedirectUri: '/', // Indicates the page to navigate after logout.
navigateToLoginRequestUrl: false, // If "true", will navigate back to the original request location before processing the auth code response.
},
cache: {
cacheLocation: 'sessionStorage', // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs.
storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge
},
system: {
loggerOptions: {
loggerCallback: (level: any, message: any, containsPii: any) => {
if (containsPii) {
return;
}
switch (level) {
case LogLevel.Error:
console.error(message);
return;
case LogLevel.Info:
console.info(message);
return;
case LogLevel.Verbose:
console.debug(message);
return;
case LogLevel.Warning:
console.warn(message);
return;
default:
return;
}
},
},
},
};
/**
* Scopes you add here will be prompted for user consent during sign-in.
* By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request.
* For more information about OIDC scopes, visit:
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
*/
export const loginRequest = {
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`],
};
/**
* An optional silentRequest object can be used to achieve silent SSO
* between applications by providing a "login_hint" property.
*/
// export const silentRequest = {
// scopes: ["openid", "profile"],
// loginHint: "example@domain.net"
// };

View File

@@ -1,81 +0,0 @@
import { useState } from 'react';
import { IActionMenuProps } from './IActionMenuProps';
import {
IconButton,
Icon,
IconName,
ListItemIcon,
ListItemText,
Menu,
MenuItem
} from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { useIsAuthenticated } from '@azure/msal-react';
const ActionMenu = ({ id, onDelete, onOpenCloseForm, onOpenCloseTracks }: IActionMenuProps) => {
const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>(
null
);
const isAuthenticated = useIsAuthenticated();
const onOpenActionMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElAction(event.currentTarget);
};
const onCloseActionMenu = () => {
setAnchorElAction(null);
};
return (
<div>
<IconButton onClick={onOpenActionMenu}>
<Icon iconName={IconName.ELLIPSIS_VERTICAL} size="sm" />
</IconButton>
<Menu
anchorEl={anchorElAction}
keepMounted
open={Boolean(anchorElAction)}
onClose={onCloseActionMenu}
>
{isAuthenticated &&
<>
<MenuItem onClick={() => onOpenCloseForm(FormMode.EDIT, id)}>
<ListItemIcon>
<Icon iconName={IconName.PEN} size="lg" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
{onOpenCloseTracks &&
<MenuItem onClick={() => onOpenCloseTracks!(FormMode.EDIT, id)}>
<ListItemIcon>
<Icon iconName={IconName.MAP_LOCATION_DOT} size="lg" />
</ListItemIcon>
<ListItemText>Tracks</ListItemText>
</MenuItem>
}
</>
}
<MenuItem onClick={() => onOpenCloseForm(FormMode.VIEW, id)}>
<ListItemIcon>
<Icon iconName={IconName.EYE} size="lg" />
</ListItemIcon>
<ListItemText>View</ListItemText>
</MenuItem>
{isAuthenticated &&
<>
<hr className="my-3" />
<MenuItem onClick={() => onDelete(id)}>
<ListItemIcon>
<Icon iconName={IconName.TRASH} size="lg" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</>
}
</Menu>
</div>
);
};
export default ActionMenu;

View File

@@ -1,52 +0,0 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Icon,
IconName,
Spinner
} from '@noahspan/noahspan-components';
import { IDialogConfirmationProps } from './IConfirmationDialogProps';
const ConfirmationDialog = ({
contentText,
isLoading,
isOpen,
onCancel,
onConfirm,
title
}: IDialogConfirmationProps) => {
return (
<Dialog
onClose={onCancel}
open={isOpen}
sx={{
'& .MuiDialog-paper': { width: '2000px' }
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ textAlign: 'center' }}>
{!isLoading && <DialogContentText>{contentText}</DialogContentText>}
{isLoading && <Spinner />}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} variant="outlined" startIcon={<Icon iconName={IconName.XMARK} />}>
No
</Button>
<Button
onClick={onConfirm}
variant="contained"
startIcon={<Icon iconName={IconName.CIRCLE_CHECK} />}
>
Yes
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmationDialog;

View File

@@ -1,8 +0,0 @@
import { FormMode } from '../../enums/formMode';
export interface ILogFormProps {
entryId?: string;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -1,9 +0,0 @@
import { Alert } from "../../interfaces/Alert.interface";
export interface ILogFormState {
alert: Alert | undefined;
isDisabled: boolean;
isLoading: boolean;
pilotOptions: { label: string; value: string }[];
selectedEntryPilotName: string;
}

View File

@@ -1,947 +0,0 @@
import React, { useEffect, useReducer } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Button,
DatePicker,
Drawer,
Grid,
Icon,
IconButton,
IconName,
Select,
TextField,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { ILogFormProps } from './ILogFormProps';
import { initialState, reducer } from './reducer';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { usePilots } from '../../hooks/pilots/UsePilots';
const LogForm: React.FC<ILogFormProps> = ({
entryId,
isDrawerOpen,
mode,
onOpenClose
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
const defaultValues = {
pilotId: '',
pilotName: '',
date: null,
aircraftMakeModel: '',
aircraftIdentity: '',
routeFrom: '',
routeTo: '',
durationOfFlight: null,
singleEngineLand: null,
simulatorAtd: null,
landingsDay: null,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: null,
night: null,
solo: null,
pilotInCommand: null,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: ''
};
const methods = useForm();
const { pilots } = usePilots();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
};
const onSubmit = async (data: unknown) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const accessToken: string = await getAccessToken();
if (!entryId) {
await httpClient.post(`api/logs`, data, {
headers: {
Authorization: accessToken
}
});
} else {
await httpClient.put(`api/logs/log/${entryId}`, data, {
headers: {
Authorization: accessToken
}
});
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
dispatch({ type: 'SET_ALERT', payload: { severity: 'error', message: axiosError.message }});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true });
}
}, [mode]);
useEffect(() => {
const getEntry = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/logs/log/${entryId}`,
config
);
const entry = response.data;
// if (mode !== FormMode.ADD) {
// const pilot = pilots?.find((pilot) => pilot.id === entry.pilotId);
// console.log(pilot.name)
// dispatch({
// type: 'SET_SELECTED_ENTRY_PILOT_NAME',
// payload: pilot.name
// });
// }
methods.reset(entry);
} catch (error) {
const axiosError = error as AxiosError;
dispatch({ type: 'SET_ALERT', payload: { severity: 'error', message: axiosError.message }});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
if (entryId && isDrawerOpen) {
getEntry();
}
}, [entryId]);
useEffect(() => {
if (pilots && FormMode.ADD) {
const newPilotsOptions = pilots.map((pilot) => {
return {
label: pilot.name,
value: pilot.id
};
});
dispatch({ type: 'SET_PILOT_OPTIONS', payload: newPilotsOptions });
}
}, [pilots]);
return (
<Drawer
open={isDrawerOpen}
anchor="right"
PaperProps={{
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<Grid container spacing={2}>
<Grid size={11}>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Entry`}</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={1}>
<IconButton onClick={onCancel}>
<Icon iconName={IconName.XMARK} />
</IconButton>
</Grid>
{state.alert && (
<Grid display="flex" justifyContent="center" size={12}>
<Alert
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
sx={{ width: '100%' }}
>
{state.alert.message}
</Alert>
</Grid>
)}
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Pilot *</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="pilotId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<Select
disabled={state.isDisabled}
fullWidth
onChange={(event: any) => {
const pilot = pilots?.find(
(pilot) => (pilot.id = event.target.value)
);
if (pilot) {
methods.setValue('pilotName', pilot.name);
}
methods.setValue('pilotId', event.target.value);
}}
options={
state.pilotOptions && state.pilotOptions.length > 0
? state.pilotOptions
: []
}
value={value ? value : ''}
/>
);
}}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Date *</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="date"
control={methods.control}
render={({ field: { onChange, value } }) => (
<DatePicker
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Aircraft Make and Model *</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="aircraftMakeModel"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
{isAuthenticated &&
<>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Aircraft Identity *</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="aircraftIdentity"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
</>
}
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Route From</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="routeFrom"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Route To</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="routeTo"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Duration Of Flight</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="durationOfFlight"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
{isAuthenticated &&
<>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Single Engine Land</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="singleEngineLand"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
</>
}
{isAuthenticated &&
<>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Simulator or ATD</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="simulatorAtd"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
</>
}
{isAuthenticated &&
<Grid size={12}>
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<Icon iconName={IconName.CHEVRON_DOWN} />}>
<Typography variant="body1">Landings</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Day</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="landingsDay"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Night</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="landingsNight"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Grid>
}
{isAuthenticated &&
<Grid size={12}>
<Accordion>
<AccordionSummary expandIcon={<Icon iconName={IconName.CHEVRON_DOWN} />}>
Instrument
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Actual</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="instrumentActual"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Simulated</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="instrumentSimulated"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">
Instrument Approaches
</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="instrumentApproaches"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={4}>
<Typography variant="body1">Holds</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="instrumentHolds"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Nav / Track</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="instrumentNavTrack"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Grid>
}
{isAuthenticated &&
<Grid size={12}>
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<Icon iconName={IconName.CHEVRON_DOWN} />}>
Type of Pilot Experience or Training
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">
Ground Training Received
</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="groundTrainingReceived"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">
Flight Training Received
</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="flightTrainingReceived"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Cross Country</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="crossCountry"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Night</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="night"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Solo</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="solo"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
<Grid alignItems="center" display="flex" size={isMedium ? 4 : 12}>
<Typography variant="body1">Pilot in Command</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="pilotInCommand"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={
methods.formState.errors.address ? true : false
}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
slotProps={{
htmlInput: {
step: 0.1
}
}}
type="number"
value={value}
/>
)}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Grid>
}
<Grid size={isMedium ? 4 : 12}>
<Typography variant="body1">Notes</Typography>
</Grid>
<Grid size={isMedium ? 8 : 12}>
<Controller
name="notes"
control={methods.control}
render={({ field: { onChange, value } }) => (
<TextField
disabled={state.isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
multiline
rows={3}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid display="flex" gap={2} justifyContent="right" size={12}>
<Button
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
startIcon={<Icon iconName={IconName.XMARK} />}
variant="outlined"
onClick={onCancel}
size="small"
>
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</Button>
{mode.toString() !== FormMode.VIEW && (
<Button
disabled={state.isDisabled}
startIcon={<Icon iconName={IconName.SAVE} />}
size="small"
type="submit"
variant="contained"
>
Save
</Button>
)}
</Grid>
</Grid>
</form>
</FormProvider>
</Drawer>
);
};
export default LogForm;

View File

@@ -1,58 +0,0 @@
import { Alert } from '../../interfaces/Alert.interface';
import { ILogFormState } from './ILogFormState';
type Action =
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_PILOT_OPTIONS'; payload: { label: string; value: string }[] }
| { type: 'SET_SELECTED_ENTRY_PILOT_NAME'; payload: string };
export const initialState: ILogFormState = {
alert: undefined,
isDisabled: false,
isLoading: true,
pilotOptions: [],
selectedEntryPilotName: ''
};
export const reducer = (
state: ILogFormState,
action: Action
): ILogFormState => {
switch (action.type) {
case 'SET_ALERT': {
return {
...state,
alert: action.payload
};
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_PILOT_OPTIONS': {
return {
...state,
pilotOptions: action.payload
};
}
case 'SET_SELECTED_ENTRY_PILOT_NAME': {
return {
...state,
selectedEntryPilotName: action.payload
};
}
default: {
return state;
}
}
};

View File

@@ -1,84 +0,0 @@
import { useEffect, useState } from 'react';
import { LogTrackMapsProps } from './LogTrackMapsProps.interface';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Pagination } from 'swiper/modules';
import { MapContainer, TileLayer } from 'react-leaflet';
import ReactLeafletKml from 'react-leaflet-kml';
import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css';
import './LogTrackMaps.css';
import 'leaflet/dist/leaflet.css';
const LogTrackMaps = ({ rowKey, trackUrls }: LogTrackMapsProps) => {
const [tracks, setTracks] = useState<any[]>([])
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
useEffect(() => {
const getTracks = async () => {
const convertedTracks: any[] = []
for (const trackUrl of trackUrls) {
const trackUrlSplit = trackUrl.split('/')
const filename = trackUrlSplit[trackUrlSplit.length - 1];
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/logs/log/${rowKey}/track?fileName=${filename}`,
config
);
const kml = new DOMParser().parseFromString(response.data, 'text/xml')
// const converted = toGeoJSON.kml(dom);
// rewind(converted, false);
convertedTracks.push(kml);
}
setTracks(convertedTracks)
}
getTracks();
}, [trackUrls])
return (
<>
<Swiper
spaceBetween={30}
pagination={{
clickable: true,
}}
modules={[Pagination]}
className="mySwiper"
>
{tracks.length > 0 && tracks.map((track) => {
return (
<SwiperSlide>
<MapContainer
center={[45.14489, -93.21019]}
scrollWheelZoom={false}
style={{ height: '500px', width: '100%' }}
zoom={8}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ReactLeafletKml kml={track} />
</MapContainer>
</SwiperSlide>
)
})}
</Swiper>
</>
);
}
export default LogTrackMaps;

View File

@@ -1,4 +0,0 @@
export interface LogTrackMapsProps {
rowKey: string;
trackUrls: string[];
}

View File

@@ -1,198 +0,0 @@
import { useEffect, useReducer } from "react";
import { Button, Drawer, Grid, Icon, IconButton, IconName, TextField, theme, Typography, useMediaQuery } from "@noahspan/noahspan-components";
import { useHttpClient } from "../../hooks/httpClient/UseHttpClient";
import { AxiosInstance, AxiosResponse } from "axios";
import { useAccessToken } from "../../hooks/accessToken/UseAcessToken";
import { LogTracksProps } from "./LogTracksProps.interface";
import { FormMode } from "../../enums/formMode";
import { useIsAuthenticated } from "@azure/msal-react";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog";
import { initialState, reducer } from "./reducer";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedRowKey }: LogTracksProps) => {
const [state, dispatch] = useReducer(reducer, initialState)
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const getConfig = async () => {
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
return config
}
const getLog = async (): Promise<ILogbookEntry> => {
const logResponse: AxiosResponse = await httpClient.get(
`api/logs/log/${selectedRowKey}`,
await getConfig()
);
const logData: ILogbookEntry = logResponse.data;
return logData
}
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true})
const file = event.target.files![0]
const formData = new FormData();
const config = await getConfig();
const formDataConfig = {
headers: {
...config.headers,
'Content-Type': 'multipart/form-data'
}
}
formData.append('file', file);
const uploadResponse: AxiosResponse = await httpClient.post(`api/logs/log/${selectedRowKey}/track`, formData, formDataConfig);
const uploadUrl = uploadResponse.data.url;
const log = await getLog();
const tracks: string[] = JSON.parse(log.tracks!);
tracks.push(uploadUrl)
log.tracks = JSON.stringify(tracks);
await httpClient.put(`api/logs/log/${selectedRowKey}`, log, config);
const updatedLog = await getLog();
dispatch({ type: 'SET_TRACKS', payload: JSON.parse(updatedLog.tracks!) })
} catch (error) {
console.log(error)
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
}
const onCancel = () => {
onOpenClose(FormMode.CANCEL)
}
const onDeleteTrack = async (fileName: string, index: number) => {
dispatch({ type: 'SET_ON_DELETE_TRACK', payload: { isConfirmDialogOpen: true, selectedTrack: { fileName: fileName, index: index }}})
}
const onConfirmDialogConfirm = async () => {
try {
const config = await getConfig();
await httpClient.delete(`api/logs/log/${selectedRowKey}/track?fileName=${state.selectedTrack!.fileName}`, config);
const log = await getLog();
const tracks: string[] = JSON.parse(log.tracks!);
tracks.splice(state.selectedTrack!.index, 1);
log.tracks = JSON.stringify(tracks);
await httpClient.put(`api/logs/log/${selectedRowKey}`, log, config);
const updatedLog = await getLog();
dispatch({ type: 'SET_TRACKS', payload: JSON.parse(updatedLog.tracks!) })
dispatch({ type: 'SET_IS_CONFIRM_DIALOG_OPEN', payload: false })
} catch (error) {
console.log(error);
}
}
const onConfirmDialogCancel = async () => {
dispatch({ type: 'SET_IS_CONFIRM_DIALOG_OPEN', payload: false })
}
useEffect(() => {
const updateTracks = async () => {
const log = await getLog();
dispatch({ type: 'SET_TRACKS', payload: JSON.parse(log.tracks!) });
}
updateTracks();
}, [])
return (
<Drawer
open={isDrawerOpen}
anchor='right'
PaperProps={{
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
>
<Grid container spacing={2}>
<Grid size={11}>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`}</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={1}>
<IconButton onClick={onCancel}>
<Icon iconName={IconName.XMARK} />
</IconButton>
</Grid>
{mode === FormMode.EDIT &&
<>
{state.tracks.length > 0 && state.tracks.map((track, index) => {
const trackSplit = track.split('/')
const filename = trackSplit[trackSplit.length - 1];
return (
<>
<Grid size={11}>
<TextField disabled={true} fullWidth value={filename} />
</Grid>
<Grid size={1}>
<IconButton onClick={() => onDeleteTrack(filename, index)}><Icon iconName={IconName.TRASH} /></IconButton>
</Grid>
</>
)
})}
<Grid display='flex' gap={2} justifyContent='right' size={12}>
<Button
startIcon={<Icon iconName={IconName.XMARK} />}
variant="outlined"
onClick={onCancel}
size="small"
>
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</Button>
{mode.toString() !== FormMode.VIEW && (
<Button
component='label'
loading={state.isLoading}
startIcon={<Icon iconName={IconName.UPLOAD} />}
variant='contained'
>
Upload Track
<input hidden onChange={handleFileUpload} type='file' />
</Button>
)}
</Grid>
</>
}
{mode === FormMode.VIEW &&
<LogTrackMaps rowKey={selectedRowKey!} trackUrls={state.tracks} />
}
</Grid>
{state.isConfirmDialogOpen && (
<ConfirmationDialog
contentText="Are you sure you want to delete this track?"
isLoading={state.isConfirmDialogLoading}
isOpen={state.isConfirmDialogOpen}
onCancel={onConfirmDialogCancel}
onConfirm={onConfirmDialogConfirm}
title="Confirm Delete"
/>
)}
</Drawer>
)
}
export default LogTracks;

View File

@@ -1,10 +0,0 @@
export interface LogTracksState {
isConfirmDialogOpen: boolean;
isConfirmDialogLoading: boolean;
isLoading: boolean;
selectedTrack: {
fileName: string,
index: number
} | undefined;
tracks: string[];
}

View File

@@ -1,309 +0,0 @@
import { useEffect, useReducer } from 'react';
import LogForm from '../logForm/LogForm';
import {
Alert,
Box,
Button,
ColumnDef,
Grid,
Icon,
IconButton,
IconName,
Spinner,
Table,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components';
import { initialState, reducer } from './reducer';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { authColumns, unauthColumns } from './columns';
import ActionMenu from '../actionMenu/ActionMenu';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import { ILogbookEntry } from './ILogbookEntry';
import LogbookCard from '../logbookCard/LogbookCard';
import LogTracks from '../logTracks/LogTracks';
const Logbook: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const actionsColumn: ColumnDef<ILogbookEntry> = {
header: 'Actions',
meta: {
align: 'center',
headerAlign: 'center'
},
cell: (info: any) => (
<ActionMenu
id={info.row.original.rowKey}
onDelete={onDeleteEntry}
onOpenCloseForm={onOpenCloseEntryForm}
onOpenCloseTracks={onOpenCloseTracks}
/>
)
}
const tracksColumn: ColumnDef<ILogbookEntry> = {
accessorKey: 'tracks',
header: 'Tracks',
cell: (info: any) => {
if (info.row.original.tracks && info.row.original.tracks.length > 0) {
return (
<IconButton onClick={() => onOpenCloseTracks(FormMode.VIEW, info.row.original.rowKey)}><Icon iconName={IconName.MAP_LOCATION_DOT} /></IconButton>
)
}
}
}
const getLogbookEntries = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(`api/logs`, config);
const entries: ILogbookEntry[] = response.data;
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
if (response.data.length > 0) {
dispatch({ type: 'SET_ENTRIES', payload: response.data });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined})
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No logbook entries found.'}})
}
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
const onOpenCloseEntryForm = (mode: FormMode, entryId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedEntryId: entryId,
isFormOpen: true
}
});
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedEntryId: undefined,
isFormOpen: false
}
});
break;
}
};
const onOpenCloseTracks = (mode: FormMode, rowKey?: string) => {
switch(mode) {
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_TRACKS',
payload: {
tracksMode: mode,
isTracksOpen: true,
selectedRowKey: rowKey
}
})
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_TRACKS',
payload: {
tracksMode: mode,
isTracksOpen: false,
selectedRowKey: undefined
}
})
break;
}
}
const onDeleteEntry = (entryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: true, selectedEntryId: entryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/logs/log/${state.selectedEntryId}`, config);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedEntryId: undefined }
});
await getLogbookEntries();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedEntryId: undefined }
});
};
useEffect(() => {
let newColumns: ColumnDef<ILogbookEntry>[];
if (isAuthenticated) {
newColumns = [...authColumns];
} else {
newColumns = [...unauthColumns];
}
const actionsColumnExists = newColumns.find((column) => column.id === 'actions');
const tracksColumnExists = newColumns.find((column) => column.id === 'actions');
if (!actionsColumnExists) {
newColumns.push(actionsColumn);
}
if (!tracksColumnExists) {
const notesColumnIndex = newColumns.findIndex((column) => column.id === 'notes')
newColumns.splice(notesColumnIndex, 0, tracksColumn)
}
dispatch({ type: 'SET_COLUMNS', payload: newColumns })
}, [isAuthenticated])
useEffect(() => {
if (!state.isFormOpen) {
getLogbookEntries();
}
}, [state.isFormOpen, state.isTracksOpen]);
return (
<Box sx={{ margin: '20px' }}>
<Grid container spacing={2}>
<Grid size={isMedium ? 11 : 6}>
<Typography variant="h4">Logbook</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={isMedium ? 1 : 6}>
{isAuthenticated &&
<Button
onClick={() => onOpenCloseEntryForm(FormMode.ADD)}
startIcon={<Icon iconName={IconName.PLUS} />}
variant="contained"
data-testid="pilot-add-button"
>
Add Entry
</Button>
}
</Grid>
{!state.isLoading && state.alert && (
<Grid display="flex" justifyContent="center" size={12}>
<Alert
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
sx={{ width: '100%' }}
>
{state.alert.message}
</Alert>
</Grid>
)}
{!state.isLoading && (
<Grid size={12}>
{isMedium && state.columns && state.columns.length > 0 && state.entries.length > 0 && (
<Table columns={state.columns} data={state.entries} />
)}
{!isMedium && state.entries.length > 0 &&
<LogbookCard logs={state.entries} onDelete={onDeleteEntry} onOpenCloseForm={onOpenCloseEntryForm} />
}
</Grid>
)}
{state.isLoading && !state.alert && (
<>
<Grid display="flex" justifyContent="center" size={12}>
<Spinner />
</Grid>
<Grid display="flex" justifyContent="center" size={12}>
Loading...
</Grid>
</>
)}
</Grid>
{state.isFormOpen && (
<LogForm
entryId={state.selectedEntryId}
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenCloseEntryForm(mode)}
/>
)}
{state.isConfirmDialogOpen && (
<ConfirmationDialog
contentText="Are you sure you want to delete the logbook entry?"
isLoading={state.isConfirmDialogLoading}
isOpen={state.isConfirmDialogOpen}
onCancel={onConfirmationDialogCancel}
onConfirm={onConfirmationDialogConfirm}
title="Confirm Delete"
/>
)}
{state.isTracksOpen &&
<LogTracks
isDrawerOpen={state.isTracksOpen}
mode={state.tracksMode}
onOpenClose={(mode) => onOpenCloseTracks(mode)}
selectedRowKey={state.selectedEntryId}
/>
}
</Box>
);
};
export default Logbook;

View File

@@ -1,252 +0,0 @@
import {
ColumnDef,
Icon,
IconButton,
IconName
} from '@noahspan/noahspan-components';
import { ILogbookEntry } from './ILogbookEntry';
const pilotName: ColumnDef<ILogbookEntry> = {
id: 'pilotName',
accessorKey: 'pilotName',
header: 'Pilot'
}
const date: ColumnDef<ILogbookEntry> = {
id: 'date',
accessorKey: 'date',
header: 'Date'
}
const aircraftMakeModel: ColumnDef<ILogbookEntry> = {
id: 'aircraftMakeModel',
accessorKey: 'aircraftMakeModel',
header: 'Aircraft Make & Model'
}
const route: ColumnDef<ILogbookEntry> = {
id: 'route',
header: 'Route of Flight',
meta: {
headerAlign: 'center'
},
columns: [
{
id: 'routeFrom',
accessorKey: 'routeFrom',
header: 'From'
},
{
id: 'routeTo',
accessorKey: 'routeTo',
header: 'To'
}
]
}
const durationOfFlight: ColumnDef<ILogbookEntry> = {
id: 'durationOfFlight',
accessorKey: 'durationOfFlight',
header: 'Duration Of Flight',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
}
const notes: ColumnDef<ILogbookEntry> = {
id: 'notes',
accessorKey: 'notes',
header: 'Notes'
}
export const unauthColumns: ColumnDef<ILogbookEntry>[] = [
pilotName,
date,
aircraftMakeModel,
route,
durationOfFlight,
notes
]
export const authColumns: ColumnDef<ILogbookEntry>[] = [
pilotName,
date,
aircraftMakeModel,
{
id: 'aircraftIdentity',
accessorKey: 'aircraftIdentity',
header: 'Aircraft Identity',
},
route,
durationOfFlight,
{
id: 'singleEngineLand',
accessorKey: 'singleEngineLand',
header: 'Single Engine Land',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'landings',
header: 'Landings',
meta: {
headerAlign: 'center'
},
columns: [
{
id: 'landingsDay',
accessorKey: 'landingsDay',
header: 'Day',
meta: {
align: 'right',
headerAlign: 'right'
}
},
{
id: 'landingsNight',
accessorKey: 'landingsNight',
header: 'Night',
meta: {
align: 'right',
headerAlign: 'right'
}
}
]
},
{
id: 'instrument',
header: 'Instrument',
meta: {
headerAlign: 'center'
},
columns: [
{
id: 'instrumentActual',
accessorKey: 'instrumentActual',
header: 'Actual',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'instrumentSimulated',
accessorKey: 'instrumentSimulated',
header: 'Simulated',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'instrumentApproaches',
accessorKey: 'instrumentApproaches',
header: 'Approaches',
meta: {
align: 'right',
headerAlign: 'right'
}
},
{
id: 'instrumentHolds',
accessorKey: 'instrumentHolds',
header: 'Holds',
meta: {
align: 'right',
headerAlign: 'right'
}
},
{
id: 'instrumentNavTrack',
accessorKey: 'instrumentNavTrack',
header: 'Nav/Track',
meta: {
align: 'right',
headerAlign: 'right'
}
}
]
},
{
id: 'experienceTraining',
header: 'Type of pilot experience or training',
meta: {
headerAlign: 'center'
},
columns: [
{
id: 'groundTrainingReceived',
accessorKey: 'groundTrainingReceived',
header: 'Ground Training Received',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'flightTrainingReceived',
accessorKey: 'flightTrainingReceived',
header: 'Flight Training Received',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'crossCountry',
accessorKey: 'crossCountry',
header: 'Cross Country',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'night',
accessorKey: 'night',
header: 'Night',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'solo',
accessorKey: 'solo',
header: 'Solo',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
},
{
id: 'pilotInCommand',
accessorKey: 'pilotInCommand',
header: 'Pilot In Command',
meta: {
align: 'right',
headerAlign: 'right'
},
cell: (info: any) =>
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
}
]
},
notes
]

View File

@@ -1,113 +0,0 @@
import { ColumnDef } from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces/Alert.interface';
import { ILogbookEntry } from './ILogbookEntry';
import { ILogbookState } from './ILogbookState';
type Action =
| { type: 'SET_COLUMNS'; payload: ColumnDef<ILogbookEntry>[] }
| {
type: 'SET_DELETE';
payload: {
isConfirmationDialogOpen: boolean;
selectedEntryId: string | undefined;
};
}
| { type: 'SET_ENTRIES'; payload: ILogbookEntry[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| {
type: 'SET_OPEN_CLOSE_ENTRY_FORM';
payload: {
formMode: FormMode;
selectedEntryId: string | undefined;
isFormOpen: boolean;
};
}
| { type: 'SET_OPEN_CLOSE_TRACKS'; payload: { tracksMode: FormMode, selectedRowKey: string | undefined, isTracksOpen: boolean; }};
export const initialState: ILogbookState = {
alert: undefined,
columns: [],
entries: [],
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
isTracksOpen: false,
selectedEntryId: undefined,
tracksMode: FormMode.CANCEL
};
export const reducer = (
state: ILogbookState,
action: Action
): ILogbookState => {
switch (action.type) {
case 'SET_COLUMNS': {
return {
...state,
columns: action.payload
}
}
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmationDialogOpen,
selectedEntryId: action.payload.selectedEntryId
};
}
case 'SET_ENTRIES': {
return {
...state,
entries: action.payload
};
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
};
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
};
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_OPEN_CLOSE_ENTRY_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedEntryId: action.payload.selectedEntryId
};
}
case 'SET_OPEN_CLOSE_TRACKS': {
return {
...state,
tracksMode: action.payload.tracksMode,
isTracksOpen: action.payload.isTracksOpen,
selectedEntryId: action.payload.selectedRowKey
}
}
default: {
return state;
}
}
};

View File

@@ -1,74 +0,0 @@
import { Card, CardContent, CardHeader, Grid, Typography } from "@noahspan/noahspan-components";
import { LogbookCardProps } from "./LogbookCardProps.interface";
import { useEffect } from "react";
import ActionMenu from "../actionMenu/ActionMenu";
const LogbookCard = ({ logs, onDelete, onOpenCloseForm }: LogbookCardProps) => {
return (
<Grid container spacing={2}>
{logs.map((log) => {
return (
<Grid size={12}>
<Card key={log.rowKey}>
<CardHeader
action={<ActionMenu id={log.rowKey} onDelete={onDelete} onOpenCloseForm={onOpenCloseForm} />}
subheader={log.pilotName}
title={log.date}
slotProps={{
subheader: {
fontSize: '16px'
},
title: {
fontSize: '24px',
}
}}
/>
<CardContent>
<Grid container spacing={1}>
<Grid size={12}>
<Typography variant="subtitle2">Aircraft Make and Model</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.aircraftMakeModel}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Route From</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.routeFrom}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Route To</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.routeTo}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Duration Of Flight</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.durationOfFlight}</Typography>
</Grid>
{log.notes &&
<>
<Grid size={12}>
<Typography variant="subtitle2">Notes</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.notes}</Typography>
</Grid>
</>
}
</Grid>
</CardContent>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default LogbookCard;

View File

@@ -1,8 +0,0 @@
import { FormMode } from "../../enums/formMode";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
export interface LogbookCardProps {
logs: ILogbookEntry[];
onDelete: (entryId: string) => void;
onOpenCloseForm: (formMode: FormMode, id: string) => void;
}

View File

@@ -1,29 +0,0 @@
import { Card, CardContent, CardHeader, Grid, Typography } from "@noahspan/noahspan-components"
import { PilotCardProps } from "./PilotCardProps.interface"
import ActionMenu from "../actionMenu/ActionMenu"
const PilotCard = ({ pilots, onDelete, onOpenCloseForm }: PilotCardProps) => {
return (
<Grid container spacing={2}>
{pilots.map((pilot) => {
return (
<Grid size={12}>
<Card key={pilot.rowKey}>
<CardHeader
action={<ActionMenu id={pilot.rowKey} onDelete={onDelete} onOpenCloseForm={onOpenCloseForm} />}
title={pilot.name}
slotProps={{
title: {
fontSize: '24px',
}
}}
/>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default PilotCard;

View File

@@ -1,431 +0,0 @@
import { useEffect, useState } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import {
Button,
Drawer,
Grid,
Icon,
IconButton,
IconName,
PeoplePicker,
StateSelect,
TextField,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components';
import { IPilotFormProps } from './IPilotFormProps';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { Person } from '@microsoft/microsoft-graph-types';
import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates';
import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements';
import PilotFormMedical from '../pilotFormMedical/PilotFormMedical';
const PilotForm: React.FC<IPilotFormProps> = ({
pilotId,
isDrawerOpen,
mode,
onOpenClose
}: IPilotFormProps) => {
const httpClient: AxiosInstance = useHttpClient();
const [peoplePickerResults, setPeoplePickerResults] = useState<any[]>([]);
const [isPeoplePickerLoading, setIsPeoplePickerLoading] =
useState<boolean>(false);
const [selectedPerson, setSelectedPerson] = useState<Person>({
userPrincipalName: '',
displayName: ''
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
const defaultValues = {
partitionKey: 'pilot',
rowKey: 'noah@noahspannbauer.com',
id: '',
name: '',
address: '',
city: '',
state: '',
postalCode: '',
email: '',
phone: '',
medicalClass: '',
medicalExpiration: '',
certificates: [],
endorsements: []
};
const methods = useForm({
defaultValues: defaultValues
});
const [isDisabled, setIsDisabled] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const onPeoplePickerSearch = async (
_event: React.SyntheticEvent,
value: string
) => {
setIsPeoplePickerLoading(true);
try {
if (value !== '') {
const searchString: string = value;
const accessToken: string = await getAccessToken();
const response: AxiosResponse = await httpClient.get(
`api/user/search?search=${searchString}`,
{
headers: {
Authorization: accessToken
}
}
);
setPeoplePickerResults(response.data);
} else {
setPeoplePickerResults([]);
}
} catch (error) {
console.log(error);
} finally {
setIsPeoplePickerLoading(false);
}
};
const onPeoplePickerSelectionChange = (
_event: React.SyntheticEvent,
value: Person,
_reason: string
) => {
methods.setValue('id', value.userPrincipalName!.toString());
methods.setValue('name', value.displayName!.toString());
setSelectedPerson(value);
};
const onCancel = () => {
methods.reset(defaultValues);
onOpenClose(FormMode.CANCEL);
setSelectedPerson({ userPrincipalName: '', displayName: '' });
setIsDisabled(false)
};
const onSubmit = async (data: unknown) => {
try {
setIsLoading(true);
const accessToken: string = await getAccessToken();
if (!pilotId) {
await httpClient.post(`api/pilots`, data, {
headers: {
Authorization: accessToken
}
});
} else {
await httpClient.put(`api/pilots/pilot/${pilotId}`, data, {
headers: {
Authorization: accessToken
}
})
}
methods.reset(defaultValues)
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
const responseData = axiosError.response?.data as any;
console.log(responseData.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (mode === FormMode.VIEW) {
setIsDisabled(true);
}
}, [mode]);
useEffect(() => {
const getPilot = async () => {
try {
setIsLoading(true);
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/pilots/pilot/${pilotId}`,
config
);
const pilot = response.data;
pilot.certificates = JSON.parse(pilot.certificates);
pilot.endorsements = JSON.parse(pilot.endorsements)
setSelectedPerson({
userPrincipalName: pilot.id,
displayName: pilot.name
});
methods.reset(pilot);
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
if (pilotId) {
getPilot();
}
}, [pilotId]);
return (
<Drawer
open={isDrawerOpen}
anchor="right"
data-testid="pilot-drawer"
PaperProps={{
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<Grid container spacing={2}>
<Grid size={11}>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={1}>
<IconButton onClick={onCancel}>
<Icon iconName={IconName.XMARK} />
</IconButton>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Name *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<PeoplePicker
disabled={isDisabled}
loading={isPeoplePickerLoading}
onInputChanged={onPeoplePickerSearch}
onSelectionChanged={onPeoplePickerSelectionChange}
options={peoplePickerResults}
value={selectedPerson}
/>
</Grid>
{isAuthenticated &&
<>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Address *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="address"
control={methods.control}
rules={{ required: 'An address is required' }}
render={({ field: { onChange, value } }) => (
<TextField
disabled={isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">City *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="city"
control={methods.control}
rules={{ required: 'A city is required' }}
render={({ field: { onChange, value } }) => (
<TextField
disabled={isDisabled}
error={methods.formState.errors.city ? true : false}
fullWidth
helperText={
methods.formState.errors.city
? methods.formState.errors.city.message
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">State *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="state"
control={methods.control}
rules={{ required: 'A state must be selected' }}
render={({ field: { onChange, value } }) => (
<StateSelect
disabled={isDisabled}
// error={methods.formState.errors.state ? true : false}
fullWidth
// helperText={
// methods.formState.errors.state
// ? methods.formState.errors.state.message?.toString()
// : undefined
// }
onChange={onChange}
value={value}
variant="outlined"
data-testid="pilot-form-state-dropdown"
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Postal Code *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="postalCode"
control={methods.control}
rules={{ required: 'A postal code is required' }}
render={({ field: { onChange, value } }) => (
<TextField
disabled={isDisabled}
error={methods.formState.errors.postalCode ? true : false}
fullWidth
helperText={
methods.formState.errors.postalCode
? methods.formState.errors.postalCode.message
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Email</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="email"
control={methods.control}
rules={{
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
}}
render={({ field: { onChange, value } }) => (
<TextField
disabled={isDisabled}
fullWidth
error={methods.formState.errors.email ? true : false}
helperText={
methods.formState.errors.email
? methods.formState.errors.email.message
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Phone Number</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<Controller
name="phone"
control={methods.control}
rules={{
pattern: {
value: /^[0-9]{3}[-\s\.][0-9]{3}[-\s\.][0-9]{4}$/i,
message: 'Enter phone number as 123-456-7890'
}
}}
render={({ field: { onChange, value } }) => (
<TextField
disabled={isDisabled}
fullWidth
error={methods.formState.errors.phone ? true : false}
helperText={
methods.formState.errors.phone
? methods.formState.errors.phone.message
: undefined
}
onChange={onChange}
value={value}
/>
)}
/>
</Grid>
</>
}
{isAuthenticated &&
<Grid size={12}>
<PilotFormMedical
isDisabled={isDisabled}
/>
</Grid>
}
<Grid size={12}>
<PilotFormCertificates isDisabled={isDisabled} mode={mode} />
</Grid>
<Grid size={12}>
<PilotFormEndorsements isDisabled={isDisabled} mode={mode} />
</Grid>
<Grid display="flex" gap={2} justifyContent="right" size={12}>
<Button
disabled={
isDisabled && mode.toString() !== FormMode.VIEW
? isDisabled
: false
}
startIcon={<Icon iconName={IconName.XMARK} />}
variant="outlined"
onClick={onCancel}
data-testid="pilot-cancel-button"
size="small"
>
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</Button>
{mode.toString() !== FormMode.VIEW && (
<Button
disabled={isDisabled}
startIcon={<Icon iconName={IconName.SAVE} />}
size="small"
type="submit"
variant="contained"
data-testid="pilot-save-button"
>
Save
</Button>
)}
</Grid>
</Grid>
</form>
</FormProvider>
</Drawer>
);
};
export default PilotForm;

View File

@@ -1,6 +0,0 @@
export interface Pilot {
partitionKey: string;
rowKey: string;
id: string;
name: string;
};

View File

@@ -1,217 +0,0 @@
import { useEffect, useReducer, useState } from 'react';
import PilotForm from '../pilotForm/PilotForm';
import {
Alert,
Box,
Button,
ColumnDef,
Grid,
Icon,
IconName,
Table,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { Pilot } from './Pilot.interface';
import { initialState, reducer } from './reducer';
import ActionMenu from '../actionMenu/ActionMenu';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import PilotCard from '../pilotCard/PilotCard';
const Pilots: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const getPilots = async () => {
try {
const response: AxiosResponse = await httpClient.get(
`api/pilots`
);
if (response.data.length > 0) {
dispatch({ type: 'SET_PILOTS', payload: response.data });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No pilots found.' }})
}
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of pilots failed with the following message: ${axiosError.message}`}
})
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
};
const onOpenClosePilotForm = async (mode: FormMode, pilotId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedPilotId: pilotId,
isFormOpen: true
}
})
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedPilotId: undefined,
isFormOpen: false
}
})
break;
}
};
const onDeleteEntry = (pilotId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: true, selectedPilotId: pilotId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/pilots/pilot/${state.selectedPilotId}`, config);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedPilotId: undefined }
});
await getPilots();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedPilotId: undefined }
});
};
const columns: ColumnDef<Pilot>[] = [
{
accessorKey: 'name',
header: 'Name'
},
{
header: 'Actions',
cell: (info: any) => (
<ActionMenu
id={info.row.original.rowKey}
onDelete={onDeleteEntry}
onOpenCloseForm={onOpenClosePilotForm}
/>
)
}
];
useEffect(() => {
if (!state.isFormOpen) {
getPilots();
}
}, [state.isFormOpen]);
return (
<Box sx={{ margin: '20px' }}>
<Grid container spacing={2}>
<Grid size={isMedium ? 11 : 6}>
<Typography variant="h4">Pilots</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={isMedium ? 1 : 6}>
{isAuthenticated &&
<Button
onClick={() => onOpenClosePilotForm(FormMode.ADD)}
startIcon={<Icon iconName={IconName.PLUS} />}
variant="contained"
data-testid="pilot-add-button"
>
Add Pilot
</Button>
}
</Grid>
{!state.isLoading && state.alert && (
<Grid display="flex" justifyContent="center" size={12}>
<Alert
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
sx={{ width: '100%' }}
>
{state.alert.message}
</Alert>
</Grid>
)}
<Grid size={12}>
{isMedium && state.pilots.length > 0 &&
<Table columns={columns} data={state.pilots} />
}
{!isMedium && state.pilots.length > 0 &&
<PilotCard pilots={state.pilots} onDelete={onDeleteEntry} onOpenCloseForm={onOpenClosePilotForm} />
}
</Grid>
</Grid>
{state.isFormOpen && (
<PilotForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenClosePilotForm(mode)}
pilotId={state.selectedPilotId}
/>
)}
{state.isConfirmDialogOpen && (
<ConfirmationDialog
contentText="Are you sure you want to delete the pilot entry? Deleting a pilot will delete the pilot and delete all logbook entries for the pilot."
isLoading={state.isConfirmDialogLoading}
isOpen={state.isConfirmDialogOpen}
onCancel={onConfirmationDialogCancel}
onConfirm={onConfirmationDialogConfirm}
title="Confirm Delete"
/>
)}
</Box>
);
};
export default Pilots;

View File

@@ -1,149 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import {
Avatar,
Button,
Icon,
IconButton,
IconName,
Menu,
MenuItem,
Navbar,
Spinner,
Typography
} from '@noahspan/noahspan-components';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios';
import { User } from '@microsoft/microsoft-graph-types';
const SiteNav = () => {
const [loading, setLoading] = useState<boolean>(false);
const [userPhoto, setUserPhoto] = useState<string>();
const [pages, setPages] = useState<{ name: string; url: string; }[]>([]);
const httpClient: AxiosInstance = useHttpClient();
const appContext = useAppContext();
const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken();
const { inProgress, instance } = useMsal();
const navigate = useNavigate();
const getPages = () => {
const pages = [
{
name: 'Logbook',
url: '/'
},
{
name: 'Pilots',
url: '/pilots'
}
];
setPages(pages)
};
const handleSignIn = () => {
instance.loginRedirect({
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`]
});
};
const handleSignOut = () => {
instance.logoutRedirect();
};
const getUserProfile = async (accessToken: string): Promise<User> => {
try {
const response: AxiosResponse = await httpClient.get(`api/user/profile`, {
headers: {
Authorization: accessToken
}
});
const userProfile: User = response.data;
return userProfile;
} catch (error) {
throw new Error();
}
};
const getUserPhoto = async (accessToken: string): Promise<string> => {
try {
const response: AxiosResponse = await httpClient.get(`api/user/photo`, {
headers: {
Authorization: accessToken
},
responseType: 'arraybuffer'
});
const arrayBufferView = new Uint8Array(response.data);
const blob = new Blob([arrayBufferView], { type: 'image/png' });
const imageUrl = window.URL.createObjectURL(blob);
return imageUrl;
} catch (error) {
console.log(error);
throw new Error();
}
};
const handlePageClick = (url: string) => {
navigate(url);
};
const Settings = () => {
return (
<MenuItem onClick={handleSignOut}>
<Icon iconName={IconName.SIGN_OUT} />
<Typography sx={{ marginLeft: '10px', textAlign: 'center' }}>
Sign Out
</Typography>
</MenuItem>
);
};
useEffect(() => {
const setUserProfile = async () => {
try {
setLoading(true);
const accessToken: string = await getAccessToken();
const userProfile = await getUserProfile(accessToken);
const userPhoto = await getUserPhoto(accessToken);
setUserPhoto(userPhoto);
appContext.dispatch({
type: 'SET_USER_PROFILE',
payload: userProfile
});
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if (
isAuthenticated &&
Object.keys(appContext.state.userProfile).length === 0
) {
setUserProfile();
getPages();
}
}, [isAuthenticated]);
useEffect(() => {
getPages();
}, [])
return (
<Navbar
handlePageClick={handlePageClick}
handleSignIn={handleSignIn}
isAuthenticated={isAuthenticated}
logo={<Icon iconName={IconName.PLANE} size="2x" />}
pages={pages}
settings={<Settings />}
userPhoto={userPhoto}
/>
);
};
export default SiteNav;

View File

@@ -1,32 +0,0 @@
import {
AuthenticationResult,
InteractionRequiredAuthError
} from '@azure/msal-browser';
import { useMsal } from '@azure/msal-react';
export const useAccessToken = () => {
const { accounts, instance } = useMsal();
const getAccessToken = async () => {
const tokenRequest = {
account: accounts[0],
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`]
};
try {
const response: AuthenticationResult =
await instance.acquireTokenSilent(tokenRequest);
return `Bearer ${response.accessToken}`;
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
await instance.acquireTokenRedirect(tokenRequest);
}
throw error;
}
};
return {
getAccessToken
};
};

View File

@@ -1,10 +0,0 @@
import { useAppContext } from '../appContext/UseAppContext';
export const useFeatureFlag = (featureFlagKey: string) => {
const appContext = useAppContext();
const featureFlag = appContext.state.featureFlags.find(
(featureFlag) => featureFlag.key === featureFlagKey
);
console.log(featureFlag)
return featureFlag;
};

View File

@@ -1,29 +0,0 @@
import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios';
export const useHttpClient = () => {
let config: CreateAxiosDefaults<any> = {
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json'
}
};
// if (import.meta.env.DEV) {
// config = {
// baseURL: import.meta.env.VITE_API_URL,
// headers: {
// 'Content-Type': 'application/json'
// }
// };
// } else {
// config = {
// headers: {
// 'Content-Type': 'application/json'
// }
// };
// }
const httpClient: AxiosInstance = axios.create(config);
return httpClient;
};

View File

@@ -1,3 +0,0 @@
body {
background-color: #f2f2f2;
}

View File

@@ -1,4 +0,0 @@
export interface Alert {
severity: 'success' | 'error' | 'info' | 'warning';
message: string;
}

View File

@@ -1,39 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import AppContextProvider from './context/appContext/AppContextProvider.tsx';
import { BrowserRouter } from 'react-router-dom';
import { AuthenticationResult, EventMessage, EventType, PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
import { msalConfig } from './auth/msalConfig';
import './index.css';
const msalInstance: PublicClientApplication = new PublicClientApplication(msalConfig);
msalInstance.initialize().then(() => {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.setActiveAccount(accounts[0]);
}
msalInstance.addEventCallback((event: EventMessage) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const payload = event.payload as AuthenticationResult;
const account = payload.account;
msalInstance.setActiveAccount(account);
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<AppContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AppContextProvider>
</MsalProvider>
</React.StrictMode>
);
})

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
preview: {
port: 8080,
strictPort: true
},
server: {
port: 8080,
strictPort: true,
host: '0.0.0.0'
}
});

Some files were not shown because too many files have changed in this diff Show More