Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd514e4c8b | |||
| d8513e6dd0 | |||
| 5de5a165da | |||
| 26c46fe6e8 | |||
| 04e5e3e16b | |||
| 0305b424eb | |||
| 8652544471 | |||
| b7b6bdcf4a | |||
| 9c96774f7b | |||
| a9abb5863c | |||
| 477469831f | |||
| 6964531e12 | |||
| 08baaa6d83 | |||
| 487afb08d6 | |||
| b7e74242b8 | |||
| 876f1f65a2 | |||
| 4db1a0fb32 | |||
| f246c1f0f6 | |||
| 36b4018858 | |||
| 6b0c292d34 | |||
| 51de91f8d4 | |||
| 41e9ae33dc | |||
| 870241b37f | |||
| a827b5a39f | |||
| 0663ac873e | |||
| 7cfe005154 | |||
| f421b8d231 | |||
| 5977659dd4 | |||
| 01c1e7015e | |||
| 10d160bbc2 | |||
| f321c2506a | |||
| 976574c6ac | |||
| f3c7fb8853 | |||
| cf8e50fb9c | |||
| fec99878de | |||
| f041e0e1ba | |||
| 64c94ca9f4 | |||
| 4ad53f99c1 | |||
| 819e632d8e | |||
| 5100a93659 | |||
| a4fdd93ede | |||
| 1e1b1f7c97 | |||
| 96b6754f29 | |||
| 952b9a806b | |||
| 42bc48c1df | |||
| 999f4c1a6a | |||
| f98a2ab127 | |||
| f94d0f7ca9 | |||
| fc5811c3b6 | |||
| 9833bc6593 | |||
| 8d1842efa7 | |||
| 294766b8da | |||
| c5f9dcce73 | |||
| a185abf6d8 | |||
| 9f13cc20a5 | |||
| 3ab9024152 | |||
| f15008a8db | |||
| 514f970233 | |||
| db94838249 | |||
| f76244d00c | |||
| 98f50641c8 | |||
| 0f1e303c27 | |||
| 08498a64d8 |
70
.github/workflows/app_build.yaml
vendored
70
.github/workflows/app_build.yaml
vendored
@@ -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
|
|
||||||
@@ -10,21 +10,16 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-and-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.environment_name }}
|
environment: ${{ inputs.environment_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
api
|
api
|
||||||
|
client
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
name: Install pnpm
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Install Nest CLI
|
- name: Install Nest CLI
|
||||||
run: |
|
run: |
|
||||||
@@ -37,16 +32,26 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pnpm install
|
npm ci
|
||||||
|
|
||||||
- name: Build
|
- name: Build API
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter api build
|
npm run build -w api
|
||||||
|
|
||||||
- name: Deploy
|
- name: Test API
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter api --prod deploy ./.prod/api
|
npm run test -w api
|
||||||
|
|
||||||
|
- name: Build Client
|
||||||
|
env:
|
||||||
|
VITE_API_URL: ${{ vars.VITE_API_URL }}
|
||||||
|
VITE_BASE_URL: ${{ vars.VITE_BASE_URL }}
|
||||||
|
VITE_CLIENT_ID: ${{ vars.VITE_CLIENT_ID }}
|
||||||
|
VITE_ISSUER_URI: ${{ vars.VITE_ISSUER_URI }}
|
||||||
|
VITE_TENANT_ID: ${{ vars.VITE_TENANT_ID }}
|
||||||
|
run: |
|
||||||
|
printenv
|
||||||
|
npm run build -w client
|
||||||
|
|
||||||
- name: Log into Docker Hub
|
- name: Log into Docker Hub
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
@@ -64,6 +69,5 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: noahspan/flying-api:${{ inputs.version_number }}
|
tags: noahspan/flying:${{ inputs.version_number }}
|
||||||
context: .
|
context: .
|
||||||
target: api
|
|
||||||
11
.github/workflows/changes.yaml
vendored
11
.github/workflows/changes.yaml
vendored
@@ -4,8 +4,8 @@ on:
|
|||||||
outputs:
|
outputs:
|
||||||
api:
|
api:
|
||||||
value: ${{ jobs.changes.outputs.api }}
|
value: ${{ jobs.changes.outputs.api }}
|
||||||
app:
|
client:
|
||||||
value: ${{ jobs.changes.outputs.app }}
|
value: ${{ jobs.changes.outputs.client }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -13,16 +13,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
api: ${{ steps.filter.outputs.api }}
|
api: ${{ steps.filter.outputs.api }}
|
||||||
app: ${{ steps.filter.outputs.app }}
|
client: ${{ steps.filter.outputs.client }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
initial-fetch-depth: 2
|
|
||||||
filters: |
|
filters: |
|
||||||
api:
|
api:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
app:
|
client:
|
||||||
- 'app/**'
|
- 'client/**'
|
||||||
6
.github/workflows/deploy.yaml
vendored
6
.github/workflows/deploy.yaml
vendored
@@ -23,13 +23,13 @@ jobs:
|
|||||||
- name: Log in to Azure
|
- name: Log in to Azure
|
||||||
uses: azure/login@v2
|
uses: azure/login@v2
|
||||||
with:
|
with:
|
||||||
client-id: ${{ vars.VITE_CLIENT_ID }}
|
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||||
tenant-id: ${{ vars.VITE_TENANT_ID }}
|
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||||
|
|
||||||
- name: Azure CLI script
|
- name: Azure CLI script
|
||||||
uses: azure/cli@v2
|
uses: azure/cli@v2
|
||||||
with:
|
with:
|
||||||
azcliversion: latest
|
azcliversion: latest
|
||||||
inlineScript: |
|
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 }}
|
||||||
39
.github/workflows/main.yaml
vendored
39
.github/workflows/main.yaml
vendored
@@ -8,48 +8,25 @@ jobs:
|
|||||||
changes:
|
changes:
|
||||||
uses: ./.github/workflows/changes.yaml
|
uses: ./.github/workflows/changes.yaml
|
||||||
|
|
||||||
build-api:
|
build:
|
||||||
if: ${{ needs.changes.outputs.api == 'true' }}
|
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true' }}
|
||||||
name: build-api
|
name: build
|
||||||
needs:
|
needs:
|
||||||
- changes
|
- changes
|
||||||
uses: ./.github/workflows/api_build.yaml
|
uses: ./.github/workflows/build_and_test.yaml
|
||||||
with:
|
with:
|
||||||
environment_name: test
|
environment_name: test
|
||||||
version_number: ${{ github.run_id }}
|
version_number: ${{ github.run_id }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
deploy-api:
|
deploy:
|
||||||
name: deploy-api
|
name: deploy
|
||||||
needs:
|
needs:
|
||||||
- changes
|
- changes
|
||||||
- build-api
|
- build
|
||||||
uses: ./.github/workflows/deploy.yaml
|
uses: ./.github/workflows/deploy.yaml
|
||||||
with:
|
with:
|
||||||
app_name: flying-api
|
app_name: flying
|
||||||
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
|
|
||||||
environment_name: test
|
environment_name: test
|
||||||
version_number: ${{ github.run_id }}
|
version_number: ${{ github.run_id }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
19
.github/workflows/pull_request.yaml
vendored
19
.github/workflows/pull_request.yaml
vendored
@@ -7,23 +7,12 @@ jobs:
|
|||||||
changes:
|
changes:
|
||||||
uses: ./.github/workflows/changes.yaml
|
uses: ./.github/workflows/changes.yaml
|
||||||
|
|
||||||
build-api:
|
build:
|
||||||
if: ${{ needs.changes.outputs.api == 'true' }}
|
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.client == 'true'}}
|
||||||
name: build-api
|
name: build
|
||||||
needs:
|
needs:
|
||||||
- changes
|
- changes
|
||||||
uses: ./.github/workflows/api_build.yaml
|
uses: ./.github/workflows/build_and_test.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
|
|
||||||
with:
|
with:
|
||||||
environment_name: pull_request
|
environment_name: pull_request
|
||||||
version_number: ${{ github.run_id }}
|
version_number: ${{ github.run_id }}
|
||||||
|
|||||||
35
.github/workflows/tag.yaml
vendored
35
.github/workflows/tag.yaml
vendored
@@ -2,43 +2,24 @@ name: Tag
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
- '**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-api:
|
build:
|
||||||
name: build-api
|
name: build
|
||||||
uses: ./.github/workflows/api_build.yaml
|
uses: ./.github/workflows/build_and_test.yaml
|
||||||
with:
|
with:
|
||||||
environment_name: prod
|
environment_name: prod
|
||||||
version_number: ${{ github.ref_name }}
|
version_number: ${{ github.ref_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
deploy-api:
|
deploy:
|
||||||
name: deploy-api
|
name: deploy
|
||||||
needs:
|
needs:
|
||||||
- build-api
|
- build
|
||||||
uses: ./.github/workflows/deploy.yaml
|
uses: ./.github/workflows/deploy.yaml
|
||||||
with:
|
with:
|
||||||
app_name: flying-api
|
app_name: flying
|
||||||
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
|
|
||||||
environment_name: prod
|
environment_name: prod
|
||||||
version_number: ${{ github.ref_name }}
|
version_number: ${{ github.ref_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -1,39 +1,16 @@
|
|||||||
# FROM --platform=linux/amd64 node:18-alpine AS base
|
FROM node:22
|
||||||
# ENV PNPM_HOME="/pnpm"
|
|
||||||
# ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
# RUN corepack enable
|
|
||||||
|
|
||||||
# FROM base AS api
|
WORKDIR app
|
||||||
# COPY /api/dist /app/dist
|
COPY ./api/dist ./api/dist
|
||||||
# COPY /api/node_modules /app/node_modules
|
COPY ./api/package.json package-lock.json ./api
|
||||||
# WORKDIR /app
|
COPY ./client/dist ./client/dist
|
||||||
# EXPOSE 3000
|
|
||||||
# # CMD ["node", "dist/main.js"]
|
|
||||||
# ENTRYPOINT ["tail", "-f", "/dev/null"]
|
|
||||||
|
|
||||||
# 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
|
WORKDIR api
|
||||||
COPY ./.prod/api .
|
RUN npm ci
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "dist/main.js"]
|
|
||||||
# ENTRYPOINT ["tail", "-f", "/dev/null"]
|
|
||||||
|
|
||||||
FROM base AS app
|
WORKDIR /
|
||||||
WORKDIR /app
|
|
||||||
COPY ./.prod/app .
|
EXPOSE 3000
|
||||||
RUN npm i -g serve
|
|
||||||
EXPOSE 8080
|
CMD ["node", "./app/api/dist/main.js"]
|
||||||
CMD [ "serve", "-s", "dist", "-p", "8080" ]
|
# ENTRYPOINT ["tail", "-f", "/dev/null"]
|
||||||
3
api/.gitignore
vendored
3
api/.gitignore
vendored
@@ -57,3 +57,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
|
|
||||||
|
|
||||||
local.settings.json
|
local.settings.json
|
||||||
|
|
||||||
|
|
||||||
|
/src/database/*.db
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "api",
|
"name": "api",
|
||||||
"version": "1.2.0",
|
"version": "2.1.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -16,33 +16,54 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"@nestjs/axios": "^3.0.3",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^11.1.6",
|
||||||
"@nestjs/config": "^3.2.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^11.1.6",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^10.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/azure-database": "^3.1.2",
|
||||||
"@noahspan/noahspan-modules": "^1.1.5",
|
"@noahspan/noahspan-modules": "^1.2.11",
|
||||||
"@schematics/angular": "^17.3.7",
|
"@schematics/angular": "^17.3.7",
|
||||||
"dotenv": "^16.4.7",
|
"@types/multer": "^1.4.12",
|
||||||
|
"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",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^10.0.0"
|
"typeorm": "^0.3.25",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"uuidv4": "^6.2.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
"@microsoft/microsoft-graph-types": "^2.40.0",
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^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": "^4.17.17",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/passport-azure-ad": "^4.3.6",
|
"@types/passport-azure-ad": "^4.3.6",
|
||||||
|
"@types/passport-openidconnect": "^0.1.3",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|||||||
@@ -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!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { LogModule } from './log/log.module';
|
||||||
import { PilotModule } from './pilot/pilot.module';
|
import { PilotModule } from './pilot/pilot.module';
|
||||||
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
|
||||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
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 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,40 +20,42 @@ import configuration from './config/configuration';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (configService: ConfigService) => {
|
||||||
return {
|
return {
|
||||||
clientId: configService.get<string>('clientId'),
|
audience: configService.get<string>('audience'),
|
||||||
clientSecret: configService.get<string>('clientSecret'),
|
issuerUrl: configService.get<string>('issuer'),
|
||||||
tenantId: configService.get<string>('tenantId')
|
jwksUri: configService.get<string>('jwksUri')
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
load: [configuration]
|
load: [configuration]
|
||||||
}),
|
}),
|
||||||
FeatureFlagModule,
|
HealthModule,
|
||||||
LogModule,
|
LogModule,
|
||||||
PilotModule,
|
MsGraphModule.registerAsync({
|
||||||
UserModule.registerAsync({
|
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (configService: ConfigService) => {
|
||||||
return {
|
return {
|
||||||
|
authority: configService.get<string>('authority'),
|
||||||
clientId: configService.get<string>('clientId'),
|
clientId: configService.get<string>('clientId'),
|
||||||
clientSecret: configService.get<string>('clientSecret'),
|
clientSecret: configService.get<string>('clientSecret'),
|
||||||
tenantId: configService.get<string>('tenantId')
|
tenantId: configService.get<string>('tenantId')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
PilotModule,
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '../..', 'client', 'dist')
|
||||||
|
}),
|
||||||
|
TrackModule,
|
||||||
|
TypeOrmModule.forRoot(dataSourceOptions)
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_FILTER,
|
provide: APP_FILTER,
|
||||||
useClass: HttpExceptionFilter
|
useClass: HttpExceptionFilter
|
||||||
},
|
}
|
||||||
// {
|
|
||||||
// provide: APP_GUARD,
|
|
||||||
// useClass: AuthGuard
|
|
||||||
// },
|
|
||||||
// Reflector
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface AuthModuleOptions {
|
|
||||||
tenantId: string;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ConfigurableModuleBuilder } from '@nestjs/common';
|
|
||||||
import { AuthModuleOptions } from './auth.interface';
|
|
||||||
|
|
||||||
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<AuthModuleOptions>().build()
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
api/src/certificate/certificate.dto.ts
Normal file
0
api/src/certificate/certificate.dto.ts
Normal file
21
api/src/certificate/certificate.entity.ts
Normal file
21
api/src/certificate/certificate.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
0
api/src/certificate/certificate.module.ts
Normal file
0
api/src/certificate/certificate.module.ts
Normal file
0
api/src/certificate/certificate.service.ts
Normal file
0
api/src/certificate/certificate.service.ts
Normal file
@@ -1,6 +1,10 @@
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
||||||
|
audience: process.env.AUDIENCE,
|
||||||
|
authority: process.env.AUTHORITY,
|
||||||
clientId: process.env.CLIENT_ID,
|
clientId: process.env.CLIENT_ID,
|
||||||
clientSecret: process.env.CLIENT_SECRET,
|
clientSecret: process.env.CLIENT_SECRET,
|
||||||
|
issuer: process.env.ISSUER_URL,
|
||||||
|
jwksUri: process.env.JWKS_URI,
|
||||||
tenantId: process.env.TENANT_ID
|
tenantId: process.env.TENANT_ID
|
||||||
})
|
})
|
||||||
20
api/src/database/data-source.ts
Normal file
20
api/src/database/data-source.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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'),
|
||||||
|
migrationsRun: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSource = new DataSource(dataSourceOptions);
|
||||||
|
|
||||||
|
export default dataSource;
|
||||||
4
api/src/database/litestream.yml
Normal file
4
api/src/database/litestream.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/data/flying.db
|
||||||
|
replicas:
|
||||||
|
- path: /mnt/data/backup
|
||||||
@@ -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"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
api/src/endorsement/endorsement.controller.ts
Normal file
65
api/src/endorsement/endorsement.controller.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
api/src/endorsement/endorsement.dto.ts
Normal file
3
api/src/endorsement/endorsement.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class EndorsementDto {
|
||||||
|
|
||||||
|
}
|
||||||
18
api/src/endorsement/endorsement.entity.ts
Normal file
18
api/src/endorsement/endorsement.entity.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { PilotEntity } from '../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
|
||||||
|
}
|
||||||
0
api/src/endorsement/endorsement.module.ts
Normal file
0
api/src/endorsement/endorsement.module.ts
Normal file
32
api/src/endorsement/endorsement.service.ts
Normal file
32
api/src/endorsement/endorsement.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export class FeatureFlagDto {
|
|
||||||
partitionKey: string;
|
|
||||||
rowKey: string;
|
|
||||||
active: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { EntityString } from '@noahspan/azure-database';
|
|
||||||
|
|
||||||
export class FeatureFlag {
|
|
||||||
@EntityString() partitionKey: string;
|
|
||||||
@EntityString() rowKey: string;
|
|
||||||
@EntityString() active: string;
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
89
api/src/file/file.service.ts
Normal file
89
api/src/file/file.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { BlobServiceClient, BlockBlobClient } from '@azure/storage-blob';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable() export class FileService {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
private containerName: string;
|
||||||
|
|
||||||
|
private streamToBuffer(readableStream: NodeJS.ReadableStream) {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
readableStream.on('data', (data) => {
|
||||||
|
chunks.push(data instanceof Buffer ? data : Buffer.from(data));
|
||||||
|
});
|
||||||
|
readableStream.on('end', () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
readableStream.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlobServiceInstance() {
|
||||||
|
const connectionString = this.configService.get<string>('azureStorageConnectionString');
|
||||||
|
const blobServiceClient: BlobServiceClient = await BlobServiceClient.fromConnectionString(connectionString)
|
||||||
|
|
||||||
|
return blobServiceClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlobClient(fileName: string): Promise<BlockBlobClient> {
|
||||||
|
const blobService = await this.getBlobServiceInstance();
|
||||||
|
const containerName = this.containerName;
|
||||||
|
const containerClient = blobService.getContainerClient(containerName);
|
||||||
|
const blockBlobClient = containerClient.getBlockBlobClient(fileName);
|
||||||
|
|
||||||
|
return blockBlobClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file: Express.Multer.File, containerName: string, logId: string): Promise<string> {
|
||||||
|
this.containerName = containerName;
|
||||||
|
|
||||||
|
const blockBlobClient = await this.getBlobClient(`${logId}/${file.originalname}`);
|
||||||
|
const fileUrl = blockBlobClient.url;
|
||||||
|
|
||||||
|
await blockBlobClient.uploadData(file.buffer);
|
||||||
|
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(containerName: string, logId: string, fileName: string): Promise<string> {
|
||||||
|
this.containerName = containerName;
|
||||||
|
|
||||||
|
const blockBlobClient = await this.getBlobClient(`${logId}/${fileName}`);
|
||||||
|
const downloadBlockBlobResponse = await blockBlobClient.download();
|
||||||
|
const downloaded: string = (await this.streamToBuffer(downloadBlockBlobResponse.readableStreamBody)).toString()
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(containerName: string, logId: string, fileName: string): Promise<void> {
|
||||||
|
this.containerName = containerName;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
api/src/health/health.controller.spec.ts
Normal file
25
api/src/health/health.controller.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { HealthController } from "./health.controller"
|
||||||
|
import { HttpStatus } from "@nestjs/common";
|
||||||
|
|
||||||
|
describe('HealthController', () => {
|
||||||
|
let controller: HealthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [HealthController]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<HealthController>(HealthController);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isHealth => should return status ok', async () => {
|
||||||
|
const result = await controller.isHealthy();
|
||||||
|
|
||||||
|
expect(result).toEqual(HttpStatus.OK);
|
||||||
|
})
|
||||||
|
})
|
||||||
13
api/src/health/health.controller.ts
Normal file
13
api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
async isHealthy(): Promise<HttpStatus> {
|
||||||
|
return HttpStatus.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
7
api/src/health/health.module.ts
Normal file
7
api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -1,39 +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];
|
|
||||||
|
|
||||||
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,
|
|
||||||
notes: log.notes
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return logs;
|
|
||||||
} else {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler.handle().pipe(map((data) => data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
449
api/src/log/log.controller.spec.ts
Normal file
449
api/src/log/log.controller.spec.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LogController } from './log.controller';
|
||||||
|
import { LogService } from './log.service';
|
||||||
|
import { LogDto } from './log.dto';
|
||||||
|
import { LogEntity } from './log.entity';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
describe('LogController', () => {
|
||||||
|
let controller: LogController;
|
||||||
|
|
||||||
|
const mockLogService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findLogsWithCount: jest.fn(),
|
||||||
|
findLogsWithTracks: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [LogController],
|
||||||
|
providers: [
|
||||||
|
ConfigService,
|
||||||
|
FileService,
|
||||||
|
{
|
||||||
|
provide: LogService,
|
||||||
|
useValue: mockLogService
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile()
|
||||||
|
|
||||||
|
controller = module.get<LogController>(LogController);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should find a log by id', async () => {
|
||||||
|
const log = {
|
||||||
|
id: '95834f84-0a02-44d3-884e-a20237adeca0',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockReturnValue(log);
|
||||||
|
|
||||||
|
const result = await controller.find(log.id);
|
||||||
|
|
||||||
|
expect(result).toEqual(log);
|
||||||
|
expect(mockLogService.find).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.find).toHaveBeenCalledWith(log.id);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should fail to find a log by id', async () => {
|
||||||
|
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockRejectedValue(new Error('Log not found'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.find(id);
|
||||||
|
|
||||||
|
fail('find did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.find).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.find).toHaveBeenCalledWith(id);
|
||||||
|
expect(mockLogService.find).rejects.toThrow('Log not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithCount => should find logs with count', async () => {
|
||||||
|
const log = {
|
||||||
|
id: '95834f84-0a02-44d3-884e-a20237adeca0',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as LogEntity;
|
||||||
|
const logs = [log]
|
||||||
|
const count = 1
|
||||||
|
const morePages = false
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'findLogsWithCount').mockReturnValue({
|
||||||
|
entities: logs,
|
||||||
|
total: count,
|
||||||
|
hasNextPage: morePages
|
||||||
|
});
|
||||||
|
|
||||||
|
const {entities, total, hasNextPage} = await controller.findLogsWithCount();
|
||||||
|
|
||||||
|
expect(entities).toEqual(logs);
|
||||||
|
expect(total).toEqual(count);
|
||||||
|
expect(hasNextPage).toEqual(morePages)
|
||||||
|
expect(mockLogService.findLogsWithCount).toHaveBeenCalled();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithCount => should fail to find logs with count', async () => {
|
||||||
|
jest.spyOn(mockLogService, 'findLogsWithCount').mockRejectedValue(new Error('Logs not found'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.findLogsWithCount();
|
||||||
|
|
||||||
|
fail('findLogsWithCount did not throw error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.findLogsWithCount).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.findLogsWithCount).rejects.toThrow('Logs not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithTracks => should find logs with tracks', async () => {
|
||||||
|
const log = {
|
||||||
|
id: '95834f84-0a02-44d3-884e-a20237adeca0',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as LogEntity;
|
||||||
|
const logs = [log]
|
||||||
|
const count = 1
|
||||||
|
const morePages = false
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'findLogsWithTracks').mockReturnValue({
|
||||||
|
entities: logs,
|
||||||
|
total: count,
|
||||||
|
hasNextPage: morePages
|
||||||
|
});
|
||||||
|
|
||||||
|
const {entities, total, hasNextPage} = await controller.findLogsWithTracks();
|
||||||
|
|
||||||
|
expect(entities).toEqual(logs);
|
||||||
|
expect(total).toEqual(count);
|
||||||
|
expect(hasNextPage).toEqual(morePages)
|
||||||
|
expect(mockLogService.findLogsWithTracks).toHaveBeenCalled();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithCount => should fail to find logs with tracks', async () => {
|
||||||
|
jest.spyOn(mockLogService, 'findLogsWithTracks').mockRejectedValue(new Error('Logs not found'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.findLogsWithTracks();
|
||||||
|
|
||||||
|
fail('findLogsWithTracks did not throw error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.findLogsWithTracks).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.findLogsWithTracks).rejects.toThrow('Logs not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should create a new log', async () => {
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto;
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
id: '95834f84-0a02-44d3-884e-a20237adeca0',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'create').mockReturnValue(log);
|
||||||
|
|
||||||
|
const result = await controller.create(logDto);
|
||||||
|
|
||||||
|
expect(mockLogService.create).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.create).toHaveBeenCalledWith(logDto);
|
||||||
|
expect(result).toEqual(log);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should fail to create a new log', async () => {
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto;
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'create').mockRejectedValue(new Error('Log failed to create'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.create(logDto);
|
||||||
|
|
||||||
|
fail('create did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.create).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.create).toHaveBeenCalledWith(logDto);
|
||||||
|
expect(mockLogService.create).rejects.toThrow('Log failed to create')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should update an existing log', async () => {
|
||||||
|
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'update').mockReturnValue(logDto);
|
||||||
|
|
||||||
|
const result = await controller.update(id, logDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(logDto);
|
||||||
|
expect(mockLogService.update).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.update).toHaveBeenCalledWith(id, logDto);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should fail to update an exising log', async () => {
|
||||||
|
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto;
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'update').mockRejectedValue(new Error('Log failed to update'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.update(id, logDto);
|
||||||
|
|
||||||
|
fail('update did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.update).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.update).toHaveBeenCalledWith(id, logDto);
|
||||||
|
expect(mockLogService.update).rejects.toThrow('Log failed to update')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete and existing log', async () => {
|
||||||
|
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'delete');
|
||||||
|
|
||||||
|
const result = await controller.delete(id);
|
||||||
|
|
||||||
|
expect(mockLogService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.delete).toHaveBeenCalledWith(id);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should fail to delete and exising log', async () => {
|
||||||
|
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'delete').mockRejectedValue(new Error('Log failed to delete'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.delete(id);
|
||||||
|
|
||||||
|
fail('delete did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockLogService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.delete).toHaveBeenCalledWith(id);
|
||||||
|
expect(mockLogService.delete).rejects.toThrow('Log failed to delete')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,40 +7,62 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LogDto } from './log.dto';
|
import { LogDto } from './log.dto';
|
||||||
import { Log } from './log.entity';
|
import { LogEntity } from './log.entity';
|
||||||
import { LogService } from './log.service';
|
import { LogService } from './log.service';
|
||||||
import { CustomError } from '../error/customError';
|
import { CustomError } from '../error/customError';
|
||||||
import { AuthGuard } from '@noahspan/noahspan-modules';
|
import { AuthGuard, Public } from '@noahspan/noahspan-modules';
|
||||||
import { LogInterceptor } from './interceptors/log.interceptor';
|
import { LogInterceptor } from './log.interceptor';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { DeleteResult, InsertResult, UpdateResult } from 'typeorm';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Logs } from './logs.interface';
|
||||||
|
|
||||||
|
const reflector = new Reflector();
|
||||||
|
|
||||||
@Controller('logs')
|
@Controller('logs')
|
||||||
@UseInterceptors(new LogInterceptor())
|
@UseInterceptors(new LogInterceptor(reflector))
|
||||||
export class LogController {
|
export class LogController {
|
||||||
constructor(private readonly logService: LogService) {}
|
constructor(
|
||||||
|
private readonly fileService: FileService,
|
||||||
@Get(':partitionKey/:rowKey')
|
private readonly logService: LogService
|
||||||
async find(
|
) {}
|
||||||
@Param('partitionKey') partitionKey: string,
|
|
||||||
@Param('rowKey') rowKey: string
|
|
||||||
): Promise<Log> {
|
|
||||||
try {
|
|
||||||
return await this.logService.find(partitionKey, rowKey);
|
|
||||||
} catch (error) {
|
|
||||||
const customError = error as CustomError;
|
|
||||||
|
|
||||||
throw new HttpException(customError.message, customError.statusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(): Promise<Log[]> {
|
@Public()
|
||||||
|
async findLogsWithCount(@Query('skip') skip?, @Query('take') take?: number,): Promise<Logs> {
|
||||||
try {
|
try {
|
||||||
return await this.logService.findAll();
|
return await this.logService.findLogsWithCount(skip, take)
|
||||||
|
} catch (error) {
|
||||||
|
const customError = error as CustomError;
|
||||||
|
|
||||||
|
throw new HttpException(customError.message, customError.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('flights')
|
||||||
|
@Public()
|
||||||
|
async findLogsWithTracks(@Query('skip') skip?, @Query('take') take?: number,): Promise<Logs> {
|
||||||
|
try {
|
||||||
|
return await this.logService.findLogsWithTracks(skip, take)
|
||||||
|
} catch (error) {
|
||||||
|
const customError = error as CustomError;
|
||||||
|
|
||||||
|
throw new HttpException(customError.message, customError.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
|
async find(
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<LogEntity> {
|
||||||
|
try {
|
||||||
|
return await this.logService.find(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -48,15 +70,11 @@ export class LogController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() logDto: LogDto): Promise<Log> {
|
@UseGuards(AuthGuard)
|
||||||
|
async create(@Body() logDto: LogDto) {
|
||||||
try {
|
try {
|
||||||
const log = new Log();
|
return await this.logService.create(logDto);
|
||||||
|
|
||||||
Object.assign(log, logDto);
|
|
||||||
|
|
||||||
return await this.logService.create(log);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -64,19 +82,14 @@ export class LogController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Put(':partitionKey/:rowKey')
|
|
||||||
async update(
|
async update(
|
||||||
@Param('partitionKey') partitionKey: string,
|
@Param('id') id: string,
|
||||||
@Param('rowKey') rowKey: string,
|
|
||||||
@Body() logDto: LogDto
|
@Body() logDto: LogDto
|
||||||
): Promise<Log> {
|
) {
|
||||||
try {
|
try {
|
||||||
const log = new Log();
|
return await this.logService.update(id, logDto);
|
||||||
|
|
||||||
Object.assign(log, logDto);
|
|
||||||
|
|
||||||
return await this.logService.update(partitionKey, rowKey, log);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -84,14 +97,13 @@ export class LogController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete(':partitionKey/:rowKey')
|
|
||||||
async delete(
|
async delete(
|
||||||
@Param('partitionKey') partitionKey: string,
|
@Param('id') id: string,
|
||||||
@Param('rowKey') rowKey: string
|
): Promise<DeleteResult> {
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
return await this.logService.delete(partitionKey, rowKey);
|
return await this.logService.delete(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
export class LogDto {
|
export class LogDto {
|
||||||
pilotId: string;
|
pilotId: string;
|
||||||
pilotName: string;
|
date: Date;
|
||||||
date: string;
|
|
||||||
aircraftMakeModel: string;
|
aircraftMakeModel: string;
|
||||||
aircraftIdentity: string;
|
aircraftIdentity: string;
|
||||||
routeFrom: string;
|
routeFrom: string;
|
||||||
routeTo: string;
|
routeTo: string;
|
||||||
durationOfFlight: number;
|
durationOfFlight?: number;
|
||||||
singleEngineLand: string;
|
singleEngineLand?: number;
|
||||||
simulatorAtd: number;
|
simulatorAtd?: number;
|
||||||
landingsDay: number;
|
landingsDay?: number;
|
||||||
landingsNight: number;
|
landingsNight?: number;
|
||||||
instrumentActual: number;
|
instrumentActual?: number;
|
||||||
instrumentSimulated: number;
|
instrumentSimulated?: number;
|
||||||
instrumentApproaches: number;
|
instrumentApproaches?: number;
|
||||||
instrumentHolds: number;
|
instrumentHolds?: number;
|
||||||
instrumentNavTrack: number;
|
instrumentNavTrack?: number;
|
||||||
groundTrainingReceived: number;
|
groundTrainingReceived?: number;
|
||||||
flightTrainingReceived: number;
|
flightTrainingReceived?: number;
|
||||||
crossCountry: number;
|
crossCountry?: number;
|
||||||
night: number;
|
night?: number;
|
||||||
solo: number;
|
solo?: number;
|
||||||
pilotInCommand: number;
|
pilotInCommand?: number;
|
||||||
notes: string;
|
notes?: string;
|
||||||
|
tracks?: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,165 @@
|
|||||||
export class Log {
|
import { PilotEntity } from '../pilot/pilot.entity';
|
||||||
partitionKey: string;
|
import { TrackEntity } from '../track/track.entity';
|
||||||
rowKey: string;
|
import { ColumnNumericTransformer } from '../transformers/columnNumeric.transformer';
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'logs' })
|
||||||
|
export class LogEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
pilotId: string;
|
pilotId: string;
|
||||||
pilotName: string;
|
|
||||||
date: string;
|
@Column()
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@Column()
|
||||||
aircraftMakeModel: string;
|
aircraftMakeModel: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
aircraftIdentity: string;
|
aircraftIdentity: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
routeFrom: string;
|
routeFrom: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
routeTo: string;
|
routeTo: string;
|
||||||
durationOfFlight: number | null;
|
|
||||||
|
@Column('numeric', {
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer()
|
||||||
|
})
|
||||||
|
durationOfFlight: number;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
singleEngineLand: number | null;
|
singleEngineLand: number | null;
|
||||||
simulatorAtd?: number | null;
|
|
||||||
landingsDay?: number | null;
|
@Column('numeric', {
|
||||||
landingsNight?: number | null;
|
nullable: true,
|
||||||
groundTrainingReceived?: number;
|
precision: 10,
|
||||||
flightTrainingReceived?: number;
|
scale: 1,
|
||||||
crossCountry?: number | null;
|
transformer: new ColumnNumericTransformer(),
|
||||||
night?: number | null;
|
})
|
||||||
solo?: number | null;
|
simulatorAtd: number | null;
|
||||||
pilotInCommand?: number | null;
|
|
||||||
instrumentActual?: number | null;
|
@Column('numeric', {
|
||||||
instrumentSimulated?: number | null;
|
nullable: true,
|
||||||
instrumentApproaches?: number | null;
|
precision: 10,
|
||||||
instrumentHolds?: number | null;
|
scale: 1,
|
||||||
instrumentNavTrack?: number | null;
|
transformer: new ColumnNumericTransformer(),
|
||||||
notes?: string;
|
})
|
||||||
|
landingsDay: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
landingsNight: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
groundTrainingReceived: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
flightTrainingReceived: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
crossCountry: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
night: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
solo: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
pilotInCommand: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
instrumentActual: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
instrumentSimulated: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
instrumentApproaches: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
instrumentHolds: number | null;
|
||||||
|
|
||||||
|
@Column('numeric', {
|
||||||
|
nullable: true,
|
||||||
|
precision: 10,
|
||||||
|
scale: 1,
|
||||||
|
transformer: new ColumnNumericTransformer(),
|
||||||
|
})
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
78
api/src/log/log.interceptor.ts
Normal file
78
api/src/log/log.interceptor.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
import { LogEntity } from './log.entity';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
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: any) => {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const limitData = (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 = jwtDecode(token);
|
||||||
|
const rolesKeyName = Object.keys(jwtPayload).find((key) => key.includes('roles'));
|
||||||
|
|
||||||
|
if (jwtPayload[rolesKeyName].includes('Flying.Read')) {
|
||||||
|
if (data.entities) {
|
||||||
|
const logs = data.entities.map((entity) => limitData(data.entities));
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities: logs,
|
||||||
|
total: data.total,
|
||||||
|
hasNextPage: data.hasNextPage
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const log = limitData(data);
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} else if (!req.headers.authorization && isPublic) {
|
||||||
|
if (data.entities) {
|
||||||
|
const publicData = data.entities.map((entity) => limitData(entity))
|
||||||
|
const logs = publicData.slice(0, 5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities: logs,
|
||||||
|
total: data.total,
|
||||||
|
hasNextPage: false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const publicData = limitData(data);
|
||||||
|
|
||||||
|
return publicData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { LogController } from './log.controller';
|
import { LogController } from './log.controller';
|
||||||
import { LogService } from './log.service';
|
import { LogService } from './log.service';
|
||||||
import { AzureTableStorageModule } from '@noahspan/azure-database';
|
import { LogEntity } from './log.entity';
|
||||||
import { Log } from './log.entity';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { FileService } from '../file/file.service';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { PilotModule } from '../pilot/pilot.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AzureTableStorageModule.forRootAsync({
|
PilotModule,
|
||||||
imports: [ConfigModule],
|
TypeOrmModule.forFeature([LogEntity])
|
||||||
useFactory: async (configService: ConfigService) => {
|
|
||||||
return {
|
|
||||||
connectionString: configService.get<string>('azureStorageConnectionString')
|
|
||||||
};
|
|
||||||
},
|
|
||||||
inject: [ConfigService]
|
|
||||||
}),
|
|
||||||
AzureTableStorageModule.forFeature(Log, {
|
|
||||||
createTableIfNotExists: false,
|
|
||||||
table: 'logs'
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [LogController],
|
controllers: [LogController],
|
||||||
providers: [LogService]
|
exports: [LogService],
|
||||||
|
providers: [
|
||||||
|
ConfigService,
|
||||||
|
FileService,
|
||||||
|
LogService
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class LogModule {}
|
export class LogModule {}
|
||||||
|
|||||||
366
api/src/log/log.service.spec.ts
Normal file
366
api/src/log/log.service.spec.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LogService } from './log.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { LogEntity } from './log.entity';
|
||||||
|
import { LogDto } from './log.dto';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PilotService } from '../pilot/pilot.service';
|
||||||
|
import { PilotEntity } from '../pilot/pilot.entity';
|
||||||
|
|
||||||
|
describe('LogService', () => {
|
||||||
|
let service: LogService;
|
||||||
|
|
||||||
|
const mockFileService = {
|
||||||
|
deleteFolder: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnThis(),
|
||||||
|
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLogRepository = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||||
|
delete: jest.fn(),
|
||||||
|
findAndCount: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPilotRepository = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ConfigService,
|
||||||
|
LogService,
|
||||||
|
PilotService,
|
||||||
|
{
|
||||||
|
provide: FileService,
|
||||||
|
useValue: mockFileService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(LogEntity),
|
||||||
|
useValue: mockLogRepository
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(PilotEntity),
|
||||||
|
useValue: mockPilotRepository
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<LogService>(LogService);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should create a log entry', async () => {
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto
|
||||||
|
|
||||||
|
jest.spyOn(mockLogRepository, 'save').mockReturnValue(logDto);
|
||||||
|
|
||||||
|
const result = await service.create(logDto);
|
||||||
|
|
||||||
|
expect(mockLogRepository.save).toHaveBeenCalled();
|
||||||
|
expect(mockLogRepository.save).toHaveBeenCalledWith(logDto);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete a log entry', async () => {
|
||||||
|
const id: string = '';
|
||||||
|
const log = {
|
||||||
|
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockFileService, 'deleteFolder').mockReturnValue(undefined);
|
||||||
|
jest.spyOn(mockLogRepository, 'delete').mockReturnValue(log);
|
||||||
|
|
||||||
|
const result = await service.delete(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(log);
|
||||||
|
expect(mockLogRepository.delete).toHaveBeenCalled();
|
||||||
|
expect(mockLogRepository.delete).toHaveBeenCalledWith({ id: id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should find a log entry by id', async () => {
|
||||||
|
const id: string = 'd685f1ca-28e0-40b9-8713-74467db12965';
|
||||||
|
const log = {
|
||||||
|
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockLogRepository, 'findOne').mockReturnValue(log);
|
||||||
|
|
||||||
|
const result = await service.find(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(log);
|
||||||
|
expect(mockLogRepository.findOne).toHaveBeenCalled();
|
||||||
|
expect(mockLogRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
relations: [
|
||||||
|
'pilot',
|
||||||
|
'tracks'
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithCount => should find log entries with count', async () => {
|
||||||
|
const log = {
|
||||||
|
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as LogEntity;
|
||||||
|
const logs = [log];
|
||||||
|
const count = 1
|
||||||
|
const morePages = false
|
||||||
|
|
||||||
|
jest.spyOn(mockQueryBuilder, 'getManyAndCount').mockReturnValue([logs, count, morePages]);
|
||||||
|
|
||||||
|
const {entities, total, hasNextPage} = await service.findLogsWithCount();
|
||||||
|
|
||||||
|
expect(entities).toEqual(logs);
|
||||||
|
expect(count).toEqual(total);
|
||||||
|
expect(hasNextPage).toEqual(morePages);
|
||||||
|
expect(mockQueryBuilder.getManyAndCount).toHaveBeenCalled();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findLogsWithTracks => should find log entries with tracks', async () => {
|
||||||
|
const log = {
|
||||||
|
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
|
||||||
|
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as LogEntity;
|
||||||
|
const logs = [log];
|
||||||
|
const count = 1
|
||||||
|
const morePages = false
|
||||||
|
|
||||||
|
jest.spyOn(mockQueryBuilder, 'getManyAndCount').mockResolvedValue([logs, count, morePages])
|
||||||
|
|
||||||
|
const {entities, total, hasNextPage} = await service.findLogsWithTracks();
|
||||||
|
|
||||||
|
expect(entities).toEqual(logs);
|
||||||
|
expect(count).toEqual(total);
|
||||||
|
expect(hasNextPage).toEqual(morePages);
|
||||||
|
expect(mockQueryBuilder.getManyAndCount).toHaveBeenCalled();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should update a log entry', async () => {
|
||||||
|
const id: string = 'd685f1ca-28e0-40b9-8713-74467db12965';
|
||||||
|
const logDto = {
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test'
|
||||||
|
} as LogDto
|
||||||
|
const log = {
|
||||||
|
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockLogRepository, 'findOneBy').mockReturnValue(log);
|
||||||
|
jest.spyOn(mockLogRepository, 'save').mockReturnValue(logDto);
|
||||||
|
|
||||||
|
const result = await service.update(id, logDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(logDto);
|
||||||
|
expect(mockLogRepository.findOneBy).toHaveBeenCalled();
|
||||||
|
expect(mockLogRepository.findOneBy).toHaveBeenCalledWith({ id: id });
|
||||||
|
expect(mockLogRepository.save).toHaveBeenCalled();
|
||||||
|
expect(mockLogRepository.save).toHaveBeenCalledWith(logDto);
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,34 +1,87 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository, Repository } from '@noahspan/azure-database';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Log } from './log.entity';
|
import { LogEntity } from './log.entity';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
|
||||||
|
import { LogDto } from './log.dto';
|
||||||
|
import { PilotService } from '../pilot/pilot.service';
|
||||||
|
import { PilotEntity } from '../pilot/pilot.entity';
|
||||||
|
import { CustomError } from '../error/customError';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { Logs } from './logs.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogService {
|
export class LogService {
|
||||||
constructor(
|
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> {
|
async find(id: string): Promise<LogEntity> {
|
||||||
return await this.logRepository.find(partitionKey, rowKey);
|
return await this.logRepository.findOne({
|
||||||
|
where: { id: id },
|
||||||
|
relations: ['pilot', 'tracks']
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Log[]> {
|
async findLogsWithCount(skip?: number, take?: number): Promise<Logs> {
|
||||||
return await this.logRepository.findAll();
|
// const [entities, total] = await this.logRepository.findAndCount({
|
||||||
|
// take,
|
||||||
|
// skip,
|
||||||
|
// order: {
|
||||||
|
// date: 'DESC'
|
||||||
|
// },
|
||||||
|
// relations: ['pilot', 'tracks']
|
||||||
|
// })
|
||||||
|
|
||||||
|
const [entities, total] = await this.logRepository
|
||||||
|
.createQueryBuilder('logs')
|
||||||
|
.innerJoinAndSelect('logs.pilot', 'pilot')
|
||||||
|
.orderBy('logs.date', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getManyAndCount()
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities,
|
||||||
|
total,
|
||||||
|
hasNextPage: skip + take < total
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(log: Log): Promise<Log> {
|
async findLogsWithTracks(skip?: number, take?: number): Promise<Logs> {
|
||||||
log.partitionKey = 'log';
|
const [entities, total] = await this.logRepository
|
||||||
log.rowKey = uuidv4();
|
.createQueryBuilder('logs')
|
||||||
|
.innerJoinAndSelect('logs.tracks', 'track')
|
||||||
|
.innerJoinAndSelect('logs.pilot', 'pilot')
|
||||||
|
.orderBy('logs.date', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
return await this.logRepository.create(log);
|
return {
|
||||||
|
entities,
|
||||||
|
total,
|
||||||
|
hasNextPage: skip + take < total
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(partitionKey: string, rowKey: string, log: Log): Promise<Log> {
|
async create(logDto: LogDto): Promise<LogDto> {
|
||||||
return await this.logRepository.update(partitionKey, rowKey, log);
|
return this.logRepository.save(logDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(partitionKey: string, rowKey: string): Promise<void> {
|
async update(id: string, logDto: LogDto): Promise<LogDto> {
|
||||||
await this.logRepository.delete(partitionKey, rowKey);
|
const logEntity: LogEntity = await this.logRepository.findOneBy({ id });
|
||||||
|
const logEntityUpdated = Object.assign(logEntity, logDto)
|
||||||
|
|
||||||
|
delete logEntityUpdated.tracks;
|
||||||
|
|
||||||
|
return await this.logRepository.save(logEntityUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<DeleteResult> {
|
||||||
|
await this.fileService.deleteFolder('tracks', id);
|
||||||
|
|
||||||
|
return await this.logRepository.delete({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
api/src/log/logs.interface.ts
Normal file
7
api/src/log/logs.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { LogEntity } from "src/log/log.entity";
|
||||||
|
|
||||||
|
export interface Logs {
|
||||||
|
entities: LogEntity[],
|
||||||
|
total: number,
|
||||||
|
hasNextPage: boolean
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { AppModule } from './app.module';
|
|||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||||
import { InternalServerErrorException } from '@nestjs/common';
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import * as session from 'express-session';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const httpService = new HttpService();
|
const httpService = new HttpService();
|
||||||
@@ -12,6 +12,13 @@ async function bootstrap() {
|
|||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
httpService.axiosRef.interceptors.response.use(
|
httpService.axiosRef.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
|||||||
71
api/src/medical/medical.controller.ts
Normal file
71
api/src/medical/medical.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
api/src/medical/medical.dto.ts
Normal file
6
api/src/medical/medical.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class MedicalDto {
|
||||||
|
id: string;
|
||||||
|
class: string;
|
||||||
|
expirationDate: string;
|
||||||
|
pilotId: string;
|
||||||
|
}
|
||||||
18
api/src/medical/medical.entity.ts
Normal file
18
api/src/medical/medical.entity.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { PilotEntity } from '../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;
|
||||||
|
}
|
||||||
0
api/src/medical/medical.module.ts
Normal file
0
api/src/medical/medical.module.ts
Normal file
33
api/src/medical/medical.service.ts
Normal file
33
api/src/medical/medical.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export class Certificate {
|
|
||||||
type: string;
|
|
||||||
issueDate: string;
|
|
||||||
number: string;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export class Endorsement {
|
|
||||||
type: string;
|
|
||||||
issueDate: Date;
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
260
api/src/pilot/pilot.controller.spec.ts
Normal file
260
api/src/pilot/pilot.controller.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PilotController } from "./pilot.controller";
|
||||||
|
import { PilotService } from './pilot.service';
|
||||||
|
import { PilotDto } from './pilot.dto';
|
||||||
|
import { PilotEntity } from './pilot.entity';
|
||||||
|
import { CustomError } from '../error/customError';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('PilotController', () => {
|
||||||
|
let controller: PilotController;
|
||||||
|
|
||||||
|
const mockPilotService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [PilotController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PilotService,
|
||||||
|
useValue: mockPilotService
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<PilotController>(PilotController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('find => should find a pilot by id', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'find').mockReturnValue(pilot);
|
||||||
|
|
||||||
|
const result = await controller.find(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(pilot);
|
||||||
|
expect(mockPilotService.find).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.find).toHaveBeenCalledWith(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it('find => should fail to find a pilot by id', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'find').mockRejectedValue(new Error('Pilot not found'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.find(id);
|
||||||
|
|
||||||
|
fail('find did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect(mockPilotService.find).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.find).toHaveBeenCalledWith(id);
|
||||||
|
expect(mockPilotService.find).rejects.toThrow('Pilot not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should find all pilots', async () => {
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity;
|
||||||
|
const pilots = [pilot];
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'findAll').mockReturnValue(pilots);
|
||||||
|
|
||||||
|
const result = await controller.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual(pilots);
|
||||||
|
expect(mockPilotService.findAll).toHaveBeenCalled();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should fail to find all pilots', async () => {
|
||||||
|
jest.spyOn(mockPilotService, 'findAll').mockRejectedValue(new Error('Pilots not found'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.findAll();
|
||||||
|
|
||||||
|
fail('findAll did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect(mockPilotService.findAll).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.findAll).rejects.toThrow('Pilots not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should create a new pilot', async () => {
|
||||||
|
const pilotDto = {
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'create').mockReturnValue(pilot);
|
||||||
|
|
||||||
|
const result = await controller.create(pilotDto);
|
||||||
|
|
||||||
|
expect(mockPilotService.create).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.create).toHaveBeenCalledWith(pilotDto)
|
||||||
|
expect(result).toEqual(pilot);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should fail to create a new pilot', async () => {
|
||||||
|
const pilotDto = {
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'create').mockRejectedValue(new Error('Pilot failed to create'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.create(pilotDto);
|
||||||
|
|
||||||
|
fail('create did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect(mockPilotService.create).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.create).toHaveBeenCalledWith(pilotDto);
|
||||||
|
expect(mockPilotService.create).rejects.toThrow('Pilot failed to create')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should update an existing pilot', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilotDto = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'update').mockReturnValue(pilotDto);
|
||||||
|
|
||||||
|
const result = await controller.update(id, pilotDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(pilotDto);
|
||||||
|
expect(mockPilotService.update).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.update).toHaveBeenCalledWith(id, pilotDto)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should fail to update an existing pilot', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilotDto = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'update').mockRejectedValue(new Error('Pilot failed to update'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.update(id, pilotDto);
|
||||||
|
|
||||||
|
fail('update function did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect(mockPilotService.update).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.update).toHaveBeenCalledWith(id, pilotDto);
|
||||||
|
expect(mockPilotService.update).rejects.toThrow('Pilot failed to update')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete an existing pilot', async () => {
|
||||||
|
const id = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'delete');
|
||||||
|
|
||||||
|
const result = await controller.delete(id);
|
||||||
|
|
||||||
|
expect(mockPilotService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.delete).toHaveBeenCalledWith(id);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should fail to delete an existing pilot', async () => {
|
||||||
|
const id = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotService, 'delete').mockRejectedValue(new Error('Pilot failed to delete'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.delete(id);
|
||||||
|
|
||||||
|
fail('delete function did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect(mockPilotService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockPilotService.delete).toHaveBeenCalledWith(id);
|
||||||
|
expect(mockPilotService.delete).rejects.toThrow('Pilot failed to delete')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,24 +11,24 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PilotDto } from './pilot.dto';
|
import { PilotDto } from './pilot.dto';
|
||||||
import { Pilot } from './pilot.entity';
|
|
||||||
import { PilotService } from './pilot.service';
|
import { PilotService } from './pilot.service';
|
||||||
import { CustomError } from '../error/customError';
|
import { CustomError } from '../error/customError';
|
||||||
import { AuthGuard } from '@noahspan/noahspan-modules'
|
import { PilotInterceptor } from './pilot.interceptor';
|
||||||
import { PilotInterceptor } from './interceptors/pilot.interceptor';
|
import { AuthGuard, Public } from '@noahspan/noahspan-modules';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
|
const reflector = new Reflector();
|
||||||
|
|
||||||
@Controller('pilots')
|
@Controller('pilots')
|
||||||
@UseInterceptors(new PilotInterceptor())
|
@UseInterceptors(new PilotInterceptor(reflector))
|
||||||
export class PilotController {
|
export class PilotController {
|
||||||
constructor(private readonly pilotService: PilotService) {}
|
constructor(private readonly pilotService: PilotService) {}
|
||||||
|
|
||||||
@Get(':partitionKey/:rowKey')
|
@Get(':id')
|
||||||
async find(
|
@Public()
|
||||||
@Param('partitionKey') partitionKey: string,
|
async find(@Param('id') id: string) {
|
||||||
@Param('rowKey') rowKey: string
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
return await this.pilotService.find(partitionKey, rowKey);
|
return await this.pilotService.find(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export class PilotController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@Public()
|
||||||
async findAll() {
|
async findAll() {
|
||||||
try {
|
try {
|
||||||
return await this.pilotService.findAll();
|
return await this.pilotService.findAll();
|
||||||
@@ -47,30 +48,11 @@ export class PilotController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() pilotDto: PilotDto) {
|
@UseGuards(AuthGuard)
|
||||||
|
async create(@Body() pilotDto: PilotDto): Promise<PilotDto> {
|
||||||
try {
|
try {
|
||||||
let pilot = new Pilot();
|
return await this.pilotService.create(pilotDto);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -78,34 +60,14 @@ export class PilotController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Put(':partitionKey/:rowKey')
|
|
||||||
async update(
|
async update(
|
||||||
@Param('partitionKey') partitionKey: string,
|
@Param('id') id: string,
|
||||||
@Param('rowKey') rowKey: string,
|
|
||||||
@Body() pilotDto: PilotDto
|
@Body() pilotDto: PilotDto
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
let pilot = new Pilot();
|
return await this.pilotService.update(id, pilotDto);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
@@ -113,14 +75,13 @@ export class PilotController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete(':partitionKey/:rowKey')
|
|
||||||
async delete(
|
async delete(
|
||||||
@Param('partitionKey') partitionKey: string,
|
@Param('id') id: string,
|
||||||
@Param('rowKey') rowKey: string
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.pilotService.delete(partitionKey, rowKey);
|
return await this.pilotService.delete(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const customError = error as CustomError;
|
const customError = error as CustomError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { Certificate } from "./certificate/certificate.entity";
|
|
||||||
import { Endorsement } from "./endorsement/endorsement.entity";
|
|
||||||
|
|
||||||
export class PilotDto {
|
export class PilotDto {
|
||||||
partitionKey: string;
|
|
||||||
rowKey: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -12,8 +6,4 @@ export class PilotDto {
|
|||||||
postalCode: string;
|
postalCode: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
medicalClass?: string;
|
|
||||||
medicalExpiration?: string;
|
|
||||||
certificates: Certificate;
|
|
||||||
endorsements: Endorsement
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,47 @@
|
|||||||
import { EntityString } from '@noahspan/azure-database';
|
import { LogEntity } from '../log/log.entity';
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
|
||||||
|
import { CertificateEntity } from '../certificate/certificate.entity';
|
||||||
|
import { EndorsementEntity } from '../endorsement/endorsement.entity';
|
||||||
|
import { MedicalEntity } from '../medical/medical.entity';
|
||||||
|
|
||||||
export class Pilot {
|
@Entity({ name: 'pilots' })
|
||||||
@EntityString() partitionKey: string;
|
export class PilotEntity {
|
||||||
@EntityString() rowKey: string;
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@EntityString() id: string;
|
id: string;
|
||||||
@EntityString() name: string;
|
|
||||||
@EntityString() address?: string;
|
@Column()
|
||||||
@EntityString() city?: string;
|
name: string;
|
||||||
@EntityString() state?: string;
|
|
||||||
@EntityString() postalCode?: string;
|
@Column()
|
||||||
@EntityString() email?: string;
|
address: string
|
||||||
@EntityString() phone?: string;
|
|
||||||
@EntityString() medicalClass?: string;
|
@Column()
|
||||||
@EntityString() medicalExpiration: string;
|
city: string;
|
||||||
@EntityString() certificates: string;
|
|
||||||
@EntityString() endorsements: 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[];
|
||||||
}
|
}
|
||||||
|
|||||||
52
api/src/pilot/pilot.interceptor.ts
Normal file
52
api/src/pilot/pilot.interceptor.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
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 = jwtDecode(token);
|
||||||
|
const rolesKeyName = Object.keys(jwtPayload).find((key) => key.includes('roles'));
|
||||||
|
|
||||||
|
if (jwtPayload[rolesKeyName].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;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PilotController } from './pilot.controller';
|
import { PilotController } from './pilot.controller';
|
||||||
import { PilotService } from './pilot.service';
|
import { PilotService } from './pilot.service';
|
||||||
import { AzureTableStorageModule } from '@noahspan/azure-database';
|
import { PilotEntity } from './pilot.entity';
|
||||||
import { Pilot } from './pilot.entity';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { Log } from 'src/log/log.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AzureTableStorageModule.forRootAsync({
|
TypeOrmModule.forFeature([PilotEntity])
|
||||||
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'
|
|
||||||
})
|
|
||||||
],
|
],
|
||||||
controllers: [PilotController],
|
controllers: [PilotController],
|
||||||
|
exports: [PilotService],
|
||||||
providers: [PilotService]
|
providers: [PilotService]
|
||||||
})
|
})
|
||||||
export class PilotModule {}
|
export class PilotModule {}
|
||||||
|
|||||||
206
api/src/pilot/pilot.service.spec.ts
Normal file
206
api/src/pilot/pilot.service.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PilotService } from './pilot.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { PilotEntity } from './pilot.entity';
|
||||||
|
import { PilotDto } from './pilot.dto';
|
||||||
|
import { CustomError } from '../error/customError';
|
||||||
|
|
||||||
|
describe('PilotService', () => {
|
||||||
|
let service: PilotService;
|
||||||
|
|
||||||
|
const mockPilotRepository = {
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
PilotService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(PilotEntity),
|
||||||
|
useValue: mockPilotRepository
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PilotService>(PilotService);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should find one pilot by id', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity;
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(pilot);
|
||||||
|
|
||||||
|
const result = await service.find(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(pilot);
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should fail to find one pilot by id', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
|
||||||
|
const mockCustomError = new CustomError('Pilot not found', 'Not found', 404)
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.find(id);
|
||||||
|
|
||||||
|
fail('find did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CustomError);
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should find all pilots', async () => {
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity;
|
||||||
|
const pilots = [pilot];
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'find').mockReturnValue(pilots);
|
||||||
|
|
||||||
|
const result = await service.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual(pilots);
|
||||||
|
expect(mockPilotRepository.find).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should create a new pilot', async () => {
|
||||||
|
const pilotDto = {
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'save').mockReturnValue(pilot);
|
||||||
|
|
||||||
|
const result = await service.create(pilotDto);
|
||||||
|
|
||||||
|
expect(mockPilotRepository.save).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.save).toHaveBeenCalledWith(pilotDto);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should update a pilot', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilotDto = {
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234'
|
||||||
|
} as PilotDto
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(pilot)
|
||||||
|
jest.spyOn(mockPilotRepository, 'save').mockReturnValue(pilotDto);
|
||||||
|
|
||||||
|
const result = await service.update(id, pilotDto)
|
||||||
|
|
||||||
|
expect(result).toEqual(pilotDto);
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
|
||||||
|
expect(mockPilotRepository.save).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.save).toHaveBeenCalledWith(pilotDto)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete a pilot', async () => {
|
||||||
|
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
|
||||||
|
const pilot = {
|
||||||
|
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
|
||||||
|
name: 'Test pilot',
|
||||||
|
address: '123 Any Street',
|
||||||
|
city: 'Any Town',
|
||||||
|
state: 'Minnesota',
|
||||||
|
postalCode: '55123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
phone: '555-555-1234',
|
||||||
|
userId: 'user@example.com',
|
||||||
|
logs: [],
|
||||||
|
certificates: [],
|
||||||
|
endorsements: [],
|
||||||
|
medical: []
|
||||||
|
} as PilotEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockPilotRepository, 'delete').mockReturnValue(pilot);
|
||||||
|
|
||||||
|
const result = await service.delete(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(pilot);
|
||||||
|
expect(mockPilotRepository.delete).toHaveBeenCalled();
|
||||||
|
expect(mockPilotRepository.delete).toHaveBeenCalledWith({ id: id });
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,54 +1,49 @@
|
|||||||
import { InjectRepository, Repository } from '@noahspan/azure-database';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { PilotEntity } from './pilot.entity';
|
||||||
import { Pilot } from './pilot.entity';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Log } from 'src/log/log.entity';
|
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
|
||||||
import { LogService } from 'src/log/log.service';
|
import { PilotDto } from './pilot.dto';
|
||||||
|
import { CustomError } from '../error/customError';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PilotService {
|
export class PilotService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Pilot) private readonly pilotRepository: Repository<Pilot>,
|
@InjectRepository(PilotEntity) private readonly pilotRepository: Repository<PilotEntity>
|
||||||
@InjectRepository(Log) private readonly logRepository: Repository<Log>
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async find(partitionKey: string, rowKey: string): Promise<Pilot> {
|
async find(id: string): Promise<PilotEntity> {
|
||||||
return await this.pilotRepository.find(partitionKey, rowKey);
|
try {
|
||||||
|
const pilotEntity: 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[]> {
|
async findAll(): Promise<PilotEntity[]> {
|
||||||
return await this.pilotRepository.findAll();
|
return await this.pilotRepository.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(pilot: Pilot): Promise<Pilot> {
|
async create(pilot: PilotDto): Promise<PilotDto> {
|
||||||
// try {
|
return await this.pilotRepository.save(pilot);
|
||||||
// return await this.pilotRepository.create(pilot);
|
|
||||||
// } catch (error) {
|
|
||||||
// throw new Error(error);
|
|
||||||
// }
|
|
||||||
return await this.pilotRepository.create(pilot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
partitionKey: string,
|
id: string,
|
||||||
rowKey: string,
|
pilotDto: PilotDto
|
||||||
pilot: Pilot
|
): Promise<PilotDto> {
|
||||||
): Promise<Pilot> {
|
const pilotEntity: PilotEntity = await this.pilotRepository.findOneBy({ id })
|
||||||
return await this.pilotRepository.update(partitionKey, rowKey, pilot);
|
const pilotEntityUpdated = Object.assign(pilotEntity, pilotDto)
|
||||||
|
|
||||||
|
return await this.pilotRepository.save(pilotEntityUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(partitionKey: string, rowKey: string): Promise<void> {
|
async delete(id: string): Promise<DeleteResult> {
|
||||||
const pilotLogs: Log[] = await this.logRepository.findAll({
|
return await this.pilotRepository.delete({ id });
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
218
api/src/track/track.controller.spec.ts
Normal file
218
api/src/track/track.controller.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TrackController } from './track.controller';
|
||||||
|
import { TrackService } from './track.service';
|
||||||
|
import { TrackDto } from './track.dto';
|
||||||
|
import { TrackEntity } from './track.entity';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { DeleteResult, InsertResult } from 'typeorm';
|
||||||
|
|
||||||
|
describe('TrackController', () => {
|
||||||
|
let controller: TrackController;
|
||||||
|
|
||||||
|
const mockTrackService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
downloadTrackFile: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [TrackController],
|
||||||
|
providers: [
|
||||||
|
ConfigService,
|
||||||
|
FileService,
|
||||||
|
{
|
||||||
|
provide: TrackService,
|
||||||
|
useValue: mockTrackService
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<TrackController>(TrackController);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should find all tracks by log id', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const track = {
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
url: 'http://track.example.com',
|
||||||
|
order: 1
|
||||||
|
} as TrackEntity
|
||||||
|
const tracks = [track]
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'findAll').mockReturnValue(tracks);
|
||||||
|
|
||||||
|
const result = await controller.findAll(logId);
|
||||||
|
|
||||||
|
expect(result).toEqual(tracks);
|
||||||
|
expect(mockTrackService.findAll).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.findAll).toHaveBeenLastCalledWith(logId);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should fail to find all tracks by log id', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'findAll').mockRejectedValue(new Error('Tracks not found'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.findAll(logId);
|
||||||
|
|
||||||
|
fail('findAll did not throw error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(HttpException);
|
||||||
|
expect(mockTrackService.findAll).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.findAll).rejects.toThrow('Tracks not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => create a track by a log id', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const order = 1;
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'file',
|
||||||
|
originalname: 'test_track.kml',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mimetype: 'application/vnd.google-earth.kml+xml',
|
||||||
|
size: 12345,
|
||||||
|
destination: '/tmp/uploads',
|
||||||
|
filename: 'unique-filename-123.kml',
|
||||||
|
path: '/tmp/uploads/unique-filename-123.kml',
|
||||||
|
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
|
||||||
|
stream: new Readable()
|
||||||
|
}
|
||||||
|
const mockInsertResult: InsertResult = {
|
||||||
|
identifiers: [
|
||||||
|
{
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generatedMaps: [
|
||||||
|
{
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
createdAd: new Date()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
raw: {
|
||||||
|
affectedRows: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'create').mockReturnValue(mockInsertResult);
|
||||||
|
|
||||||
|
const result = await controller.create(logId, order, file);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockInsertResult);
|
||||||
|
expect(mockTrackService.create).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.create).toHaveBeenCalledWith(logId, order, file)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should fail to create a track by log id', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const order = 1;
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'file',
|
||||||
|
originalname: 'test_track.kml',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mimetype: 'application/vnd.google-earth.kml+xml',
|
||||||
|
size: 12345,
|
||||||
|
destination: '/tmp/uploads',
|
||||||
|
filename: 'unique-filename-123.kml',
|
||||||
|
path: '/tmp/uploads/unique-filename-123.kml',
|
||||||
|
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
|
||||||
|
stream: new Readable()
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'create').mockRejectedValue(new Error('Track failed to create'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.create(logId, order, file)
|
||||||
|
|
||||||
|
fail('create did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(HttpException);
|
||||||
|
expect(mockTrackService.create).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.create).toHaveBeenCalledWith(logId, order, file);
|
||||||
|
expect(mockTrackService.create).rejects.toThrow('Track failed to create')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete a track by log id', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde'
|
||||||
|
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const fileName = 'test_track.kml';
|
||||||
|
const mockDeleteResult: DeleteResult ={
|
||||||
|
raw: [],
|
||||||
|
affected: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'delete').mockReturnValue(mockDeleteResult);
|
||||||
|
|
||||||
|
const result = await controller.delete(id, fileName, logId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDeleteResult);
|
||||||
|
expect(mockTrackService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.delete).toHaveBeenCalledWith(id, logId, fileName);
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should fail to delete a track by log id', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde'
|
||||||
|
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const fileName = 'test_track.kml';
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'delete').mockRejectedValue(new Error('Track failed to delete'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.delete(id, fileName, logId);
|
||||||
|
|
||||||
|
fail('delete did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(HttpException);
|
||||||
|
expect(mockTrackService.delete).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.delete).toHaveBeenCalledWith(id, logId, fileName);
|
||||||
|
expect(mockTrackService.delete).rejects.toThrow('Track failed to delete')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('downloadTrack => should download a track by log id', async () => {
|
||||||
|
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const fileName = 'test_track.kml';
|
||||||
|
const mockStreamToBufferString = 'fake-stream-to-buffer-string'
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'downloadTrackFile').mockReturnValue(mockStreamToBufferString);
|
||||||
|
|
||||||
|
const result = await controller.downloadTrack(logId, fileName);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStreamToBufferString);
|
||||||
|
expect(mockTrackService.downloadTrackFile).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.downloadTrackFile).toHaveBeenCalledWith(logId, fileName)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('downloadTrack => should fail to download a track by log id', async () => {
|
||||||
|
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const fileName = 'test_track.kml';
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackService, 'downloadTrackFile').mockRejectedValue(new Error('Track failed to download'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.downloadTrack(logId, fileName);
|
||||||
|
|
||||||
|
fail('downloadTrack did not throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(HttpException);
|
||||||
|
expect(mockTrackService.downloadTrackFile).toHaveBeenCalled();
|
||||||
|
expect(mockTrackService.downloadTrackFile).toHaveBeenCalledWith(logId, fileName);
|
||||||
|
expect(mockTrackService.downloadTrackFile).rejects.toThrow('Track failed to download')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
80
api/src/track/track.controller.ts
Normal file
80
api/src/track/track.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5
api/src/track/track.dto.ts
Normal file
5
api/src/track/track.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class TrackDto {
|
||||||
|
logId: string;
|
||||||
|
order: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
18
api/src/track/track.entity.ts
Normal file
18
api/src/track/track.entity.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { LogEntity } from '../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;
|
||||||
|
}
|
||||||
20
api/src/track/track.module.ts
Normal file
20
api/src/track/track.module.ts
Normal 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 '../log/log.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
LogModule,
|
||||||
|
TypeOrmModule.forFeature([TrackEntity])
|
||||||
|
],
|
||||||
|
controllers: [TrackController],
|
||||||
|
providers: [
|
||||||
|
FileService,
|
||||||
|
TrackService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class TrackModule {}
|
||||||
346
api/src/track/track.service.spec.ts
Normal file
346
api/src/track/track.service.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TrackService } from './track.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { TrackEntity } from './track.entity'
|
||||||
|
import { TrackDto } from './track.dto';
|
||||||
|
import { FileService } from '../file/file.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LogService } from '../log/log.service';
|
||||||
|
import { LogEntity } from '../log/log.entity';
|
||||||
|
import { PilotService } from '../pilot/pilot.service';
|
||||||
|
import { PilotEntity } from '../pilot/pilot.entity';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { DeleteResult, InsertResult, UpdateResult } from 'typeorm';
|
||||||
|
import { CustomError } from '../error/customError';
|
||||||
|
|
||||||
|
describe('TrackService', () => {
|
||||||
|
let service: TrackService;
|
||||||
|
|
||||||
|
const mockFileService = {
|
||||||
|
deleteFile: jest.fn(),
|
||||||
|
downloadFile: jest.fn(),
|
||||||
|
uploadFile: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLogService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPilotRepository = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTrackRepository = {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
downloadTrackFile: jest.fn(),
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
insert: jest.fn(),
|
||||||
|
update: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ConfigService,
|
||||||
|
LogService,
|
||||||
|
PilotService,
|
||||||
|
TrackService,
|
||||||
|
{
|
||||||
|
provide: FileService,
|
||||||
|
useValue: mockFileService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LogService,
|
||||||
|
useValue: mockLogService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(PilotEntity),
|
||||||
|
useValue: mockPilotRepository
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(TrackEntity),
|
||||||
|
useValue: mockTrackRepository
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compile()
|
||||||
|
|
||||||
|
service = module.get<TrackService>(TrackService);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should upload a track file', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const log = {
|
||||||
|
id: '094ec69c-72c4-4995-8821-79d5b79bedda',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
const order = 1;
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'file',
|
||||||
|
originalname: 'test_track.kml',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mimetype: 'application/vnd.google-earth.kml+xml',
|
||||||
|
size: 12345,
|
||||||
|
destination: '/tmp/uploads',
|
||||||
|
filename: 'unique-filename-123.kml',
|
||||||
|
path: '/tmp/uploads/unique-filename-123.kml',
|
||||||
|
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
|
||||||
|
stream: new Readable()
|
||||||
|
}
|
||||||
|
const mockInsertResult: InsertResult = {
|
||||||
|
identifiers: [
|
||||||
|
{
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generatedMaps: [
|
||||||
|
{
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
createdAd: new Date()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
raw: {
|
||||||
|
affectedRows: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const track = {
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
url: 'http://track.example.com',
|
||||||
|
order: 1
|
||||||
|
} as TrackEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockReturnValue(log)
|
||||||
|
jest.spyOn(mockTrackRepository, 'create').mockReturnValue(track)
|
||||||
|
jest.spyOn(mockTrackRepository, 'insert').mockReturnValue(mockInsertResult);
|
||||||
|
|
||||||
|
const result = await service.create(logId, order, file);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockInsertResult)
|
||||||
|
expect(mockTrackRepository.insert).toHaveBeenCalled();
|
||||||
|
expect(mockTrackRepository.insert).toHaveBeenCalledWith({
|
||||||
|
id: track.id,
|
||||||
|
order: track.order,
|
||||||
|
url: track.url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create => should fail to find log', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const order = 1;
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'file',
|
||||||
|
originalname: 'test_track.kml',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mimetype: 'application/vnd.google-earth.kml+xml',
|
||||||
|
size: 12345,
|
||||||
|
destination: '/tmp/uploads',
|
||||||
|
filename: 'unique-filename-123.kml',
|
||||||
|
path: '/tmp/uploads/unique-filename-123.kml',
|
||||||
|
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
|
||||||
|
stream: new Readable()
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.create(logId, order, file);
|
||||||
|
|
||||||
|
fail('find failed to throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CustomError);
|
||||||
|
expect(mockLogService.find).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.find).toHaveBeenCalledWith(logId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should delete a track file', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const fileName = 'test_track.kml'
|
||||||
|
const mockDeleteResult: DeleteResult ={
|
||||||
|
raw: [],
|
||||||
|
affected: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackRepository, 'delete').mockReturnValue(mockDeleteResult);
|
||||||
|
|
||||||
|
const result = await service.delete(id, logId, fileName)
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDeleteResult);
|
||||||
|
expect(mockTrackRepository.delete).toHaveBeenCalled();
|
||||||
|
expect(mockTrackRepository.delete).toHaveBeenCalledWith({ id: id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete => should fail to delete file in file service', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const fileName = 'test_track.kml'
|
||||||
|
|
||||||
|
jest.spyOn(mockFileService, 'deleteFile').mockRejectedValue(new Error('Failed to delete file'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.delete(id, logId, fileName)
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(mockFileService.deleteFile).toHaveBeenCalled();
|
||||||
|
expect(mockFileService.deleteFile).toHaveBeenCalledWith('tracks', logId, fileName)
|
||||||
|
expect(mockFileService.deleteFile).rejects.toThrow('Failed to delete file')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('downloadTrackFile => should download a track file by log id and filename', async () => {
|
||||||
|
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
|
||||||
|
const fileName = 'test_track.kml';
|
||||||
|
const mockStreamToBufferString = 'fake-stream-to-buffer-string'
|
||||||
|
|
||||||
|
jest.spyOn(mockFileService, 'downloadFile').mockReturnValue(mockStreamToBufferString);
|
||||||
|
|
||||||
|
const result = await service.downloadTrackFile(logId, fileName);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStreamToBufferString);
|
||||||
|
expect(mockFileService.downloadFile).toHaveBeenCalled();
|
||||||
|
expect(mockFileService.downloadFile).toHaveBeenCalledWith('tracks', logId, fileName);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find => should find a track file by id', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
|
||||||
|
const track = {
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
url: 'http://track.example.com',
|
||||||
|
order: 1
|
||||||
|
} as TrackEntity
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackRepository, 'findOneBy').mockReturnValue(track)
|
||||||
|
|
||||||
|
const result = await service.find(id);
|
||||||
|
|
||||||
|
expect(result).toEqual(track);
|
||||||
|
expect(mockTrackRepository.findOneBy).toHaveBeenCalled();
|
||||||
|
expect(mockTrackRepository.findOneBy).toHaveBeenCalledWith({ id: id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should find all track files by log id', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
const log = {
|
||||||
|
id: '094ec69c-72c4-4995-8821-79d5b79bedda',
|
||||||
|
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
|
||||||
|
date: new Date('2026-02-21'),
|
||||||
|
aircraftMakeModel: 'Cessna 172M',
|
||||||
|
aircraftIdentity: 'N12345',
|
||||||
|
routeFrom: 'KMSP',
|
||||||
|
routeTo: 'KMSP',
|
||||||
|
durationOfFlight: 1,
|
||||||
|
singleEngineLand: 1,
|
||||||
|
simulatorAtd: null,
|
||||||
|
landingsDay: 1,
|
||||||
|
landingsNight: null,
|
||||||
|
groundTrainingReceived: null,
|
||||||
|
flightTrainingReceived: null,
|
||||||
|
crossCountry: 1,
|
||||||
|
night: null,
|
||||||
|
solo: 1,
|
||||||
|
pilotInCommand: 1,
|
||||||
|
instrumentActual: null,
|
||||||
|
instrumentSimulated: null,
|
||||||
|
instrumentApproaches: null,
|
||||||
|
instrumentHolds: null,
|
||||||
|
instrumentNavTrack: null,
|
||||||
|
notes: 'This is a test',
|
||||||
|
tracks: []
|
||||||
|
} as LogEntity;
|
||||||
|
const track = {
|
||||||
|
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
|
||||||
|
url: 'http://track.example.com',
|
||||||
|
order: 1
|
||||||
|
} as TrackEntity;
|
||||||
|
const tracks = [track]
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockReturnValue(log)
|
||||||
|
jest.spyOn(mockTrackRepository, 'find').mockReturnValue(tracks);
|
||||||
|
|
||||||
|
const result = await service.findAll(logId);
|
||||||
|
|
||||||
|
expect(result).toEqual(tracks);
|
||||||
|
expect(mockTrackRepository.find).toHaveBeenCalled();
|
||||||
|
expect(mockTrackRepository.find).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
log: {
|
||||||
|
id: logId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('findAll => should fail to find log', async () => {
|
||||||
|
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
|
||||||
|
|
||||||
|
jest.spyOn(mockLogService, 'find').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.findAll(logId);
|
||||||
|
|
||||||
|
fail('find failed to throw error')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CustomError);
|
||||||
|
expect(mockLogService.find).toHaveBeenCalled();
|
||||||
|
expect(mockLogService.find).toHaveBeenCalledWith(logId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update => should update a track', async () => {
|
||||||
|
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
|
||||||
|
const track = {
|
||||||
|
|
||||||
|
} as TrackDto;
|
||||||
|
const mockUpdateResult: UpdateResult = {
|
||||||
|
affected: 1,
|
||||||
|
raw: [],
|
||||||
|
generatedMaps: []
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.spyOn(mockTrackRepository, 'update').mockReturnValue(mockUpdateResult)
|
||||||
|
|
||||||
|
const result = await service.update(id, track);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUpdateResult);
|
||||||
|
expect(mockTrackRepository.update).toHaveBeenCalled();
|
||||||
|
expect(mockTrackRepository.update).toHaveBeenCalledWith(id, track)
|
||||||
|
})
|
||||||
|
})
|
||||||
86
api/src/track/track.service.ts
Normal file
86
api/src/track/track.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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 "../log/log.service";
|
||||||
|
import { LogEntity } from "../log/log.entity";
|
||||||
|
import { CustomError } from "../error/customError";
|
||||||
|
import { FileService } from "../file/file.service";
|
||||||
|
import { Logs } from "src/log/logs.interface";
|
||||||
|
|
||||||
|
@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> {
|
||||||
|
return await this.trackRepository.findOneBy({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
throw new CustomError('Tracks not found', 'Not found', 404);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return await this.trackRepository.update(id, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadTrackFile(logId: string, fileName: string): Promise<string> {
|
||||||
|
return await this.fileService.downloadFile(this.containerName, logId, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/transformers/columnNumeric.transformer.ts
Normal file
9
api/src/transformers/columnNumeric.transformer.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class ColumnNumericTransformer {
|
||||||
|
to(data: number): number {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
from (data: string): number {
|
||||||
|
return parseFloat(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +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.5.1",
|
|
||||||
"axios": "^1.7.2",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-hook-form": "^7.51.4",
|
|
||||||
"react-router-dom": "^6.23.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
|
||||||
"@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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
||||||
import Pilots from './components/pilots/Pilots';
|
|
||||||
import Logbook from './components/logbook/Logbook';
|
|
||||||
import SiteNav from './components/siteNav/SiteNav';
|
|
||||||
import { useIsAuthenticated } from '@azure/msal-react';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const isAuthenticated = useIsAuthenticated()
|
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
|
||||||
return isAuthenticated ? children : <Navigate to='/' />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SiteNav />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/pilots" element={<Pilots />} />
|
|
||||||
<Route path="/" element={<Logbook />} />
|
|
||||||
</Routes>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -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"
|
|
||||||
// };
|
|
||||||
@@ -1,70 +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 }: 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>
|
|
||||||
}
|
|
||||||
<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;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
|
|
||||||
export interface IActionMenuProps {
|
|
||||||
id: string;
|
|
||||||
onDelete: (entryId: string) => void;
|
|
||||||
onOpenCloseForm: (formMode: FormMode, id: string) => void;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
|
|
||||||
export interface ILogFormProps {
|
|
||||||
entryId?: string;
|
|
||||||
isDrawerOpen: boolean;
|
|
||||||
mode: FormMode;
|
|
||||||
onOpenClose: (mode: FormMode) => void;
|
|
||||||
}
|
|
||||||
@@ -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)}>
|
|
||||||
<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;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
import { Alert } from '../../interfaces/Alert.interface';
|
|
||||||
import { ILogbookEntry } from './ILogbookEntry';
|
|
||||||
|
|
||||||
export interface ILogbookState {
|
|
||||||
alert: Alert | undefined;
|
|
||||||
entries: ILogbookEntry[];
|
|
||||||
formMode: FormMode;
|
|
||||||
isConfirmDialogLoading: boolean;
|
|
||||||
isConfirmDialogOpen: boolean;
|
|
||||||
isFormOpen: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
selectedEntryId: string | undefined;
|
|
||||||
}
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
import { useEffect, useReducer } from 'react';
|
|
||||||
import LogForm from '../logForm/LogForm';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
ColumnDef,
|
|
||||||
Grid,
|
|
||||||
Icon,
|
|
||||||
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 ActionMenu from '../actionMenu/ActionMenu';
|
|
||||||
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
|
|
||||||
import { ILogbookEntry } from './ILogbookEntry';
|
|
||||||
import LogbookCard from '../logbookCard/LogbookCard';
|
|
||||||
|
|
||||||
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 getLogbookEntries = async () => {
|
|
||||||
try {
|
|
||||||
dispatch({ type: 'SET_IS_LOADING', payload: true });
|
|
||||||
|
|
||||||
const response: AxiosResponse = await httpClient.get(`api/logs`);
|
|
||||||
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 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 }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unauthColumns: ColumnDef<ILogbookEntry>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'pilotName',
|
|
||||||
header: 'Pilot',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'date',
|
|
||||||
header: 'Date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'aircraftMakeModel',
|
|
||||||
header: 'Aircraft Make & Model'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'route',
|
|
||||||
header: 'Route of Flight',
|
|
||||||
meta: {
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
accessorKey: 'routeFrom',
|
|
||||||
header: 'From'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'routeTo',
|
|
||||||
header: 'To'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'durationOfFlight',
|
|
||||||
header: 'Duration Of Flight',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'notes',
|
|
||||||
header: 'Notes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
meta: {
|
|
||||||
align: 'center',
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
cell: (info: any) => (
|
|
||||||
<ActionMenu
|
|
||||||
id={info.row.original.rowKey}
|
|
||||||
onDelete={onDeleteEntry}
|
|
||||||
onOpenCloseForm={onOpenCloseEntryForm}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const authColumns: ColumnDef<ILogbookEntry>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'pilotName',
|
|
||||||
header: 'Pilot',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'date',
|
|
||||||
header: 'Date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'aircraftMakeModel',
|
|
||||||
header: 'Aircraft Make & Model'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'aircraftIdentity',
|
|
||||||
header: 'Aircraft Identity',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'route',
|
|
||||||
header: 'Route of Flight',
|
|
||||||
meta: {
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
accessorKey: 'routeFrom',
|
|
||||||
header: 'From'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'routeTo',
|
|
||||||
header: 'To'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'durationOfFlight',
|
|
||||||
header: 'Duration Of Flight',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: [
|
|
||||||
{
|
|
||||||
accessorKey: 'landingsDay',
|
|
||||||
header: 'Day',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'landingsNight',
|
|
||||||
header: 'Night',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'instrument',
|
|
||||||
header: 'Instrument',
|
|
||||||
meta: {
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
accessorKey: 'instrumentActual',
|
|
||||||
header: 'Actual',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'instrumentSimulated',
|
|
||||||
header: 'Simulated',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'instrumentApproaches',
|
|
||||||
header: 'Approaches',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'instrumentHolds',
|
|
||||||
header: 'Holds',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'instrumentNavTrack',
|
|
||||||
header: 'Nav/Track',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'experienceTraining',
|
|
||||||
header: 'Type of pilot experience or training',
|
|
||||||
meta: {
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
accessorKey: 'groundTrainingReceived',
|
|
||||||
header: 'Ground Training Received',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'flightTrainingReceived',
|
|
||||||
header: 'Flight Training Received',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'crossCountry',
|
|
||||||
header: 'Cross Country',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'night',
|
|
||||||
header: 'Night',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'solo',
|
|
||||||
header: 'Solo',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'pilotInCommand',
|
|
||||||
header: 'Pilot In Command',
|
|
||||||
meta: {
|
|
||||||
align: 'right',
|
|
||||||
headerAlign: 'right'
|
|
||||||
},
|
|
||||||
cell: (info: any) =>
|
|
||||||
info.getValue() ? parseFloat(info.getValue()).toFixed(1) : ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'notes',
|
|
||||||
header: 'Notes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
meta: {
|
|
||||||
align: 'center',
|
|
||||||
headerAlign: 'center'
|
|
||||||
},
|
|
||||||
cell: (info: any) => (
|
|
||||||
<ActionMenu
|
|
||||||
id={info.row.original.rowKey}
|
|
||||||
onDelete={onDeleteEntry}
|
|
||||||
onOpenCloseForm={onOpenCloseEntryForm}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.isFormOpen) {
|
|
||||||
getLogbookEntries();
|
|
||||||
}
|
|
||||||
}, [state.isFormOpen]);
|
|
||||||
|
|
||||||
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.entries.length > 0 && (
|
|
||||||
<Table columns={isAuthenticated ? authColumns : unauthColumns} 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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Logbook;
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
import { Alert } from '../../interfaces/Alert.interface';
|
|
||||||
import { ILogbookEntry } from './ILogbookEntry';
|
|
||||||
import { ILogbookState } from './ILogbookState';
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initialState: ILogbookState = {
|
|
||||||
alert: undefined,
|
|
||||||
entries: [],
|
|
||||||
formMode: FormMode.CANCEL,
|
|
||||||
isConfirmDialogLoading: false,
|
|
||||||
isConfirmDialogOpen: false,
|
|
||||||
isFormOpen: false,
|
|
||||||
isLoading: false,
|
|
||||||
selectedEntryId: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reducer = (
|
|
||||||
state: ILogbookState,
|
|
||||||
action: Action
|
|
||||||
): ILogbookState => {
|
|
||||||
switch (action.type) {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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)}>
|
|
||||||
<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;
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { PilotFormCertificatesProps } from './PilotFormCertificatesProps.interface';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DatePicker,
|
|
||||||
Grid,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
IconName,
|
|
||||||
Select,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from '@noahspan/noahspan-components';
|
|
||||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
|
|
||||||
const PilotFormCertificates = ({
|
|
||||||
isDisabled,
|
|
||||||
mode
|
|
||||||
}: PilotFormCertificatesProps ) => {
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext();
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
name: 'certificates',
|
|
||||||
control
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
spacing={2}
|
|
||||||
>
|
|
||||||
{fields.length > 0 || mode !== FormMode.VIEW &&
|
|
||||||
<Grid size={12}>
|
|
||||||
<Typography variant="h5">Certificates</Typography>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Grid size={4}>
|
|
||||||
<Typography variant="h6">Type</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={4}>
|
|
||||||
<Typography variant="h6">Number</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={3}>
|
|
||||||
<Typography variant="h6">Date of Issue</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={1}>
|
|
||||||
</Grid>
|
|
||||||
{fields.map((field, index) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Grid size={4}>
|
|
||||||
<Controller
|
|
||||||
name={`certificates.${index}.type`}
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
disabled={isDisabled}
|
|
||||||
fullWidth
|
|
||||||
onChange={onChange}
|
|
||||||
options={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: 'Student',
|
|
||||||
value: 'student'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Private',
|
|
||||||
value: 'private'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Instrument',
|
|
||||||
value: 'instrument'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Recreational',
|
|
||||||
value: 'recreational'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sport',
|
|
||||||
value: 'sport'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={4}>
|
|
||||||
<Controller
|
|
||||||
name={`certificates.${index}.number`}
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
disabled={isDisabled}
|
|
||||||
fullWidth
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={3}>
|
|
||||||
<Controller
|
|
||||||
name={`certificates.${index}.dateOfIssue`}
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<DatePicker
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={1}>
|
|
||||||
<IconButton
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
sx={{
|
|
||||||
marginTop: '-5px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon iconName={IconName.TRASH} size='sm' />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isDisabled &&
|
|
||||||
<Grid display="flex" justifyContent="right" size={12}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
append({
|
|
||||||
type: '',
|
|
||||||
number: '',
|
|
||||||
dateOfIssue: null
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
startIcon={<Icon iconName={IconName.PLUS} />}
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
Add Certificate
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PilotFormCertificates;
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { PilotFormEndorsementsProps } from './PilotFormEndorsementsProps.interface';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DatePicker,
|
|
||||||
Grid,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
IconName,
|
|
||||||
Select,
|
|
||||||
Typography
|
|
||||||
} from '@noahspan/noahspan-components';
|
|
||||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
import { FormMode } from '../../enums/formMode';
|
|
||||||
|
|
||||||
const PilotFormEndorsements = ({
|
|
||||||
mode,
|
|
||||||
isDisabled
|
|
||||||
}: PilotFormEndorsementsProps) => {
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
setValue
|
|
||||||
} = useFormContext();
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
name: 'endorsements',
|
|
||||||
control
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
spacing={2}
|
|
||||||
>
|
|
||||||
{fields.length > 0 || mode !== FormMode.VIEW &&
|
|
||||||
<Grid size={12}>
|
|
||||||
<Typography variant="h5">Endorsements</Typography>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Grid size={8}>
|
|
||||||
<Typography variant="h6">Type</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={3}>
|
|
||||||
<Typography variant="h6">Date of Issue</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={1}></Grid>
|
|
||||||
{fields.map((field, index) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Grid size={8}>
|
|
||||||
<Controller
|
|
||||||
name={`endorsements.${index}.type`}
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
disabled={isDisabled}
|
|
||||||
fullWidth
|
|
||||||
onChange={onChange}
|
|
||||||
options={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: 'Complex',
|
|
||||||
value: 'complex'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'High Performance',
|
|
||||||
value: 'highPerfomance'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'High Altitude',
|
|
||||||
value: 'highAltitude'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tailwheel',
|
|
||||||
value: 'tailwheel'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
value={value ? value : ''}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={3}>
|
|
||||||
<Controller
|
|
||||||
name={`endorsements.${index}.dateOfIssue`}
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<DatePicker
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={1}>
|
|
||||||
<IconButton
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
sx={{
|
|
||||||
marginTop: '-5px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon iconName={IconName.TRASH} size="sm" />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isDisabled &&
|
|
||||||
<Grid display="flex" justifyContent="right" size={12}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
append({
|
|
||||||
type: '',
|
|
||||||
dateOfIssue: null
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
startIcon={<Icon iconName={IconName.PLUS} />}
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
Add Endorsement
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PilotFormEndorsements;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { DatePicker, Grid, Select, theme, Typography, useMediaQuery } from '@noahspan/noahspan-components';
|
|
||||||
import { Controller, useFormContext } from "react-hook-form"
|
|
||||||
import { PilotFormMedicalProps } from "./PilotFormMedicalProps.interface";
|
|
||||||
|
|
||||||
const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
setValue
|
|
||||||
} = useFormContext();
|
|
||||||
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid size={12}>
|
|
||||||
<Typography variant="h5">Medical</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={isMedium ? 3 : 12}>
|
|
||||||
<Typography variant="h6">Class</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={isMedium ? 9 : 12}>
|
|
||||||
<Controller
|
|
||||||
name="medicalClass"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
disabled={isDisabled}
|
|
||||||
fullWidth
|
|
||||||
onChange={onChange}
|
|
||||||
options={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: 'First',
|
|
||||||
value: 'first'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Second',
|
|
||||||
value: 'second'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Third',
|
|
||||||
value: 'third'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Basic Med',
|
|
||||||
value: 'basicMed'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
value={value ? value : ''}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={isMedium ? 3 : 12}>
|
|
||||||
<Typography variant="h6">Expiration</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={isMedium ? 9 : 12}>
|
|
||||||
<Controller
|
|
||||||
name="medicalExpiration"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<DatePicker
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PilotFormMedical
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface Pilot {
|
|
||||||
partitionKey: string;
|
|
||||||
rowKey: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user