Compare commits

188 Commits

Author SHA1 Message Date
d4ae137115 Merge pull request 'Use current datetime for validation' (#17) from main into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2025-10-16 18:27:37 +02:00
9f51f508ce Merge pull request 'Always use current datetime for validation' (#16) from fix/stale-datetime-validation into main
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2025-10-16 18:23:44 +02:00
62824549fc Always use current datetime for validation
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/pr Build is passing
2025-10-16 18:21:14 +02:00
0cb5e44f7a Merge pull request 'main' (#15) from main into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2025-10-16 17:54:03 +02:00
7d7f5750e3 Merge pull request 'Fix bash syntax for creating a variable' (#14) from fix/pipeline into main
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2025-10-16 17:51:33 +02:00
789ba35c60 Fix bash syntax for creating a variable
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/pr Build is passing
2025-10-16 17:48:05 +02:00
1226c42f19 Merge pull request 'Echo docker image with tag in pipeline' (#13) from feature/docker-image-echoed-in-pipeline into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #13
2025-10-16 17:41:32 +02:00
5e083aeaf6 Echo docker image with tag in pipeline
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-10-16 17:36:15 +02:00
31efd6b4ad Merge pull request 'Prod: Better debug create consumption error due to datetime' (#12) from main into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #12
2025-10-16 17:33:53 +02:00
69bb19e4eb Merge pull request 'Better debug date time error when creating a consumptions' (#11) from fix/bad-request-due-to-date into main
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2025-10-16 17:28:43 +02:00
db791a1183 Add endpoint to query the system's current time
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-10-16 17:23:47 +02:00
ad77c2fe2b Fix logs showing non enumerated enumerable as error messages
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-16 17:15:11 +02:00
87a0241f11 Update packages 2025-10-16 17:14:49 +02:00
f248be4e1f Merge pull request 'Seq API Key support and package updates' (#10) from prepare-for-prod into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2025-09-21 11:52:08 +02:00
67d29333d9 Merge branch 'production' into prepare-for-prod
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
# Conflicts:
#	.drone.yml
2025-09-21 11:48:42 +02:00
5956f27646 Merge pull request 'feature/add-seq-api-key-support' (#8) from feature/add-seq-api-key-support into main
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
Reviewed-on: #8
2025-09-21 11:14:06 +02:00
69901a295c Do not build and push docker image for pull requests
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-09-21 11:07:26 +02:00
527759eb7b Fix fluent assertions version
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-09-21 10:56:56 +02:00
d4fff6741c Update packages
Some checks failed
continuous-integration/drone/pr Build is failing
2025-09-21 10:50:33 +02:00
a10070b9c7 Add seq api key support 2025-09-21 10:50:08 +02:00
d10d1a6fdb Docker push and build for production branch as well
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-19 19:01:27 +02:00
c57972d9a6 Docker push and build for production branch as well
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-19 18:59:01 +02:00
ea019ebfa6 Merge pull request '[PROD] Add seq support' (#7) from main into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2025-08-19 18:56:10 +02:00
97a275478d Update configuration documentation in README
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-07-22 21:13:26 +02:00
731eab3898 Prevent using seq if no seq host is set 2025-07-22 21:13:18 +02:00
f018e62163 Fix Docker build
node:lts seems to be bugged, npm binary does not work right
2025-07-22 21:12:50 +02:00
10e02b5e9b Merge pull request 'Add Seq support' (#6) from feature/traces into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #6
2025-07-22 20:20:24 +02:00
c365af1d42 Add Seq support
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-07-22 20:19:57 +02:00
66c23ffb4f Merge pull request 'Use full type as log category' (#5) from main into production
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #5
2025-07-21 21:42:57 +02:00
7ddc346e88 Use full type as log category
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-07-21 21:41:16 +02:00
00e0869a13 Merge pull request '[Prod] More logging' (#4) from main into production
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #4
2025-07-21 21:25:21 +02:00
925293d626 Merge pull request 'Add docker build intructions' (#3) from feature/readme into main
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #3
2025-07-21 21:22:18 +02:00
9b024967e6 Add docker build intructions
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-07-21 21:22:05 +02:00
288d470c1b Merge pull request 'Add more logging and trace parameters' (#2) from feature/better-observability into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2025-07-21 21:14:07 +02:00
84a72a8557 Add more logging and trace parameters
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-07-21 20:58:45 +02:00
d4223ed38f Use port proxying
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-29 12:02:16 +02:00
9f2c5db825 Install npm dependencies as .NET build target 2025-06-29 12:02:16 +02:00
18cbc2225f Remove upload project after both qa and prod have been deployed and migrated
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-28 18:39:33 +02:00
267c4165dd Fix line endings in docker image
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-06-28 17:48:16 +02:00
ef1c1d8ba1 Fix resolver by always using local resolver 2025-06-28 17:46:14 +02:00
8d4ae30224 Just go with one environment variable
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-28 16:54:14 +02:00
02e7ed7030 Fix support for https and http endpoints
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-27 20:23:29 +02:00
9595bedd8e Add support for https and http api url environment variable
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-27 19:50:42 +02:00
af661632cc Add docker dns resolver 2025-06-27 19:50:27 +02:00
5062887010 Add console project to create data to migrate
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-27 19:14:50 +02:00
b41d5c5d33 Ensure correct sorting and thus also correct liter per 100km calculation
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 20:35:49 +02:00
4b377ce9f4 Fix resource name to be a valid Aspire resource name 2025-06-24 20:35:26 +02:00
5e084ab0a8 Fix recurring mock data
All checks were successful
continuous-integration/drone/push Build is passing
Reusing the faker instance like I used to seems to (suddenly?!)
result in the same data, which newly results in a conflict response
2025-06-24 20:01:45 +02:00
559804765b Use persons' first names for mock data to reduce chance of conflicts
Some checks failed
continuous-integration/drone/push Build is failing
Vehicle models seems to have a high enough probability that it sometimes
fails
2025-06-24 19:43:44 +02:00
5da1e2fd75 Make endpoint methods private which were not before
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-24 19:35:29 +02:00
ab32be98a6 Use concrete types
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 19:28:55 +02:00
8681247e76 Adjustments for working deployment
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 19:12:29 +02:00
f6dbf489ad Dockerize web app
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 18:38:26 +02:00
eaa06029bb Reset selected car if it is deleted
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 16:53:11 +02:00
9e16d6004a Fix liter per 100 km calculation for multiple cars 2025-06-23 16:50:07 +02:00
0df7449a99 Add pgweb and pgadmin in development env 2025-06-23 16:49:52 +02:00
7f61e011ed Add car name duplicate validation 2025-06-23 16:49:37 +02:00
9c372b31a6 Add managing cars
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 16:20:44 +02:00
fd7a8024a9 Install dependencies before launching app
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 15:20:52 +02:00
4a8e3d02e0 Fix date times
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-22 13:18:22 +02:00
f7af144275 Display consumption in liter per 100 km
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-22 12:36:19 +02:00
cb3c8c0d18 Include necessary info directly in get consumption entries response dto
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-22 11:51:38 +02:00
a997a3b825 Remove ignoreInCalculation from Frontend 2025-06-22 11:51:02 +02:00
c58f6fe364 Drop IgnoreInCalculation property
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-22 11:07:02 +02:00
69bc76cab4 Add idempotent migration script
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-20 22:02:56 +02:00
4b1f9e78df Fix paths in create migrations script 2025-06-20 22:02:25 +02:00
4c00f868c7 Update READMEs
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-20 21:44:05 +02:00
8b9ccdc694 Hide clear button for select which should always have a value
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-20 21:05:54 +02:00
b8d1fddd91 Remove time portion
Some checks are pending
continuous-integration/drone/push Build is running
No time is entered when creating / editing
2025-06-20 21:03:47 +02:00
9246729edf Order cars by name 2025-06-20 21:02:38 +02:00
e13b5f2cdc Remove log messages 2025-06-20 21:02:05 +02:00
63c7624a00 Persist and use selected car
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-20 20:45:08 +02:00
f58613d661 Remove unused variable
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 19:35:19 +02:00
d71e523074 Terminate task after debug ends
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 19:00:40 +02:00
1c8e02b3fa Add error handling
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 18:56:49 +02:00
feadab4dff Sort entries both on the backend and frontend 2025-06-19 18:56:40 +02:00
41c342bb0f Add more accurate loading skeletons 2025-06-19 18:56:24 +02:00
2e3000c3fc Add loading entry data when updating an entry 2025-06-19 18:49:04 +02:00
92e4da4b93 Add icons in card
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 17:42:14 +02:00
5978a96dd7 Make date required and set today as default 2025-06-19 17:42:08 +02:00
b9375d66b6 Update imports
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 17:11:11 +02:00
b07b0c1f0f Make code more understandable
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 17:05:19 +02:00
fd9b9c7c2e Fix copied texts
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 15:04:27 +02:00
b6f9b5fb26 Remove unnecessary db roundtrip when deleting an entry 2025-06-19 15:04:09 +02:00
87d81f98e9 Remove duplicate import and switch to css @use
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 14:49:27 +02:00
c5555b3003 Finish implementing editing and displaying entries
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 14:40:14 +02:00
d8f82bb2d1 Add entry filtering
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 13:38:40 +02:00
390241aa53 Add sending entries to api 2025-06-19 13:38:29 +02:00
b323f7a29f Fix api clients 2025-06-19 13:38:15 +02:00
8ca16936a8 Add special liter l for unit
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:45:38 +02:00
f0998c818a Add required marker for required form field 2025-06-19 12:45:28 +02:00
0cf9f3cd0f Fix dropdown value mapping 2025-06-19 12:45:17 +02:00
b382446828 Add styling based in Weight Tracker UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:33:46 +02:00
16318c70f7 Add and fix routes
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 21:29:24 +02:00
f173d46c2e Copy more stuff to make app compile
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 21:15:26 +02:00
73fbe30b3d Add missing information on overview page for it to compile 2025-06-18 20:54:21 +02:00
229bfe0b79 Add import paths 2025-06-18 20:54:04 +02:00
321ffc3b7c Test querying all consumption entries and cars
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 21:05:48 +02:00
0fa5b080d8 Add API models and clients manually 2025-06-16 21:05:07 +02:00
85052df8a5 Add descriptions for endpoints for use in openapi 2025-06-16 20:34:09 +02:00
bcbf76fda6 Specify API returns types for swagger 2025-06-16 20:28:37 +02:00
b989c43ec3 Revert to using manually created api classes 2025-06-16 19:54:45 +02:00
cba564a811 Switch to Scalar swagger ui
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 19:52:04 +02:00
297af2b95d Copy more from weight tracker
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 18:49:07 +02:00
70acaf9738 Copy overview page structure from weight tracker
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 18:20:17 +02:00
354d28d167 Fix dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 18:03:33 +02:00
07ab1efbe5 Add build step for Angular
Some checks failed
continuous-integration/drone/push Build encountered an error
2025-06-16 18:02:50 +02:00
8a0776bc33 Add primeng
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 17:52:38 +02:00
0baf50a2a2 Fix downgrade to Angular 19
Some checks are pending
continuous-integration/drone/push Build is running
2025-06-16 17:51:17 +02:00
766d060707 Downgrade to Angular 19
All checks were successful
continuous-integration/drone/push Build is passing
Multiple packages are not officially compatible yet
2025-06-16 17:25:06 +02:00
df93f8299f Update Angular launch profile
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 17:16:00 +02:00
b28bc04a53 Add http health check for Angular app
To have a better indication in the dashboard as to when the app has
actually started, because the dashboard otherwise displays a running
state after the launch command has been given, but then the app only
begins to compile and takes a few seconds to actually launch
2025-06-16 17:15:01 +02:00
28148e4f69 Allow for the web app to be run separately to allow debugging
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 12:49:31 +02:00
9fb0f584a6 Update launch configurations
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 12:33:52 +02:00
edafe0e4ec Fix API return type mismatch
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 11:30:03 +02:00
a1174e3b42 Expose swagger UI again 2025-06-15 11:29:46 +02:00
4bf07b0972 Add editor config and apply code cleanup
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 09:36:52 +02:00
7d6f85db82 Try fetching data from protected endpoint
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-14 20:30:47 +02:00
f426368d15 Use random port for web app 2025-06-14 20:30:00 +02:00
5727707cce Add simply example for retrieving data from the api
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 20:13:06 +02:00
f00e3cdb6a Add npm app to Aspire resources 2025-06-13 20:12:43 +02:00
20ba638b64 Fix server info endpoint not being accessibly without authentication 2025-06-13 20:12:26 +02:00
d80a53761d Generate Angular app 2025-06-13 19:48:07 +02:00
e29c5b2458 Return DateTimeOffset
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 19:39:46 +02:00
7aa8599535 Update request localization 2025-06-13 19:39:37 +02:00
16bc250789 Fix project name spelling
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-12 19:20:22 +02:00
9847b6e6f7 Change assembly name constant spelling
Some checks are pending
continuous-integration/drone/push Build is running
2025-06-12 19:18:17 +02:00
ada0e2f665 Use custom activity source 2025-06-12 19:12:38 +02:00
b28bd2826b Keep db container after apps shutdown 2025-06-12 19:12:26 +02:00
a1999bfe41 Rename WebApi project to Vegasco.Server.Api
All checks were successful
continuous-integration/drone/push Build is passing
And update all references including comments etc.
2025-06-12 18:23:09 +02:00
9d71c86474 Fix broken swagger route 2025-06-12 17:58:50 +02:00
d91b837e44 Update packages, use explicit type, use Microsoft OpenApi package 2025-06-12 17:43:22 +02:00
b3ca1ba703 Remove experimental own test step
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 17:42:12 +01:00
108960d074 Add debug
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:24:06 +01:00
05686c4cdd Add own test step
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:20:10 +01:00
cb440e7c6d Use latest postgres version in integration tests
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 17:11:09 +01:00
4ea0978cf6 Fix integration test database connection string
Some checks are pending
continuous-integration/drone/push Build is running
2024-12-28 17:10:36 +01:00
ff2707a0e8 Add Aspire documentation to README
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:08:01 +01:00
6d23494fd3 Add Aspire orchestration
Some checks failed
continuous-integration/drone/push Build is failing
Therefore remove previous OpenTelemetry configuration and use the one
provided in service defaults
2024-12-28 17:01:18 +01:00
bbac953660 Update legacy sln file
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 15:59:31 +01:00
854be19fd5 Remove system tests
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 15:55:34 +01:00
cf1a086e31 Recover sln file and revert pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 14:32:34 +01:00
918477fb3a Use prerelease docker images which work with slnx file
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 14:29:23 +01:00
857863a4d8 Replace sln file with slnx
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 14:23:27 +01:00
7a2c50cb9a Update vulnerable packages
Some checks are pending
continuous-integration/drone/push Build is running
2024-12-28 14:22:21 +01:00
5d0a49632a Update .NET version in pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-27 19:25:19 +01:00
0e065b58b7 Update packages
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-27 19:22:13 +01:00
22f47f4461 Upgrade to .NET 9 and update nuget packages
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-01 19:26:54 +01:00
d6c75654b0 Use wrapper class for get all api endpoints
All checks were successful
continuous-integration/drone/push Build is passing
To enable e.g. pagination in the future
2024-08-25 13:39:00 +02:00
136dd2311d Prevent local test db to disappear on docker restart 2024-08-25 13:14:54 +02:00
351a1a4635 Fix nested classes in open api document 2024-08-25 13:14:32 +02:00
4db35dbdb5 Remove unnecessary migrations in integration tests
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 14:26:38 +02:00
d0704aea12 Fix permissions in Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 14:24:18 +02:00
92e91de9c2 Include healthcheck in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2024-08-24 13:50:03 +02:00
de7e9a7131 Tweak log levels for non-dev environments
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 13:44:23 +02:00
6b422545d9 Remove migrations sql script 2024-08-24 13:43:58 +02:00
4a1f1a5a67 Apply migrations on startup 2024-08-24 13:43:43 +02:00
d3d3675e3d Fix onfiguration documentation in README
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 13:20:29 +02:00
88090878ee Include docker job in notification
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 12:55:25 +02:00
f410f69e9d Only report failures
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 12:46:33 +02:00
036f4d1dfc Fix Dockerfile for nbgv 2024-08-24 12:46:27 +02:00
ea689bb7a1 Only build docker image on main
Some checks failed
continuous-integration/drone/push Build is failing
For now
2024-08-24 12:39:30 +02:00
4855336c33 Add docker build and push to pipeline 2024-08-24 12:36:15 +02:00
ad9391093d Update nbgv version config 2024-08-24 12:36:15 +02:00
89afc435fc Remove broken dependencies
All checks were successful
continuous-integration/drone Build is passing
2024-08-23 19:02:01 +02:00
2d79b5a0bf Revert "Replace with gitea actions"
This reverts commit 70f47b0dd1.

# Conflicts:
#	.gitea/workflows/build.yaml
#	vegasco-server.sln
2024-08-23 18:56:01 +02:00
dcb82414b9 Add nbgv and add server info endpoint
Some checks failed
Build Vegasco Server / build (push) Failing after 59s
2024-08-23 18:55:05 +02:00
d19d68f5a2 Setup docker in pipeline
Some checks failed
Build Vegasco Server / build (push) Failing after 2m24s
2024-08-23 18:35:51 +02:00
1c88d2b2c6 Exclude empty test project
Some checks failed
Build Vegasco Server / build (push) Failing after 2m22s
2024-08-23 18:32:01 +02:00
155ed22fb0 Explicitly specify package 2024-08-23 18:31:54 +02:00
4a46c46222 Fix setting up pipeline
Some checks failed
Build Vegasco Server / build (push) Failing after 2m34s
2024-08-23 18:20:35 +02:00
f4846bc66a Update on
Some checks failed
Build Vegasco Server / build (push) Failing after 1m22s
2024-08-23 18:17:27 +02:00
70f47b0dd1 Replace with gitea actions 2024-08-23 18:15:28 +02:00
e20f713fdb Add initial cicd yaml 2024-08-23 18:05:34 +02:00
2463c11be3 Add consumption logic and endpoints 2024-08-23 18:02:18 +02:00
d47e4c1971 Add consumption entity and use strongly typed ids 2024-08-17 18:00:23 +02:00
4bfc57ef9f Remove unused launch configs 2024-08-17 16:38:41 +02:00
5c532a6bb5 Experiment with setting up system test docker services 2024-08-17 16:38:41 +02:00
4f287d85dd Use full keycloak realm export from cli instead of partial export from web interface 2024-08-17 16:38:41 +02:00
7f734aa2a2 Initial setup work for system tests 2024-08-17 16:38:41 +02:00
81b5c89a25 Fix Dockerfile 2024-08-17 16:38:40 +02:00
1d6ecfee6e Require oidc metadata url instead of individual values 2024-08-17 16:38:40 +02:00
4be9fd2043 Update convenience scripts 2024-08-17 16:38:40 +02:00
19b105b0e8 Add integration tests 2024-08-17 16:38:40 +02:00
ff2da66a22 Add unit tests 2024-08-17 16:38:40 +02:00
877e7989cd Implement all car endpoints 2024-08-17 16:38:40 +02:00
a708ed25e7 Initial endpoint configuration with authentication 2024-08-17 16:38:40 +02:00
e579d76560 Create api project 2024-08-17 16:38:40 +02:00
170 changed files with 16118 additions and 159 deletions

View File

@@ -1,7 +1,7 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
#**/.git
**/.gitignore
**/.project
**/.settings

99
.drone.yml Normal file
View File

@@ -0,0 +1,99 @@
kind: pipeline
type: docker
name: Build and test
trigger:
event:
include:
- push
- pull_request
- custom
steps:
- name: compile (.NET)
image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
environment:
CI_WORKSPACE: "/drone/src"
commands:
- dotnet build ./vegasco-server.slnx
- name: compile (Angular)
image: node:lts
commands:
- npm install -g pnpm
- cd src/Vegasco-Web
- pnpm install
- pnpm build
- name: test
image: quay.io/testcontainers/dind-drone-plugin
environment:
CI_WORKSPACE: "/drone/src"
settings:
build_image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
cmd:
- dotnet test --no-build
volumes:
- name: dockersock
path: /var/run
depends_on:
- compile (.NET)
- name: docker build and push
image: docker:24.0.7
commands:
- dockerImageWithTag="$docker_registry$docker_repo:$DRONE_BRANCH"
- docker build . -t $dockerImageWithTag
- echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry
- docker push $dockerImageWithTag
- echo "Built and pushed $dockerImageWithTag"
environment:
docker_username:
from_secret: docker_username
docker_password:
from_secret: docker_password
docker_repo:
from_secret: docker_repo
docker_registry:
from_secret: docker_registry
volumes:
- name: dockersock
path: /var/run
when:
branch:
- main
- production
event:
exclude:
- pull_request
depends_on:
- compile (.NET)
- test
- name: Telegram notification
image: appleboy/drone-telegram
settings:
token:
from_secret: telegram_token
to:
from_secret: telegram_user_id
when:
status:
- failure
depends_on:
- compile (.NET)
- compile (Angular)
- test
- docker build and push
services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run
volumes:
- name: dockersock
temp: { }

271
.editorconfig Normal file
View File

@@ -0,0 +1,271 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = tab
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = false
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = false:suggestion
dotnet_style_allow_statement_immediately_after_block_experimental = false:suggestion
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = warning
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_underscore.severity = warning
dotnet_naming_rule.private_or_internal_field_should_be_underscore.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_underscore.style = underscore
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.severity = warning
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.symbols = private_or_internal_static_field
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.style = sunderscore
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
dotnet_naming_symbols.private_or_internal_static_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_static_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_static_field.required_modifiers = static
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.underscore.required_prefix = _
dotnet_naming_style.underscore.required_suffix =
dotnet_naming_style.underscore.word_separator =
dotnet_naming_style.underscore.capitalization = camel_case
dotnet_naming_style.sunderscore.required_prefix = s_
dotnet_naming_style.sunderscore.required_suffix =
dotnet_naming_style.sunderscore.word_separator =
dotnet_naming_style.sunderscore.capitalization = camel_case
[*.{cs,vb}]
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf

2
Create-Migration.ps1 Normal file
View File

@@ -0,0 +1,2 @@
dotnet ef migrations add $args[0] --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output-dir Persistence/Migrations
dotnet ef migrations script --idempotent --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output ./src/Vegasco.Server.Api/migrations/migration.sql

9
Directory.Build.props Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
<PrivateAssets>all</PrivateAssets>
<Version>3.6.141</Version>
</PackageReference>
</ItemGroup>
</Project>

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
RUN apt-get update && apt-get install -y curl
USER app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/Vegasco.Server.Api/Vegasco.Server.Api.csproj", "src/Vegasco.Server.Api/"]
RUN dotnet restore "./src/Vegasco.Server.Api/Vegasco.Server.Api.csproj"
COPY . .
WORKDIR "/src/src/Vegasco.Server.Api"
RUN dotnet build "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
HEALTHCHECK --interval=20s --timeout=1s --start-period=10s --retries=3 CMD curl --fail http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Vegasco.Server.Api.dll"]

View File

@@ -1 +1,85 @@
# vegasco-server
# Vegasco Server
Vegasco (**VE**hicle **GAS** **CO**nsumption) application.
Includes the backend (`src/Vegasco.Server.Api`) and the frontend (`src/Vegasco-Web`). Uses [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview).
## Getting Started
### Configuration
| Configuration | Description | Default | Required |
|------------------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
| JWT:MetadataUrl | The oidc meta data url | - | true |
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
| ConnectionStrings:seq | The seq http endpoint to send the logs and traces to. If not set, logs and traces will not be sent to seq. | - | false |
| ConnectionStrings:vegasco-database | The connection string to the postgres database. | - | true |
The application uses the prefix `Vegasco_` for environment variable names. The prefix is removed when the application reads the environment variables and duplicate entries are overwritten by the environment variables.
Example:
- `foo=bar1`
- `Vegasco_foo=bar2`
Results in:
- `foo=bar2`
- `Vegasco_foo=bar2`
Configuration hierarchy in environment variables is usually denoted using a colon (`:`). But because on some systems the colon character is a reserved character, you can use a double underscore (`__`) as an alternative. The application will replace the double underscore with a colon when reading the environment variables.
Example:
The environment variable `foo__bar=value` (as well as `Vegasco_foo__bar=value`) will be converted to `foo:bar=value` in the application.
### Configuration examples
As environment variables:
```env
Vegasco_JWT__Authority=https://example.authority.com
Vegasco_JWT__Audience=example-audience
Vegasco_JWT__Issuer=https://example.authority.com/realms/example-realm/
Vegasco_JWT__NameClaimType=preferred_username
```
As appsettings.json (or a environment specific appsettings.*.json):
**Note: the `Vegasco_` prefix is only for environment variables**
```json
{
"JWT": {
"Authority": "https://example.authority.com/realms/example-realm",
"Audience": "example-audience",
"Issuer": "https://example.authority.com/realms/example-realm/",
"NameClaimType": "preferred_username"
}
}
```
### Running the application
The solution uses Aspire to orchestrate the application. Specifically, it introduces sensible service defaults, including but not limited to OpenTelemetry,
creates a Postgres database as a docker container, and starts the Api with the correct configuration to communicate with the database.
Ensure you have an identity provider set up, for example Keycloak, and configured the relevant options described above.
Then, to run the application, ensure you have Docker running, then run either the `http` or `https` launch profile of the `Vegasco.Server.AppHost` project.
## Deployment
Build server by running in project root:
```shell
docker build . -t docker.nuyken.dev/vegasco/api:main
```
Builder web client by running in `src/Vegasco-Web`:
```shell
docker build -t docker.nuyken.dev/vegasco/web:main --build-arg CONFIGURATION=production .
```

1
Run-PostgresDb.ps1 Normal file
View File

@@ -0,0 +1 @@
docker run -d -p 5432:5432 --restart always --name vegasco-test-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres postgres:16.3-alpine

View File

@@ -1,6 +0,0 @@
<Solution>
<Project Path="src\Vegasco.WebApi\Vegasco.WebApi.csproj" Type="C#" />
<Properties Name="Visual Studio">
<Property Name="OpenWith" Value="Visual Studio Version 17" />
</Properties>
</Solution>

7
nuget.config Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
src/Vegasco-Web/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

14
src/Vegasco-Web/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Web (Chrome)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"postDebugTask": "Terminate All Tasks",
"url": "http://localhost:44200/",
}
]
}

63
src/Vegasco-Web/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,63 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"label": "Terminate All Tasks",
"command": "echo ${input:terminate}",
"type": "shell",
"problemMatcher": []
},
{
"type": "npm",
"script": "start",
"options": {
"env": {
"PORT": "44200",
"services__Api__https__0": "https://localhost:7098",
"NODE_ENV": "development"
}
},
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
],
"inputs": [
{
"id": "terminate",
"type": "command",
"command": "workbench.action.tasks.terminate",
"args": "terminateAll"
}
]
}

View File

@@ -0,0 +1,19 @@
FROM node:latest AS build
RUN npm install -g pnpm
ARG CONFIGURATION=development
WORKDIR /usr/local/app
COPY . .
RUN pnpm install
RUN pnpm "build:$CONFIGURATION"
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/*
RUN apk add --update dos2unix
ENV DOLLAR=$
WORKDIR /usr/share/nginx/html
COPY --from=build /usr/local/app/dist/Vegasco-Web/browser .
COPY nginx.conf /etc/nginx/nginx.conf
RUN dos2unix /etc/nginx/nginx.conf
COPY webserver.conf.template /etc/nginx/templates/webserver.conf.template
RUN dos2unix /etc/nginx/templates/webserver.conf.template
EXPOSE 80

69
src/Vegasco-Web/README.md Normal file
View File

@@ -0,0 +1,69 @@
# VegascoWeb
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## API Proxy
Because the solution utilizes Aspire which injects endpoint references for the API as environment variables, this application uses a proxy to access the API. The proxy is configured in the `proxy.config.js` file which is used in the `serve` section of the `angular.json` file. This makes the dev server provide a proxy when serving the application.
The environment variables for the API endpoint are named `services__Api__https__0` and `services__Api__http__0` for the https and the http endpoints respectively. If the https endpoint is not configured, the http endpoint is used. At least one of them has to be configured.
To allow the dev proxy to accept otherwise untrusted server certificates, set `NODE_ENV` to `development`. Otherwise the dev proxy rejects untrusted certificates.
When deploying the application elsewhere, another proxy has to be configured to provide the same functionality to ensure the application works correctly.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,120 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"Vegasco-Web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/Vegasco-Web",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.config.js"
},
"configurations": {
"production": {
"buildTarget": "Vegasco-Web:build:production"
},
"development": {
"buildTarget": "Vegasco-Web:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -0,0 +1,8 @@
events { }
http {
include mime.types;
resolver 127.0.0.11;
include /etc/nginx/conf.d/webserver.conf;
}

View File

@@ -0,0 +1,52 @@
{
"name": "vegasco-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "run-script-os",
"start:win32": "ng serve --port %PORT% --configuration development",
"start:default": "ng serve --port $PORT --configuration development",
"build": "pnpm build:development",
"build:development": "ng build",
"build:production": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.14",
"@angular/compiler": "^19.2.14",
"@angular/core": "^19.2.14",
"@angular/forms": "^19.2.14",
"@angular/platform-browser": "^19.2.14",
"@angular/router": "^19.2.14",
"@ng-icons/core": "^31.4.0",
"@ng-icons/material-file-icons": "^31.4.0",
"@ng-icons/material-icons": "^31.4.0",
"@primeng/themes": "^19.1.3",
"@tailwindcss/postcss": "^4.1.10",
"dayjs": "^1.11.13",
"keycloak-angular": "^19.0.2",
"postcss": "^8.5.6",
"primeng": "^19.1.3",
"rxjs": "~7.8.2",
"tailwindcss": "^4.1.10",
"tailwindcss-primeui": "^0.6.1",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.15",
"@angular/cli": "^19.2.15",
"@angular/compiler-cli": "^19.2.14",
"@types/jasmine": "~5.1.8",
"jasmine-core": "~5.7.1",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"run-script-os": "^1.1.6",
"typescript": "~5.8.3"
}
}

9091
src/Vegasco-Web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
module.exports = {
"/api": {
target:
process.env["services__Api__https__0"] ||
process.env["services__Api__http__0"],
secure: process.env["NODE_ENV"] !== "development",
pathRewrite: {
"^/api": "",
},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,6 @@
import { InjectionToken } from "@angular/core";
/**
* The base path for all API requests, e.g. when using a proxy on the origin's address.
*/
export const API_BASE_PATH = new InjectionToken<string>('API_BASE_PATH');

View File

@@ -0,0 +1,35 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from "@angular/core";
import { map, Observable } from 'rxjs';
import { API_BASE_PATH } from "../api-base-path";
@Injectable({
providedIn: 'root',
})
export class CarClient {
private readonly http = inject(HttpClient);
private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
getAll(): Observable<GetCarsResponse> {
return this.http.get<GetCarsResponse>(`${this.apiBasePath}/v1/cars`);
}
getSingle(id: string): Observable<Car> {
return this.http.get<Car>(`${this.apiBasePath}/v1/cars/${id}`);
}
create(request: CreateCarRequest): Observable<Car> {
return this.http.post<Car>(`${this.apiBasePath}/v1/cars`, request);
}
update(id: string, request: UpdateCarRequest): Observable<Car> {
return this.http.put<Car>(`${this.apiBasePath}/v1/cars/${id}`, request);
}
delete(id: string): Observable<void> {
return this.http.delete(`${this.apiBasePath}/v1/cars/${id}`)
.pipe(
map(_ => undefined)
);
}
}

View File

@@ -0,0 +1,4 @@
interface Car {
id: string;
name: string;
}

View File

@@ -0,0 +1,3 @@
interface CreateCarRequest {
name: string;
}

View File

@@ -0,0 +1,3 @@
interface GetCarsResponse {
cars: Car[];
}

View File

@@ -0,0 +1,3 @@
interface UpdateCarRequest {
name: string;
}

View File

@@ -0,0 +1,35 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
import { API_BASE_PATH } from '../api-base-path';
@Injectable({
providedIn: 'root',
})
export class ConsumptionClient {
private readonly http = inject(HttpClient);
private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
getAll(): Observable<GetConsumptionEntriesResponse> {
return this.http.get<GetConsumptionEntriesResponse>(`${this.apiBasePath}/v1/consumptions`);
}
getSingle(id: string): Observable<ConsumptionEntry> {
return this.http.get<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions/${id}`);
}
create(request: CreateConsumptionEntry): Observable<ConsumptionEntry> {
return this.http.post<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions`, request);
}
update(id: string, request: UpdateConsumptionEntry): Observable<ConsumptionEntry> {
return this.http.put<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions/${id}`, request);
}
delete(id: string): Observable<void> {
return this.http.delete(`${this.apiBasePath}/v1/consumptions/${id}`)
.pipe(
map(_ => undefined),
);
}
}

View File

@@ -0,0 +1,7 @@
interface ConsumptionEntry {
id: string;
dateTime: string;
distance: number;
amount: number;
carId: string;
}

View File

@@ -0,0 +1,6 @@
interface CreateConsumptionEntry {
dateTime: string;
distance: number;
amount: number;
carId: string;
}

View File

@@ -0,0 +1,11 @@
interface GetConsumptionEntriesEntry {
id: string;
dateTime: string;
distance: number;
amount: number;
car: {
id: string;
name: string;
};
literPer100Km: number | null;
}

View File

@@ -0,0 +1,3 @@
interface GetConsumptionEntriesResponse {
consumptions: GetConsumptionEntriesEntry[];
}

View File

@@ -0,0 +1,6 @@
interface UpdateConsumptionEntry {
dateTime: string;
distance: number;
amount: number;
carId: string;
}

View File

@@ -0,0 +1,30 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import Lara from '@primeng/themes/lara';
import { includeBearerTokenInterceptor } from 'keycloak-angular';
import { providePrimeNG } from 'primeng/config';
import { routes } from './app.routes';
import { provideKeycloakAngular } from './auth/auth.config';
import {API_BASE_PATH} from './api/api-base-path';
export const appConfig: ApplicationConfig = {
providers: [
provideKeycloakAngular(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([includeBearerTokenInterceptor])),
provideAnimationsAsync(),
providePrimeNG({
theme: {
preset: Lara
},
ripple: true
}),
{
provide: API_BASE_PATH,
useValue: '/api'
}
]
};

View File

@@ -0,0 +1,21 @@
<main class="main">
<header class="h-12 bg-primary text-primary-contrast">
<div class="header max-content-width mx-auto flex items-center justify-between">
<a routerLink="/" class="reset cursor-pointer">
Vegasco
</a>
<span class="flex items-center gap-4">
<a routerLink="/entries" class="reset cursor-pointer">
Einträge
</a>
<a routerLink="/cars" class="reset cursor-pointer">
Autos
</a>
</span>
</div>
</header>
<div class="content max-content-width mx-auto">
<p-toast />
<router-outlet></router-outlet>
</div>
</main>

View File

@@ -0,0 +1,17 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'entries',
pathMatch: 'full'
},
{
path: 'entries',
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
},
{
path: 'cars',
loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes)
}
];

View File

@@ -0,0 +1,10 @@
.content {
padding: 1rem;
}
.header {
padding: 0 1rem;
display: flex;
align-items: center;
height: 100%;
}

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Vegasco-Web');
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet, ToastModule],
providers: [MessageService],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
}

View File

@@ -0,0 +1,45 @@
import { environment } from '@vegasco-web/environments/environment';
import {
AutoRefreshTokenService,
createInterceptorCondition,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
IncludeBearerTokenCondition,
provideKeycloak,
UserActivityService,
withAutoRefreshToken
} from 'keycloak-angular';
const serverHostBearerInterceptorCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
// The API is consumed through a proxy running on the same origin as the application.
// This means that the interceptor should include the bearer token for requests to the same origin
// which includes requests starting to / which implicitly sends the request to the same origin.
urlPattern: new RegExp(`^(${window.origin}|/)`)
});
export const provideKeycloakAngular = () =>
provideKeycloak({
config: {
url: environment.keycloak.host,
realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId,
},
initOptions: {
onLoad: 'login-required',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
redirectUri: window.location.origin + '/',
checkLoginIframe: false,
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'login',
})
],
providers: [
AutoRefreshTokenService,
UserActivityService,
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [serverHostBearerInterceptorCondition]
}
]
});

View File

@@ -0,0 +1,17 @@
import { Routes } from "@angular/router";
import { CarsComponent } from "./cars/cars.component";
export const routes: Routes = [
{
path: '',
component: CarsComponent
},
{
path: 'create',
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
},
{
path: 'edit/:id',
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
}
];

View File

@@ -0,0 +1,38 @@
<section>
<p-scrollTop />
<div class="mb-4 flex gap-2 md:justify-end">
<div>
<p-button label="Erstellen" routerLink="/cars/create">
<ng-icon name="matAddSharp"></ng-icon>
</p-button>
</div>
</div>
<div>
@if (nonDeletedCars$ | async; as cars) {
<p-dataView
[value]="cars"
[paginator]="true"
[rows]="25"
[rowsPerPageOptions]="[10, 25, 50, 100]"
[pageLinks]="0"
[showCurrentPageReport]="true"
currentPageReportTemplate="{currentPage} / {totalPages}"
layout="list">
<ng-template #list let-cars>
<div class="flex flex-col gap-2">
@for (car of cars; track car.id) {
<app-car-card [car]="car"
(carDeleted)="onCarDeleted($event)" />
}
</div>
</ng-template>
</p-dataView>
} @else {
<div class="flex flex-col gap-2">
@for (_ of skeletonsIterationSource; track $index) {
<p-skeleton height="4rem" styleClass="mb-2" />
}
</div>
}
</div>
</section>

View File

@@ -0,0 +1,3 @@
th, td {
padding: 0.5rem;
}

View File

@@ -0,0 +1,127 @@
import { AsyncPipe, CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matAddSharp,
} from '@ng-icons/material-icons/sharp';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { DataViewModule } from 'primeng/dataview';
import { ScrollTopModule } from 'primeng/scrolltop';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import {
BehaviorSubject,
catchError,
combineLatest,
EMPTY,
map,
Observable,
throwError
} from 'rxjs';
import { CarCardComponent } from './components/car-card/car-card.component';
import { SelectedCarService } from '@vegasco-web/modules/entries/services/selected-car.service';
@Component({
selector: 'app-entries',
imports: [
AsyncPipe,
ButtonModule,
CommonModule,
DataViewModule,
CarCardComponent,
NgIconComponent,
ReactiveFormsModule,
RouterLink,
ScrollTopModule,
SelectModule,
SkeletonModule,
],
providers: [
provideIcons({
matAddSharp,
}),
],
templateUrl: './cars.component.html',
styleUrl: './cars.component.scss'
})
export class CarsComponent {
private readonly carClient = inject(CarClient);
private readonly messageService = inject(MessageService);
private readonly selectedCarService = inject(SelectedCarService);
protected readonly nonDeletedCars$: Observable<Car[]>;
protected readonly skeletonsIterationSource = Array(10).fill(0);
private readonly deletedCars$ = new BehaviorSubject(<string[]>[]);
constructor() {
const cars$ = this.carClient.getAll()
.pipe(
map(response => response.cars),
map((cars) => cars
.sort((a, b) => a.name.localeCompare(b.name))),
);
this.nonDeletedCars$ = combineLatest([
cars$,
this.deletedCars$
])
.pipe(
takeUntilDestroyed(),
map(([cars, deletedCars]) => cars.filter(car => !deletedCars.includes(car.id))),
catchError((error) => this.handleGetCarsError(error)),
);
}
onCarDeleted(car: Car): void {
this.deletedCars$.next([...this.deletedCars$.value, car.id]);
this.messageService.add({
severity: 'success',
summary: 'Auto gelöscht',
detail: 'Das Auto wurde erfolgreich gelöscht.',
});
this.resetSelectedCarIfDeleted(car);
}
private resetSelectedCarIfDeleted(car: Car) {
const selectedCarId = this.selectedCarService.getSelectedCarId();
if (selectedCarId === car.id) {
this.selectedCarService.setSelectedCarId(null);
}
}
private handleGetCarsError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => new Error('An unexpected error occurred'));
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1,22 @@
<p-confirmDialog></p-confirmDialog>
<div class="flex rounded-border shadow">
<div class="grow p-4 pos-relative edit-button" (click)="navigateToEdit()" role="button"
aria-roledescription="Bearbeite diesen Eintrag">
<div class="grid grid-cols-4 gap-4">
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
<div class="flex gap-2 items-center">
<ng-icon name="matDirectionsCarOutline" />
<div>{{ car().name }}</div>
</div>
</div>
</div>
</div>
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
<button type="button" title="Löschen" class="reset cursor-pointer primary-color-text p-4 h-full rounded-r"
(click)="confirmDeleteCar()">
<ng-icon name="matDeleteSharp"></ng-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.edit-button {
cursor: pointer;
}

View File

@@ -0,0 +1,116 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matDirectionsCarOutline,
} from '@ng-icons/material-icons/outline';
import {
matDeleteSharp
} from '@ng-icons/material-icons/sharp';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
@Component({
selector: 'app-car-card',
imports: [
ButtonModule,
CardModule,
ConfirmDialogModule,
NgIconComponent,
],
providers: [
provideIcons({
matDeleteSharp,
matDirectionsCarOutline,
}),
ConfirmationService,
],
templateUrl: './car-card.component.html',
styleUrl: './car-card.component.scss'
})
export class CarCardComponent {
readonly car = input.required<Car>();
readonly carDeleted = output<Car>();
private readonly routingService = inject(RoutingService);
private readonly carClient = inject(CarClient);
private readonly messageService = inject(MessageService);
private readonly confirmationService = inject(ConfirmationService);
private readonly destroyRef = inject(DestroyRef);
async navigateToEdit(): Promise<void> {
await this.routingService.navigateToEditCar(this.car().id);
}
confirmDeleteCar(): void {
this.confirmationService.confirm({
closeOnEscape: true,
dismissableMask: true,
header: 'Bist du sicher?',
message: `Möchtest du das Auto "${this.car().name}" wirklich löschen?`,
acceptButtonProps: {
label: 'Löschen',
severity: 'danger',
},
rejectButtonProps: {
label: 'Abbrechen',
outlined: true,
},
accept: () => this.deleteCar(),
});
}
deleteCar(): void {
this.carClient.delete(this.car().id)
.pipe(
takeUntilDestroyed(this.destroyRef),
tap(() => this.carDeleted.emit(this.car())),
catchError((error) => this.handleError(error)),
)
.subscribe();
}
private handleError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Löschen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Löschen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1 @@
<span class="required">*</span>

View File

@@ -0,0 +1,3 @@
.required {
color: red;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-required-marker',
imports: [
],
templateUrl: './required-marker.component.html',
styleUrl: './required-marker.component.scss'
})
export class RequiredMarkerComponent {
}

View File

@@ -0,0 +1,31 @@
@if (!isCarDataLoaded()) {
<div class="flex flex-col gap-6">
<p-skeleton height="3.5rem" />
<div class="flex flex-row gap-4">
<p-skeleton height="3.5rem" width="10rem" />
<p-skeleton height="3.5rem" width="10rem" />
</div>
</div>
} @else {
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
<div class="flex flex-col gap-2">
<label [for]="formFieldNames.name">
Name
<app-required-marker />
</label>
<input
id="name"
placeholder="Name eingeben"
type="text"
pInputText
[formControlName]="formFieldNames.name" />
</div>
<div class="flex gap-2">
<p-button type="button" label="Abbrechen" (click)="navigateToOverviewPage()" severity="warn" />
<p-button type="submit" label="Abschicken" severity="success" [disabled]="formGroup.invalid" />
</div>
</form>
}

View File

@@ -0,0 +1,225 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { ChipModule } from 'primeng/chip';
import { DatePickerModule } from 'primeng/datepicker';
import { FloatLabelModule } from 'primeng/floatlabel';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { InputNumberModule } from 'primeng/inputnumber';
import { InputTextModule } from 'primeng/inputtext';
import { MultiSelectModule } from 'primeng/multiselect';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import { catchError, EMPTY, Observable, switchMap, tap, throwError } from 'rxjs';
import { RequiredMarkerComponent } from './components/required-marker.component';
@Component({
selector: 'app-edit-entry',
imports: [
ButtonModule,
ChipModule,
DatePickerModule,
FloatLabelModule,
InputGroupAddonModule,
InputGroupModule,
InputNumberModule,
InputTextModule,
MultiSelectModule,
ReactiveFormsModule,
RequiredMarkerComponent,
SelectModule,
SkeletonModule,
],
templateUrl: './edit-car.component.html',
styleUrl: './edit-car.component.scss'
})
export class EditCarComponent implements OnInit {
private readonly carClient = inject(CarClient);
private readonly routingService = inject(RoutingService);
private readonly destroyRef = inject(DestroyRef);
private readonly messageService = inject(MessageService);
protected readonly id = input<string | undefined>(undefined);
protected readonly today = new Date();
protected readonly formFieldNames = {
name: 'name',
} as const;
protected readonly formGroup = new FormGroup({
[this.formFieldNames.name]: new FormControl<string | null>({ value: null, disabled: true }, [Validators.required]),
});
protected readonly isCarDataLoaded = signal(false);
ngOnInit(): void {
this.loadEntryDetailsAndEnableControls();
}
private loadEntryDetailsAndEnableControls() {
const carId = this.id();
if (carId === undefined || carId === null) {
this.enableFormControls();
this.isCarDataLoaded.set(true);
return;
}
this.carClient
.getSingle(carId)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleGetError(error)),
tap((car) => {
this.formGroup.patchValue({
[this.formFieldNames.name]: car.name,
});
}),
tap(() => {
this.enableFormControls();
this.isCarDataLoaded.set(true);
}),
)
.subscribe();
}
private enableFormControls(): void {
for (const controlName of Object.values(this.formFieldNames)) {
const control = this.formGroup.get(controlName);
if (control) {
control.enable();
} else {
console.warn(`Form control '${controlName}' not found.`);
}
}
}
async navigateToOverviewPage(): Promise<void> {
await this.routingService.navigateToCars();
}
onSubmit(): void {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
var carId = this.id();
if (carId === undefined || carId === null) {
this.createCar();
return;
}
this.updateCar(carId);
}
private getFormData() {
return {
name: this.formGroup.controls[this.formFieldNames.name].value!,
};
}
createCar() {
var request: CreateCarRequest = this.getFormData();
this.carClient.create(request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error, false)),
switchMap(() => this.routingService.navigateToCars())
)
.subscribe();
}
updateCar(id: string) {
var request: UpdateCarRequest = this.getFormData();
this.carClient.update(id, request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error, true)),
switchMap(() => this.routingService.navigateToCars())
)
.subscribe();
}
private handleGetError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Abrufen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Abrufen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
private handleCreateOrUpdateError(error: unknown, isUpdate: boolean): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
const action = isUpdate ? 'Aktualisieren' : 'Erstellen';
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
`Beim ${action} des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.`,
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
case error.status === 409:
this.messageService.add({
severity: 'warn',
summary: 'Konflikt',
detail:
'Es existiert bereits ein Auto mit diesem Namen. Bitte wähle einen anderen Namen.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
`Beim ${action} des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.`,
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1 @@
<span class="required">*</span>

View File

@@ -0,0 +1,3 @@
.required {
color: red;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-required-marker',
imports: [
],
templateUrl: './required-marker.component.html',
styleUrl: './required-marker.component.scss'
})
export class RequiredMarkerComponent {
}

View File

@@ -0,0 +1,90 @@
@if (isLoading()) {
<div class="flex flex-col gap-6">
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<div class="flex flex-row gap-4">
<p-skeleton height="3.5rem" width="10rem" />
<p-skeleton height="3.5rem" width="10rem" />
</div>
</div>
} @else {
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
<div class="flex flex-col gap-2">
<label [for]="formFieldNames.car">
Auto
<app-required-marker />
</label>
@if (cars(); as cars) {
<p-select
[options]="cars"
placeholder="Auto auswählen"
[formControlName]="formFieldNames.car"
optionLabel="name"
[inputId]="formFieldNames.car"
styleClass="w-full" />
}
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2 items-center">
<label [for]="formFieldNames.date">
Datum
<app-required-marker />
</label>
</div>
<p-datepicker [iconDisplay]="'input'"
[firstDayOfWeek]="1"
placeholder="Datum auswählen"
[showIcon]="true"
[maxDate]="today"
[defaultDate]="today"
[inputId]="formFieldNames.date"
[formControlName]="formFieldNames.date"
styleClass="w-full"
dateFormat="dd.mm.yy" />
</div>
<div class="flex flex-col gap-2">
<label [for]="formFieldNames.mileage">
Kilometerstand
<app-required-marker />
</label>
<p-inputGroup>
<input
id="mileage"
placeholder="Kilometerstand eingeben"
type="number"
min="1"
pInputText
[formControlName]="formFieldNames.mileage" />
<p-inputGroupAddon>km</p-inputGroupAddon>
</p-inputGroup>
</div>
<div class="flex flex-col gap-2">
<label [for]="formFieldNames.amount">
Menge
<app-required-marker />
</label>
<p-inputGroup>
<input
id="amount"
placeholder="Menge eingeben"
type="number"
min="1"
pInputText
[formControlName]="formFieldNames.amount" />
<p-inputGroupAddon>&#8467;</p-inputGroupAddon>
</p-inputGroup>
</div>
<div class="flex gap-2">
<p-button type="button" label="Abbrechen" (click)="navigateToOverviewPage()" severity="warn" />
<p-button type="submit" label="Abschicken" severity="success" [disabled]="formGroup.invalid" />
</div>
</form>
}

View File

@@ -0,0 +1,293 @@
import dayjs from 'dayjs';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, DestroyRef, inject, input, OnInit, signal, Signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { ChipModule } from 'primeng/chip';
import { DatePickerModule } from 'primeng/datepicker';
import { FloatLabelModule } from 'primeng/floatlabel';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { InputNumberModule } from 'primeng/inputnumber';
import { InputTextModule } from 'primeng/inputtext';
import { MultiSelectModule } from 'primeng/multiselect';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import { catchError, combineLatest, EMPTY, filter, map, Observable, switchMap, tap, throwError } from 'rxjs';
import { RequiredMarkerComponent } from './components/required-marker.component';
import { SelectedCarService } from '../services/selected-car.service';
@Component({
selector: 'app-edit-entry',
imports: [
ButtonModule,
ChipModule,
DatePickerModule,
FloatLabelModule,
InputGroupAddonModule,
InputGroupModule,
InputNumberModule,
InputTextModule,
MultiSelectModule,
ReactiveFormsModule,
RequiredMarkerComponent,
SelectModule,
SkeletonModule,
],
templateUrl: './edit-entry.component.html',
styleUrl: './edit-entry.component.scss'
})
export class EditEntryComponent implements OnInit {
private readonly carClient = inject(CarClient);
private readonly consumptionClient = inject(ConsumptionClient);
private readonly routingService = inject(RoutingService);
private readonly destroyRef = inject(DestroyRef);
private readonly messageService = inject(MessageService);
private readonly selectedCarService = inject(SelectedCarService);
protected readonly id = input<string | undefined>(undefined);
protected readonly today = new Date();
protected readonly formFieldNames = {
car: 'car',
date: 'date',
mileage: 'mileage',
amount: 'amount',
} as const;
protected readonly formGroup = new FormGroup({
[this.formFieldNames.car]: new FormControl<Car | null>({ value: null, disabled: true }, [Validators.required]),
[this.formFieldNames.date]: new FormControl<Date>({ value: new Date(), disabled: true }, [Validators.required, this.dateTimeGreaterThanOrEqualToTodayValidator]),
[this.formFieldNames.mileage]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
[this.formFieldNames.amount]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
});
private readonly cars$: Observable<Car[]>;
protected readonly cars: Signal<Car[] | undefined>;
private readonly isEntryDataLoaded = signal(false);
protected readonly isLoading = computed(() => {
var cars = this.cars();
var isEntryDataLoaded = this.isEntryDataLoaded();
return cars === undefined || !isEntryDataLoaded;
});
constructor() {
this.cars$ = this.carClient
.getAll()
.pipe(
takeUntilDestroyed(),
map(response => response.cars
.sort((a, b) => a.name.localeCompare(b.name))),
tap(cars => {
const selectedCarId = this.selectedCarService.getSelectedCarId();
if (selectedCarId === null) {
const firstCar = cars[0];
this.formGroup.controls[this.formFieldNames.car].setValue(firstCar);
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
return;
}
const selectedCar = cars.find(car => car.id === selectedCarId);
this.formGroup.controls[this.formFieldNames.car].setValue(selectedCar ?? null);
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
}),
);
this.cars = toSignal(this.cars$);
}
ngOnInit(): void {
this.loadEntryDetailsAndEnableControls();
this.formGroup.controls[this.formFieldNames.car]
.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef),
tap((car) => {
this.selectedCarService.setSelectedCarId(car?.id ?? null);
})
)
.subscribe();
}
private loadEntryDetailsAndEnableControls() {
const entryId = this.id();
if (entryId === undefined || entryId === null) {
this.enableFormControls();
this.isEntryDataLoaded.set(true);
return;
}
const consumption$ = this.consumptionClient
.getSingle(entryId);
combineLatest([
consumption$, this.cars$
])
.pipe(
filter(([_, cars]) => cars !== undefined),
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleGetError(error)),
tap(([consumption, cars]) => {
this.formGroup.patchValue({
[this.formFieldNames.car]: cars!.find(c => c.id === consumption.carId) ?? null,
[this.formFieldNames.date]: new Date(consumption.dateTime),
[this.formFieldNames.mileage]: consumption.distance,
[this.formFieldNames.amount]: consumption.amount,
});
}),
tap(() => {
this.enableFormControls();
this.isEntryDataLoaded.set(true);
}),
)
.subscribe();
}
private enableFormControls(): void {
for (const controlName of Object.values(this.formFieldNames)) {
const control = this.formGroup.get(controlName);
if (control) {
control.enable();
} else {
console.warn(`Form control '${controlName}' not found.`);
}
}
}
async navigateToOverviewPage(): Promise<void> {
await this.routingService.navigateToEntries();
}
onSubmit(): void {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
var entryId = this.id();
if (entryId === undefined || entryId === null) {
this.createEntry();
return;
}
this.updateEntry(entryId);
}
private getFormData() {
var dateTime = new Date((this.formGroup.controls[this.formFieldNames.date].value ?? new Date).setHours(0, 0, 0, 0));
return {
carId: this.formGroup.controls[this.formFieldNames.car].value!.id,
dateTime: dateTime.toISOString(),
distance: this.formGroup.controls[this.formFieldNames.mileage].value!,
amount: this.formGroup.controls[this.formFieldNames.amount].value!,
};
}
createEntry() {
var request: CreateConsumptionEntry = this.getFormData();
this.consumptionClient.create(request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error)),
switchMap(() => this.routingService.navigateToEntries())
)
.subscribe();
}
updateEntry(id: string) {
var request: UpdateConsumptionEntry = this.getFormData();
this.consumptionClient.update(id, request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error)),
switchMap(() => this.routingService.navigateToEntries())
)
.subscribe();
}
private handleGetError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
private handleCreateOrUpdateError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
private dateTimeGreaterThanOrEqualToTodayValidator(control: FormControl<Date>): ValidationErrors | null {
const tomorrowStartOfDay = dayjs().add(1, 'day').startOf('day');
const controlDate = dayjs(control.value);
if (controlDate.isBefore(tomorrowStartOfDay)) {
return null;
}
return { dateTimeGreaterThanOrEqualToToday: true };
}
}

View File

@@ -0,0 +1,17 @@
import { Routes } from "@angular/router";
import { EntriesComponent } from "./entries/entries.component";
export const routes: Routes = [
{
path: '',
component: EntriesComponent
},
{
path: 'create',
loadComponent: () => import('./edit-entry/edit-entry.component').then(m => m.EditEntryComponent)
},
{
path: 'edit/:id',
loadComponent: () => import('./edit-entry/edit-entry.component').then(m => m.EditEntryComponent)
}
];

View File

@@ -0,0 +1,49 @@
<p-confirmDialog></p-confirmDialog>
<div class="flex rounded-border shadow">
<div class="grow p-4 pos-relative edit-button" (click)="navigateToEdit()" role="button"
aria-roledescription="Bearbeite diesen Eintrag">
<div class="grid grid-cols-4 gap-4">
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
<div class="flex gap-2 items-center">
<ng-icon name="matCalendarMonthSharp" />
<div>{{ entry().dateTime | date:"dd.MM.yyyy" }}</div>
</div>
</div>
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
<div class="flex gap-2 items-center">
<ng-icon name="matStraightenSharp" />
<div>{{entry().distance }} km</div>
</div>
</div>
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
<div class="flex gap-2 items-center">
<ng-icon name="matLocalGasStationSharp" />
<div>{{entry().amount }} &#8467;</div>
</div>
</div>
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
@if (formattedLiterPer100Km(); as formattedLiterPer100Km) {
<div class="flex gap-2 items-center">
<ng-icon name="matSpeedSharp" />
<div class="flex items-center gap-1">
{{ formattedLiterPer100Km }}
<app-fraction numerator="&#8467;" [denominator]="'100km'" />
</div>
</div>
}
</div>
</div>
</div>
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
<button type="button" title="Löschen" class="reset cursor-pointer primary-color-text p-4 h-full rounded-r"
(click)="confirmDeleteEntry()">
<ng-icon name="matDeleteSharp"></ng-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.edit-button {
cursor: pointer;
}

View File

@@ -0,0 +1,141 @@
import { DatePipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, DestroyRef, inject, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matCalendarMonthSharp,
matDeleteSharp,
matLocalGasStationSharp,
matSpeedSharp,
matStraightenSharp,
} from '@ng-icons/material-icons/sharp';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
import { FractionComponent } from "../fraction/fraction.component";
@Component({
selector: 'app-entry-card',
imports: [
ButtonModule,
CardModule,
ConfirmDialogModule,
DatePipe,
NgIconComponent,
FractionComponent
],
providers: [
provideIcons({
matDeleteSharp,
matCalendarMonthSharp,
matSpeedSharp,
matStraightenSharp,
matLocalGasStationSharp,
}),
ConfirmationService,
],
templateUrl: './entry-card.component.html',
styleUrl: './entry-card.component.scss'
})
export class EntryCardComponent {
readonly entry = input.required<GetConsumptionEntriesEntry>();
protected readonly formattedLiterPer100Km = computed(() => {
const entry = this.entry();
const formatted = entry.literPer100Km
?.toFixed(2)
.replace('.', ',');
return formatted;
})
readonly entryDeleted = output<GetConsumptionEntriesEntry>();
private readonly routingService = inject(RoutingService);
private readonly consumptionClient = inject(ConsumptionClient);
private readonly messageService = inject(MessageService);
private readonly confirmationService = inject(ConfirmationService);
private readonly destroyRef = inject(DestroyRef);
async navigateToEdit(): Promise<void> {
await this.routingService.navigateToEditEntry(this.entry().id);
}
confirmDeleteEntry(): void {
const weighedAt = new Date(
Date.parse(this.entry().dateTime),
).toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
this.confirmationService.confirm({
closeOnEscape: true,
dismissableMask: true,
header: 'Bist du sicher?',
message: `Möchtest du diesen Eintrag (${weighedAt} für ${this.entry().car.name}) wirklich löschen?`,
acceptButtonProps: {
label: 'Löschen',
severity: 'danger',
},
rejectButtonProps: {
label: 'Abbrechen',
outlined: true,
},
accept: () => this.deleteEntry(),
});
}
deleteEntry(): void {
this.consumptionClient.delete(this.entry().id)
.pipe(
takeUntilDestroyed(this.destroyRef),
tap(() => this.entryDeleted.emit(this.entry())),
catchError((error) => this.handleError(error)),
)
.subscribe();
}
private handleError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Löschen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Löschen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1,9 @@
<div class="flex flex-col items-center text-half-size">
<span>
{{ numerator() }}
</span>
<span class="separator"></span>
<span>
{{ denominator() }}
</span>
</div>

View File

@@ -0,0 +1,11 @@
.separator {
border-bottom-width: 1px;
width: 100%
}
.text-half-size {
// Specifically use em here to allow the parent to control the font size
// The font size here should be smaller because it is a fraction and would
// otherwise look too large
font-size: 0.75em;
}

View File

@@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-fraction',
imports: [],
templateUrl: './fraction.component.html',
styleUrl: './fraction.component.scss'
})
export class FractionComponent {
readonly numerator = input.required<number | string>();
readonly denominator = input.required<number | string>();
}

View File

@@ -0,0 +1,43 @@
<section>
<p-scrollTop />
<div class="mb-4 flex gap-2 md:justify-between">
<div class="basis-full lg:basis-1/4 md:basis-1/2 p-0">
<p-select styleClass="w-full" [formControl]="selectedCar" placeholder="Auto" [showClear]="false"
[options]="(cars$ | async)!" optionLabel="name" />
</div>
<div>
<p-button label="Erstellen" routerLink="/entries/create">
<ng-icon name="matAddSharp"></ng-icon>
</p-button>
</div>
</div>
<div>
@if (consumptionEntries$ | async; as entries) {
<p-dataView
[value]="entries"
[paginator]="true"
[rows]="25"
[rowsPerPageOptions]="[10, 25, 50, 100]"
[pageLinks]="0"
[showCurrentPageReport]="true"
currentPageReportTemplate="{currentPage} / {totalPages}"
layout="list"
>
<ng-template #list let-entries>
<div class="flex flex-col gap-2">
@for (entry of entries; track entry.id) {
<app-entry-card [entry]="entry"
(entryDeleted)="onEntryDeleted($event)" />
}
</div>
</ng-template>
</p-dataView>
} @else {
<div class="flex flex-col gap-2">
@for (_ of skeletonsIterationSource; track $index) {
<p-skeleton height="4rem" styleClass="mb-2" />
}
</div>
}
</div>
</section>

View File

@@ -0,0 +1,3 @@
th, td {
padding: 0.5rem;
}

View File

@@ -0,0 +1,171 @@
import { AsyncPipe, CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matAddSharp,
} from '@ng-icons/material-icons/sharp';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { DataViewModule } from 'primeng/dataview';
import { ScrollTopModule } from 'primeng/scrolltop';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import {
BehaviorSubject,
catchError,
combineLatest,
EMPTY,
map,
Observable,
startWith,
tap,
throwError
} from 'rxjs';
import { SelectedCarService } from '../services/selected-car.service';
import { EntryCardComponent } from './components/entry-card/entry-card.component';
@Component({
selector: 'app-entries',
imports: [
AsyncPipe,
ButtonModule,
CommonModule,
DataViewModule,
EntryCardComponent,
NgIconComponent,
ReactiveFormsModule,
RouterLink,
ScrollTopModule,
SelectModule,
SkeletonModule,
],
providers: [
provideIcons({
matAddSharp,
}),
],
templateUrl: './entries.component.html',
styleUrl: './entries.component.scss'
})
export class EntriesComponent implements OnInit {
private readonly carClient = inject(CarClient);
private readonly consumptionClient = inject(ConsumptionClient);
private readonly messageService = inject(MessageService);
private readonly selectedCarService = inject(SelectedCarService);
private readonly destroyRef = inject(DestroyRef);
protected readonly consumptionEntries$: Observable<GetConsumptionEntriesEntry[]>;
protected readonly cars$: Observable<Car[]>;
protected readonly skeletonsIterationSource = Array(10).fill(0);
protected readonly selectedCar = new FormControl<Car | null | undefined>(null);
private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]);
constructor() {
const entries = this.consumptionClient.getAll()
.pipe(
takeUntilDestroyed(),
map(response => response.consumptions.sort((a, b) => b.dateTime.localeCompare(a.dateTime))),
catchError((error) => this.handleGetEntriesError(error))
);
this.consumptionEntries$ = combineLatest([
entries,
this.selectedCar.valueChanges.pipe(startWith(null)),
this.deletedEntries$,
])
.pipe(
takeUntilDestroyed(),
map(([entries, selectedCar, deletedEntries]) => {
const nonDeletedEntries =
deletedEntries.length === 0
? entries
: entries.filter(entry => !deletedEntries.includes(entry.id));
if (!selectedCar) {
return nonDeletedEntries;
}
return nonDeletedEntries.filter(entry => entry.car.id === selectedCar.id);
}),
);
this.cars$ = this.carClient.getAll()
.pipe(
takeUntilDestroyed(),
map(response => response.cars),
map((cars) => cars
.sort((a, b) => a.name.localeCompare(b.name))),
tap((cars) => {
const selectedCarId = this.selectedCarService.getSelectedCarId();
if (selectedCarId === null) {
const firstCar = cars[0];
this.selectedCar.setValue(firstCar);
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
return;
}
const selectedCar = cars.find(car => car.id === selectedCarId);
this.selectedCar.setValue(selectedCar ?? null);
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
}),
);
}
ngOnInit(): void {
this.selectedCar.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef),
tap((car) => {
this.selectedCarService.setSelectedCarId(car?.id ?? null);
})
)
.subscribe();
}
onEntryDeleted(entry: GetConsumptionEntriesEntry): void {
this.deletedEntries$.next([...this.deletedEntries$.value, entry.id]);
this.messageService.add({
severity: 'success',
summary: 'Eintrag gelöscht',
detail: 'Der Eintrag wurde erfolgreich gelöscht.',
});
}
private handleGetEntriesError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => new Error('An unexpected error occurred'));
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, tap } from "rxjs";
@Injectable({
providedIn: "root",
})
export class SelectedCarService {
static readonly SELECTED_CAR_ID_KEY = "SELECTED_CAR_ID";
private selectedCarId: string | null = null;
constructor() {
this.loadStoredCarId();
}
private loadStoredCarId(): void {
this.selectedCarId = localStorage.getItem(SelectedCarService.SELECTED_CAR_ID_KEY);
}
getSelectedCarId() {
return this.selectedCarId;
}
setSelectedCarId(carId: string | null): void {
this.selectedCarId = carId;
if (carId === null) {
localStorage.removeItem(SelectedCarService.SELECTED_CAR_ID_KEY);
} else {
localStorage.setItem(SelectedCarService.SELECTED_CAR_ID_KEY, carId);
}
}
}

View File

@@ -0,0 +1,33 @@
import { inject, Injectable } from "@angular/core";
import { Router } from "@angular/router";
@Injectable({
providedIn: 'root'
})
export class RoutingService {
private readonly router = inject(Router);
async navigateToEntries(): Promise<void> {
await this.router.navigateByUrl('/entries');
}
async navigateToEditEntry(entryId: string): Promise<void> {
await this.router.navigate(['entries', 'edit', entryId]);
}
async navigateToCreateEntry(): Promise<void> {
await this.router.navigate(['entries', 'create']);
}
async navigateToCars(): Promise<void> {
await this.router.navigateByUrl('/cars');
}
async navigateToEditCar(entryId: string): Promise<void> {
await this.router.navigate(['cars', 'edit', entryId]);
}
async navigateToCreateCar(): Promise<void> {
await this.router.navigate(['cars', 'create']);
}
}

View File

@@ -0,0 +1,11 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "Dev",
isProduction: false,
keycloak: {
host: "https://login.nuyken.dev",
realm: "development",
clientId: "vegasco"
}
};

View File

@@ -0,0 +1,17 @@
/** The app's configuration based on the target environment */
export interface Environment {
/** A name for this configuration, e.g. 'Prod' */
name: string;
/** Whether this configuration is for production or not */
isProduction: boolean;
/** Keycloak login configuration */
keycloak: {
/** The host under which the keycloak is reachable */
host: string;
/** The keycloak realm in which the client lives */
realm: string;
/** The app's client id */
clientId: string;
}
}

View File

@@ -0,0 +1,11 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "Prod",
isProduction: true,
keycloak: {
host: "https://login.nuyken.dev",
realm: "apps",
clientId: "vegasco"
}
};

View File

@@ -0,0 +1,11 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "",
isProduction: false,
keycloak: {
host: "",
realm: "",
clientId: ""
}
};

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VegascoWeb</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,39 @@
@use "tailwindcss";
@plugin "tailwindcss-primeui";
html,
body {
height: 100%;
margin: 0;
}
body {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif
}
.max-content-width {
max-width: 1200px;
}
.pos-absolute {
position: absolute;
}
.pos-relative {
position: relative;
}
.trbl-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.primary-color-text {
color: var(--primary-color-text);
}
.visually-hidden {
visibility: hidden;
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,40 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@vegasco-web/*": ["src/app/*"],
"@vegasco-web/assets/*": ["assets/*"],
"@vegasco-web/environments/*": ["src/environments/*"]
},
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -0,0 +1,12 @@
server {
listen 80;
location ~ ^/api/(.*) {
proxy_pass ${apiUrl}/${DOLLAR}1;
}
location / {
root /usr/share/nginx/html;
try_files ${DOLLAR}uri ${DOLLAR}uri/ /index.html =404;
}
}

View File

@@ -0,0 +1,3 @@
using StronglyTypedIds;
[assembly: StronglyTypedIdDefaults(Template.Guid, "guid-efcore")]

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Vegasco.Server.Api.Authentication;
public class JwtOptions
{
public const string SectionName = "JWT";
public string ValidAudience { get; set; } = "";
public string MetadataUrl { get; set; } = "";
public string? NameClaimType { get; set; }
public bool AllowHttpMetadataUrl { get; set; }
}
public class JwtOptionsValidator : AbstractValidator<JwtOptions>
{
public JwtOptionsValidator()
{
RuleFor(x => x.ValidAudience)
.NotEmpty();
RuleFor(x => x.MetadataUrl)
.NotEmpty();
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Options;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
namespace Vegasco.Server.Api.Authentication;
public sealed class UserAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<JwtOptions> _jwtOptions;
/// <summary>
/// Stores the username upon first retrieval
/// </summary>
private string? _cachedUsername;
/// <summary>
/// Stores the id upon first retrieval
/// </summary>
private string? _cachedId;
public UserAccessor(IHttpContextAccessor httpContextAccessor, IOptions<JwtOptions> jwtOptions)
{
_httpContextAccessor = httpContextAccessor;
_jwtOptions = jwtOptions;
}
public string GetUsername()
{
if (string.IsNullOrEmpty(_cachedUsername))
{
_cachedUsername = GetClaimValue(_jwtOptions.Value.NameClaimType ?? ClaimTypes.Name);
}
return _cachedUsername;
}
public string GetUserId()
{
if (string.IsNullOrEmpty(_cachedId))
{
_cachedId = GetClaimValue(ClaimTypes.NameIdentifier);
}
return _cachedId;
}
private string GetClaimValue(string claimType)
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
{
ThrowForMissingHttpContext();
}
string? claimValue = httpContext.User.FindFirstValue(claimType);
if (string.IsNullOrWhiteSpace(claimValue))
{
ThrowForMissingClaim(claimType);
}
return claimValue;
}
[DoesNotReturn]
private static void ThrowForMissingHttpContext()
{
throw new InvalidOperationException("No HttpContext available.");
}
[DoesNotReturn]
private static void ThrowForMissingClaim(string claimType)
{
throw new InvalidOperationException($"No claim of type '{claimType}' found on the current user.");
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public class Car
{
public CarId Id { get; set; } = CarId.New();
public string Name { get; set; } = "";
public string UserId { get; set; } = "";
public virtual User User { get; set; } = null!;
public virtual ICollection<Consumption> Consumptions { get; set; } = [];
}
public class CarTableConfiguration : IEntityTypeConfiguration<Car>
{
public const int NameMaxLength = 50;
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<CarId.EfCoreValueConverter>();
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(NameMaxLength);
builder.Property(x => x.UserId)
.IsRequired();
builder.HasOne(x => x.User)
.WithMany(x => x.Cars);
}
}

View File

@@ -0,0 +1,6 @@
using StronglyTypedIds;
namespace Vegasco.Server.Api.Cars;
[StronglyTypedId]
public partial struct CarId;

View File

@@ -0,0 +1,96 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public static class CreateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("cars", Endpoint)
.WithTags("Cars")
.WithDescription("Creates a new car")
.Produces<Response>(201)
.ProducesValidationProblem()
.Produces(409);
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
private static async Task<IResult> Endpoint(
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
ILogger logger = loggerFactory.CreateLogger(typeof(CreateCar));
List<ValidationResult> failedValidations =
await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
if (failedValidations.Count > 0)
{
string[] errors = failedValidations
.Where(x => !x.IsValid)
.SelectMany(x => x.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
logger.LogDebug(
"Validation failed for request {@Request} with errors {@Errors}",
request,
errors);
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
bool isDuplicate = await dbContext.Cars
.AnyAsync(x => x.Name.ToUpper() == request.Name.ToUpper(), cancellationToken);
if (isDuplicate)
{
logger.LogDebug("Car with name '{CarName}' (case insensitive) already exists", request.Name);
return TypedResults.Conflict();
}
string userId = userAccessor.GetUserId();
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
if (user is null)
{
logger.LogDebug("User with ID '{UserId}' not found, creating new user", userId);
user = new User { Id = userId };
await dbContext.Users.AddAsync(user, cancellationToken);
}
Car car = new() { Name = request.Name.Trim(), UserId = userId };
await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogTrace("Created new car: {@Car}", car);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response);
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class DeleteCar
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("cars/{id:guid}", Endpoint)
.WithTags("Cars")
.WithDescription("Deletes a car by ID")
.Produces(204)
.Produces(404);
}
private static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
Activity? activity = Activity.Current;
activity?.SetTag("id", id);
int rows = await dbContext.Cars
.Where(x => x.Id == new CarId(id))
.ExecuteDeleteAsync(cancellationToken);
if (rows == 0)
{
return TypedResults.NotFound();
}
if (rows > 1)
{
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteCar));
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{CarId}'", rows, id);
}
return TypedResults.NoContent();
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCar
{
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars/{id:guid}", Endpoint)
.WithDescription("Returns a single car by ID")
.WithTags("Cars")
.Produces<Response>()
.Produces(404);
}
private static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
Response response = new Response(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCars
{
public class ApiResponse
{
public IEnumerable<ResponseDto> Cars { get; set; } = [];
}
public record ResponseDto(Guid Id, string Name);
public class Request
{
[FromQuery(Name = "page")] public int? Page { get; set; }
[FromQuery(Name = "pageSize")] public int? PageSize { get; set; }
}
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars", Endpoint)
.WithDescription("Returns all cars")
.WithTags("Cars")
.Produces<ApiResponse>();
}
private static async Task<IResult> Endpoint(
[AsParameters] Request request,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Activity? activity = Activity.Current;
List<ResponseDto> cars = await dbContext.Cars
.Select(x => new ResponseDto(x.Id.Value, x.Name))
.ToListAsync(cancellationToken);
activity?.SetTag("carCount", cars.Count);
ApiResponse response = new()
{
Cars = cars
};
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,90 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class UpdateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("cars/{id:guid}", Endpoint)
.WithTags("Cars")
.WithDescription("Updates a car by ID")
.Produces<Response>()
.ProducesValidationProblem()
.Produces(404)
.Produces(409);
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
private static async Task<IResult> Endpoint(
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateCar));
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
string[] errors = failedValidations
.Where(x => !x.IsValid)
.SelectMany(x => x.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
logger.LogDebug(
"Validation failed for request {@Request} with errors {@Errors}",
request,
errors);
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
bool isDuplicate = await dbContext.Cars
.AnyAsync(x => x.Name.ToUpper() == request.Name.ToUpper(), cancellationToken);
if (isDuplicate)
{
logger.LogDebug("Car with name '{CarName}' (case insensitive) already exists", request.Name);
return TypedResults.Conflict();
}
car.Name = request.Name.Trim();
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogTrace("Updated car: {@Car}", car);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,9 @@
namespace Vegasco.Server.Api.Common;
public static class Constants
{
public static class Authorization
{
public const string RequireAuthenticatedUserPolicy = "RequireAuthenticatedUser";
}
}

View File

@@ -0,0 +1,177 @@
using Asp.Versioning;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Reflection;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Common;
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds all the Api related services to the Dependency Injection container.
/// </summary>
/// <param name="builder"></param>
public static void AddApiServices(this IHostApplicationBuilder builder)
{
builder.AddBuilderServices();
builder.Services
.AddMiscellaneousServices()
.AddCustomOpenApi()
.AddApiVersioning()
.AddAuthenticationAndAuthorization(builder.Environment);
builder.AddDbContext();
}
private static IHostApplicationBuilder AddBuilderServices(this IHostApplicationBuilder builder)
{
string? seqHost = builder.Configuration.GetConnectionString("seq");
if (!string.IsNullOrEmpty(seqHost))
{
builder.AddSeqEndpoint("seq", o =>
{
var apiKey = builder.Configuration.GetValue<string>("seq-api-key");
if (!string.IsNullOrEmpty(apiKey))
{
o.ApiKey = apiKey;
}
});
}
return builder;
}
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
{
services.AddSingleton(() =>
{
string assemblyName = Assembly.GetExecutingAssembly()
.GetName()
.Name ?? "Vegasco.Server.Api";
return new ActivitySource(assemblyName);
});
services.AddResponseCompression();
services.AddValidatorsFromAssemblies(
[
typeof(IApiMarker).Assembly
], ServiceLifetime.Singleton);
services.AddHealthChecks();
services.AddHttpContextAccessor();
services.AddHostedService<ApplyMigrationsService>();
services.AddRequestLocalization(o =>
{
string[] cultures =
[
"en-US",
"en",
"de-DE",
"de"
];
o.SetDefaultCulture(cultures[0])
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
});
return services;
}
private static IServiceCollection AddCustomOpenApi(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddOpenApi(o =>
{
o.CreateSchemaReferenceId = jsonTypeInfo =>
{
if (string.IsNullOrEmpty(jsonTypeInfo.Type.FullName))
{
return jsonTypeInfo.Type.Name;
}
string? fullClassName = jsonTypeInfo.Type.FullName;
if (!string.IsNullOrEmpty(jsonTypeInfo.Type.Namespace))
{
fullClassName = fullClassName
.Replace(jsonTypeInfo.Type.Namespace, "")
.TrimStart('.');
}
fullClassName = fullClassName.Replace('+', '_');
return fullClassName;
};
});
return services;
}
private static IServiceCollection AddApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1);
o.ApiVersionReader = new UrlSegmentApiVersionReader();
o.ReportApiVersions = true;
})
.AddApiExplorer(o =>
{
o.GroupNameFormat = "'v'V";
o.SubstituteApiVersionInUrl = true;
});
return services;
}
private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services, IHostEnvironment environment)
{
services.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateFluently()
.ValidateOnStart();
IOptions<JwtOptions> jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
{
o.MetadataAddress = jwtOptions.Value.MetadataUrl;
o.TokenValidationParameters.ValidAudience = jwtOptions.Value.ValidAudience;
o.TokenValidationParameters.ValidateAudience = true;
if (!string.IsNullOrWhiteSpace(jwtOptions.Value.NameClaimType))
{
o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType;
}
o.RequireHttpsMetadata = !jwtOptions.Value.AllowHttpMetadataUrl && !environment.IsDevelopment();
});
services.AddAuthorizationBuilder()
.AddPolicy(Constants.Authorization.RequireAuthenticatedUserPolicy, p => p
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
services.AddScoped<UserAccessor>();
return services;
}
private static IHostApplicationBuilder AddDbContext(this IHostApplicationBuilder builder)
{
builder.AddNpgsqlDbContext<ApplicationDbContext>(AppHost.Shared.Constants.Database.Name);
return builder;
}
}

View File

@@ -0,0 +1,37 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.Server.Api.Common;
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
List<ValidationResult> failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -0,0 +1,3 @@
namespace Vegasco.Server.Api.Common;
public interface IApiMarker;

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