Compare commits
78 Commits
92e4da4b93
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| d4ae137115 | |||
| 9f51f508ce | |||
| 62824549fc | |||
| 0cb5e44f7a | |||
| 7d7f5750e3 | |||
| 789ba35c60 | |||
| 1226c42f19 | |||
| 5e083aeaf6 | |||
| 31efd6b4ad | |||
| 69bb19e4eb | |||
| db791a1183 | |||
| ad77c2fe2b | |||
| 87a0241f11 | |||
| f248be4e1f | |||
| 67d29333d9 | |||
| 5956f27646 | |||
| 69901a295c | |||
| 527759eb7b | |||
| d4fff6741c | |||
| a10070b9c7 | |||
| d10d1a6fdb | |||
| c57972d9a6 | |||
| ea019ebfa6 | |||
| 97a275478d | |||
| 731eab3898 | |||
| f018e62163 | |||
| 10e02b5e9b | |||
| c365af1d42 | |||
| 66c23ffb4f | |||
| 7ddc346e88 | |||
| 00e0869a13 | |||
| 925293d626 | |||
| 9b024967e6 | |||
| 288d470c1b | |||
| 84a72a8557 | |||
| d4223ed38f | |||
| 9f2c5db825 | |||
| 18cbc2225f | |||
| 267c4165dd | |||
| ef1c1d8ba1 | |||
| 8d4ae30224 | |||
| 02e7ed7030 | |||
| 9595bedd8e | |||
| af661632cc | |||
| 5062887010 | |||
| b41d5c5d33 | |||
| 4b377ce9f4 | |||
| 5e084ab0a8 | |||
| 559804765b | |||
| 5da1e2fd75 | |||
| ab32be98a6 | |||
| 8681247e76 | |||
| f6dbf489ad | |||
| eaa06029bb | |||
| 9e16d6004a | |||
| 0df7449a99 | |||
| 7f61e011ed | |||
| 9c372b31a6 | |||
| fd7a8024a9 | |||
| 4a8e3d02e0 | |||
| f7af144275 | |||
| cb3c8c0d18 | |||
| a997a3b825 | |||
| c58f6fe364 | |||
| 69bc76cab4 | |||
| 4b1f9e78df | |||
| 4c00f868c7 | |||
| 8b9ccdc694 | |||
| b8d1fddd91 | |||
| 9246729edf | |||
| e13b5f2cdc | |||
| 63c7624a00 | |||
| f58613d661 | |||
| d71e523074 | |||
| 1c8e02b3fa | |||
| feadab4dff | |||
| 41c342bb0f | |||
| 2e3000c3fc |
10
.drone.yml
10
.drone.yml
@@ -42,9 +42,11 @@ steps:
|
||||
- name: docker build and push
|
||||
image: docker:24.0.7
|
||||
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
|
||||
- docker push $docker_registry$docker_repo:$DRONE_BRANCH
|
||||
- docker push $dockerImageWithTag
|
||||
- echo "Built and pushed $dockerImageWithTag"
|
||||
environment:
|
||||
docker_username:
|
||||
from_secret: docker_username
|
||||
@@ -60,6 +62,10 @@ steps:
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- production
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
depends_on:
|
||||
- compile (.NET)
|
||||
- test
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
dotnet ef migrations add $args[0] --project .\src\WebApi\WebApi.csproj --output-dir Persistence/Migrations
|
||||
dotnet ef migrations script --idempotent --project .\src\WebApi\WebApi.csproj --output migrations/migration.sql
|
||||
dotnet ef migrations add $args[0] --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output-dir Persistence/Migrations
|
||||
dotnet ef migrations script --idempotent --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output ./src/Vegasco.Server.Api/migrations/migration.sql
|
||||
|
||||
34
README.md
34
README.md
@@ -1,17 +1,21 @@
|
||||
# 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
|
||||
|
||||
### Configuration
|
||||
|
||||
| Configuration | Description | Default | Required |
|
||||
|--------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
|
||||
| JWT:MetadataUrl | The oidc meta data url | - | true |
|
||||
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
|
||||
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
|
||||
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
|
||||
| Configuration | Description | Default | Required |
|
||||
|------------------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
|
||||
| JWT:MetadataUrl | The oidc meta data url | - | true |
|
||||
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
|
||||
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
|
||||
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
|
||||
| ConnectionStrings:seq | The seq http endpoint to send the logs and traces to. If not set, logs and traces will not be sent to seq. | - | false |
|
||||
| ConnectionStrings:vegasco-database | The connection string to the postgres database. | - | true |
|
||||
|
||||
The application uses the prefix `Vegasco_` for environment variable names. The prefix is removed when the application reads the environment variables and duplicate entries are overwritten by the environment variables.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 .
|
||||
```
|
||||
|
||||
10
src/Vegasco-Web/.dockerignore
Normal file
10
src/Vegasco-Web/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
3
src/Vegasco-Web/.vscode/launch.json
vendored
3
src/Vegasco-Web/.vscode/launch.json
vendored
@@ -3,10 +3,11 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch (Chrome)",
|
||||
"name": "Launch Web (Chrome)",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"postDebugTask": "Terminate All Tasks",
|
||||
"url": "http://localhost:44200/",
|
||||
}
|
||||
]
|
||||
|
||||
16
src/Vegasco-Web/.vscode/tasks.json
vendored
16
src/Vegasco-Web/.vscode/tasks.json
vendored
@@ -2,13 +2,19 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Terminate All Tasks",
|
||||
"command": "echo ${input:terminate}",
|
||||
"type": "shell",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"options": {
|
||||
"env": {
|
||||
"PORT": "44200",
|
||||
"services__Vegasco-Server-Api__https__0": "https://localhost:7098",
|
||||
"services__Api__https__0": "https://localhost:7098",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
@@ -45,5 +51,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "terminate",
|
||||
"type": "command",
|
||||
"command": "workbench.action.tasks.terminate",
|
||||
"args": "terminateAll"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
19
src/Vegasco-Web/Dockerfile
Normal file
19
src/Vegasco-Web/Dockerfile
Normal 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
|
||||
@@ -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.
|
||||
|
||||
## API Proxy
|
||||
|
||||
Because the solution utilizes Aspire which injects endpoint references for the API as environment variables, this application uses a proxy to access the API. The proxy is configured in the `proxy.config.js` file which is used in the `serve` section of the `angular.json` file. This makes the dev server provide a proxy when serving the application.
|
||||
|
||||
The environment variables for the API endpoint are named `services__Api__https__0` and `services__Api__http__0` for the https and the http endpoints respectively. If the https endpoint is not configured, the http endpoint is used. At least one of them has to be configured.
|
||||
|
||||
To allow the dev proxy to accept otherwise untrusted server certificates, set `NODE_ENV` to `development`. Otherwise the dev proxy rejects untrusted certificates.
|
||||
|
||||
When deploying the application elsewhere, another proxy has to be configured to provide the same functionality to ensure the application works correctly.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/tmp",
|
||||
"outputPath": "dist/Vegasco-Web",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
|
||||
8
src/Vegasco-Web/nginx.conf
Normal file
8
src/Vegasco-Web/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
||||
events { }
|
||||
http {
|
||||
include mime.types;
|
||||
|
||||
resolver 127.0.0.11;
|
||||
|
||||
include /etc/nginx/conf.d/webserver.conf;
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"start": "run-script-os",
|
||||
"start:win32": "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",
|
||||
"test": "ng test"
|
||||
},
|
||||
@@ -23,6 +25,7 @@
|
||||
"@ng-icons/material-icons": "^31.4.0",
|
||||
"@primeng/themes": "^19.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"dayjs": "^1.11.13",
|
||||
"keycloak-angular": "^19.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"primeng": "^19.1.3",
|
||||
|
||||
8
src/Vegasco-Web/pnpm-lock.yaml
generated
8
src/Vegasco-Web/pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
keycloak-angular:
|
||||
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)
|
||||
@@ -2229,6 +2232,9 @@ packages:
|
||||
resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
@@ -6763,6 +6769,8 @@ snapshots:
|
||||
|
||||
date-format@4.0.14: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module.exports = {
|
||||
"/api": {
|
||||
target:
|
||||
process.env["services__Vegasco-Server-Api__https__0"] ||
|
||||
process.env["services__Vegasco-Server-Api__http__0"],
|
||||
process.env["services__Api__https__0"] ||
|
||||
process.env["services__Api__http__0"],
|
||||
secure: process.env["NODE_ENV"] !== "development",
|
||||
pathRewrite: {
|
||||
"^/api": "",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {API_BASE_PATH} from '../api-base-path';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { API_BASE_PATH } from '../api-base-path';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsumptionClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiBasePath = inject(API_BASE_PATH, {optional: true});
|
||||
private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
|
||||
|
||||
getAll(): Observable<GetConsumptionEntriesResponse> {
|
||||
return this.http.get<GetConsumptionEntriesResponse>(`${this.apiBasePath}/v1/consumptions`);
|
||||
|
||||
@@ -3,6 +3,5 @@ interface ConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
ignoreInCalculation: boolean;
|
||||
carId: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@ interface CreateConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
ignoreInCalculation: boolean;
|
||||
carId: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
interface GetConsumptionEntriesEntry {
|
||||
id: string;
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
car: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
literPer100Km: number | null;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
interface GetConsumptionEntriesResponse {
|
||||
consumptions: ConsumptionEntry[];
|
||||
}
|
||||
consumptions: GetConsumptionEntriesEntry[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@ interface UpdateConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
ignoreInCalculation: boolean;
|
||||
carId: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface Consumption {
|
||||
id: string;
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
ignoreInCalculation: boolean;
|
||||
carId: string;
|
||||
car: Car;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
<main class="main">
|
||||
<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">
|
||||
Vegasco
|
||||
</a>
|
||||
<span class="flex items-center gap-4">
|
||||
<a routerLink="/entries" class="reset cursor-pointer">
|
||||
Einträge
|
||||
</a>
|
||||
<a routerLink="/cars" class="reset cursor-pointer">
|
||||
Autos
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content max-content-width mx-auto">
|
||||
<p-toast />
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
@@ -9,5 +9,9 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'entries',
|
||||
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'cars',
|
||||
loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, ToastModule],
|
||||
imports: [RouterLink, RouterOutlet, ToastModule],
|
||||
providers: [MessageService],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
|
||||
17
src/Vegasco-Web/src/app/modules/cars/cars.routes.ts
Normal file
17
src/Vegasco-Web/src/app/modules/cars/cars.routes.ts
Normal 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)
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
127
src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts
Normal file
127
src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.edit-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<span class="required">*</span>
|
||||
@@ -0,0 +1,3 @@
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
@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 {
|
||||
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
|
||||
|
||||
@@ -30,6 +39,8 @@
|
||||
[firstDayOfWeek]="1"
|
||||
placeholder="Datum auswählen"
|
||||
[showIcon]="true"
|
||||
[maxDate]="today"
|
||||
[defaultDate]="today"
|
||||
[inputId]="formFieldNames.date"
|
||||
[formControlName]="formFieldNames.date"
|
||||
styleClass="w-full"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, DestroyRef, inject, input, Signal } from '@angular/core';
|
||||
import { Component, computed, DestroyRef, inject, input, OnInit, signal, Signal } from '@angular/core';
|
||||
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 { 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';
|
||||
@@ -17,8 +18,9 @@ import { InputTextModule } from 'primeng/inputtext';
|
||||
import { MultiSelectModule } from 'primeng/multiselect';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import { catchError, EMPTY, map, Observable, switchMap, throwError } 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({
|
||||
selector: 'app-edit-entry',
|
||||
@@ -40,15 +42,17 @@ import { RequiredMarkerComponent } from './components/required-marker.component'
|
||||
templateUrl: './edit-entry.component.html',
|
||||
styleUrl: './edit-entry.component.scss'
|
||||
})
|
||||
export class EditEntryComponent {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
export class EditEntryComponent implements OnInit {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly routingService = inject(RoutingService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly selectedCarService = inject(SelectedCarService);
|
||||
|
||||
protected readonly entryId = input<string | undefined>(undefined);
|
||||
protected readonly id = input<string | undefined>(undefined);
|
||||
|
||||
protected readonly today = new Date();
|
||||
|
||||
protected readonly formFieldNames = {
|
||||
car: 'car',
|
||||
@@ -57,34 +61,107 @@ export class EditEntryComponent {
|
||||
amount: 'amount',
|
||||
} as const;
|
||||
|
||||
protected readonly formGroup = this.formBuilder.group({
|
||||
[this.formFieldNames.car]: [<Car | null>null, Validators.required],
|
||||
[this.formFieldNames.date]: [<Date>new Date(), Validators.required],
|
||||
[this.formFieldNames.mileage]: [
|
||||
<number | null>null,
|
||||
[Validators.required, Validators.min(1)],
|
||||
],
|
||||
[this.formFieldNames.amount]: [
|
||||
<number | null>null,
|
||||
[Validators.required, Validators.min(1)],
|
||||
],
|
||||
protected readonly formGroup = new FormGroup({
|
||||
[this.formFieldNames.car]: new FormControl<Car | null>({ value: null, disabled: true }, [Validators.required]),
|
||||
[this.formFieldNames.date]: new FormControl<Date>({ value: new Date(), disabled: true }, [Validators.required, this.dateTimeGreaterThanOrEqualToTodayValidator]),
|
||||
[this.formFieldNames.mileage]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
|
||||
[this.formFieldNames.amount]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
|
||||
});
|
||||
|
||||
private readonly cars$: Observable<Car[]>;
|
||||
protected readonly cars: Signal<Car[] | undefined>;
|
||||
|
||||
private readonly isEntryDataLoaded = signal(false);
|
||||
|
||||
protected readonly isLoading = computed(() => {
|
||||
return this.cars() === undefined;
|
||||
})
|
||||
var cars = this.cars();
|
||||
var isEntryDataLoaded = this.isEntryDataLoaded();
|
||||
return cars === undefined || !isEntryDataLoaded;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.cars = toSignal(
|
||||
this.carClient
|
||||
.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.cars)
|
||||
),
|
||||
);
|
||||
this.cars$ = this.carClient
|
||||
.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.cars
|
||||
.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
tap(cars => {
|
||||
const selectedCarId = this.selectedCarService.getSelectedCarId();
|
||||
|
||||
if (selectedCarId === null) {
|
||||
const firstCar = cars[0];
|
||||
this.formGroup.controls[this.formFieldNames.car].setValue(firstCar);
|
||||
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCar = cars.find(car => car.id === selectedCarId);
|
||||
this.formGroup.controls[this.formFieldNames.car].setValue(selectedCar ?? null);
|
||||
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
|
||||
}),
|
||||
);
|
||||
this.cars = toSignal(this.cars$);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEntryDetailsAndEnableControls();
|
||||
|
||||
this.formGroup.controls[this.formFieldNames.car]
|
||||
.valueChanges
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap((car) => {
|
||||
this.selectedCarService.setSelectedCarId(car?.id ?? null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private loadEntryDetailsAndEnableControls() {
|
||||
const entryId = this.id();
|
||||
|
||||
if (entryId === undefined || entryId === null) {
|
||||
this.enableFormControls();
|
||||
this.isEntryDataLoaded.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const consumption$ = this.consumptionClient
|
||||
.getSingle(entryId);
|
||||
|
||||
combineLatest([
|
||||
consumption$, this.cars$
|
||||
])
|
||||
.pipe(
|
||||
filter(([_, cars]) => cars !== undefined),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleGetError(error)),
|
||||
tap(([consumption, cars]) => {
|
||||
this.formGroup.patchValue({
|
||||
[this.formFieldNames.car]: cars!.find(c => c.id === consumption.carId) ?? null,
|
||||
[this.formFieldNames.date]: new Date(consumption.dateTime),
|
||||
[this.formFieldNames.mileage]: consumption.distance,
|
||||
[this.formFieldNames.amount]: consumption.amount,
|
||||
});
|
||||
}),
|
||||
tap(() => {
|
||||
this.enableFormControls();
|
||||
this.isEntryDataLoaded.set(true);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private enableFormControls(): void {
|
||||
for (const controlName of Object.values(this.formFieldNames)) {
|
||||
const control = this.formGroup.get(controlName);
|
||||
if (control) {
|
||||
control.enable();
|
||||
} else {
|
||||
console.warn(`Form control '${controlName}' not found.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToOverviewPage(): Promise<void> {
|
||||
@@ -92,7 +169,12 @@ export class EditEntryComponent {
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
var entryId = this.entryId();
|
||||
if (this.formGroup.invalid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
var entryId = this.id();
|
||||
if (entryId === undefined || entryId === null) {
|
||||
this.createEntry();
|
||||
return;
|
||||
@@ -109,7 +191,6 @@ export class EditEntryComponent {
|
||||
dateTime: dateTime.toISOString(),
|
||||
distance: this.formGroup.controls[this.formFieldNames.mileage].value!,
|
||||
amount: this.formGroup.controls[this.formFieldNames.amount].value!,
|
||||
ignoreInCalculation: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,7 +199,7 @@ export class EditEntryComponent {
|
||||
this.consumptionClient.create(request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleError(error)),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error)),
|
||||
switchMap(() => this.routingService.navigateToEntries())
|
||||
)
|
||||
.subscribe();
|
||||
@@ -129,13 +210,41 @@ export class EditEntryComponent {
|
||||
this.consumptionClient.update(id, request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleError(error)),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error)),
|
||||
switchMap(() => this.routingService.navigateToEntries())
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): Observable<never> {
|
||||
private handleGetError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private handleCreateOrUpdateError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
@@ -170,4 +279,15 @@ export class EditEntryComponent {
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
</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="matDirectionsCarOutline" />
|
||||
<div>{{ entry().car.name }}</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" />
|
||||
@@ -32,6 +25,18 @@
|
||||
<div>{{entry().amount }} ℓ</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="ℓ" [denominator]="'100km'" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.edit-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject, input, output } from '@angular/core';
|
||||
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,
|
||||
matStraightenSharp,
|
||||
matLocalGasStationSharp,
|
||||
matSpeedSharp,
|
||||
matStraightenSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import {
|
||||
matDirectionsCarOutline,
|
||||
} from '@ng-icons/material-icons/outline';
|
||||
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
|
||||
import { Consumption } from '@vegasco-web/api/models/consumption';
|
||||
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',
|
||||
@@ -30,12 +27,13 @@ import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
|
||||
ConfirmDialogModule,
|
||||
DatePipe,
|
||||
NgIconComponent,
|
||||
FractionComponent
|
||||
],
|
||||
providers: [
|
||||
provideIcons({
|
||||
matDeleteSharp,
|
||||
matCalendarMonthSharp,
|
||||
matDirectionsCarOutline,
|
||||
matSpeedSharp,
|
||||
matStraightenSharp,
|
||||
matLocalGasStationSharp,
|
||||
}),
|
||||
@@ -45,9 +43,18 @@ import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
|
||||
styleUrl: './entry-card.component.scss'
|
||||
})
|
||||
export class EntryCardComponent {
|
||||
readonly entry = input.required<Consumption>();
|
||||
readonly entry = input.required<GetConsumptionEntriesEntry>();
|
||||
|
||||
readonly entryDeleted = output<Consumption>();
|
||||
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);
|
||||
@@ -67,8 +74,6 @@ export class EntryCardComponent {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
this.confirmationService.confirm({
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<p-scrollTop />
|
||||
<div class="mb-4 flex gap-2 md:justify-between">
|
||||
<div class="basis-full lg:basis-1/4 md:basis-1/2 p-0">
|
||||
<p-select styleClass="w-full" [formControl]="selectedCar" placeholder="Auto" [showClear]="true"
|
||||
<p-select styleClass="w-full" [formControl]="selectedCar" placeholder="Auto" [showClear]="false"
|
||||
[options]="(cars$ | async)!" optionLabel="name" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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 { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
matAddSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DataViewModule } from 'primeng/dataview';
|
||||
@@ -11,17 +18,17 @@ import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
map,
|
||||
Observable,
|
||||
startWith
|
||||
startWith,
|
||||
tap,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { EntriesOverviewService } from './services/entries-overview.service';
|
||||
import { SelectedCarService } from '../services/selected-car.service';
|
||||
import { EntryCardComponent } from './components/entry-card/entry-card.component';
|
||||
import {
|
||||
matAddSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entries',
|
||||
@@ -42,16 +49,18 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
provideIcons({
|
||||
matAddSharp,
|
||||
}),
|
||||
EntriesOverviewService,
|
||||
],
|
||||
templateUrl: './entries.component.html',
|
||||
styleUrl: './entries.component.scss'
|
||||
})
|
||||
export class EntriesComponent {
|
||||
private readonly entriesOverviewService = inject(EntriesOverviewService);
|
||||
export class EntriesComponent implements OnInit {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly selectedCarService = inject(SelectedCarService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly consumptionEntries$: Observable<ConsumptionEntry[]>;
|
||||
protected readonly consumptionEntries$: Observable<GetConsumptionEntriesEntry[]>;
|
||||
protected readonly cars$: Observable<Car[]>;
|
||||
|
||||
protected readonly skeletonsIterationSource = Array(10).fill(0);
|
||||
@@ -61,8 +70,12 @@ export class EntriesComponent {
|
||||
private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]);
|
||||
|
||||
constructor() {
|
||||
const entries = this.entriesOverviewService.getEntries()
|
||||
.pipe(takeUntilDestroyed());
|
||||
const entries = this.consumptionClient.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.consumptions.sort((a, b) => b.dateTime.localeCompare(a.dateTime))),
|
||||
catchError((error) => this.handleGetEntriesError(error))
|
||||
);
|
||||
|
||||
this.consumptionEntries$ = combineLatest([
|
||||
entries,
|
||||
@@ -81,15 +94,45 @@ export class EntriesComponent {
|
||||
return nonDeletedEntries;
|
||||
}
|
||||
|
||||
return nonDeletedEntries.filter(entry => entry.carId === selectedCar.id);
|
||||
})
|
||||
return nonDeletedEntries.filter(entry => entry.car.id === selectedCar.id);
|
||||
}),
|
||||
);
|
||||
|
||||
this.cars$ = this.entriesOverviewService.getCars()
|
||||
.pipe(takeUntilDestroyed());
|
||||
this.cars$ = this.carClient.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.cars),
|
||||
map((cars) => cars
|
||||
.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
tap((cars) => {
|
||||
const selectedCarId = this.selectedCarService.getSelectedCarId();
|
||||
|
||||
if (selectedCarId === null) {
|
||||
const firstCar = cars[0];
|
||||
this.selectedCar.setValue(firstCar);
|
||||
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCar = cars.find(car => car.id === selectedCarId);
|
||||
this.selectedCar.setValue(selectedCar ?? null);
|
||||
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onEntryDeleted(entry: ConsumptionEntry): void {
|
||||
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',
|
||||
@@ -97,4 +140,32 @@ export class EntriesComponent {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { CarClient } from "@vegasco-web/api/cars/car-client";
|
||||
import { ConsumptionClient } from "@vegasco-web/api/consumptions/consumption-client";
|
||||
import { Consumption } from "@vegasco-web/api/models/consumption";
|
||||
import { RoutingService } from "@vegasco-web/services/routing.service";
|
||||
import { combineLatest, map, Observable, shareReplay } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class EntriesOverviewService {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly routingService = inject(RoutingService);
|
||||
|
||||
private cachedCars$: Observable<Car[]> | null = null;
|
||||
|
||||
private ensureCarsAreCached(): void {
|
||||
if (this.cachedCars$ !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedCars$ = this.carClient.getAll()
|
||||
.pipe(
|
||||
map(response => response.cars),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
|
||||
getEntries(): Observable<Consumption[]> {
|
||||
this.ensureCarsAreCached();
|
||||
|
||||
const entries$ = this.consumptionClient.getAll()
|
||||
.pipe(map(response => response.consumptions));
|
||||
|
||||
return combineLatest([this.cachedCars$!, entries$])
|
||||
.pipe(
|
||||
map(([cars, entries]) => {
|
||||
return entries.map((entry): Consumption => ({
|
||||
...entry,
|
||||
car: cars.find(car => car.id === entry.carId)!
|
||||
}));
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getCars(): Observable<Car[]> {
|
||||
this.ensureCarsAreCached();
|
||||
return this.cachedCars$!;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,16 @@ export class RoutingService {
|
||||
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']);
|
||||
}
|
||||
}
|
||||
12
src/Vegasco-Web/webserver.conf.template
Normal file
12
src/Vegasco-Web/webserver.conf.template
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Vegasco.Server.Api.Authentication;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -10,6 +11,7 @@ namespace Vegasco.Server.Api.Cars;
|
||||
public static class CreateCar
|
||||
{
|
||||
public record Request(string Name);
|
||||
|
||||
public record Response(Guid Id, string Name);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
@@ -19,7 +21,8 @@ public static class CreateCar
|
||||
.WithTags("Cars")
|
||||
.WithDescription("Creates a new car")
|
||||
.Produces<Response>(201)
|
||||
.ProducesValidationProblem();
|
||||
.ProducesValidationProblem()
|
||||
.Produces(409);
|
||||
}
|
||||
|
||||
public class Validator : AbstractValidator<Request>
|
||||
@@ -32,41 +35,62 @@ public static class CreateCar
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IResult> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ApplicationDbContext dbContext,
|
||||
UserAccessor userAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
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)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
bool isDuplicate = await dbContext.Cars
|
||||
.AnyAsync(x => x.Name.ToUpper() == request.Name.ToUpper(), cancellationToken);
|
||||
|
||||
if (isDuplicate)
|
||||
{
|
||||
logger.LogDebug("Car with name '{CarName}' (case insensitive) already exists", request.Name);
|
||||
return TypedResults.Conflict();
|
||||
}
|
||||
|
||||
string userId = userAccessor.GetUserId();
|
||||
|
||||
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Id = userId
|
||||
};
|
||||
logger.LogDebug("User with ID '{UserId}' not found, creating new user", userId);
|
||||
|
||||
user = new User { Id = userId };
|
||||
await dbContext.Users.AddAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
Car car = new()
|
||||
{
|
||||
Name = request.Name,
|
||||
UserId = userId
|
||||
};
|
||||
Car car = new() { Name = request.Name.Trim(), UserId = userId };
|
||||
|
||||
await dbContext.Cars.AddAsync(car, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogTrace("Created new car: {@Car}", car);
|
||||
|
||||
Response response = new(car.Id.Value, car.Name);
|
||||
return TypedResults.Created($"/v1/cars/{car.Id}", response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Cars;
|
||||
@@ -15,13 +16,16 @@ public static class DeleteCar
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
public static async Task<IResult> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
Guid id,
|
||||
ApplicationDbContext dbContext,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await dbContext.Cars
|
||||
Activity? activity = Activity.Current;
|
||||
activity?.SetTag("id", id);
|
||||
|
||||
int rows = await dbContext.Cars
|
||||
.Where(x => x.Id == new CarId(id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
@@ -32,7 +36,7 @@ public static class DeleteCar
|
||||
|
||||
if (rows > 1)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(nameof(DeleteCar));
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteCar));
|
||||
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{CarId}'", rows, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ public static class GetCar
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Cars;
|
||||
@@ -34,11 +35,15 @@ public static class GetCars
|
||||
ApplicationDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Activity? activity = Activity.Current;
|
||||
|
||||
List<ResponseDto> cars = await dbContext.Cars
|
||||
.Select(x => new ResponseDto(x.Id.Value, x.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var response = new ApiResponse
|
||||
activity?.SetTag("carCount", cars.Count);
|
||||
|
||||
ApiResponse response = new()
|
||||
{
|
||||
Cars = cars
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Vegasco.Server.Api.Authentication;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -9,6 +10,7 @@ namespace Vegasco.Server.Api.Cars;
|
||||
public static class UpdateCar
|
||||
{
|
||||
public record Request(string Name);
|
||||
|
||||
public record Response(Guid Id, string Name);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
@@ -19,7 +21,8 @@ public static class UpdateCar
|
||||
.WithDescription("Updates a car by ID")
|
||||
.Produces<Response>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.Produces(409);
|
||||
}
|
||||
|
||||
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,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ApplicationDbContext dbContext,
|
||||
UserAccessor userAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateCar));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -53,10 +70,21 @@ public static class UpdateCar
|
||||
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);
|
||||
|
||||
logger.LogTrace("Updated car: {@Car}", car);
|
||||
|
||||
Response response = new(car.Id.Value, car.Name);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public static class DependencyInjectionExtensions
|
||||
/// <param name="builder"></param>
|
||||
public static void AddApiServices(this IHostApplicationBuilder builder)
|
||||
{
|
||||
builder.AddBuilderServices();
|
||||
|
||||
builder.Services
|
||||
.AddMiscellaneousServices()
|
||||
.AddCustomOpenApi()
|
||||
@@ -27,6 +29,24 @@ public static class DependencyInjectionExtensions
|
||||
builder.AddDbContext();
|
||||
}
|
||||
|
||||
private static IHostApplicationBuilder AddBuilderServices(this IHostApplicationBuilder builder)
|
||||
{
|
||||
string? seqHost = builder.Configuration.GetConnectionString("seq");
|
||||
if (!string.IsNullOrEmpty(seqHost))
|
||||
{
|
||||
builder.AddSeqEndpoint("seq", o =>
|
||||
{
|
||||
var apiKey = builder.Configuration.GetValue<string>("seq-api-key");
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
o.ApiKey = apiKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(() =>
|
||||
@@ -121,7 +141,7 @@ public static class DependencyInjectionExtensions
|
||||
.ValidateFluently()
|
||||
.ValidateOnStart();
|
||||
|
||||
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
|
||||
IOptions<JwtOptions> jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
|
||||
|
||||
@@ -14,8 +14,6 @@ public class Consumption
|
||||
|
||||
public double Amount { get; set; }
|
||||
|
||||
public bool IgnoreInCalculation { get; set; }
|
||||
|
||||
public CarId CarId { get; set; }
|
||||
|
||||
public virtual Car Car { get; set; } = null!;
|
||||
@@ -39,9 +37,6 @@ public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumptio
|
||||
builder.Property(x => x.Amount)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.IgnoreInCalculation)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.CarId)
|
||||
.IsRequired()
|
||||
.HasConversion<CarId.EfCoreValueConverter>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Cars;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -8,9 +9,9 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -25,8 +26,13 @@ public static class CreateConsumption
|
||||
{
|
||||
public Validator(TimeProvider timeProvider)
|
||||
{
|
||||
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
|
||||
.Date
|
||||
.AddDays(1)
|
||||
.AddTicks(-1);
|
||||
|
||||
RuleFor(x => x.DateTime.ToUniversalTime())
|
||||
.LessThanOrEqualTo(timeProvider.GetUtcNow())
|
||||
.LessThanOrEqualTo(_ => getTodayEndOfDay())
|
||||
.WithName(nameof(Request.DateTime));
|
||||
|
||||
RuleFor(x => x.Distance)
|
||||
@@ -44,11 +50,25 @@ public static class CreateConsumption
|
||||
ApplicationDbContext dbContext,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(CreateConsumption));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -58,19 +78,21 @@ public static class CreateConsumption
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var consumption = new Consumption
|
||||
Consumption consumption = new()
|
||||
{
|
||||
DateTime = request.DateTime.ToUniversalTime(),
|
||||
Distance = request.Distance,
|
||||
Amount = request.Amount,
|
||||
IgnoreInCalculation = request.IgnoreInCalculation,
|
||||
CarId = new CarId(request.CarId)
|
||||
};
|
||||
|
||||
dbContext.Consumptions.Add(consumption);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogTrace("Created new consumption: {@Consumption}", consumption);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Consumptions;
|
||||
@@ -21,7 +22,10 @@ public static class DeleteConsumption
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await dbContext.Consumptions
|
||||
Activity? activity = Activity.Current;
|
||||
activity?.SetTag("id", id);
|
||||
|
||||
int rows = await dbContext.Consumptions
|
||||
.Where(x => x.Id == new ConsumptionId(id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
@@ -32,7 +36,7 @@ public static class DeleteConsumption
|
||||
|
||||
if (rows > 1)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(nameof(DeleteConsumption));
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteConsumption));
|
||||
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{ConsumptionId}'", rows, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -28,8 +28,12 @@ public static class GetConsumption
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
|
||||
consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value);
|
||||
Response response = new(
|
||||
consumption.Id.Value,
|
||||
consumption.DateTime,
|
||||
consumption.Distance,
|
||||
consumption.Amount,
|
||||
consumption.CarId.Value);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Cars;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Consumptions;
|
||||
@@ -17,8 +19,18 @@ public static class GetConsumptions
|
||||
DateTimeOffset DateTime,
|
||||
double Distance,
|
||||
double Amount,
|
||||
bool IgnoreInCalculation,
|
||||
Guid CarId);
|
||||
CarDto Car,
|
||||
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
|
||||
{
|
||||
@@ -38,16 +50,52 @@ public static class GetConsumptions
|
||||
private static async Task<Ok<ApiResponse>> Endpoint(
|
||||
[AsParameters] Request request,
|
||||
ApplicationDbContext dbContext,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ResponseDto> consumptions = await dbContext.Consumptions
|
||||
.Select(x =>
|
||||
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(GetConsumptions));
|
||||
|
||||
logger.LogTrace("Received request to get consumptions with parameters: {@Request}", request);
|
||||
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()
|
||||
{
|
||||
Consumptions = consumptions
|
||||
Consumptions = responses
|
||||
};
|
||||
return TypedResults.Ok(apiResponse);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -26,8 +26,13 @@ public static class UpdateConsumption
|
||||
{
|
||||
public Validator(TimeProvider timeProvider)
|
||||
{
|
||||
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
|
||||
.Date
|
||||
.AddDays(1)
|
||||
.AddTicks(-1);
|
||||
|
||||
RuleFor(x => x.DateTime.ToUniversalTime())
|
||||
.LessThanOrEqualTo(timeProvider.GetUtcNow())
|
||||
.LessThanOrEqualTo(_ => getTodayEndOfDay())
|
||||
.WithName(nameof(Request.DateTime));
|
||||
|
||||
RuleFor(x => x.Distance)
|
||||
@@ -43,11 +48,25 @@ public static class UpdateConsumption
|
||||
Guid id,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateConsumption));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -60,10 +79,12 @@ public static class UpdateConsumption
|
||||
consumption.DateTime = request.DateTime.ToUniversalTime();
|
||||
consumption.Distance = request.Distance;
|
||||
consumption.Amount = request.Amount;
|
||||
consumption.IgnoreInCalculation = request.IgnoreInCalculation;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,6 @@ public static class EndpointExtensions
|
||||
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
|
||||
|
||||
GetServerInfo.MapEndpoint(versionedApis);
|
||||
GetCurrentTime.MapEndpoint(versionedApis);
|
||||
}
|
||||
}
|
||||
|
||||
21
src/Vegasco.Server.Api/Info/GetCurrentTime.cs
Normal file
21
src/Vegasco.Server.Api/Info/GetCurrentTime.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Vegasco.Server.Api.Info;
|
||||
|
||||
public class GetServerInfo
|
||||
public static class GetServerInfo
|
||||
{
|
||||
public record Response(
|
||||
string FullVersion,
|
||||
|
||||
@@ -11,12 +11,12 @@ public class ApplyMigrationsService(
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = activitySource.StartActivity("ApplyMigrations");
|
||||
using Activity? activity = activitySource.StartActivity("ApplyMigrations");
|
||||
|
||||
logger.LogInformation("Starting migrations");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
117
src/Vegasco.Server.Api/Persistence/Migrations/20250622085121_DropIgnoreInCalculation.Designer.cs
generated
Normal file
117
src/Vegasco.Server.Api/Persistence/Migrations/20250622085121_DropIgnoreInCalculation.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
@@ -18,7 +17,7 @@ namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.8")
|
||||
.HasAnnotation("ProductVersion", "9.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -61,9 +60,6 @@ namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
b.Property<double>("Distance")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IgnoreInCalculation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CarId");
|
||||
|
||||
@@ -13,23 +13,24 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Http" 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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</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="OpenTelemetry" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" 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.Templates" Version="1.0.0-beta08" />
|
||||
</ItemGroup>
|
||||
@@ -40,7 +41,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
85
src/Vegasco.Server.Api/migrations/migration.sql
Normal file
85
src/Vegasco.Server.Api/migrations/migration.sql
Normal 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;
|
||||
|
||||
@@ -4,7 +4,7 @@ public static class Constants
|
||||
{
|
||||
public static class Projects
|
||||
{
|
||||
public const string Api = "Vegasco-Server-Api";
|
||||
public const string Api = "Api";
|
||||
}
|
||||
|
||||
public static class Database
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Vegasco.Server.AppHost.Shared;
|
||||
|
||||
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)
|
||||
.WithDataVolume()
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithImageTag("latest");
|
||||
|
||||
IResourceBuilder<PostgresDatabaseResource> postgres = postgresBuilder
|
||||
.AddDatabase(Constants.Database.Name);
|
||||
|
||||
IResourceBuilder<ProjectResource> api = builder
|
||||
.AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api)
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithReference(seq)
|
||||
.WaitFor(seq);
|
||||
|
||||
builder
|
||||
.AddNpmApp("Vegasco-Web", "../Vegasco-Web")
|
||||
.WithReference(api)
|
||||
.WaitFor(api)
|
||||
.WithHttpEndpoint(port: 44200, env: "PORT", isProxied: false)
|
||||
.WithHttpEndpoint(port: 44200, env: "PORT")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithHttpHealthCheck("/", 200);
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
|
||||
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.3.1" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.3.0" />
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.5.1" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Aspire.Hosting.Seq" Version="9.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,4 +26,14 @@
|
||||
<ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
|
||||
</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>
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ namespace Vegasco.Server.Api.Tests.Integration;
|
||||
|
||||
internal class CarFaker
|
||||
{
|
||||
private readonly Faker _faker = new();
|
||||
|
||||
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()
|
||||
{
|
||||
return new UpdateCar.Request(_faker.Vehicle.Model());
|
||||
Faker faker = new();
|
||||
return new UpdateCar.Request(faker.Person.FirstName);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class CreateCarTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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());
|
||||
|
||||
_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()
|
||||
{
|
||||
// Arrange
|
||||
var createCarRequest = new CreateCar.Request("");
|
||||
CreateCar.Request createCarRequest = new CreateCar.Request("");
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
|
||||
// Assert
|
||||
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 =>
|
||||
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DeleteCarTests : IAsyncLifetime
|
||||
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
|
||||
@@ -43,7 +43,7 @@ public class DeleteCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");
|
||||
|
||||
@@ -21,7 +21,7 @@ public class GetCarTests : IAsyncLifetime
|
||||
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
|
||||
@@ -37,14 +37,14 @@ public class GetCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
|
||||
|
||||
// Assert
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class GetCarsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ public class GetCarsTests : IAsyncLifetime
|
||||
List<CreateCar.Response> createdCars = [];
|
||||
|
||||
const int numberOfCars = 5;
|
||||
for (var i = 0; i < numberOfCars; i++)
|
||||
for (int i = 0; i < numberOfCars; i++)
|
||||
{
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
createdCars.Add(createdCar!);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class GetCarsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
|
||||
|
||||
@@ -40,7 +40,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
|
||||
|
||||
@@ -57,16 +57,16 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
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
|
||||
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
|
||||
|
||||
// Assert
|
||||
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 =>
|
||||
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -80,7 +80,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
{
|
||||
// Arrange
|
||||
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest);
|
||||
|
||||
@@ -5,25 +5,22 @@ namespace Vegasco.Server.Api.Tests.Integration;
|
||||
|
||||
internal class ConsumptionFaker
|
||||
{
|
||||
private readonly Faker _faker = new();
|
||||
|
||||
internal CreateConsumption.Request CreateConsumptionRequest(Guid carId)
|
||||
{
|
||||
Faker faker = new();
|
||||
return new CreateConsumption.Request(
|
||||
_faker.Date.RecentOffset(),
|
||||
_faker.Random.Int(1, 1_000),
|
||||
_faker.Random.Int(20, 70),
|
||||
_faker.Random.Bool(),
|
||||
faker.Date.RecentOffset(),
|
||||
faker.Random.Int(1, 1_000),
|
||||
faker.Random.Int(20, 70),
|
||||
carId);
|
||||
}
|
||||
|
||||
internal UpdateConsumption.Request UpdateConsumptionRequest()
|
||||
{
|
||||
CreateConsumption.Request createRequest = CreateConsumptionRequest(default);
|
||||
CreateConsumption.Request createRequest = CreateConsumptionRequest(Guid.Empty);
|
||||
return new UpdateConsumption.Request(
|
||||
createRequest.DateTime,
|
||||
createRequest.Distance,
|
||||
createRequest.Amount,
|
||||
createRequest.IgnoreInCalculation);
|
||||
createRequest.Amount);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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());
|
||||
|
||||
_dbContext.Consumptions.Should().HaveCount(1)
|
||||
@@ -64,7 +64,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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 =>
|
||||
x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -76,7 +76,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
|
||||
public async Task DeleteConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var consumptionId = Guid.NewGuid();
|
||||
Guid consumptionId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
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);
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
return createdConsumption!;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public class GetConsumptionTests : IAsyncLifetime
|
||||
// Assert
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class GetConsumptionTests : IAsyncLifetime
|
||||
public async Task GetConsumptions_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var consumptionId = Guid.NewGuid();
|
||||
Guid consumptionId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
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);
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
return createdConsumption!;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class GetConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class GetConsumptionsTests : IAsyncLifetime
|
||||
// Arrange
|
||||
List<CreateConsumption.Response> createdConsumptions = [];
|
||||
const int numberOfConsumptions = 3;
|
||||
for (var i = 0; i < numberOfConsumptions; i++)
|
||||
for (int i = 0; i < numberOfConsumptions; i++)
|
||||
{
|
||||
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
|
||||
createdConsumptions.Add(createdConsumption);
|
||||
@@ -42,8 +42,16 @@ public class GetConsumptionsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
|
||||
apiResponse!.Consumptions.Should().BeEquivalentTo(createdConsumptions);
|
||||
GetConsumptions.ApiResponse? apiResponse =
|
||||
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]
|
||||
@@ -56,26 +64,32 @@ public class GetConsumptionsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task<CreateConsumption.Response> CreateConsumptionAsync()
|
||||
{
|
||||
CreateCar.Response createdCarResponse = await CreateCarAsync();
|
||||
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
CreateConsumption.Request createConsumptionRequest =
|
||||
_consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
|
||||
using HttpResponseMessage response =
|
||||
await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption =
|
||||
await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
return createdConsumption!;
|
||||
}
|
||||
|
||||
private async Task<CreateCar.Response> CreateCarAsync()
|
||||
{
|
||||
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();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse =
|
||||
await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
|
||||
// Assert
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
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());
|
||||
|
||||
_dbContext.Consumptions.Should().HaveCount(1)
|
||||
@@ -59,7 +59,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
|
||||
// Arrange
|
||||
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
|
||||
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest() with { Distance = -42 };
|
||||
var randomGuid = Guid.NewGuid();
|
||||
Guid randomGuid = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest);
|
||||
@@ -67,7 +67,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
|
||||
// Assert
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
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 =>
|
||||
x.Equals(nameof(updateConsumptionRequest.Distance), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -80,7 +80,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
|
||||
// Arrange
|
||||
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
|
||||
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest();
|
||||
var randomGuid = Guid.NewGuid();
|
||||
Guid randomGuid = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
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);
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
return createdConsumption!;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ public class UpdateConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public class GetServerInfoTests
|
||||
|
||||
// Assert
|
||||
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.CommitDate.Should().BeAfter(23.August(2024))
|
||||
.And.NotBeAfter(DateTime.Now);
|
||||
|
||||
@@ -19,7 +19,7 @@ internal sealed class PostgresRespawner : IDisposable
|
||||
DbConnection connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var respawner = await Respawner.CreateAsync(connection,
|
||||
Respawner respawner = await Respawner.CreateAsync(connection,
|
||||
new RespawnerOptions
|
||||
{
|
||||
SchemasToInclude = ["public"],
|
||||
|
||||
@@ -10,21 +10,21 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" Version="1.14.0" />
|
||||
<PackageReference Include="Bogus" Version="35.6.3" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageReference Include="Bogus" Version="35.6.4" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="[7.2.0,8.0.0)" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="Respawn" Version="6.2.1" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="9.0.5" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.5.0" />
|
||||
<PackageReference Include="System.Formats.Asn1" Version="9.0.10" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.7.0" />
|
||||
<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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -40,7 +40,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,10 +19,9 @@ public class CreateConsumptionRequestValidatorTests
|
||||
_sut = new CreateConsumption.Validator(_timeProvider);
|
||||
|
||||
_validRequest = new CreateConsumption.Request(
|
||||
_utcNow.AddDays(-1),
|
||||
_utcNow.Date.AddDays(1).AddTicks(-1),
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
Guid.NewGuid());
|
||||
}
|
||||
|
||||
@@ -39,10 +38,10 @@ public class CreateConsumptionRequestValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcNow()
|
||||
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcToday()
|
||||
{
|
||||
// Arrange
|
||||
CreateConsumption.Request request = _validRequest with { DateTime = _utcNow.AddDays(1) };
|
||||
CreateConsumption.Request request = _validRequest with { DateTime = _utcNow.Date.AddDays(1) };
|
||||
|
||||
// Act
|
||||
ValidationResult? result = await _sut.ValidateAsync(request);
|
||||
|
||||
@@ -20,10 +20,9 @@ public class UpdateConsumptionRequestValidatorTests
|
||||
_sut = new UpdateConsumption.Validator(_timeProvider);
|
||||
|
||||
_validRequest = new UpdateConsumption.Request(
|
||||
_utcNow.AddDays(-1),
|
||||
_utcNow.Date.AddDays(1).AddTicks(-1),
|
||||
1,
|
||||
1,
|
||||
false);
|
||||
1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -39,10 +38,10 @@ public class UpdateConsumptionRequestValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcNow()
|
||||
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcToday()
|
||||
{
|
||||
// Arrange
|
||||
UpdateConsumption.Request request = _validRequest with { DateTime = _utcNow.AddDays(1) };
|
||||
UpdateConsumption.Request request = _validRequest with { DateTime = _utcNow.Date.AddDays(1) };
|
||||
|
||||
// Act
|
||||
ValidationResult? result = await _sut.ValidateAsync(request);
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.7.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -34,7 +34,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<File Path="version.json" />
|
||||
</Folder>
|
||||
<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/Vegasco.Server.AppHost.csproj" />
|
||||
<Project Path="src/Vegasco.Server.ServiceDefaults/Vegasco.Server.ServiceDefaults.csproj" />
|
||||
<Project Path="src/Vegasco.Server.Api/Vegasco.Server.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Vegasco.Server.Api.Tests.Integration/Vegasco.Server.Api.Tests.Integration.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user