Compare commits

...

104 Commits

Author SHA1 Message Date
0806e114e1 fixing build and test workflow
All checks were successful
Main / changes (push) Successful in 18s
Main / build-and-test (push) Successful in 5m48s
Main / deploy (push) Successful in 2m28s
Tag / build (push) Successful in 5m51s
Tag / deploy (push) Successful in 2m27s
2026-05-31 06:21:20 -05:00
fc716c664b fixing build and test workflow
All checks were successful
Main / changes (push) Successful in 22s
Main / build-and-test (push) Successful in 5m46s
Main / deploy (push) Successful in 2m34s
2026-05-31 06:09:58 -05:00
5a1e24a1ce updating version to 2.1.5
All checks were successful
Main / changes (push) Successful in 26s
Main / build-and-test (push) Successful in 5m58s
Main / deploy (push) Successful in 2m33s
2026-05-31 05:51:16 -05:00
b93327f840 updating log interceptor for read role
All checks were successful
Main / changes (push) Successful in 18s
Main / build-and-test (push) Successful in 5m49s
Main / deploy (push) Successful in 2m27s
2026-05-21 08:10:48 -05:00
bbcf6cb3de updating log interceptor for read role
Some checks failed
Main / changes (push) Successful in 24s
Main / build-and-test (push) Successful in 5m49s
Main / deploy (push) Failing after 2m24s
2026-05-21 07:44:36 -05:00
344d4f5625 updating log interceptor for read role
Some checks failed
Main / changes (push) Successful in 23s
Main / build-and-test (push) Successful in 5m51s
Main / deploy (push) Failing after 21s
2026-05-21 07:35:42 -05:00
22fbf7615b updating log interceptor for read role
Some checks failed
Main / changes (push) Successful in 18s
Main / build (push) Failing after 32s
Main / deploy (push) Has been skipped
2026-05-21 07:30:57 -05:00
5a84c221d6 updating log interceptor for read role
Some checks failed
Main / changes (push) Successful in 22s
Main / build (push) Successful in 6m15s
Main / deploy (push) Failing after 18s
2026-05-20 22:12:51 -05:00
13f1e86e72 Merge pull request '133-read-role-returns-500-from-api' (#135) from 133-read-role-returns-500-from-api into main
All checks were successful
Main / changes (push) Successful in 23s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
Reviewed-on: #135
2026-05-20 23:10:54 -04:00
25f935171e updating log interceptor for read role
All checks were successful
Pull Request / changes (pull_request) Successful in 23s
Pull Request / build-and-test (pull_request) Successful in 1m48s
2026-05-20 22:07:33 -05:00
92fec88ad1 updating log interceptor for read role (#134)
Some checks failed
Main / changes (push) Successful in 25s
Main / build (push) Successful in 7m32s
Main / deploy (push) Failing after 48s
Reviewed-on: #134
Co-authored-by: noahspannbauer <noahspannbauer@icloud.com>
Co-committed-by: noahspannbauer <noahspannbauer@icloud.com>
2026-05-20 22:37:05 -04:00
833b1b107c updating log interceptor for read role
All checks were successful
Pull Request / changes (pull_request) Successful in 23s
Pull Request / build-and-test (pull_request) Successful in 4m15s
2026-05-20 21:31:16 -05:00
674ae1cf3c updating log interceptor for read role
All checks were successful
Pull Request / changes (pull_request) Successful in 25s
Pull Request / build (pull_request) Has been skipped
2026-05-20 21:27:06 -05:00
f0b61703b7 renaming .github to .gitea
All checks were successful
Main / changes (push) Successful in 26s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 12:41:38 -05:00
c152f06b63 renaming .github to .gitea
All checks were successful
Main / changes (push) Successful in 59s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 12:39:48 -05:00
3c9d26e085 renaming .github to .gitea
All checks were successful
Main / changes (push) Successful in 11s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 12:20:37 -05:00
12b4f1a65b renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:19:28 -05:00
ae03c0200c renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:16:08 -05:00
b9668a9d48 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:12:07 -05:00
19041e3967 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 1m41s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:08:55 -05:00
5729c72fe1 renaming .github to .gitea
All checks were successful
Main / changes (push) Successful in 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:07:15 -05:00
8760998c78 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:05:59 -05:00
61f8dc0e1e renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:04:05 -05:00
cc7b694fd5 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 08:02:14 -05:00
9553c2ec34 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:59:57 -05:00
0a8c461e3d renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:54:43 -05:00
931d56e753 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 7s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:50:58 -05:00
7ebf78f348 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 58s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:41:50 -05:00
95e2d59abc renaming .github to .gitea
All checks were successful
Main / changes (push) Successful in 3s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:40:54 -05:00
945081dd7c renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 8s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:39:04 -05:00
7213dcf9e1 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 21s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:38:04 -05:00
a13ffeaebd renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 22s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:34:15 -05:00
620b258e94 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 21s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:22:46 -05:00
cc21a43825 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 26s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:18:07 -05:00
112b56080c renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 1m12s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:14:06 -05:00
92f97a93bf renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 9s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-19 07:12:40 -05:00
4f6e9a072c renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 1m49s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-18 20:34:54 -05:00
0c8e4b9fba renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 58s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-18 20:21:39 -05:00
63d2765d19 renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 4m59s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-18 20:12:52 -05:00
da76000b8d renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 1m47s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-18 19:56:36 -05:00
b789e1264a renaming .github to .gitea
Some checks failed
Main / changes (push) Failing after 0s
Main / build (push) Has been skipped
Main / deploy (push) Has been skipped
2026-05-18 19:42:09 -05:00
9cc86fe79f renaming .github to .gitea 2026-05-18 19:22:36 -05:00
ee0e10072c updating version number 2026-04-26 17:19:20 -05:00
899f92f342 126 all logs shown when unauthenticated (#131)
* fixing all logs showing when user is unauthenticated

* updating api log interceptor

* updating api log interceptor

* updating api log interceptor

* logbook page unauthenticated alert
2026-04-26 17:08:34 -05:00
5c4245179f 126 all logs shown when unauthenticated (#130)
* fixing all logs showing when user is unauthenticated

* updating api log interceptor

* updating api log interceptor

* updating api log interceptor
2026-04-18 11:37:23 -07:00
a7a9f01fd9 126 all logs shown when unauthenticated (#129)
* fixing all logs showing when user is unauthenticated

* updating api log interceptor

* updating api log interceptor
2026-04-18 11:09:50 -07:00
a367b517db updating api log interceptor (#128)
* fixing all logs showing when user is unauthenticated

* updating api log interceptor
2026-04-16 06:31:28 -07:00
fd514e4c8b fixing all logs showing when user is unauthenticated (#127) 2026-04-07 20:44:21 -05:00
d8513e6dd0 fixing logbook skip take order by (#125) 2026-03-23 20:19:58 -05:00
5de5a165da 122 fix flights skip take and order by (#123)
* fixing flights skip take order by

* fixing flights skip take order by

* fixing flights skip take order by
2026-03-22 10:19:06 -05:00
26c46fe6e8 updating verison number to 2.1.0 2026-03-22 09:44:06 -05:00
04e5e3e16b 119 fix pilot page responsiveness (#121)
* fixing pilot page responsiveness

* fixing pilot page responsiveness
2026-03-22 09:27:15 -05:00
0305b424eb fixing pilot page responsiveness (#120) 2026-03-22 09:14:24 -05:00
8652544471 115 add load more button to logbook mobile view (#118)
* fixing logbook page responsiveness

* fixing logbook page responsiveness

* fixing logbook page responsiveness

* fixing logbook page responsiveness
2026-03-21 20:57:22 -05:00
b7b6bdcf4a 115 add load more button to logbook mobile view (#117)
* fixing logbook page responsiveness

* fixing logbook page responsiveness

* fixing logbook page responsiveness
2026-03-21 20:41:50 -05:00
9c96774f7b fixing logbook page responsiveness (#116)
* fixing logbook page responsiveness

* fixing logbook page responsiveness
2026-03-21 20:30:05 -05:00
a9abb5863c adding load more button to flights page (#114) 2026-03-21 10:43:44 -05:00
477469831f adding pagination to logbook table (#112) 2026-03-16 07:30:29 -05:00
6964531e12 Update logbook table pagesize (#110)
* updating default logbook table page size

* updating default logbook table page size
2026-03-08 18:05:38 -05:00
08baaa6d83 updating default logbook table page size (#109) 2026-03-08 17:40:10 -05:00
487afb08d6 adding api unit tests (#108)
* adding api unit tests

* adding not signed in alerts to flights and logs
2026-03-08 14:58:24 -05:00
b7e74242b8 minor fixes 2026-01-29 08:56:11 -06:00
876f1f65a2 updating log entity with numeric columns 2026-01-27 12:54:08 -06:00
4db1a0fb32 updataing version to 2.0.0-beta-2 2026-01-25 11:17:16 -06:00
f246c1f0f6 updataing tag github actions workflow 2026-01-25 08:43:34 -06:00
36b4018858 updataing version to 2.0.0-beta-1 2026-01-25 08:23:53 -06:00
6b0c292d34 minor fixes 2026-01-11 10:54:21 -06:00
51de91f8d4 minor fixes 2026-01-11 10:51:28 -06:00
41e9ae33dc minor fixes 2026-01-02 09:33:13 -06:00
870241b37f enabling cors 2026-01-01 19:23:55 -06:00
a827b5a39f enabling cors 2026-01-01 16:10:33 -06:00
0663ac873e enabling cors 2025-12-31 17:34:44 -06:00
7cfe005154 enabling cors 2025-12-31 17:21:04 -06:00
f421b8d231 disabling cors 2025-12-31 15:33:17 -06:00
5977659dd4 updating api cors origin 2025-12-31 15:08:31 -06:00
01c1e7015e fixing github actions build workflow 2025-12-31 14:11:44 -06:00
10d160bbc2 fixing github actions build workflow 2025-12-31 14:08:31 -06:00
f321c2506a fixing github actions build workflow 2025-12-31 14:01:23 -06:00
976574c6ac updating docker compose 2025-12-29 23:07:30 -06:00
f3c7fb8853 updating docker compose 2025-12-29 22:36:46 -06:00
cf8e50fb9c updating docker compose 2025-12-29 21:56:04 -06:00
fec99878de 104 switch to daisyui (#107)
* adding daisyui

* adding daisy ui

* switching to daisy ui

* switching to daisy ui

* adding daisy ui

* adding daisy ui

* updating docker compose
2025-12-29 21:51:56 -06:00
f041e0e1ba 104 switch to daisyui (#106)
* adding daisyui

* adding daisy ui

* switching to daisy ui

* switching to daisy ui

* adding daisy ui

* adding daisy ui
2025-12-29 19:43:27 -06:00
64c94ca9f4 switching to auth0 (#103) 2025-12-02 17:10:45 -06:00
4ad53f99c1 misc fixes 2025-11-29 13:22:32 -06:00
819e632d8e misc fixes 2025-11-29 13:09:56 -06:00
5100a93659 misc fixes 2025-11-29 12:59:56 -06:00
a4fdd93ede updating version number 2025-11-23 11:50:49 -06:00
1e1b1f7c97 updating version number 2025-11-23 11:48:38 -06:00
96b6754f29 updating version number 2025-11-23 11:21:48 -06:00
952b9a806b 96 switch from azure table storage to sqlite (#100)
* adding typeorm to api

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

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

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

* updating infrastructure

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

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

* updating infrastructure

* updating infrastructure

* updating infrastructure

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

* switching to sqlite

* switching to sqlite

* switching to sqlite

* migrating to sqlite

* updating terraform

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

* adding logbook page totals

* adding logbook page totals

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

* adding logbook page totals

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

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

* adding flights page

* adding flights page

* adding flights page

* removing unused dependencies

* adding flights page
2025-03-23 11:48:41 -05:00
223 changed files with 25530 additions and 17003 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -10,7 +10,7 @@ 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:
@@ -19,12 +19,7 @@ jobs:
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: ${{ inputs.environment_name == 'test' && vars.VITE_BASE_URL_TEST || vars.VITE_BASE_URL_PROD }}
VITE_CLIENT_ID: ${{ inputs.environment_name == 'test' && vars.VITE_CLIENT_ID_TEST || vars.VITE_CLIENT_ID_PROD }}
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

View File

@@ -4,8 +4,11 @@ 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 }}
permissions:
contents: read
jobs: jobs:
changes: changes:
@@ -13,16 +16,21 @@ 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 - name: 'Setup Node'
uses: actions/setup-node@v6
with:
node-version: '22.x'
- name: Checkout
uses: actions/checkout@v4
- 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/**'

View File

@@ -0,0 +1,34 @@
name: Deploy
on:
workflow_call:
inputs:
app_name:
required: true
type: string
environment_name:
required: true
type: string
version_number:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- name: Install Azure CLI
run: |
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
- name: Log in to Azure
run: |
az login --service-principal --username ${{ vars.AZURE_CLIENT_ID }} --password ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ vars.AZURE_TENANT_ID }}
- name: Update Container App
run: |
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --container-name ${{ inputs.app_name }} --resource-group ${{ vars.RESOURCE_GROUP }} --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}

View File

@@ -0,0 +1,32 @@
name: Main
on:
push:
branches:
- main
jobs:
changes:
uses: ./.gitea/workflows/changes.yaml
build-and-test:
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true' }}
name: build-and-test
needs:
- changes
uses: ./.gitea/workflows/build_and_test.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
deploy:
name: deploy
needs:
- changes
- build
uses: ./.gitea/workflows/deploy.yaml
with:
app_name: flying
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit

View File

@@ -0,0 +1,19 @@
name: Pull Request
on:
pull_request:
jobs:
changes:
uses: ./.gitea/workflows/changes.yaml
build:
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true'}}
name: build-and-test
needs:
- changes
uses: ./.gitea/workflows/build_and_test.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}
secrets: inherit

25
.gitea/workflows/tag.yaml Normal file
View File

@@ -0,0 +1,25 @@
name: Tag
on:
push:
tags:
- '**'
jobs:
build:
name: build
uses: ./.gitea/workflows/build_and_test.yaml
with:
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit
deploy:
name: deploy
needs:
- build
uses: ./.gitea/workflows/deploy.yaml
with:
app_name: flying
environment_name: prod
version_number: ${{ github.ref_name }}
secrets: inherit

View File

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

View File

@@ -1,35 +0,0 @@
name: Deploy
on:
workflow_call:
inputs:
app_name:
required: true
type: string
environment_name:
required: true
type: string
version_number:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ vars.VITE_CLIENT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant-id: ${{ vars.VITE_TENANT_ID }}
- name: Azure CLI script
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --resource-group noahspan-flying --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}

View File

@@ -1,55 +0,0 @@
name: Main
on:
push:
branches:
- main
jobs:
changes:
uses: ./.github/workflows/changes.yaml
build-api:
if: ${{ needs.changes.outputs.api == 'true' }}
name: build-api
needs:
- changes
uses: ./.github/workflows/api_build.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
deploy-api:
name: deploy-api
needs:
- changes
- build-api
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-api
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
build-app:
if: ${{ needs.changes.outputs.app == 'true' }}
name: build-app
needs:
- changes
uses: ./.github/workflows/app_build.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
deploy-app:
name: deploy-app
needs:
- changes
- build-app
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying-app
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit

View File

@@ -1,30 +0,0 @@
name: Pull Request
on:
pull_request:
jobs:
changes:
uses: ./.github/workflows/changes.yaml
build-api:
if: ${{ needs.changes.outputs.api == 'true' }}
name: build-api
needs:
- changes
uses: ./.github/workflows/api_build.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}
secrets: inherit
build-app:
if: ${{ needs.changes.outputs.app == 'true' }}
name: build-app
needs:
- changes
uses: ./.github/workflows/app_build.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}
secrets: inherit

View File

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

View File

@@ -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
View File

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

View File

@@ -1,3 +1,4 @@
# Flying API # Flying API
An API for the Flying app. An API for the Flying app.
...

View File

@@ -1,6 +1,6 @@
{ {
"name": "api", "name": "api",
"version": "1.3.0", "version": "2.1.5",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -16,24 +16,40 @@
"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", "@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",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"dotenv": "^16.4.7", "better-sqlite3": "^12.2.0",
"dotenv": "^16.6.1",
"express-session": "^1.18.2",
"jwks-rsa": "^3.2.0",
"jwt-decode": "^4.0.0",
"node-gyp": "^11.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.25",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"uuidv4": "^6.2.13" "uuidv4": "^6.2.13"
}, },
@@ -41,11 +57,13 @@
"@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",

View File

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

View File

@@ -1,12 +1,17 @@
import { Module } from '@nestjs/common'; import { 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,35 +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
}, }
] ]
}) })
export class AppModule {} export class AppModule {}

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@@ -1,6 +1,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
}) })

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
})
})

View 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
}
}

View File

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

View File

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

View File

@@ -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')
}
})
})

View File

@@ -8,48 +8,61 @@ import {
Post, Post,
Put, Put,
Query, Query,
StreamableFile,
UploadedFile,
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 { FileService } from '../file/file.service';
import { FileInterceptor } from '@nestjs/platform-express'; 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(reflector))
export class LogController { export class LogController {
constructor( constructor(
private readonly fileService: FileService, private readonly fileService: FileService,
private readonly logService: LogService private readonly logService: LogService
) {} ) {}
@Get(':partitionKey/:rowKey')
@UseInterceptors(new LogInterceptor())
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()
@UseInterceptors(new LogInterceptor()) @Public()
async findAll(): Promise<Log[]> { 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;
@@ -57,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;
@@ -73,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;
@@ -93,52 +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) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@UseGuards(AuthGuard)
@Post(':partitionKey/:rowKey/track')
@UseInterceptors(FileInterceptor('file'))
async createTrack(@Param('rowKey') rowKey: string, @UploadedFile() file: Express.Multer.File) {
try {
const containerName = 'tracks';
const url = await this.fileService.uploadFile(file, containerName, rowKey);
return { url }
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get(':partitionKey/:rowKey/track')
async downloadTrack(@Param('rowKey') rowKey: string, @Query('fileName') fileName: string): Promise<string> {
const containerName = 'tracks';
const downloadedFile: string = await this.fileService.downloadFile(containerName, rowKey, fileName)
return downloadedFile;
}
@UseGuards(AuthGuard)
@Delete(':partitionKey/:rowKey/track')
async deleteTrack(@Param('rowKey') rowKey: string, @Query('fileName') fileName: string): Promise<void> {
try {
const containerName = 'tracks';
return await this.fileService.deleteFile(containerName, rowKey, fileName)
} catch (error) { } catch (error) {
const customError = error as CustomError; const customError = error as CustomError;

View File

@@ -1,27 +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;
tracks: string[]; notes?: string;
notes: string; tracks?: []
} }

View File

@@ -1,29 +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(),
tracks?: string[]; })
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;
} }

View 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(entity));
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: logs.length,
hasNextPage: false
};
} else {
const publicData = limitData(data);
return publicData
}
}
})
);
}
}

View File

@@ -1,28 +1,19 @@
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 { 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],
exports: [LogService],
providers: [ providers: [
ConfigService, ConfigService,
FileService, FileService,

View 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);
})
})

View File

@@ -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 });
} }
} }

View File

@@ -0,0 +1,7 @@
import { LogEntity } from "src/log/log.entity";
export interface Logs {
entities: LogEntity[],
total: number,
hasNextPage: boolean
}

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')
}
})
})

View File

@@ -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;

View File

@@ -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
} }

View File

@@ -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[];
} }

View 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;
}
})
);
}
}

View File

@@ -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 {}

View 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 });
})
})

View File

@@ -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
} }
} }

View 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')
}
})
})

View File

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

View File

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

View File

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

View File

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

View 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)
})
})

View 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
}
}
}

View File

@@ -0,0 +1,9 @@
export class ColumnNumericTransformer {
to(data: number): number {
return data;
}
from (data: string): number {
return parseFloat(data);
}
}

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
import { FormMode } from '../../enums/formMode';
export interface IActionMenuProps {
id: string;
onDelete: (entryId: string) => void;
onOpenCloseForm: (formMode: FormMode, id: string) => void;
onOpenCloseTracks?: (formMode: FormMode, id: string) => void;
}

View File

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

View File

@@ -1,32 +0,0 @@
import { Box, Container } from "@noahspan/noahspan-components";
import LogbookCard from "../logbookCard/LogbookCard";
import { useEffect, useState } from "react";
import { useLogs } from "../../hooks/logs/UseLogs";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
const Flights = () => {
const [flights, setFlights] = useState<ILogbookEntry[]>([])
const { logs } = useLogs();
useEffect(() => {
const flights: ILogbookEntry[] | undefined = logs?.filter((log: ILogbookEntry) => {
if (log.tracks && JSON.parse(log.tracks).length > 0) {
return log;
}
})
if (flights && flights.length > 0) {
setFlights(flights)
}
}, [logs])
return (
<Container>
<Box sx={{ margin: '20px' }}>
<LogbookCard logs={flights} mode='flights' />
</Box>
</Container>
)
}
export default Flights;

View File

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

View File

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

View File

@@ -1,53 +0,0 @@
#app {
height: 100%;
}
html,
body {
position: relative;
height: 100%;
}
body {
background: #eee;
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 14px;
color: #000;
margin: 0;
padding: 0;
}
.swiper {
width: 100%;
height: 100%;
}
.swiper-wrapper {
margin-bottom: 20px;
}
.swiper-slide {
text-align: center;
font-size: 18px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.swiper-slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.swiper-pagination-bullet {
background-color: #000000;
height: 13px;
width: 13px;
border: 2px solid #FFFFFF;
}
.swiper-pagination-bullet-active {
box-shadow: 0 0 0 1px #000000;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
import { ColumnDef } from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces/Alert.interface';
import { ILogbookEntry } from './ILogbookEntry';
export interface ILogbookState {
alert: Alert | undefined;
columns: ColumnDef<ILogbookEntry>[];
entries: ILogbookEntry[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
isTracksOpen: boolean;
selectedEntryId: string | undefined;
tracksMode: FormMode;
}

View File

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

View File

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

View File

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

View File

@@ -1,101 +0,0 @@
import { Card, CardContent, CardHeader, Grid, Typography } from "@noahspan/noahspan-components";
import { LogbookCardProps } from "./LogbookCardProps.interface";
import ActionMenu from "../actionMenu/ActionMenu";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps) => {
return (
<Grid container spacing={2}>
{logs.map((log) => {
console.log(log)
return (
<Grid size={12}>
<Card key={log.rowKey}>
<CardHeader
action={mode === 'logbook' ? <ActionMenu id={log.rowKey} onDelete={onDelete!} onOpenCloseForm={onOpenCloseForm!} /> : null}
subheader={log.pilotName}
title={log.date}
slotProps={{
subheader: {
fontSize: '16px'
},
title: {
fontSize: '24px',
}
}}
/>
<CardContent>
<Grid container spacing={1}>
{mode === 'flights' && log.tracks && JSON.parse(log.tracks).length > 0 &&
<Grid size={12}>
<LogTrackMaps
rowKey={log.rowKey}
trackUrls={JSON.parse(log.tracks)}
/>
</Grid>
}
<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>
{mode === 'logbook' && log.tracks && JSON.parse(log.tracks).length > 0 &&
<>
<Grid size={12}>
<Typography variant="subtitle2">Tracks</Typography>
</Grid>
{JSON.parse(log.tracks).map((track: string) => {
const trackSplit = track.split('/')
const filename = trackSplit[trackSplit.length - 1];
return (
<Grid size={12}>
<Typography variant="body1">{filename}</Typography>
</Grid>
)
})}
</>
}
{log.notes &&
<>
<Grid size={12}>
<Typography variant="subtitle2">Notes</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.notes}</Typography>
</Grid>
</>
}
</Grid>
</CardContent>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default LogbookCard;

View File

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

View File

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

View File

@@ -1,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;

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