Compare commits

92 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
99 changed files with 2184 additions and 347 deletions

View File

@@ -42,9 +42,11 @@ steps:
- name: docker build and push - name: docker build and push
image: docker:24.0.7 image: docker:24.0.7
commands: commands:
- docker build . -t $docker_registry$docker_repo:$DRONE_BRANCH - dockerImageWithTag="$docker_registry$docker_repo:$DRONE_BRANCH"
- docker build . -t $dockerImageWithTag
- echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry - echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry
- docker push $docker_registry$docker_repo:$DRONE_BRANCH - docker push $dockerImageWithTag
- echo "Built and pushed $dockerImageWithTag"
environment: environment:
docker_username: docker_username:
from_secret: docker_username from_secret: docker_username
@@ -60,6 +62,10 @@ steps:
when: when:
branch: branch:
- main - main
- production
event:
exclude:
- pull_request
depends_on: depends_on:
- compile (.NET) - compile (.NET)
- test - test

View File

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

View File

@@ -1,17 +1,21 @@
# Vegasco Server # Vegasco Server
Backend for the vegasco (**VE**hicle **GAS** **CO**nsumption) application. 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 ## Getting Started
### Configuration ### Configuration
| Configuration | Description | Default | Required | | Configuration | Description | Default | Required |
|--------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------| |------------------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
| JWT:MetadataUrl | The oidc meta data url | - | true | | JWT:MetadataUrl | The oidc meta data url | - | true |
| JWT:ValidAudience | The valid audience of the JWT token. | - | 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: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 | | 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. 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.
@@ -64,4 +68,18 @@ creates a Postgres database as a docker container, and starts the Api with the c
Ensure you have an identity provider set up, for example Keycloak, and configured the relevant options described above. 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 the `Vegasco.Server.AppHost` launch profile. 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 .
```

View File

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

View File

@@ -3,10 +3,11 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch (Chrome)", "name": "Launch Web (Chrome)",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "preLaunchTask": "npm: start",
"postDebugTask": "Terminate All Tasks",
"url": "http://localhost:44200/", "url": "http://localhost:44200/",
} }
] ]

View File

@@ -2,13 +2,19 @@
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "Terminate All Tasks",
"command": "echo ${input:terminate}",
"type": "shell",
"problemMatcher": []
},
{ {
"type": "npm", "type": "npm",
"script": "start", "script": "start",
"options": { "options": {
"env": { "env": {
"PORT": "44200", "PORT": "44200",
"services__Vegasco-Server-Api__https__0": "https://localhost:7098", "services__Api__https__0": "https://localhost:7098",
"NODE_ENV": "development" "NODE_ENV": "development"
} }
}, },
@@ -45,5 +51,13 @@
} }
} }
} }
],
"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

View File

@@ -12,6 +12,16 @@ 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. 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 ## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run: Angular CLI includes powerful code scaffolding tools. To generate a new component, run:

View File

@@ -17,7 +17,7 @@
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/tmp", "outputPath": "dist/Vegasco-Web",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": [

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

@@ -6,7 +6,9 @@
"start": "run-script-os", "start": "run-script-os",
"start:win32": "ng serve --port %PORT% --configuration development", "start:win32": "ng serve --port %PORT% --configuration development",
"start:default": "ng serve --port $PORT --configuration development", "start:default": "ng serve --port $PORT --configuration development",
"build": "ng build", "build": "pnpm build:development",
"build:development": "ng build",
"build:production": "ng build --configuration production",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"
}, },
@@ -18,8 +20,12 @@
"@angular/forms": "^19.2.14", "@angular/forms": "^19.2.14",
"@angular/platform-browser": "^19.2.14", "@angular/platform-browser": "^19.2.14",
"@angular/router": "^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", "@primeng/themes": "^19.1.3",
"@tailwindcss/postcss": "^4.1.10", "@tailwindcss/postcss": "^4.1.10",
"dayjs": "^1.11.13",
"keycloak-angular": "^19.0.2", "keycloak-angular": "^19.0.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"primeng": "^19.1.3", "primeng": "^19.1.3",

View File

@@ -26,12 +26,24 @@ importers:
'@angular/router': '@angular/router':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) version: 19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
'@ng-icons/core':
specifier: ^31.4.0
version: 31.4.0(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@ng-icons/material-file-icons':
specifier: ^31.4.0
version: 31.4.0
'@ng-icons/material-icons':
specifier: ^31.4.0
version: 31.4.0
'@primeng/themes': '@primeng/themes':
specifier: ^19.1.3 specifier: ^19.1.3
version: 19.1.3 version: 19.1.3
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10 version: 4.1.10
dayjs:
specifier: ^1.11.13
version: 1.11.13
keycloak-angular: keycloak-angular:
specifier: ^19.0.2 specifier: ^19.0.2
version: 19.0.2(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/router@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(keycloak-js@26.2.0) version: 19.0.2(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/router@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(keycloak-js@26.2.0)
@@ -1305,6 +1317,19 @@ packages:
resolution: {integrity: sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==} resolution: {integrity: sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
'@ng-icons/core@31.4.0':
resolution: {integrity: sha512-JfLiJGDX/ihWmawcnLGXtwyCqMi2qXz7gMJyXXWdUN5JA18EAnt3JnyuxDAGkoU/u7wRlcOI7irlXHU4spAKOg==}
peerDependencies:
'@angular/common': '>=18.0.0'
'@angular/core': '>=18.0.0'
rxjs: ^6.5.3 || ^7.4.0
'@ng-icons/material-file-icons@31.4.0':
resolution: {integrity: sha512-Ffh61ghuuDRxelfTe/rHQ5IFCqUget/JeZ/NLq6QWLBycxUC6PjiEIIAXQvnVmYwCHNgxjBIRExP1/+vdHriNQ==}
'@ng-icons/material-icons@31.4.0':
resolution: {integrity: sha512-JCxwM0LXwOgT5LD99p5TwPM6dPQ5x1BGieNzAstz7vk5+aiASg3fqs3rjNx7CbN3c2QjJ8+KuKrCCBzT9DCkOQ==}
'@ngtools/webpack@19.2.15': '@ngtools/webpack@19.2.15':
resolution: {integrity: sha512-H37nop/wWMkSgoU2VvrMzanHePdLRRrX52nC5tT2ZhH3qP25+PrnMyw11PoLDLv3iWXC68uB1AiKNIT+jiQbuQ==} resolution: {integrity: sha512-H37nop/wWMkSgoU2VvrMzanHePdLRRrX52nC5tT2ZhH3qP25+PrnMyw11PoLDLv3iWXC68uB1AiKNIT+jiQbuQ==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -2207,6 +2232,9 @@ packages:
resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@2.6.9: debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies:
@@ -5797,6 +5825,21 @@ snapshots:
'@napi-rs/nice-win32-x64-msvc': 1.0.1 '@napi-rs/nice-win32-x64-msvc': 1.0.1
optional: true optional: true
'@ng-icons/core@31.4.0(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)':
dependencies:
'@angular/common': 19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/core': 19.2.14(rxjs@7.8.2)(zone.js@0.15.1)
rxjs: 7.8.2
tslib: 2.8.1
'@ng-icons/material-file-icons@31.4.0':
dependencies:
tslib: 2.8.1
'@ng-icons/material-icons@31.4.0':
dependencies:
tslib: 2.8.1
'@ngtools/webpack@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.98.0(esbuild@0.25.4))': '@ngtools/webpack@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.98.0(esbuild@0.25.4))':
dependencies: dependencies:
'@angular/compiler-cli': 19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3) '@angular/compiler-cli': 19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3)
@@ -6726,6 +6769,8 @@ snapshots:
date-format@4.0.14: {} date-format@4.0.14: {}
dayjs@1.11.13: {}
debug@2.6.9: debug@2.6.9:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0

View File

@@ -1,8 +1,8 @@
module.exports = { module.exports = {
"/api": { "/api": {
target: target:
process.env["services__Vegasco-Server-Api__https__0"] || process.env["services__Api__https__0"] ||
process.env["services__Vegasco-Server-Api__http__0"], process.env["services__Api__http__0"],
secure: process.env["NODE_ENV"] !== "development", secure: process.env["NODE_ENV"] !== "development",
pathRewrite: { pathRewrite: {
"^/api": "", "^/api": "",

View File

@@ -22,8 +22,8 @@ export class CarClient {
return this.http.post<Car>(`${this.apiBasePath}/v1/cars`, request); return this.http.post<Car>(`${this.apiBasePath}/v1/cars`, request);
} }
update(request: UpdateCarRequest): Observable<Car> { update(id: string, request: UpdateCarRequest): Observable<Car> {
return this.http.put<Car>(`${this.apiBasePath}/v1/cars`, request); return this.http.put<Car>(`${this.apiBasePath}/v1/cars/${id}`, request);
} }
delete(id: string): Observable<void> { delete(id: string): Observable<void> {

View File

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

View File

@@ -3,6 +3,5 @@ interface ConsumptionEntry {
dateTime: string; dateTime: string;
distance: number; distance: number;
amount: number; amount: number;
ignoreInCalculation: boolean;
carId: string; carId: string;
} }

View File

@@ -2,6 +2,5 @@ interface CreateConsumptionEntry {
dateTime: string; dateTime: string;
distance: number; distance: number;
amount: number; amount: number;
ignoreInCalculation: boolean;
carId: string; 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

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

View File

@@ -2,6 +2,5 @@ interface UpdateConsumptionEntry {
dateTime: string; dateTime: string;
distance: number; distance: number;
amount: number; amount: number;
ignoreInCalculation: boolean;
carId: string; carId: string;
} }

View File

@@ -1,9 +1,17 @@
<main class="main"> <main class="main">
<header class="h-12 bg-primary text-primary-contrast"> <header class="h-12 bg-primary text-primary-contrast">
<div class="header max-content-width mx-auto"> <div class="header max-content-width mx-auto flex items-center justify-between">
<a routerLink="/" class="reset cursor-pointer"> <a routerLink="/" class="reset cursor-pointer">
Vegasco Vegasco
</a> </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> </div>
</header> </header>
<div class="content max-content-width mx-auto"> <div class="content max-content-width mx-auto">

View File

@@ -9,5 +9,9 @@ export const routes: Routes = [
{ {
path: 'entries', path: 'entries',
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes) loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
},
{
path: 'cars',
loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes)
} }
]; ];

View File

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

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

@@ -1,27 +1,46 @@
@if (isLoading()) { @if (isLoading()) {
<p-skeleton height="4rem" styleClass="mb-2" /> <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 { } @else {
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()"> <form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label [for]="formFieldNames.car"> Auto </label> <label [for]="formFieldNames.car">
Auto
<app-required-marker />
</label>
@if (cars(); as cars) { @if (cars(); as cars) {
<p-select <p-select
[options]="cars" [options]="cars"
placeholder="Auto auswählen" placeholder="Auto auswählen"
[formControlName]="formFieldNames.car" [formControlName]="formFieldNames.car"
[optionLabel]="formFieldNames.car" optionLabel="name"
[inputId]="formFieldNames.car" [inputId]="formFieldNames.car"
styleClass="w-full" /> styleClass="w-full" />
} }
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label [for]="formFieldNames.date"> Datum </label> <div class="flex gap-2 items-center">
<label [for]="formFieldNames.date">
Datum
<app-required-marker />
</label>
</div>
<p-datepicker [iconDisplay]="'input'" <p-datepicker [iconDisplay]="'input'"
[firstDayOfWeek]="1" [firstDayOfWeek]="1"
placeholder="Datum auswählen" placeholder="Datum auswählen"
[showIcon]="true" [showIcon]="true"
[maxDate]="today"
[defaultDate]="today"
[inputId]="formFieldNames.date" [inputId]="formFieldNames.date"
[formControlName]="formFieldNames.date" [formControlName]="formFieldNames.date"
styleClass="w-full" styleClass="w-full"
@@ -29,7 +48,10 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label [for]="formFieldNames.mileage"> Kilometerstand </label> <label [for]="formFieldNames.mileage">
Kilometerstand
<app-required-marker />
</label>
<p-inputGroup> <p-inputGroup>
<input <input
id="mileage" id="mileage"
@@ -43,7 +65,10 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label [for]="formFieldNames.amount"> Menge </label> <label [for]="formFieldNames.amount">
Menge
<app-required-marker />
</label>
<p-inputGroup> <p-inputGroup>
<input <input
id="amount" id="amount"
@@ -52,7 +77,7 @@
min="1" min="1"
pInputText pInputText
[formControlName]="formFieldNames.amount" /> [formControlName]="formFieldNames.amount" />
<p-inputGroupAddon>l</p-inputGroupAddon> <p-inputGroupAddon>&#8467;</p-inputGroupAddon>
</p-inputGroup> </p-inputGroup>
</div> </div>

View File

@@ -1,8 +1,12 @@
import { Component, computed, inject, Signal } from '@angular/core'; 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 { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { CarClient } from '@vegasco-web/api/cars/car-client'; 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 { ButtonModule } from 'primeng/button';
import { ChipModule } from 'primeng/chip'; import { ChipModule } from 'primeng/chip';
import { DatePickerModule } from 'primeng/datepicker'; import { DatePickerModule } from 'primeng/datepicker';
@@ -14,7 +18,9 @@ import { InputTextModule } from 'primeng/inputtext';
import { MultiSelectModule } from 'primeng/multiselect'; import { MultiSelectModule } from 'primeng/multiselect';
import { SelectModule } from 'primeng/select'; import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton'; import { SkeletonModule } from 'primeng/skeleton';
import { map } from 'rxjs'; 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({ @Component({
selector: 'app-edit-entry', selector: 'app-edit-entry',
@@ -29,141 +35,259 @@ import { map } from 'rxjs';
InputTextModule, InputTextModule,
MultiSelectModule, MultiSelectModule,
ReactiveFormsModule, ReactiveFormsModule,
RequiredMarkerComponent,
SelectModule, SelectModule,
SkeletonModule, SkeletonModule,
], ],
templateUrl: './edit-entry.component.html', templateUrl: './edit-entry.component.html',
styleUrl: './edit-entry.component.scss' styleUrl: './edit-entry.component.scss'
}) })
export class EditEntryComponent { export class EditEntryComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder);
private readonly carClient = inject(CarClient); private readonly carClient = inject(CarClient);
private readonly router = inject(Router); 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 = { protected readonly formFieldNames = {
car: 'car', car: 'car',
date: 'date', date: 'date',
mileage: 'mileage', mileage: 'mileage',
amount: 'amount', amount: 'amount',
} } as const;
protected readonly formGroup = this.formBuilder.group({ protected readonly formGroup = new FormGroup({
[this.formFieldNames.car]: [<Car | null>null, Validators.required], [this.formFieldNames.car]: new FormControl<Car | null>({ value: null, disabled: true }, [Validators.required]),
[this.formFieldNames.date]: [<Date | null>null], [this.formFieldNames.date]: new FormControl<Date>({ value: new Date(), disabled: true }, [Validators.required, this.dateTimeGreaterThanOrEqualToTodayValidator]),
[this.formFieldNames.mileage]: [ [this.formFieldNames.mileage]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
<number | null>null, [this.formFieldNames.amount]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
[Validators.required, Validators.min(1)],
],
[this.formFieldNames.amount]: [
<number | null>null,
[Validators.required, Validators.min(1)],
],
}); });
private readonly cars$: Observable<Car[]>;
protected readonly cars: Signal<Car[] | undefined>; protected readonly cars: Signal<Car[] | undefined>;
private readonly isEntryDataLoaded = signal(false);
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {
return this.cars() === undefined; var cars = this.cars();
}) var isEntryDataLoaded = this.isEntryDataLoaded();
return cars === undefined || !isEntryDataLoaded;
});
constructor() { constructor() {
this.cars = toSignal( this.cars$ = this.carClient
this.carClient
.getAll() .getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
map(response => response.cars) 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> { async navigateToOverviewPage(): Promise<void> {
await this.router.navigateByUrl(`/entries`); await this.routingService.navigateToEntries();
} }
onSubmit(): void { onSubmit(): void {
// if (!this.entryId) { if (this.formGroup.invalid) {
// this.createEntry(); this.formGroup.markAllAsTouched();
// return; return;
// } }
// this.updateEntry(); 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() { createEntry() {
// this.api var request: CreateConsumptionEntry = this.getFormData();
// .createWeightEntry( this.consumptionClient.create(request)
// this.getWeighedAt(), .pipe(
// this.formGroup.controls['weight'].value, takeUntilDestroyed(this.destroyRef),
// this.formGroup.controls['comment'].value, catchError((error) => this.handleCreateOrUpdateError(error)),
// this.formGroup.controls['rabbit'].value!.id, switchMap(() => this.routingService.navigateToEntries())
// this.formGroup.controls['medicines'].value?.map((x) => x.id) ?? [], )
// ) .subscribe();
// .subscribe({
// next: (_) => {
// this.router.navigateByUrl('/weight-entries');
// },
// error: (error: HttpErrorResponse) => {
// 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:
// break;
// }
// },
// });
} }
updateEntry() { updateEntry(id: string) {
// this.api var request: UpdateConsumptionEntry = this.getFormData();
// .updateWeightEntry( this.consumptionClient.update(id, request)
// this.entryId!, .pipe(
// this.getWeighedAt(), takeUntilDestroyed(this.destroyRef),
// this.formGroup.controls['weight'].value, catchError((error) => this.handleCreateOrUpdateError(error)),
// this.formGroup.controls['comment'].value, switchMap(() => this.routingService.navigateToEntries())
// this.formGroup.controls['rabbit'].value!.id, )
// this.formGroup.controls['medicines'].value?.map((x) => x.id) ?? [], .subscribe();
// ) }
// .subscribe({
// next: (_) => { private handleGetError(error: unknown): Observable<never> {
// this.router.navigateByUrl('/weight-entries'); if (!(error instanceof HttpErrorResponse)) {
// }, return throwError(() => error);
// error: (error: HttpErrorResponse) => { }
// switch (true) {
// case error.status >= 500 && error.status <= 599: switch (true) {
// this.messageService.add({ case error.status >= 500 && error.status <= 599:
// severity: 'error', this.messageService.add({
// summary: 'Serverfehler', severity: 'error',
// detail: summary: 'Serverfehler',
// 'Beim Aktualisieren des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.', detail:
// }); 'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
// break; });
// case error.status == 400: break;
// this.messageService.add({ default:
// severity: 'error', console.error(error);
// summary: 'Clientfehler', this.messageService.add({
// detail: severity: 'error',
// 'Die Anwendung scheint falsche Daten an den Server zu senden.', summary: 'Unerwarteter Fehler',
// }); detail:
// break; 'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
// default: });
// break; 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,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

@@ -2,12 +2,12 @@
<p-scrollTop /> <p-scrollTop />
<div class="mb-4 flex gap-2 md:justify-between"> <div class="mb-4 flex gap-2 md:justify-between">
<div class="basis-full lg:basis-1/4 md:basis-1/2 p-0"> <div class="basis-full lg:basis-1/4 md:basis-1/2 p-0">
<!-- <p-select styleClass="w-full" [formControl]="selectedRabbit" placeholder="Kaninchen" [showClear]="true" <p-select styleClass="w-full" [formControl]="selectedCar" placeholder="Auto" [showClear]="false"
[options]="(rabbits$ | async)!" optionLabel="name" /> --> [options]="(cars$ | async)!" optionLabel="name" />
</div> </div>
<div> <div>
<p-button label="Erstellen" routerLink="/entries/create"> <p-button label="Erstellen" routerLink="/entries/create">
<!-- <ng-icon name="matAddSharp"></ng-icon> --> <ng-icon name="matAddSharp"></ng-icon>
</p-button> </p-button>
</div> </div>
</div> </div>
@@ -26,9 +26,8 @@
<ng-template #list let-entries> <ng-template #list let-entries>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@for (entry of entries; track entry.id) { @for (entry of entries; track entry.id) {
{{ entry | json }} <app-entry-card [entry]="entry"
<!-- <app-weight-entry-card [weightEntry]="weightEntry" (entryDeleted)="onEntryDeleted($event)" />
(entryDeleted)="onEntryDeleted($event)"></app-weight-entry-card> -->
} }
</div> </div>
</ng-template> </ng-template>

View File

@@ -1,20 +1,34 @@
import { AsyncPipe, CommonModule } from '@angular/common'; import { AsyncPipe, CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; 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 { CarClient } from '@vegasco-web/api/cars/car-client';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client'; import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { DataViewModule } from 'primeng/dataview'; import { DataViewModule } from 'primeng/dataview';
import { ScrollTopModule } from 'primeng/scrolltop'; import { ScrollTopModule } from 'primeng/scrolltop';
import { SelectModule } from 'primeng/select'; import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton'; import { SkeletonModule } from 'primeng/skeleton';
import { import {
BehaviorSubject,
catchError,
combineLatest,
EMPTY,
map, map,
Observable, Observable,
tap startWith,
tap,
throwError
} from 'rxjs'; } from 'rxjs';
import { SelectedCarService } from '../services/selected-car.service';
import { EntryCardComponent } from './components/entry-card/entry-card.component';
@Component({ @Component({
selector: 'app-entries', selector: 'app-entries',
@@ -23,41 +37,135 @@ import {
ButtonModule, ButtonModule,
CommonModule, CommonModule,
DataViewModule, DataViewModule,
SkeletonModule, EntryCardComponent,
SelectModule, NgIconComponent,
ReactiveFormsModule, ReactiveFormsModule,
RouterLink, RouterLink,
ScrollTopModule, ScrollTopModule,
SelectModule,
SkeletonModule,
],
providers: [
provideIcons({
matAddSharp,
}),
], ],
templateUrl: './entries.component.html', templateUrl: './entries.component.html',
styleUrl: './entries.component.scss' styleUrl: './entries.component.scss'
}) })
export class EntriesComponent { export class EntriesComponent implements OnInit {
private readonly consumptionClient = inject(ConsumptionClient);
private readonly carClient = inject(CarClient); 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<ConsumptionEntry[]>; protected readonly consumptionEntries$: Observable<GetConsumptionEntriesEntry[]>;
protected readonly cars$: Observable<Car[]>; protected readonly cars$: Observable<Car[]>;
protected readonly skeletonsIterationSource = Array(10).fill(0); protected readonly skeletonsIterationSource = Array(10).fill(0);
protected readonly selectedCar = new FormControl<Car | null | undefined>(null);
private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]);
constructor() { constructor() {
this.consumptionEntries$ = this.consumptionClient.getAll() const entries = this.consumptionClient.getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
tap((response) => { map(response => response.consumptions.sort((a, b) => b.dateTime.localeCompare(a.dateTime))),
console.log('Entries response:', response); 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);
}), }),
map(response => response.consumptions)
); );
this.cars$ = this.carClient.getAll() this.cars$ = this.carClient.getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
tap((response) => { map(response => response.cars),
console.log('Cars response:', response); 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);
}), }),
map(response => response.cars)
); );
} }
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

@@ -1,5 +1,4 @@
@import "tailwindcss"; @use "tailwindcss";
@import "tailwindcss";
@plugin "tailwindcss-primeui"; @plugin "tailwindcss-primeui";
html, html,

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

@@ -1,5 +1,6 @@
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Authentication; using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common; using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
@@ -10,6 +11,7 @@ namespace Vegasco.Server.Api.Cars;
public static class CreateCar public static class CreateCar
{ {
public record Request(string Name); public record Request(string Name);
public record Response(Guid Id, string Name); public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
@@ -19,7 +21,8 @@ public static class CreateCar
.WithTags("Cars") .WithTags("Cars")
.WithDescription("Creates a new car") .WithDescription("Creates a new car")
.Produces<Response>(201) .Produces<Response>(201)
.ProducesValidationProblem(); .ProducesValidationProblem()
.Produces(409);
} }
public class Validator : AbstractValidator<Request> public class Validator : AbstractValidator<Request>
@@ -32,40 +35,61 @@ public static class CreateCar
} }
} }
public static async Task<IResult> Endpoint( private static async Task<IResult> Endpoint(
Request request, Request request,
IEnumerable<IValidator<Request>> validators, IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
UserAccessor userAccessor, UserAccessor userAccessor,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken: cancellationToken); ILogger logger = loggerFactory.CreateLogger(typeof(CreateCar));
List<ValidationResult> failedValidations =
await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
if (failedValidations.Count > 0) 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())); 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(); string userId = userAccessor.GetUserId();
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken); User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
if (user is null) if (user is null)
{ {
user = new User logger.LogDebug("User with ID '{UserId}' not found, creating new user", userId);
{
Id = userId user = new User { Id = userId };
};
await dbContext.Users.AddAsync(user, cancellationToken); await dbContext.Users.AddAsync(user, cancellationToken);
} }
Car car = new() Car car = new() { Name = request.Name.Trim(), UserId = userId };
{
Name = request.Name,
UserId = userId
};
await dbContext.Cars.AddAsync(car, cancellationToken); await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
logger.LogTrace("Created new car: {@Car}", car);
Response response = new(car.Id.Value, car.Name); Response response = new(car.Id.Value, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response); return TypedResults.Created($"/v1/cars/{car.Id}", response);
} }

View File

@@ -1,4 +1,6 @@
using Vegasco.Server.Api.Persistence; using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars; namespace Vegasco.Server.Api.Cars;
@@ -14,20 +16,29 @@ public static class DeleteCar
.Produces(404); .Produces(404);
} }
public static async Task<IResult> Endpoint( private static async Task<IResult> Endpoint(
Guid id, Guid id,
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken); Activity? activity = Activity.Current;
activity?.SetTag("id", id);
if (car is null) int rows = await dbContext.Cars
.Where(x => x.Id == new CarId(id))
.ExecuteDeleteAsync(cancellationToken);
if (rows == 0)
{ {
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
dbContext.Cars.Remove(car); if (rows > 1)
await dbContext.SaveChangesAsync(cancellationToken); {
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteCar));
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{CarId}'", rows, id);
}
return TypedResults.NoContent(); return TypedResults.NoContent();
} }

View File

@@ -29,7 +29,7 @@ public static class GetCar
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
var response = new Response(car.Id.Value, car.Name); Response response = new Response(car.Id.Value, car.Name);
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
} }

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars; namespace Vegasco.Server.Api.Cars;
@@ -34,11 +35,15 @@ public static class GetCars
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Activity? activity = Activity.Current;
List<ResponseDto> cars = await dbContext.Cars List<ResponseDto> cars = await dbContext.Cars
.Select(x => new ResponseDto(x.Id.Value, x.Name)) .Select(x => new ResponseDto(x.Id.Value, x.Name))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var response = new ApiResponse activity?.SetTag("carCount", cars.Count);
ApiResponse response = new()
{ {
Cars = cars Cars = cars
}; };

View File

@@ -1,5 +1,6 @@
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Authentication; using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common; using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
@@ -9,6 +10,7 @@ namespace Vegasco.Server.Api.Cars;
public static class UpdateCar public static class UpdateCar
{ {
public record Request(string Name); public record Request(string Name);
public record Response(Guid Id, string Name); public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
@@ -19,7 +21,8 @@ public static class UpdateCar
.WithDescription("Updates a car by ID") .WithDescription("Updates a car by ID")
.Produces<Response>() .Produces<Response>()
.ProducesValidationProblem() .ProducesValidationProblem()
.Produces(404); .Produces(404)
.Produces(409);
} }
public class Validator : AbstractValidator<Request> public class Validator : AbstractValidator<Request>
@@ -32,17 +35,31 @@ public static class UpdateCar
} }
} }
public static async Task<IResult> Endpoint( private static async Task<IResult> Endpoint(
Guid id, Guid id,
Request request, Request request,
IEnumerable<IValidator<Request>> validators, IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
UserAccessor userAccessor, UserAccessor userAccessor,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateCar));
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken); List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0) 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())); return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
} }
@@ -53,9 +70,20 @@ public static class UpdateCar
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
car.Name = request.Name; 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); await dbContext.SaveChangesAsync(cancellationToken);
logger.LogTrace("Updated car: {@Car}", car);
Response response = new(car.Id.Value, car.Name); Response response = new(car.Id.Value, car.Name);
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }

View File

@@ -18,6 +18,8 @@ public static class DependencyInjectionExtensions
/// <param name="builder"></param> /// <param name="builder"></param>
public static void AddApiServices(this IHostApplicationBuilder builder) public static void AddApiServices(this IHostApplicationBuilder builder)
{ {
builder.AddBuilderServices();
builder.Services builder.Services
.AddMiscellaneousServices() .AddMiscellaneousServices()
.AddCustomOpenApi() .AddCustomOpenApi()
@@ -27,6 +29,24 @@ public static class DependencyInjectionExtensions
builder.AddDbContext(); 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) private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
{ {
services.AddSingleton(() => services.AddSingleton(() =>
@@ -121,7 +141,7 @@ public static class DependencyInjectionExtensions
.ValidateFluently() .ValidateFluently()
.ValidateOnStart(); .ValidateOnStart();
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>(); IOptions<JwtOptions> jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o => .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>

View File

@@ -14,8 +14,6 @@ public class Consumption
public double Amount { get; set; } public double Amount { get; set; }
public bool IgnoreInCalculation { get; set; }
public CarId CarId { get; set; } public CarId CarId { get; set; }
public virtual Car Car { get; set; } = null!; public virtual Car Car { get; set; } = null!;
@@ -39,9 +37,6 @@ public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumptio
builder.Property(x => x.Amount) builder.Property(x => x.Amount)
.IsRequired(); .IsRequired();
builder.Property(x => x.IgnoreInCalculation)
.IsRequired();
builder.Property(x => x.CarId) builder.Property(x => x.CarId)
.IsRequired() .IsRequired()
.HasConversion<CarId.EfCoreValueConverter>(); .HasConversion<CarId.EfCoreValueConverter>();

View File

@@ -1,5 +1,6 @@
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using System.Diagnostics;
using Vegasco.Server.Api.Cars; using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common; using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
@@ -8,9 +9,9 @@ namespace Vegasco.Server.Api.Consumptions;
public static class CreateConsumption public static class CreateConsumption
{ {
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId); public record Request(DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId); public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{ {
@@ -25,8 +26,13 @@ public static class CreateConsumption
{ {
public Validator(TimeProvider timeProvider) public Validator(TimeProvider timeProvider)
{ {
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
.Date
.AddDays(1)
.AddTicks(-1);
RuleFor(x => x.DateTime.ToUniversalTime()) RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow()) .LessThanOrEqualTo(_ => getTodayEndOfDay())
.WithName(nameof(Request.DateTime)); .WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance) RuleFor(x => x.Distance)
@@ -44,11 +50,25 @@ public static class CreateConsumption
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
Request request, Request request,
IEnumerable<IValidator<Request>> validators, IEnumerable<IValidator<Request>> validators,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ILogger logger = loggerFactory.CreateLogger(typeof(CreateConsumption));
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken); List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0) 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())); return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
} }
@@ -58,19 +78,21 @@ public static class CreateConsumption
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
var consumption = new Consumption Consumption consumption = new()
{ {
DateTime = request.DateTime.ToUniversalTime(), DateTime = request.DateTime.ToUniversalTime(),
Distance = request.Distance, Distance = request.Distance,
Amount = request.Amount, Amount = request.Amount,
IgnoreInCalculation = request.IgnoreInCalculation,
CarId = new CarId(request.CarId) CarId = new CarId(request.CarId)
}; };
dbContext.Consumptions.Add(consumption); dbContext.Consumptions.Add(consumption);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
logger.LogTrace("Created new consumption: {@Consumption}", consumption);
return TypedResults.Created($"consumptions/{consumption.Id.Value}", return TypedResults.Created($"consumptions/{consumption.Id.Value}",
new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value)); new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount,
consumption.CarId.Value));
} }
} }

View File

@@ -1,4 +1,6 @@
using Vegasco.Server.Api.Persistence; using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions; namespace Vegasco.Server.Api.Consumptions;
@@ -15,18 +17,28 @@ public static class DeleteConsumption
} }
private static async Task<IResult> Endpoint( private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id, Guid id,
ApplicationDbContext dbContext,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken); Activity? activity = Activity.Current;
if (consumption is null) activity?.SetTag("id", id);
int rows = await dbContext.Consumptions
.Where(x => x.Id == new ConsumptionId(id))
.ExecuteDeleteAsync(cancellationToken);
if (rows == 0)
{ {
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
dbContext.Consumptions.Remove(consumption); if (rows > 1)
await dbContext.SaveChangesAsync(cancellationToken); {
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteConsumption));
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{ConsumptionId}'", rows, id);
}
return TypedResults.NoContent(); return TypedResults.NoContent();
} }

View File

@@ -4,7 +4,7 @@ namespace Vegasco.Server.Api.Consumptions;
public static class GetConsumption public static class GetConsumption
{ {
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId); public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{ {
@@ -28,8 +28,12 @@ public static class GetConsumption
return TypedResults.NotFound(); return TypedResults.NotFound();
} }
var response = new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, Response response = new(
consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value); consumption.Id.Value,
consumption.DateTime,
consumption.Distance,
consumption.Amount,
consumption.CarId.Value);
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
} }

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions; namespace Vegasco.Server.Api.Consumptions;
@@ -17,8 +19,18 @@ public static class GetConsumptions
DateTimeOffset DateTime, DateTimeOffset DateTime,
double Distance, double Distance,
double Amount, double Amount,
bool IgnoreInCalculation, CarDto Car,
Guid CarId); double? LiterPer100Km);
public record CarDto(
Guid Id,
string Name)
{
public static CarDto FromCar(Car car)
{
return new CarDto(car.Id.Value, car.Name);
}
}
public class Request public class Request
{ {
@@ -38,16 +50,52 @@ public static class GetConsumptions
private static async Task<Ok<ApiResponse>> Endpoint( private static async Task<Ok<ApiResponse>> Endpoint(
[AsParameters] Request request, [AsParameters] Request request,
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<ResponseDto> consumptions = await dbContext.Consumptions ILogger logger = loggerFactory.CreateLogger(typeof(GetConsumptions));
.Select(x =>
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value)) logger.LogTrace("Received request to get consumptions with parameters: {@Request}", request);
.ToListAsync(cancellationToken); Activity? activity = Activity.Current;
Dictionary<CarId, List<Consumption>> consumptionsByCar = await dbContext.Consumptions
.Include(x => x.Car)
.GroupBy(x => x.CarId)
.ToDictionaryAsync(x => x.Key, x => x.OrderByDescending(x => x.DateTime).ToList(), cancellationToken);
List<ResponseDto> responses = [];
foreach (List<Consumption> consumptions in consumptionsByCar.Select(x => x.Value))
{
for (int i = 0; i < consumptions.Count; i++)
{
Consumption consumption = consumptions[i];
double? literPer100Km = null;
bool isLast = i == consumptions.Count - 1;
if (!isLast)
{
Consumption previousConsumption = consumptions[i + 1];
double distanceDiff = consumption.Distance - previousConsumption.Distance;
literPer100Km = consumption.Amount / (distanceDiff / 100);
}
responses.Add(new ResponseDto(
consumption.Id.Value,
consumption.DateTime,
consumption.Distance,
consumption.Amount,
CarDto.FromCar(consumption.Car),
literPer100Km));
}
}
activity?.SetTag("consumptionCount", responses.Count);
ApiResponse apiResponse = new() ApiResponse apiResponse = new()
{ {
Consumptions = consumptions Consumptions = responses
}; };
return TypedResults.Ok(apiResponse); return TypedResults.Ok(apiResponse);
} }

View File

@@ -7,9 +7,9 @@ namespace Vegasco.Server.Api.Consumptions;
public static class UpdateConsumption public static class UpdateConsumption
{ {
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation); public record Request(DateTimeOffset DateTime, double Distance, double Amount);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId); public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder) public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{ {
@@ -26,8 +26,13 @@ public static class UpdateConsumption
{ {
public Validator(TimeProvider timeProvider) public Validator(TimeProvider timeProvider)
{ {
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
.Date
.AddDays(1)
.AddTicks(-1);
RuleFor(x => x.DateTime.ToUniversalTime()) RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow()) .LessThanOrEqualTo(_ => getTodayEndOfDay())
.WithName(nameof(Request.DateTime)); .WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance) RuleFor(x => x.Distance)
@@ -43,11 +48,25 @@ public static class UpdateConsumption
Guid id, Guid id,
Request request, Request request,
IEnumerable<IValidator<Request>> validators, IEnumerable<IValidator<Request>> validators,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateConsumption));
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken); List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0) 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())); return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
} }
@@ -60,10 +79,12 @@ public static class UpdateConsumption
consumption.DateTime = request.DateTime.ToUniversalTime(); consumption.DateTime = request.DateTime.ToUniversalTime();
consumption.Distance = request.Distance; consumption.Distance = request.Distance;
consumption.Amount = request.Amount; consumption.Amount = request.Amount;
consumption.IgnoreInCalculation = request.IgnoreInCalculation;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value)); logger.LogTrace("Updated consumption: {@Consumption}", consumption);
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
consumption.Amount, consumption.CarId.Value));
} }
} }

View File

@@ -41,5 +41,6 @@ public static class EndpointExtensions
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy); .RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetServerInfo.MapEndpoint(versionedApis); GetServerInfo.MapEndpoint(versionedApis);
GetCurrentTime.MapEndpoint(versionedApis);
} }
} }

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Http.HttpResults;
namespace Vegasco.Server.Api.Info;
public static class GetCurrentTime
{
public record Response(DateTimeOffset CurrentTime);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("info/time", Endpoint)
.WithTags("Info");
}
private static Ok<Response> Endpoint(
TimeProvider timeProvider)
{
return TypedResults.Ok(new Response(timeProvider.GetUtcNow()));
}
}

View File

@@ -2,7 +2,7 @@
namespace Vegasco.Server.Api.Info; namespace Vegasco.Server.Api.Info;
public class GetServerInfo public static class GetServerInfo
{ {
public record Response( public record Response(
string FullVersion, string FullVersion,

View File

@@ -11,12 +11,12 @@ public class ApplyMigrationsService(
{ {
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
using var activity = activitySource.StartActivity("ApplyMigrations"); using Activity? activity = activitySource.StartActivity("ApplyMigrations");
logger.LogInformation("Starting migrations"); logger.LogInformation("Starting migrations");
using IServiceScope scope = scopeFactory.CreateScope(); using IServiceScope scope = scopeFactory.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); await using ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken); await dbContext.Database.MigrateAsync(cancellationToken);
} }

View File

@@ -0,0 +1,117 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250622085121_DropIgnoreInCalculation")]
partial class DropIgnoreInCalculation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.HasOne("Vegasco.Server.Api.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
/// <inheritdoc />
public partial class DropIgnoreInCalculation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IgnoreInCalculation",
table: "Consumptions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IgnoreInCalculation",
table: "Consumptions",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -6,7 +6,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
#nullable disable #nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations namespace Vegasco.Server.Api.Persistence.Migrations
@@ -18,7 +17,7 @@ namespace Vegasco.Server.Api.Persistence.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.8") .HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -61,9 +60,6 @@ namespace Vegasco.Server.Api.Persistence.Migrations
b.Property<double>("Distance") b.Property<double>("Distance")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CarId"); b.HasIndex("CarId");

View File

@@ -13,23 +13,24 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.3.0" /> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
<PackageReference Include="Aspire.Seq" Version="9.5.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.2" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" /> <PackageReference Include="OpenTelemetry" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.4.16" /> <PackageReference Include="Scalar.AspNetCore" Version="2.9.0" />
<PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" /> <PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" />
<PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" /> <PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" />
</ItemGroup> </ItemGroup>
@@ -40,7 +41,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" /> <PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,85 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Users" (
"Id" text NOT NULL,
CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Cars" (
"Id" uuid NOT NULL,
"Name" character varying(50) NOT NULL,
"UserId" text NOT NULL,
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Cars_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Consumptions" (
"Id" uuid NOT NULL,
"DateTime" timestamp with time zone NOT NULL,
"Distance" double precision NOT NULL,
"Amount" double precision NOT NULL,
"IgnoreInCalculation" boolean NOT NULL,
"CarId" uuid NOT NULL,
CONSTRAINT "PK_Consumptions" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Consumptions_Cars_CarId" FOREIGN KEY ("CarId") REFERENCES "Cars" ("Id") ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE INDEX "IX_Cars_UserId" ON "Cars" ("UserId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE INDEX "IX_Consumptions_CarId" ON "Consumptions" ("CarId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240818105918_Initial', '9.0.5');
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20250622085121_DropIgnoreInCalculation') THEN
ALTER TABLE "Consumptions" DROP COLUMN "IgnoreInCalculation";
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20250622085121_DropIgnoreInCalculation') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250622085121_DropIgnoreInCalculation', '9.0.5');
END IF;
END $EF$;
COMMIT;

View File

@@ -4,7 +4,7 @@ public static class Constants
{ {
public static class Projects public static class Projects
{ {
public const string Api = "Vegasco-Server-Api"; public const string Api = "Api";
} }
public static class Database public static class Database

View File

@@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning"> <PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version> <Version>3.8.118</Version>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>

View File

@@ -1,22 +1,40 @@
using Microsoft.Extensions.Hosting;
using Vegasco.Server.AppHost.Shared; using Vegasco.Server.AppHost.Shared;
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
IResourceBuilder<PostgresDatabaseResource> postgres = builder.AddPostgres(Constants.Database.ServiceName) IResourceBuilder<PostgresServerResource> postgresBuilder = builder.AddPostgres(Constants.Database.ServiceName)
.WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume();
if (builder.Environment.IsDevelopment())
{
postgresBuilder = postgresBuilder
.WithPgWeb()
.WithPgAdmin();
}
IResourceBuilder<SeqResource> seq = builder.AddSeq("seq")
.WithLifetime(ContainerLifetime.Persistent) .WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume() .WithDataVolume()
.WithExternalHttpEndpoints()
.WithImageTag("latest");
IResourceBuilder<PostgresDatabaseResource> postgres = postgresBuilder
.AddDatabase(Constants.Database.Name); .AddDatabase(Constants.Database.Name);
IResourceBuilder<ProjectResource> api = builder IResourceBuilder<ProjectResource> api = builder
.AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api) .AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api)
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres)
.WithReference(seq)
.WaitFor(seq);
builder builder
.AddNpmApp("Vegasco-Web", "../Vegasco-Web") .AddNpmApp("Vegasco-Web", "../Vegasco-Web")
.WithReference(api) .WithReference(api)
.WaitFor(api) .WaitFor(api)
.WithHttpEndpoint(port: 44200, env: "PORT", isProxied: false) .WithHttpEndpoint(port: 44200, env: "PORT")
.WithExternalHttpEndpoints() .WithExternalHttpEndpoints()
.WithHttpHealthCheck("/", 200); .WithHttpHealthCheck("/", 200);

View File

@@ -12,12 +12,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" /> <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.3.1" /> <PackageReference Include="Aspire.Hosting.NodeJs" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.3.0" /> <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.5.1" />
<PackageReference Update="Nerdbank.GitVersioning"> <PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version> <Version>3.8.118</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Aspire.Hosting.Seq" Version="9.5.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -25,4 +26,14 @@
<ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" /> <ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="RestoreNpm" BeforeTargets="Build" Condition=" '$(DesignTimeBuild)' != 'true' ">
<ItemGroup>
<PackageJsons Include="..\*\package.json" />
</ItemGroup>
<!-- Install npm packages if node_modules is missing -->
<Message Importance="Normal" Text="Installing npm packages for %(PackageJsons.RelativeDir)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
<Exec Command="pnpm install" WorkingDirectory="%(PackageJsons.RootDir)%(PackageJsons.Directory)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
</Target>
</Project> </Project>

View File

@@ -10,15 +10,15 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.0" /> <PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Update="Nerdbank.GitVersioning"> <PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version> <Version>3.8.118</Version>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>

View File

@@ -5,15 +5,15 @@ namespace Vegasco.Server.Api.Tests.Integration;
internal class CarFaker internal class CarFaker
{ {
private readonly Faker _faker = new();
internal CreateCar.Request CreateCarRequest() internal CreateCar.Request CreateCarRequest()
{ {
return new CreateCar.Request(_faker.Vehicle.Model()); Faker faker = new();
return new CreateCar.Request(faker.Person.FirstName);
} }
internal UpdateCar.Request UpdateCarRequest() internal UpdateCar.Request UpdateCarRequest()
{ {
return new UpdateCar.Request(_faker.Vehicle.Model()); Faker faker = new();
return new UpdateCar.Request(faker.Person.FirstName);
} }
} }

View File

@@ -35,7 +35,7 @@ public class CreateCarTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.Created); response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers()); createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id)) _dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id))
@@ -46,14 +46,14 @@ public class CreateCarTests : IAsyncLifetime
public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid() public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{ {
// Arrange // Arrange
var createCarRequest = new CreateCar.Request(""); CreateCar.Request createCarRequest = new CreateCar.Request("");
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(); ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x => validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase)); x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));

View File

@@ -27,7 +27,7 @@ public class DeleteCarTests : IAsyncLifetime
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist() public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{ {
// Arrange // Arrange
var randomCarId = Guid.NewGuid(); Guid randomCarId = Guid.NewGuid();
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}"); HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
@@ -43,7 +43,7 @@ public class DeleteCarTests : IAsyncLifetime
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest(); CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}"); HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");

View File

@@ -21,7 +21,7 @@ public class GetCarTests : IAsyncLifetime
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist() public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{ {
// Arrange // Arrange
var randomCarId = Guid.NewGuid(); Guid randomCarId = Guid.NewGuid();
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}"); HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
@@ -37,14 +37,14 @@ public class GetCarTests : IAsyncLifetime
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest(); CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}"); HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var car = await response.Content.ReadFromJsonAsync<GetCar.Response>(); GetCar.Response? car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
car.Should().BeEquivalentTo(createdCar); car.Should().BeEquivalentTo(createdCar);
} }

View File

@@ -27,7 +27,7 @@ public class GetCarsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>(); GetCars.ApiResponse? apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEmpty(); apiResponse!.Cars.Should().BeEmpty();
} }
@@ -38,13 +38,13 @@ public class GetCarsTests : IAsyncLifetime
List<CreateCar.Response> createdCars = []; List<CreateCar.Response> createdCars = [];
const int numberOfCars = 5; const int numberOfCars = 5;
for (var i = 0; i < numberOfCars; i++) for (int i = 0; i < numberOfCars; i++)
{ {
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest(); CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCars.Add(createdCar!); createdCars.Add(createdCar!);
} }
@@ -53,7 +53,7 @@ public class GetCarsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>(); GetCars.ApiResponse? apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEquivalentTo(createdCars); apiResponse!.Cars.Should().BeEquivalentTo(createdCars);
} }

View File

@@ -31,7 +31,7 @@ public class UpdateCarTests : IAsyncLifetime
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest(); CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest(); UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
@@ -40,7 +40,7 @@ public class UpdateCarTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
updatedCar!.Id.Should().Be(createdCar.Id); updatedCar!.Id.Should().Be(createdCar.Id);
updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers()); updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
@@ -57,16 +57,16 @@ public class UpdateCarTests : IAsyncLifetime
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest(); CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
var updateCarRequest = new UpdateCar.Request(""); UpdateCar.Request updateCarRequest = new UpdateCar.Request("");
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest); HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(); ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x => validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase)); x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
@@ -80,7 +80,7 @@ public class UpdateCarTests : IAsyncLifetime
{ {
// Arrange // Arrange
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest(); UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
var randomCarId = Guid.NewGuid(); Guid randomCarId = Guid.NewGuid();
// Act // Act
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest); HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest);

View File

@@ -5,25 +5,22 @@ namespace Vegasco.Server.Api.Tests.Integration;
internal class ConsumptionFaker internal class ConsumptionFaker
{ {
private readonly Faker _faker = new();
internal CreateConsumption.Request CreateConsumptionRequest(Guid carId) internal CreateConsumption.Request CreateConsumptionRequest(Guid carId)
{ {
Faker faker = new();
return new CreateConsumption.Request( return new CreateConsumption.Request(
_faker.Date.RecentOffset(), faker.Date.RecentOffset(),
_faker.Random.Int(1, 1_000), faker.Random.Int(1, 1_000),
_faker.Random.Int(20, 70), faker.Random.Int(20, 70),
_faker.Random.Bool(),
carId); carId);
} }
internal UpdateConsumption.Request UpdateConsumptionRequest() internal UpdateConsumption.Request UpdateConsumptionRequest()
{ {
CreateConsumption.Request createRequest = CreateConsumptionRequest(default); CreateConsumption.Request createRequest = CreateConsumptionRequest(Guid.Empty);
return new UpdateConsumption.Request( return new UpdateConsumption.Request(
createRequest.DateTime, createRequest.DateTime,
createRequest.Distance, createRequest.Distance,
createRequest.Amount, createRequest.Amount);
createRequest.IgnoreInCalculation);
} }
} }

View File

@@ -39,7 +39,7 @@ public class CreateConsumptionTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.Created); response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
createdConsumption.Should().BeEquivalentTo(createConsumptionRequest, o => o.ExcludingMissingMembers()); createdConsumption.Should().BeEquivalentTo(createConsumptionRequest, o => o.ExcludingMissingMembers());
_dbContext.Consumptions.Should().HaveCount(1) _dbContext.Consumptions.Should().HaveCount(1)
@@ -64,7 +64,7 @@ public class CreateConsumptionTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(); ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x => validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase)); x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase));
@@ -76,7 +76,7 @@ public class CreateConsumptionTests : IAsyncLifetime
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }

View File

@@ -43,7 +43,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
public async Task DeleteConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist() public async Task DeleteConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
{ {
// Arrange // Arrange
var consumptionId = Guid.NewGuid(); Guid consumptionId = Guid.NewGuid();
// Act // Act
using HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/consumptions/{consumptionId}"); using HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/consumptions/{consumptionId}");
@@ -58,7 +58,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!; return createdConsumption!;
} }
@@ -67,7 +67,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }

View File

@@ -37,7 +37,7 @@ public class GetConsumptionTests : IAsyncLifetime
// Assert // Assert
string content = await response.Content.ReadAsStringAsync(); string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var consumption = await response.Content.ReadFromJsonAsync<GetConsumption.Response>(); GetConsumption.Response? consumption = await response.Content.ReadFromJsonAsync<GetConsumption.Response>();
consumption.Should().BeEquivalentTo(createdConsumption); consumption.Should().BeEquivalentTo(createdConsumption);
} }
@@ -45,7 +45,7 @@ public class GetConsumptionTests : IAsyncLifetime
public async Task GetConsumptions_ShouldReturnNotFound_WhenConsumptionDoesNotExist() public async Task GetConsumptions_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
{ {
// Arrange // Arrange
var consumptionId = Guid.NewGuid(); Guid consumptionId = Guid.NewGuid();
// Act // Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/consumptions{consumptionId}"); using HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/consumptions{consumptionId}");
@@ -60,7 +60,7 @@ public class GetConsumptionTests : IAsyncLifetime
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!; return createdConsumption!;
} }
@@ -69,7 +69,7 @@ public class GetConsumptionTests : IAsyncLifetime
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }

View File

@@ -31,7 +31,7 @@ public class GetConsumptionsTests : IAsyncLifetime
// Arrange // Arrange
List<CreateConsumption.Response> createdConsumptions = []; List<CreateConsumption.Response> createdConsumptions = [];
const int numberOfConsumptions = 3; const int numberOfConsumptions = 3;
for (var i = 0; i < numberOfConsumptions; i++) for (int i = 0; i < numberOfConsumptions; i++)
{ {
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync(); CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
createdConsumptions.Add(createdConsumption); createdConsumptions.Add(createdConsumption);
@@ -42,8 +42,16 @@ public class GetConsumptionsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>(); GetConsumptions.ApiResponse? apiResponse =
apiResponse!.Consumptions.Should().BeEquivalentTo(createdConsumptions); await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse.Should().NotBeNull();
apiResponse.Consumptions.Should().HaveCount(createdConsumptions.Count);
apiResponse.Consumptions.Should().BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers());
apiResponse.Consumptions
.Select(x => x.Car.Id)
.Should()
.BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers());
} }
[Fact] [Fact]
@@ -56,26 +64,32 @@ public class GetConsumptionsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>(); GetConsumptions.ApiResponse? apiResponse =
await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse!.Consumptions.Should().BeEmpty(); apiResponse!.Consumptions.Should().BeEmpty();
} }
private async Task<CreateConsumption.Response> CreateConsumptionAsync() private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{ {
CreateCar.Response createdCarResponse = await CreateCarAsync(); CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); CreateConsumption.Request createConsumptionRequest =
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response =
await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption =
await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!; return createdConsumption!;
} }
private async Task<CreateCar.Response> CreateCarAsync() private async Task<CreateCar.Response> CreateCarAsync()
{ {
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse =
await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse =
await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }

View File

@@ -39,7 +39,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
// Assert // Assert
string content = await response.Content.ReadAsStringAsync(); string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedConsumption = await response.Content.ReadFromJsonAsync<UpdateConsumption.Response>(); UpdateConsumption.Response? updatedConsumption = await response.Content.ReadFromJsonAsync<UpdateConsumption.Response>();
updatedConsumption.Should().BeEquivalentTo(updateConsumptionRequest, o => o.ExcludingMissingMembers()); updatedConsumption.Should().BeEquivalentTo(updateConsumptionRequest, o => o.ExcludingMissingMembers());
_dbContext.Consumptions.Should().HaveCount(1) _dbContext.Consumptions.Should().HaveCount(1)
@@ -59,7 +59,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
// Arrange // Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync(); CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest() with { Distance = -42 }; UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest() with { Distance = -42 };
var randomGuid = Guid.NewGuid(); Guid randomGuid = Guid.NewGuid();
// Act // Act
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest); using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest);
@@ -67,7 +67,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
// Assert // Assert
string content = await response.Content.ReadAsStringAsync(); string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(); ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x => validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(updateConsumptionRequest.Distance), StringComparison.OrdinalIgnoreCase)); x.Equals(nameof(updateConsumptionRequest.Distance), StringComparison.OrdinalIgnoreCase));
@@ -80,7 +80,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
// Arrange // Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync(); CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest(); UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest();
var randomGuid = Guid.NewGuid(); Guid randomGuid = Guid.NewGuid();
// Act // Act
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest); using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest);
@@ -98,7 +98,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!; return createdConsumption!;
} }
@@ -107,7 +107,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }

View File

@@ -0,0 +1,32 @@
using FluentAssertions;
using FluentAssertions.Extensions;
using System.Net.Http.Json;
using Vegasco.Server.Api.Info;
namespace Vegasco.Server.Api.Tests.Integration.Info;
[Collection(SharedTestCollection.Name)]
public sealed class GetCurrentTimeTests
{
private readonly WebAppFactory _factory;
public GetCurrentTimeTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetServerInfo_ShouldReturnServerInfo_WhenCalled()
{
// Arrange
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("/v1/info/time");
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
GetCurrentTime.Response? timeInfo = await response.Content.ReadFromJsonAsync<GetCurrentTime.Response>();
timeInfo.Should().NotBeNull();
timeInfo.CurrentTime.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(10));
}
}

View File

@@ -25,7 +25,7 @@ public class GetServerInfoTests
// Assert // Assert
response.IsSuccessStatusCode.Should().BeTrue(); response.IsSuccessStatusCode.Should().BeTrue();
var serverInfo = await response.Content.ReadFromJsonAsync<GetServerInfo.Response>(); GetServerInfo.Response? serverInfo = await response.Content.ReadFromJsonAsync<GetServerInfo.Response>();
serverInfo!.Environment.Should().NotBeEmpty(); serverInfo!.Environment.Should().NotBeEmpty();
serverInfo.CommitDate.Should().BeAfter(23.August(2024)) serverInfo.CommitDate.Should().BeAfter(23.August(2024))
.And.NotBeAfter(DateTime.Now); .And.NotBeAfter(DateTime.Now);

View File

@@ -19,7 +19,7 @@ internal sealed class PostgresRespawner : IDisposable
DbConnection connection = new NpgsqlConnection(connectionString); DbConnection connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, Respawner respawner = await Respawner.CreateAsync(connection,
new RespawnerOptions new RespawnerOptions
{ {
SchemasToInclude = ["public"], SchemasToInclude = ["public"],

View File

@@ -10,21 +10,21 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.0" /> <PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Bogus" Version="35.6.3" /> <PackageReference Include="Bogus" Version="35.6.4" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentAssertions" Version="[7.2.0,8.0.0)" /> <PackageReference Include="FluentAssertions" Version="[7.2.0,8.0.0)" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Respawn" Version="6.2.1" /> <PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="System.Formats.Asn1" Version="9.0.5" /> <PackageReference Include="System.Formats.Asn1" Version="9.0.10" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.5.0" /> <PackageReference Include="Testcontainers.PostgreSql" Version="4.7.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -40,7 +40,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" /> <PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -19,10 +19,9 @@ public class CreateConsumptionRequestValidatorTests
_sut = new CreateConsumption.Validator(_timeProvider); _sut = new CreateConsumption.Validator(_timeProvider);
_validRequest = new CreateConsumption.Request( _validRequest = new CreateConsumption.Request(
_utcNow.AddDays(-1), _utcNow.Date.AddDays(1).AddTicks(-1),
1, 1,
1, 1,
false,
Guid.NewGuid()); Guid.NewGuid());
} }
@@ -39,10 +38,10 @@ public class CreateConsumptionRequestValidatorTests
} }
[Fact] [Fact]
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcNow() public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcToday()
{ {
// Arrange // Arrange
CreateConsumption.Request request = _validRequest with { DateTime = _utcNow.AddDays(1) }; CreateConsumption.Request request = _validRequest with { DateTime = _utcNow.Date.AddDays(1) };
// Act // Act
ValidationResult? result = await _sut.ValidateAsync(request); ValidationResult? result = await _sut.ValidateAsync(request);

View File

@@ -20,10 +20,9 @@ public class UpdateConsumptionRequestValidatorTests
_sut = new UpdateConsumption.Validator(_timeProvider); _sut = new UpdateConsumption.Validator(_timeProvider);
_validRequest = new UpdateConsumption.Request( _validRequest = new UpdateConsumption.Request(
_utcNow.AddDays(-1), _utcNow.Date.AddDays(1).AddTicks(-1),
1, 1,
1, 1);
false);
} }
[Fact] [Fact]
@@ -39,10 +38,10 @@ public class UpdateConsumptionRequestValidatorTests
} }
[Fact] [Fact]
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcNow() public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcToday()
{ {
// Arrange // Arrange
UpdateConsumption.Request request = _validRequest with { DateTime = _utcNow.AddDays(1) }; UpdateConsumption.Request request = _validRequest with { DateTime = _utcNow.Date.AddDays(1) };
// Act // Act
ValidationResult? result = await _sut.ValidateAsync(request); ValidationResult? result = await _sut.ValidateAsync(request);

View File

@@ -14,12 +14,12 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="FluentAssertions" Version="8.3.0" /> <PackageReference Include="FluentAssertions" Version="8.7.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -34,7 +34,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" /> <PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -6,10 +6,10 @@
<File Path="version.json" /> <File Path="version.json" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/Vegasco.Server.Api/Vegasco.Server.Api.csproj" />
<Project Path="src/Vegasco.Server.AppHost.Shared/Vegasco.Server.AppHost.Shared.csproj" /> <Project Path="src/Vegasco.Server.AppHost.Shared/Vegasco.Server.AppHost.Shared.csproj" />
<Project Path="src/Vegasco.Server.AppHost/Vegasco.Server.AppHost.csproj" /> <Project Path="src/Vegasco.Server.AppHost/Vegasco.Server.AppHost.csproj" />
<Project Path="src/Vegasco.Server.ServiceDefaults/Vegasco.Server.ServiceDefaults.csproj" /> <Project Path="src/Vegasco.Server.ServiceDefaults/Vegasco.Server.ServiceDefaults.csproj" />
<Project Path="src/Vegasco.Server.Api/Vegasco.Server.Api.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/Vegasco.Server.Api.Tests.Integration/Vegasco.Server.Api.Tests.Integration.csproj" /> <Project Path="tests/Vegasco.Server.Api.Tests.Integration/Vegasco.Server.Api.Tests.Integration.csproj" />