From 73d0fd5fa26e6f41f43044d2cfd7ebdce65d5e3e Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 01:56:31 +0200 Subject: [PATCH 1/9] Fix something I don't understand why I can't just import the method wtf --- API/Data/Repositories/ProductRepository.cs | 17 ++++++++--------- .../QueryableExtensions.cs | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) rename API/{Entities => Extensions}/QueryableExtensions.cs (91%) diff --git a/API/Data/Repositories/ProductRepository.cs b/API/Data/Repositories/ProductRepository.cs index 6433ff8..32a1ffc 100644 --- a/API/Data/Repositories/ProductRepository.cs +++ b/API/Data/Repositories/ProductRepository.cs @@ -1,6 +1,5 @@ using API.DTOs; using API.Entities; -using API.Entities.Enums; using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -71,31 +70,31 @@ public async Task> GetDtoByIds(IEnumerable ids) public async Task> GetAll(bool onlyEnabled = false) { - return await ctx.Products - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.Products, onlyEnabled, p => p.Enabled) .ToListAsync(); } public async Task> GetAllDto(bool onlyEnabled = false) { - return await ctx.Products - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.Products, onlyEnabled, p => p.Enabled) .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(); } public async Task> GetAllCategories(bool onlyEnabled = false) { - return await ctx.ProductCategories - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled) .OrderBy(p => p.SortValue) .ToListAsync(); } public async Task> GetAllCategoriesDtos(bool onlyEnabled = false) { - return await ctx.ProductCategories - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled) .OrderBy(p => p.SortValue) .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Entities/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs similarity index 91% rename from API/Entities/QueryableExtensions.cs rename to API/Extensions/QueryableExtensions.cs index fc01b98..67ec950 100644 --- a/API/Entities/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace API.Entities; +namespace API.Extensions; public static class QueryableExtensions { From a26af1dd32510bf76f5a25e18961112003bce8d6 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 02:09:18 +0200 Subject: [PATCH 2/9] Simple telemetry base --- API/API.csproj | 5 +++ API/Helpers/Telemetry/OperationTracker.cs | 30 +++++++++++++++ API/Helpers/Telemetry/TelemetryHelper.cs | 46 +++++++++++++++++++++++ API/Services/StockService.cs | 14 +++++-- API/Startup.cs | 12 ++++++ In-Out.sln.DotSettings.user | 4 ++ 6 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 API/Helpers/Telemetry/OperationTracker.cs create mode 100644 API/Helpers/Telemetry/TelemetryHelper.cs diff --git a/API/API.csproj b/API/API.csproj index 2e0fe3b..78ef9d5 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -25,6 +25,10 @@ + + + + @@ -36,6 +40,7 @@ + diff --git a/API/Helpers/Telemetry/OperationTracker.cs b/API/Helpers/Telemetry/OperationTracker.cs new file mode 100644 index 0000000..71c3af4 --- /dev/null +++ b/API/Helpers/Telemetry/OperationTracker.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace API.Helpers.Telemetry; + +public class OperationTracker: IDisposable +{ + private readonly Stopwatch _stopwatch; + private readonly Histogram _histogram; + private readonly Dictionary _tags; + private bool _disposed; + + internal OperationTracker(Histogram histogram, string operationName, Dictionary? tags) + { + _histogram = histogram; + _tags = tags ?? []; + _stopwatch = Stopwatch.StartNew(); + + _tags["operation"] = operationName; + } + + public void Dispose() + { + if (_disposed) return; + + _stopwatch.Stop(); + _histogram.Record(_stopwatch.Elapsed.TotalMilliseconds, _tags.ToArray()); + _disposed = true; + } +} \ No newline at end of file diff --git a/API/Helpers/Telemetry/TelemetryHelper.cs b/API/Helpers/Telemetry/TelemetryHelper.cs new file mode 100644 index 0000000..31d7074 --- /dev/null +++ b/API/Helpers/Telemetry/TelemetryHelper.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Flurl.Util; + +namespace API.Helpers.Telemetry; + +public static class TelemetryHelper +{ + + private static readonly ActivitySource ActivitySource; + private static readonly Meter Meter; + + private static readonly Histogram MethodTiming; + private static readonly Counter InvalidOperationCounts; + + static TelemetryHelper() + { + var serviceName = BuildInfo.AppName; + + ActivitySource = new ActivitySource(serviceName); + Meter = new Meter(serviceName); + + MethodTiming = Meter.CreateHistogram( + "method_timing_duration_ms", + "ms", + "Duration of specific method, see method tag" + ); + + InvalidOperationCounts = Meter.CreateCounter( + "invalid_operations_counter", + "absolute", + "Amount of invalid operations performed by users"); + } + + public static OperationTracker TrackOperation(string operationName, Dictionary? tags = null) + { + return new OperationTracker(MethodTiming, operationName, tags); + } + + public static void InvalidOperation(string operationName, Dictionary? tags = null) + { + tags ??= []; + tags.Add("operation", operationName); + InvalidOperationCounts.Add(1, tags.ToArray()); + } +} \ No newline at end of file diff --git a/API/Services/StockService.cs b/API/Services/StockService.cs index 5dd10f2..52df099 100644 --- a/API/Services/StockService.cs +++ b/API/Services/StockService.cs @@ -3,8 +3,8 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Helpers; +using API.Helpers.Telemetry; namespace API.Services; @@ -25,12 +25,18 @@ public class StockService(ILogger logger, IUnitOfWork unitOfWork, public async Task>> UpdateStockBulkAsync(User user, IList dtos) { - if (dtos == null || !dtos.Any()) + dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList(); + + if (!dtos.Any()) { return Result>.Failure(await localization.Translate(user.Id, "stock-bulk-empty-list")); } - - dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList(); + + using var tracker = TelemetryHelper.TrackOperation("bulk_stock_update", new Dictionary + { + ["user_id"] = user.Id, + ["update_operations"] = dtos.Count, + }); return await unitOfWork.ExecuteWithRetryAsync(async () => { diff --git a/API/Startup.cs b/API/Startup.cs index 485289f..df22b53 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -10,6 +10,8 @@ using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using Serilog; using Serilog.Events; @@ -85,6 +87,15 @@ public void ConfigureServices(IServiceCollection services) }); services.AddResponseCaching(); // TODO: Rate limitter + + services.AddOpenTelemetry() + .ConfigureResource(src => src + .AddService(BuildInfo.AppName)) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddMeter(BuildInfo.AppName) + .AddPrometheusExporter()); } public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime) @@ -164,6 +175,7 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, //endpoints.MapHub("hubs/messages"); //endpoints.MapHub("hubs/logs"); endpoints.MapFallbackToController("Index", "Fallback"); + endpoints.MapPrometheusScrapingEndpoint(); }); applicationLifetime.ApplicationStarted.Register(() => diff --git a/In-Out.sln.DotSettings.user b/In-Out.sln.DotSettings.user index f8ae1c9..35e7817 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -1,5 +1,9 @@  + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="/Users/amelia/.nuget/packages/testcontainers/4.6.0/lib/net9.0/Testcontainers.dll" /> From befbd36eaaf49c0858474e8c7fd4ba15b420d8ef Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 02:13:41 +0200 Subject: [PATCH 3/9] Track some more methods --- API/Services/DeliveryService.cs | 17 +++++++++++++++++ API/Services/LocalizationService.cs | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/API/Services/DeliveryService.cs b/API/Services/DeliveryService.cs index 222afc7..82247c9 100644 --- a/API/Services/DeliveryService.cs +++ b/API/Services/DeliveryService.cs @@ -7,6 +7,7 @@ using API.Entities.Enums; using API.Exceptions; using API.Extensions; +using API.Helpers.Telemetry; namespace API.Services; @@ -31,6 +32,11 @@ public async Task CreateDelivery(int userId, DeliveryDto dto) var client = await unitOfWork.ClientRepository.GetClientById(dto.ClientId); if (client == null) throw new InOutException("errors.client-not-found"); + + using var tracker = TelemetryHelper.TrackOperation("create_client", new Dictionary + { + ["user_id"] = userId, + }); var lines = dto.Lines.GroupBy(l => l.ProductId) .Select(g => new DeliveryLineDto @@ -95,6 +101,11 @@ public async Task UpdateDelivery(ClaimsPrincipal actor, DeliveryDto dt var user = await userService.GetUser(actor); if (delivery.UserId != user.Id && !actor.IsInRole(PolicyConstants.CreateForOthers)) throw new UnauthorizedAccessException(); + + using var tracker = TelemetryHelper.TrackOperation("update_client", new Dictionary + { + ["user_id"] = user.Id, + }); delivery.Message = dto.Message; @@ -205,6 +216,12 @@ public async Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, Deli throw new InOutException("errors.invalid-next-state"); delivery.State = nextState; + + if (delivery.State == DeliveryState.Cancelled) + { + // TODO: Refund stock + } + await unitOfWork.CommitAsync(); } diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 581d169..cc78737 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using API.Data; +using API.Helpers.Telemetry; using Microsoft.Extensions.Caching.Memory; namespace API.Services; @@ -78,6 +79,11 @@ public LocalizationService(ILogger logger, IDirectoryServic public async Task?> LoadLanguage(string languageCode) { + using var tracker = TelemetryHelper.TrackOperation("load_language", new Dictionary + { + ["language"] = languageCode, + }); + if (string.IsNullOrWhiteSpace(languageCode)) { languageCode = DefaultLocale; @@ -105,6 +111,12 @@ public LocalizationService(ILogger logger, IDirectoryServic public async Task Get(string locale, string key, params object[] args) { + using var tracker = TelemetryHelper.TrackOperation("get_translations", new Dictionary + { + ["locale"] = locale, + ["key"] = key, + }); + var cacheKey = $"{locale}_{key}"; if (!_memoryCache.TryGetValue(cacheKey, out string? translatedString)) { From 99177366b31fb64c8d523a2b97d7ac4a36bb4c51 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 09:21:08 +0200 Subject: [PATCH 4/9] Message field & client in title --- UI/Web/public/assets/i18n/en.json | 7 +++++-- .../manage-delivery/manage-delivery.component.html | 12 +++++++++++- .../app/manage-delivery/manage-delivery.component.ts | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index f16214c..8402a59 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -61,7 +61,8 @@ "manage-delivery": { "loading": "Loading...", "new-delivery": "New Delivery", - "edit-delivery-with-id": "Edit Delivery #{{id}}", + "edit-delivery-with-id": "Edit Delivery #{{id}} to {{name}}", + "unknown": "unknown", "items-selected": "{{count}} items selected", "save-delivery": "Save Delivery", "save": "{{common.save}}", @@ -71,7 +72,9 @@ "from": "From", "success": "Successfully updated your delivery", "failed": "Failed to submit your delivery, check your input", - "delivery-locked": "This delivery cannot be changed in its current state" + "delivery-locked": "This delivery cannot be changed in its current state", + "message-label": "Message", + "message-tooltip": "An optional message, as extra information" }, "filter": { diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.html b/UI/Web/src/app/manage-delivery/manage-delivery.component.html index 73536f5..54e96f3 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.html +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.html @@ -16,7 +16,7 @@

@if (delivery().id === -1) { {{ t('new-delivery') }} } @else { - {{ t('edit-delivery-with-id', { id: delivery().id }) }} + {{ t('edit-delivery-with-id', { id: delivery().id, name: selectedClient()?.name ?? t('unknown') }) }} }

@if (totalItems() > 0) { @@ -169,6 +169,16 @@
} } + +
+ @if (deliveryForm.get('message'); as formControl) { + + + + + + } +
diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts index 06ae610..26ed3ff 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts @@ -20,10 +20,11 @@ import { } from '../browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; import {ModalService} from '../_services/modal.service'; +import {SettingsItemComponent} from '../shared/components/settings-item/settings-item.component'; @Component({ selector: 'app-manage-delivery', - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, TypeaheadComponent, RouterLink], + imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, TypeaheadComponent, RouterLink, SettingsItemComponent], templateUrl: './manage-delivery.component.html', styleUrl: './manage-delivery.component.scss', changeDetection: ChangeDetectionStrategy.OnPush From 0bc8714dcb74664575d6198a8c0785074417af7a Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 09:38:20 +0200 Subject: [PATCH 5/9] Postgres, api key for metrics --- API/API.csproj | 1 + API/Startup.cs | 23 +++++++++++++++++++++++ In-Out.sln.DotSettings.user | 3 +++ 3 files changed, 27 insertions(+) diff --git a/API/API.csproj b/API/API.csproj index 78ef9d5..7f730cb 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -25,6 +25,7 @@ + diff --git a/API/Startup.cs b/API/Startup.cs index df22b53..b293e3b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -8,8 +8,10 @@ using API.Middleware; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; +using Npgsql; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using Serilog; @@ -94,6 +96,7 @@ public void ConfigureServices(IServiceCollection services) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() .AddMeter(BuildInfo.AppName) .AddPrometheusExporter()); } @@ -169,6 +172,26 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, ctx.Context.Response.Headers["X-Robots-Tag"] = "noindex,nofollow"; } }); + + + + var apiKey = cfg.GetValue("ApiKey"); + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/metrics")) + { + if (!ctx.Request.Query.TryGetValue("api-key", out var key) || + key != apiKey || string.IsNullOrWhiteSpace(apiKey)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + await ctx.Response.WriteAsync("Unauthorized"); + return; + } + } + + await next(); + }); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/In-Out.sln.DotSettings.user b/In-Out.sln.DotSettings.user index 35e7817..71401df 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -1,9 +1,12 @@  ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="/Users/amelia/.nuget/packages/testcontainers/4.6.0/lib/net9.0/Testcontainers.dll" /> From 6a3858900dada6c0cec05e0a46094d330ffb5e77 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 16 Aug 2025 09:39:32 +0200 Subject: [PATCH 6/9] Update appsettings template --- API/config/appsettings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 10f68b8..e04ad28 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "OpenIdConnect": { + "Authority": "", + "ClientId": "" + }, + "ConnectionStrings": { + "Postgres": "" + }, + "ApiKey": "" } From d01a1fbb3456b69d65fd7211e951a42c5fa49293 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 17 Aug 2025 15:12:05 +0200 Subject: [PATCH 7/9] Defaults values, nicer table sizes --- UI/Web/public/assets/i18n/en.json | 1 + .../browse-deliveries.component.html | 24 +++++++++++++------ .../client-modal/client-modal.component.html | 14 +++++------ .../client-modal/client-modal.component.ts | 4 +++- .../management-clients.component.html | 10 +++++--- .../management-clients.component.ts | 2 ++ UI/Web/src/theme/components/table.scss | 6 +++++ 7 files changed, 43 insertions(+), 18 deletions(-) diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index f16214c..620a10a 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -89,6 +89,7 @@ "from": "From", "to": "Recipient", "state": "Delivery state", + "size": "Size", "created": "Created on", "actions": "Actions", "transition": "Transition" diff --git a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html index c29ea71..18a8810 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -12,6 +12,7 @@ {{t('from')}} {{t('to')}} {{t('state')}} + {{t('size')}} {{t('created')}} {{t('actions')}} @@ -23,16 +24,25 @@ {{delivery.state | deliveryState}} + + {{delivery.lines.length}} + {{ delivery.createdUtc ?? '—' | utcToLocalTime:'shortDate' }} - + + +
+ - + + + - - - + +
diff --git a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html index 86903a5..e000fad 100644 --- a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html +++ b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html @@ -22,7 +22,7 @@ @if (clientForm.get('name'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -35,7 +35,7 @@ @if (clientForm.get('address'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -48,7 +48,7 @@ @if (clientForm.get('companyNumber'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -61,7 +61,7 @@ @if (clientForm.get('contactEmail'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -74,7 +74,7 @@ @if (clientForm.get('contactName'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -87,7 +87,7 @@ @if (clientForm.get('contactNumber'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -100,7 +100,7 @@ @if (clientForm.get('invoiceEmail'); as control) { - {{control.value}} + {{control.value | defaultValue}} diff --git a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts index ad3210a..916dfc4 100644 --- a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts +++ b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts @@ -6,6 +6,7 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} fr import {ModalDismissReasons, NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ClientService} from '../../../../_services/client.service'; import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; +import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; @Component({ selector: 'app-client-modal', @@ -14,7 +15,8 @@ import {SettingsItemComponent} from '../../../../shared/components/settings-item LoadingSpinnerComponent, FormsModule, ReactiveFormsModule, - SettingsItemComponent + SettingsItemComponent, + DefaultValuePipe ], templateUrl: './client-modal.component.html', styleUrl: './client-modal.component.scss', diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.html b/UI/Web/src/app/management/management-clients/management-clients.component.html index 87e5f52..3e05c6a 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.html +++ b/UI/Web/src/app/management/management-clients/management-clients.component.html @@ -67,13 +67,17 @@

{{t('no-clients.title')}}

{{item.name}} - {{item.companyNumber}} + {{item.companyNumber | defaultValue}} - {{item.contactEmail}} + @if (item.contactEmail) { + {{item.contactEmail}} + } @else { + {{null | defaultValue}} + } - {{item.contactNumber}} + {{item.contactNumber | defaultValue}} +
+ + + + + + diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.scss b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts new file mode 100644 index 0000000..3dfe792 --- /dev/null +++ b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts @@ -0,0 +1,224 @@ +import {ChangeDetectionStrategy, Component, computed, inject, signal} from '@angular/core'; +import {LoadingSpinnerComponent} from '../../../../shared/components/loading-spinner/loading-spinner.component'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {ClientService} from '../../../../_services/client.service'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {FormArray, FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {FileUploadComponent, FileUploadValidators} from '@iplab/ngx-file-upload'; +import {map} from 'rxjs'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {ToastrService} from 'ngx-toastr'; +import Papa from 'papaparse'; +import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; +import {ClientFieldPipe} from '../../../../_pipes/client-field-pipe'; +import {Client} from '../../../../_models/client'; +import {TableComponent} from '../../../../shared/components/table/table.component'; +import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; +import {ClientsTableComponent} from '../clients-table/clients-table.component'; + +enum StageId { + FileImport = 'file-import', + HeaderMatch = 'header-match', + Confirm = 'confirm', +} + +export enum ClientField { + Name = 0, + Address = 1, + CompanyNumber = 2, + InvoiceEmail = 3, + ContactName = 4, + ContactEmail = 5, + ContactNumber = 6, +} + +const fields: ClientField[] = [ClientField.Name, ClientField.Address, ClientField.CompanyNumber, +ClientField.InvoiceEmail, ClientField.ContactName, ClientField.ContactEmail, ClientField.ContactNumber]; + +type HeaderMappingControl = FormGroup<{ + header: FormControl, + field: FormControl, + index: FormControl +}>; + +@Component({ + selector: 'app-import-client-modal', + imports: [ + LoadingSpinnerComponent, + TranslocoDirective, + ReactiveFormsModule, + FileUploadComponent, + SettingsItemComponent, + ClientFieldPipe, + TableComponent, + DefaultValuePipe, + ClientsTableComponent, + ], + templateUrl: './import-client-modal.component.html', + styleUrl: './import-client-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ImportClientModalComponent { + + private readonly clientService = inject(ClientService); + protected readonly modal = inject(NgbActiveModal); + private readonly toastr = inject(ToastrService); + private readonly fb = inject(NonNullableFormBuilder); + + fileUploadControl = new FormControl>(undefined, [ + FileUploadValidators.accept(['.csv']), FileUploadValidators.filesLimit(1) + ]); + + uploadForm = new FormGroup({ + files: this.fileUploadControl, + }); + + headerMatchForm: FormGroup<{headers: FormArray}> = new FormGroup({ + headers: new FormArray([]) + }); + + isSaving = signal(false); + currentStage = signal(StageId.FileImport); + // CSV data without header + data = signal([]); + clients = signal([]); + + isFileSelected = toSignal(this.uploadForm.get('files')!.valueChanges + .pipe(map((files) => !!files && files.length == 1)), {initialValue: false}); + + buttonLabel = computed(() => { + switch (this.currentStage()) { + case StageId.FileImport: + return translate('import-client-modal.next'); + case StageId.HeaderMatch: + return translate('import-client-modal.next'); + case StageId.Confirm : + return translate('import-client-modal.import'); + } + }); + canMoveToNext = computed(() => { + switch (this.currentStage()) { + case StageId.FileImport: + return this.isFileSelected(); + case StageId.HeaderMatch: + return this.headerMatchForm.valid; + case StageId.Confirm : + return true; + } + }); + + get headerArray(): FormArray { + return this.headerMatchForm.get('headers')! as FormArray; + } + + async nextStep() { + switch (this.currentStage()) { + case StageId.FileImport: + await this.handleFileImport(); + break; + case StageId.HeaderMatch: + await this.constructClients(); + break; + case StageId.Confirm: + this.import(); + break; + } + } + + private async handleFileImport() { + const files = this.fileUploadControl.value; + if (!files || files.length === 0) { + this.toastr.error(translate('import-client-modal.select-files-warning')); + return; + } + + const file = files[0]; + const text = await file.text(); + const res = Papa.parse(text, {header: false, delimiter: ','}); + if (res.errors.length > 0) { + console.log(res); + this.toastr.error(translate('import-client-modal.parse-error')); + return; + } + + const data = res.data; + if (data.length < 2) { + this.toastr.error(translate('import-client-modal.header-only-or-none')); + return; + } + + const headers = data[0]; + this.headerArray.setValue([]); + + headers.forEach((header, idx) => { + this.headerArray.push(this.fb.group({ + header: this.fb.control(header), + field: this.fb.control(fields[idx % fields.length]), + index: this.fb.control(idx), + })); + }); + + this.data.set(res.data.slice(1).filter(d => d.length === headers.length)); + this.currentStage.set(StageId.HeaderMatch); + } + + private async constructClients() { + const mappings = this.headerArray.controls.map(c => c.value); + + const clients: Client[] = []; + + for (const dataRow of this.data()) { + + const client: Partial = {}; + + + for (const field of fields) { + const mapping = mappings.find(mapping => mapping.field === field); + const value = (mapping !== undefined && mapping.index !== undefined) ? dataRow[mapping.index] : ''; + + switch (field) { + case ClientField.Name: + client.name = value; + break; + case ClientField.Address: + client.address = value; + break; + case ClientField.ContactEmail: + client.contactEmail = value; + break; + case ClientField.CompanyNumber: + client.companyNumber = value; + break; + case ClientField.InvoiceEmail: + client.invoiceEmail = value; + break; + case ClientField.ContactNumber: + client.contactNumber = value; + break; + case ClientField.ContactName: + client.contactName = value; + break; + } + } + + clients.push(client as Client); + } + + this.clients.set(clients); + this.currentStage.set(StageId.Confirm); + } + + private import() { + this.clientService.createBulk(this.clients()).subscribe({ + next: () => this.close(), + }); + } + + close() { + this.modal.close(); + } + + protected readonly StageId = StageId; + protected readonly fields = fields; + protected readonly JSON = JSON; +} diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.html b/UI/Web/src/app/management/management-clients/management-clients.component.html index 3e05c6a..f0808e7 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.html +++ b/UI/Web/src/app/management/management-clients/management-clients.component.html @@ -5,7 +5,8 @@

{{t('title')}}

@@ -42,43 +43,8 @@

{{t('no-clients.title')}}

} @else {
- - - - - {{t('header.name')}} - - - {{t('header.companyNumber')}} - - - {{t('header.contactEmail')}} - - - {{t('header.contactNumber')}} - - - {{t('header.actions')}} - - - - - - {{item.name}} - - - {{item.companyNumber | defaultValue}} - - - @if (item.contactEmail) { - {{item.contactEmail}} - } @else { - {{null | defaultValue}} - } - - - {{item.contactNumber | defaultValue}} - + +
- + } diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.ts b/UI/Web/src/app/management/management-clients/management-clients.component.ts index 2f76342..add2a3a 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.ts +++ b/UI/Web/src/app/management/management-clients/management-clients.component.ts @@ -8,6 +8,8 @@ import {TableComponent} from '../../shared/components/table/table.component'; import {ClientModalComponent} from './_components/client-modal/client-modal.component'; import {DefaultModalOptions} from '../../_models/default-modal-options'; import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; +import {ImportClientModalComponent} from './_components/import-client-modal/import-client-modal.component'; +import {ClientsTableComponent} from './_components/clients-table/clients-table.component'; @Component({ selector: 'app-management-clients', @@ -16,6 +18,7 @@ import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; LoadingSpinnerComponent, TableComponent, DefaultValuePipe, + ClientsTableComponent, ], templateUrl: './management-clients.component.html', styleUrl: './management-clients.component.scss', @@ -47,13 +50,19 @@ export class ManagementClientsComponent implements OnInit { return `${client.id}` } + importClients() { + const [modal, component] = this.modalService.open(ImportClientModalComponent, DefaultModalOptions); + + modal.closed.subscribe(() => this.loadClients()); + } + createOrUpdateClient(client?: Client) { const [modal, component] = this.modalService.open(ClientModalComponent, DefaultModalOptions); if (client) { component.client.set(client); } - modal.closed.subscribe(() => this.loadClients()) + modal.closed.subscribe(() => this.loadClients()); } async deleteClient(client: Client) { From f46043ebb49dbc9f76bb66b3f0fb7184c333acc8 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:45:07 +0200 Subject: [PATCH 9/9] Allow env var for the db connection string --- API/Extensions/ApplicationServiceExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index ab97e25..d197ba0 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -39,7 +39,8 @@ public static void AddApplicationServices(this IServiceCollection services, ICon private static void AddPostgres(this IServiceCollection services, IConfiguration configuration) { - var pgConnectionString = configuration.GetConnectionString("Postgres"); + var pgConnectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING") + ?? configuration.GetConnectionString("Postgres"); services.AddDbContextPool(options => {