From ede31fbb7efc9b83267e04db505d3f9f207b7e40 Mon Sep 17 00:00:00 2001 From: Stein Helge Riise Date: Mon, 17 Nov 2025 08:32:46 +0100 Subject: [PATCH] Initial import --- .gitignore | 346 ++++ README.md | 84 + backend/.config/dotnet-tools.json | 13 + backend/MinAttest.Api.sln | 144 ++ .../MinAttest.AppHost.csproj | 23 + backend/MinAttest.AppHost/Program.cs | 14 + .../Properties/launchSettings.json | 29 + .../appsettings.Development.json | 8 + backend/MinAttest.AppHost/appsettings.json | 9 + .../MinAttest.ServiceDefaults/Extensions.cs | 118 ++ .../MinAttest.ServiceDefaults.csproj | 22 + backend/NuGet.config | 28 + backend/README.md | 81 + .../Features/Employer/Employer.cs | 157 ++ .../MinAttest.Api/Features/Health/Health.cs | 27 + .../MinAttest.Api/Features/Persons/Persons.cs | 106 + .../Features/ShareLinks/ShareLinks.cs | 47 + .../src/MinAttest.Api/MinAttest.Api.csproj | 36 + backend/src/MinAttest.Api/MinAttest.Api.http | 75 + backend/src/MinAttest.Api/Program.cs | 132 ++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 39 + backend/src/MinAttest.Api/appsettings.json | 9 + .../Abstractions/IAppDbContext.cs | 16 + .../src/MinAttest.Application/Application.cs | 4 + .../Common/Behaviors/LoggingBehavior.cs | 31 + .../Common/Behaviors/ValidationBehavior.cs | 29 + .../Attests/Commands/CompleteUploadCommand.cs | 62 + .../CompleteUploadCommandValidator.cs | 23 + .../Commands/EmployerIssueAttestCommand.cs | 52 + .../EmployerIssueAttestCommandValidator.cs | 23 + .../Commands/PersonUploadAttestCommand.cs | 49 + .../PersonUploadAttestCommandValidator.cs | 22 + .../Attests/Queries/GetAttestByIdQuery.cs | 29 + .../Attests/Queries/GetAttestContentQuery.cs | 28 + .../Attests/Queries/GetAttestsQuery.cs | 37 + .../Queries/ListEmployerAttestsQuery.cs | 22 + .../Attests/Queries/ListPersonAttestsQuery.cs | 22 + .../Commands/DeleteEmployerUserCommand.cs | 20 + .../Commands/UpsertEmployerCommand.cs | 28 + .../UpsertEmployerCommandValidator.cs | 13 + .../Commands/UpsertEmployerUserCommand.cs | 55 + .../UpsertEmployerUserCommandValidator.cs | 16 + .../Employers/Queries/GetEmployerQuery.cs | 18 + .../Employers/Queries/GetEmployerUserQuery.cs | 19 + .../Queries/ListEmployerUsersQuery.cs | 21 + .../Employers/Queries/ListEmployersQuery.cs | 19 + .../Persons/Commands/UpsertPersonCommand.cs | 36 + .../Commands/UpsertPersonCommandValidator.cs | 13 + .../Persons/Queries/GetPersonQuery.cs | 37 + .../Commands/CreateShareLinkCommand.cs | 48 + .../Commands/RevokeShareLinkCommand.cs | 21 + .../ShareLinks/Queries/ListShareLinksQuery.cs | 20 + .../MinAttest.Application.csproj | 17 + .../Attests/AttestsContracts.cs | 33 + .../Attests/EmployerAttestUploadRequest.cs | 14 + .../Attests/PersonAttestUploadRequest.cs | 13 + .../Employers/EmployerUsersContracts.cs | 18 + .../Employers/EmployersContracts.cs | 12 + .../MinAttest.Contracts.csproj | 7 + .../Persons/PersonsContracts.cs | 17 + .../ShareLinks/ShareLinksContracts.cs | 5 + .../src/MinAttest.Domain/Entities/Attest.cs | 30 + .../src/MinAttest.Domain/Entities/AuditLog.cs | 13 + .../src/MinAttest.Domain/Entities/Employer.cs | 11 + .../MinAttest.Domain/Entities/EmployerUser.cs | 17 + .../src/MinAttest.Domain/Entities/Enums.cs | 21 + .../src/MinAttest.Domain/Entities/Person.cs | 12 + .../MinAttest.Domain/Entities/ShareLink.cs | 13 + .../MinAttest.Domain/MinAttest.Domain.csproj | 7 + .../Data/AppDbContext.cs | 143 ++ .../Data/AppDbContextFactory.cs | 31 + ...0250913095523_InitialSqlServer.Designer.cs | 249 +++ .../20250913095523_InitialSqlServer.cs | 172 ++ ...919_AddAttestVerificationCheck.Designer.cs | 252 +++ ...250913104919_AddAttestVerificationCheck.cs | 63 + ...913112309_EnableTemporalTables.Designer.cs | 336 ++++ .../20250913112309_EnableTemporalTables.cs | 187 ++ ...20250913122233_AddEmployerUser.Designer.cs | 406 ++++ .../20250913122233_AddEmployerUser.cs | 69 + ...124425_AddAttestContentColumns.Designer.cs | 416 ++++ .../20250913124425_AddAttestContentColumns.cs | 49 + .../Migrations/AppDbContextModelSnapshot.cs | 413 ++++ .../MinAttest.Infrastructure.csproj | 20 + .../MinAttest.Tests/Domain/ValidationTests.cs | 63 + .../Integration/Api/EmployersApiTests.cs | 61 + .../Integration/Api/PersonsApiTests.cs | 41 + .../Integration/Api/ShareLinksApiTests.cs | 56 + .../Integration/AttestsIntegrationTests.cs | 46 + .../EmployerUsersIntegrationTests.cs | 45 + .../EmployersAttestsIntegrationTests.cs | 48 + .../Integration/EmployersIntegrationTests.cs | 31 + .../Integration/IntegrationCollection.cs | 9 + .../Integration/IntegrationTestFixture.cs | 67 + .../Integration/Server/ApiWebAppFactory.cs | 22 + .../Integration/Server/TestDb.cs | 42 + .../Integration/ShareLinksIntegrationTests.cs | 52 + .../MinAttest.Tests/MinAttest.Tests.csproj | 30 + .../appsettings.Integration.json | 5 + .../minattest-app-host/MinAttest.AppHost.sln | 34 + .../OpenApi/OpenApiExtensions.cs | 16 + frontend/minattest-app-host/Program.cs | 56 + .../Properties/launchSettings.json | 15 + frontend/minattest-app-host/README.md | 26 + .../appsettings.Development.json | 42 + frontend/minattest-app-host/appsettings.json | 42 + .../minattest-app-host.csproj | 19 + frontend/minattest-app/README.md | 43 + frontend/minattest-app/checkDependencies.js | 52 + frontend/minattest-app/dist/index.html | 14 + frontend/minattest-app/index.html | 13 + frontend/minattest-app/package-lock.json | 1789 +++++++++++++++++ frontend/minattest-app/package.json | 22 + frontend/minattest-app/scripts/dev-https.ps1 | 35 + frontend/minattest-app/src/api.ts | 89 + frontend/minattest-app/src/main.tsx | 5 + frontend/minattest-app/src/styles.css | 120 ++ frontend/minattest-app/src/types.ts | 27 + frontend/minattest-app/src/ui/App.tsx | 110 + frontend/minattest-app/src/ui/DatePicker.tsx | 127 ++ .../minattest-app/src/ui/EmployerView.tsx | 254 +++ frontend/minattest-app/src/ui/HomeLanding.tsx | 58 + frontend/minattest-app/src/ui/LoginGate.tsx | 49 + frontend/minattest-app/src/ui/PersonView.tsx | 256 +++ frontend/minattest-app/src/utils.ts | 60 + frontend/minattest-app/tsconfig.json | 21 + frontend/minattest-app/tsconfig.node.json | 11 + frontend/minattest-app/tsconfig.tsbuildinfo | 1 + frontend/minattest-app/vite.config.ts | 21 + 129 files changed, 9514 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.config/dotnet-tools.json create mode 100644 backend/MinAttest.Api.sln create mode 100644 backend/MinAttest.AppHost/MinAttest.AppHost.csproj create mode 100644 backend/MinAttest.AppHost/Program.cs create mode 100644 backend/MinAttest.AppHost/Properties/launchSettings.json create mode 100644 backend/MinAttest.AppHost/appsettings.Development.json create mode 100644 backend/MinAttest.AppHost/appsettings.json create mode 100644 backend/MinAttest.ServiceDefaults/Extensions.cs create mode 100644 backend/MinAttest.ServiceDefaults/MinAttest.ServiceDefaults.csproj create mode 100644 backend/NuGet.config create mode 100644 backend/README.md create mode 100644 backend/src/MinAttest.Api/Features/Employer/Employer.cs create mode 100644 backend/src/MinAttest.Api/Features/Health/Health.cs create mode 100644 backend/src/MinAttest.Api/Features/Persons/Persons.cs create mode 100644 backend/src/MinAttest.Api/Features/ShareLinks/ShareLinks.cs create mode 100644 backend/src/MinAttest.Api/MinAttest.Api.csproj create mode 100644 backend/src/MinAttest.Api/MinAttest.Api.http create mode 100644 backend/src/MinAttest.Api/Program.cs create mode 100644 backend/src/MinAttest.Api/Properties/launchSettings.json create mode 100644 backend/src/MinAttest.Api/appsettings.Development.json create mode 100644 backend/src/MinAttest.Api/appsettings.json create mode 100644 backend/src/MinAttest.Application/Abstractions/IAppDbContext.cs create mode 100644 backend/src/MinAttest.Application/Application.cs create mode 100644 backend/src/MinAttest.Application/Common/Behaviors/LoggingBehavior.cs create mode 100644 backend/src/MinAttest.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestByIdQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestContentQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestsQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Queries/ListEmployerAttestsQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Attests/Queries/ListPersonAttestsQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Commands/DeleteEmployerUserCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerUserQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployerUsersQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployersQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommandValidator.cs create mode 100644 backend/src/MinAttest.Application/Features/Persons/Queries/GetPersonQuery.cs create mode 100644 backend/src/MinAttest.Application/Features/ShareLinks/Commands/CreateShareLinkCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/ShareLinks/Commands/RevokeShareLinkCommand.cs create mode 100644 backend/src/MinAttest.Application/Features/ShareLinks/Queries/ListShareLinksQuery.cs create mode 100644 backend/src/MinAttest.Application/MinAttest.Application.csproj create mode 100644 backend/src/MinAttest.Contracts/Attests/AttestsContracts.cs create mode 100644 backend/src/MinAttest.Contracts/Attests/EmployerAttestUploadRequest.cs create mode 100644 backend/src/MinAttest.Contracts/Attests/PersonAttestUploadRequest.cs create mode 100644 backend/src/MinAttest.Contracts/Employers/EmployerUsersContracts.cs create mode 100644 backend/src/MinAttest.Contracts/Employers/EmployersContracts.cs create mode 100644 backend/src/MinAttest.Contracts/MinAttest.Contracts.csproj create mode 100644 backend/src/MinAttest.Contracts/Persons/PersonsContracts.cs create mode 100644 backend/src/MinAttest.Contracts/ShareLinks/ShareLinksContracts.cs create mode 100644 backend/src/MinAttest.Domain/Entities/Attest.cs create mode 100644 backend/src/MinAttest.Domain/Entities/AuditLog.cs create mode 100644 backend/src/MinAttest.Domain/Entities/Employer.cs create mode 100644 backend/src/MinAttest.Domain/Entities/EmployerUser.cs create mode 100644 backend/src/MinAttest.Domain/Entities/Enums.cs create mode 100644 backend/src/MinAttest.Domain/Entities/Person.cs create mode 100644 backend/src/MinAttest.Domain/Entities/ShareLink.cs create mode 100644 backend/src/MinAttest.Domain/MinAttest.Domain.csproj create mode 100644 backend/src/MinAttest.Infrastructure/Data/AppDbContext.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/AppDbContextFactory.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.Designer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.Designer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.Designer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.Designer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.Designer.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.cs create mode 100644 backend/src/MinAttest.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj create mode 100644 backend/tests/MinAttest.Tests/Domain/ValidationTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/Api/EmployersApiTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/Api/PersonsApiTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/Api/ShareLinksApiTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/AttestsIntegrationTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/EmployerUsersIntegrationTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/EmployersAttestsIntegrationTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/EmployersIntegrationTests.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/IntegrationCollection.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/IntegrationTestFixture.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/Server/ApiWebAppFactory.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/Server/TestDb.cs create mode 100644 backend/tests/MinAttest.Tests/Integration/ShareLinksIntegrationTests.cs create mode 100644 backend/tests/MinAttest.Tests/MinAttest.Tests.csproj create mode 100644 backend/tests/MinAttest.Tests/appsettings.Integration.json create mode 100644 frontend/minattest-app-host/MinAttest.AppHost.sln create mode 100644 frontend/minattest-app-host/OpenApi/OpenApiExtensions.cs create mode 100644 frontend/minattest-app-host/Program.cs create mode 100644 frontend/minattest-app-host/Properties/launchSettings.json create mode 100644 frontend/minattest-app-host/README.md create mode 100644 frontend/minattest-app-host/appsettings.Development.json create mode 100644 frontend/minattest-app-host/appsettings.json create mode 100644 frontend/minattest-app-host/minattest-app-host.csproj create mode 100644 frontend/minattest-app/README.md create mode 100644 frontend/minattest-app/checkDependencies.js create mode 100644 frontend/minattest-app/dist/index.html create mode 100644 frontend/minattest-app/index.html create mode 100644 frontend/minattest-app/package-lock.json create mode 100644 frontend/minattest-app/package.json create mode 100644 frontend/minattest-app/scripts/dev-https.ps1 create mode 100644 frontend/minattest-app/src/api.ts create mode 100644 frontend/minattest-app/src/main.tsx create mode 100644 frontend/minattest-app/src/styles.css create mode 100644 frontend/minattest-app/src/types.ts create mode 100644 frontend/minattest-app/src/ui/App.tsx create mode 100644 frontend/minattest-app/src/ui/DatePicker.tsx create mode 100644 frontend/minattest-app/src/ui/EmployerView.tsx create mode 100644 frontend/minattest-app/src/ui/HomeLanding.tsx create mode 100644 frontend/minattest-app/src/ui/LoginGate.tsx create mode 100644 frontend/minattest-app/src/ui/PersonView.tsx create mode 100644 frontend/minattest-app/src/utils.ts create mode 100644 frontend/minattest-app/tsconfig.json create mode 100644 frontend/minattest-app/tsconfig.node.json create mode 100644 frontend/minattest-app/tsconfig.tsbuildinfo create mode 100644 frontend/minattest-app/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22b014f --- /dev/null +++ b/.gitignore @@ -0,0 +1,346 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Nx cache +.nx/cache/ + +# Environment files with secrets +**/.env + +# Ignore Entropy.bin file created by docker compose volume mount for sql database +Entropy.bin + +tools/ +.structurizr-cli/ +plantuml/ +structurizr-cli*.zip + +# Genererte mellomfiler +*.puml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..47af378 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# 📄 Produktdokumentasjon – MinAttest + +## 1. Hva er MinAttest? +MinAttest er en digital plattform for utstedelse, lagring, deling og verifisering av arbeidsattester. +Målet er å erstatte papir/PDF-attester med en **standardisert, sikker og brukervennlig løsning** som gir verdi både for arbeidsgivere, ansatte og rekrutterere. + +- For ansatte: trygg tilgang til attester hele livet. +- For arbeidsgivere: enkel, sikker og standardisert attesthåndtering. +- For rekrutterere: rask verifisering og innsikt i kandidatens arbeidshistorikk. + +--- + +## 2. Brukere i systemet + +### Privatperson (ansatt / tidligere ansatt) +- Logger inn med **BankID** (MVP) eller **ID-porten** (senere). +- Kan se, laste ned og dele sine attester. +- Kan laste opp tidligere attester (merkes som *ikke verifisert*). +- Får en samlet oversikt over arbeidserfaring og attester. + +### Bedrift (HR / leder) +- Logger inn med **Azure AD / Entra ID** (via B2C). +- Kan laste opp og utstede attester på ansatte (knyttet til fødselsnummer). +- Har oversikt over attester bedriften har utstedt. + +### Rekrutterer / tredjepart +- Får tilgang via en **verifiseringslenke** (delt av kandidaten). +- Kan validere at en attest faktisk er utstedt av en bedrift. +- Har ikke egen innlogging i første versjon. + +--- + +## 3. Hovedfunksjoner i første versjon (MVP) + +- **Innlogging** + - Privatpersoner: BankID. + - Bedrifter: Entra ID (federert via B2C). + +- **Attesthåndtering** + - Bedrifter kan laste opp attester for ansatte. + - Privatpersoner kan se egne attester. + - Mulighet for privatperson å laste opp tidligere attester (markeres *ikke verifisert*). + +- **Deling** + - Privatperson kan dele en attest via en unik verifiseringslenke. + +- **Grunnleggende sikkerhet** + - Fødselsnummer brukes for å koble attest til riktig person (lagres kryptert). + - Tilgangskontroll: + - Privatperson ser kun egne attester. + - Bedrift ser kun egne utstedelser. + - All data lagres kryptert og tilgjengeliggjøres kun via autentisert tilgang. + +--- + +## 4. Sikkerhetsmodell + +- **Autentisering:** BankID (privatperson), Entra ID (bedrifter). +- **Autorisasjon:** RBAC (rollebasert tilgang) i API. +- **Data:** + - Fødselsnummer krypteres i databasen. + - Attester lagres sikkert (Azure Blob Storage + SAS URL). +- **Deling:** Verifiseringslenker utstedes med tidsbegrenset token. +- **Logging og sporing:** Hvem har utstedt, åpnet eller delt en attest logges. + +--- + +## 5. Fremtidige features (roadmap) + +### Kortsiktig (V2–V3) +- Generere attester fra mal direkte i systemet. +- Signering med BankID/eSignering. +- Varsling (epost/SMS) når ny attest er tilgjengelig. +- Admin-dashboard med statistikk for bedrifter. + +### Langsiktig / visjonære features +- **Automatisk CV-generering** basert på attester (kronologisk arbeidshistorikk). +- **Integrert AI**: + - Generere jobbsøknadstekster basert på brukerens attester + CV. + - Foreslå relevante stillinger basert på historikk. +- **Jobb-annonsekobling**: + - Privatperson kan koble seg direkte på en stillingsannonse og bruke sine attester som grunnlag for søknad. +- **Integrasjon med HR-systemer** (Visma, SAP, Tripletex m.fl.) for automatisk generering av attester. +- **Internasjonalisering** – kunne brukes utenfor Norge (andre eID-løsninger). diff --git a/backend/.config/dotnet-tools.json b/backend/.config/dotnet-tools.json new file mode 100644 index 0000000..7cf5ad2 --- /dev/null +++ b/backend/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/MinAttest.Api.sln b/backend/MinAttest.Api.sln new file mode 100644 index 0000000..d177bb6 --- /dev/null +++ b/backend/MinAttest.Api.sln @@ -0,0 +1,144 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Api", "src\MinAttest.Api\MinAttest.Api.csproj", "{FD304824-536A-4282-8B28-18FD2939F2E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.AppHost", "MinAttest.AppHost\MinAttest.AppHost.csproj", "{8602EA83-F78D-4CD4-92CA-E539FC68E8E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.ServiceDefaults", "MinAttest.ServiceDefaults\MinAttest.ServiceDefaults.csproj", "{832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Domain", "src\MinAttest.Domain\MinAttest.Domain.csproj", "{87321FFD-8C90-4268-80EE-5767BE1E3B98}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Infrastructure", "src\MinAttest.Infrastructure\MinAttest.Infrastructure.csproj", "{90654289-0EC1-4BD9-82BA-C13FF5577705}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Application", "src\MinAttest.Application\MinAttest.Application.csproj", "{D455F132-39FE-4350-9C36-1B81A5F238C6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Contracts", "src\MinAttest.Contracts\MinAttest.Contracts.csproj", "{CBB5189D-C63A-4CEC-AF59-391C31E36985}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinAttest.Tests", "tests\MinAttest.Tests\MinAttest.Tests.csproj", "{DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|x64.Build.0 = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Debug|x86.Build.0 = Debug|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|Any CPU.Build.0 = Release|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|x64.ActiveCfg = Release|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|x64.Build.0 = Release|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|x86.ActiveCfg = Release|Any CPU + {FD304824-536A-4282-8B28-18FD2939F2E9}.Release|x86.Build.0 = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|x64.Build.0 = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Debug|x86.Build.0 = Debug|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|Any CPU.Build.0 = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|x64.ActiveCfg = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|x64.Build.0 = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|x86.ActiveCfg = Release|Any CPU + {8602EA83-F78D-4CD4-92CA-E539FC68E8E1}.Release|x86.Build.0 = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|x64.ActiveCfg = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|x64.Build.0 = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|x86.ActiveCfg = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Debug|x86.Build.0 = Debug|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|Any CPU.Build.0 = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|x64.ActiveCfg = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|x64.Build.0 = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|x86.ActiveCfg = Release|Any CPU + {832B2A1B-7957-4ED7-A838-9C8D0AE0FA88}.Release|x86.Build.0 = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|x64.ActiveCfg = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|x64.Build.0 = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|x86.ActiveCfg = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Debug|x86.Build.0 = Debug|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|Any CPU.Build.0 = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|x64.ActiveCfg = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|x64.Build.0 = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|x86.ActiveCfg = Release|Any CPU + {87321FFD-8C90-4268-80EE-5767BE1E3B98}.Release|x86.Build.0 = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|x64.ActiveCfg = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|x64.Build.0 = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|x86.ActiveCfg = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Debug|x86.Build.0 = Debug|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|Any CPU.Build.0 = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|x64.ActiveCfg = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|x64.Build.0 = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|x86.ActiveCfg = Release|Any CPU + {90654289-0EC1-4BD9-82BA-C13FF5577705}.Release|x86.Build.0 = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|x64.Build.0 = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Debug|x86.Build.0 = Debug|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|Any CPU.Build.0 = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|x64.ActiveCfg = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|x64.Build.0 = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|x86.ActiveCfg = Release|Any CPU + {D455F132-39FE-4350-9C36-1B81A5F238C6}.Release|x86.Build.0 = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|x64.Build.0 = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Debug|x86.Build.0 = Debug|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|Any CPU.Build.0 = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|x64.ActiveCfg = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|x64.Build.0 = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|x86.ActiveCfg = Release|Any CPU + {CBB5189D-C63A-4CEC-AF59-391C31E36985}.Release|x86.Build.0 = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|x64.Build.0 = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Debug|x86.Build.0 = Debug|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|Any CPU.Build.0 = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|x64.ActiveCfg = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|x64.Build.0 = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|x86.ActiveCfg = Release|Any CPU + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FD304824-536A-4282-8B28-18FD2939F2E9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {87321FFD-8C90-4268-80EE-5767BE1E3B98} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {90654289-0EC1-4BD9-82BA-C13FF5577705} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D455F132-39FE-4350-9C36-1B81A5F238C6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CBB5189D-C63A-4CEC-AF59-391C31E36985} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DD19FAFD-79C6-48D4-8AFC-5D4DE9C01C5D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/backend/MinAttest.AppHost/MinAttest.AppHost.csproj b/backend/MinAttest.AppHost/MinAttest.AppHost.csproj new file mode 100644 index 0000000..4c897f5 --- /dev/null +++ b/backend/MinAttest.AppHost/MinAttest.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net9.0 + enable + enable + true + 627e5d7f-da52-404a-8d76-c9ba6c1f65f5 + + + + + + + + + + + + diff --git a/backend/MinAttest.AppHost/Program.cs b/backend/MinAttest.AppHost/Program.cs new file mode 100644 index 0000000..89c7962 --- /dev/null +++ b/backend/MinAttest.AppHost/Program.cs @@ -0,0 +1,14 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var sqlPass = builder.AddParameter("sqlPassword", "Your_password123"); + +var sql = builder.AddSqlServer("minattest-dev-sql", password: sqlPass, port: 14335) + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + +builder.AddProject("minattest-api") + .WithExternalHttpEndpoints() + .WithReference(sql) + .WaitFor(sql); + +builder.Build().Run(); diff --git a/backend/MinAttest.AppHost/Properties/launchSettings.json b/backend/MinAttest.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..c298a92 --- /dev/null +++ b/backend/MinAttest.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17105;http://localhost:15182", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21157", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22256" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15182", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19259", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20072" + } + } + } +} diff --git a/backend/MinAttest.AppHost/appsettings.Development.json b/backend/MinAttest.AppHost/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/backend/MinAttest.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/MinAttest.AppHost/appsettings.json b/backend/MinAttest.AppHost/appsettings.json new file mode 100644 index 0000000..2185f95 --- /dev/null +++ b/backend/MinAttest.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/backend/MinAttest.ServiceDefaults/Extensions.cs b/backend/MinAttest.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..c6f05d7 --- /dev/null +++ b/backend/MinAttest.ServiceDefaults/Extensions.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/backend/MinAttest.ServiceDefaults/MinAttest.ServiceDefaults.csproj b/backend/MinAttest.ServiceDefaults/MinAttest.ServiceDefaults.csproj new file mode 100644 index 0000000..426266c --- /dev/null +++ b/backend/MinAttest.ServiceDefaults/MinAttest.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/backend/NuGet.config b/backend/NuGet.config new file mode 100644 index 0000000..457cbad --- /dev/null +++ b/backend/NuGet.config @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..fe87608 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,81 @@ +# MinAttest Backend + +Backend for MinAttest som .NET 9 Web API, EF Core (SQL Server) og .NET Aspire for lokal orkestrering (API + databaser i Docker). + +**Hovedpunkter** +- API under `/api/v1` (uten autentisering – POC). +- EF Core med SQL Server og migrasjoner. Status på attester normaliseres automatisk ved lagring. +- .NET Aspire AppHost starter SQL Server‑container og injiserer connection string til API. + +**Struktur** +- `MinAttest.sln` – løsning for backend +- `src/MinAttest.Api/` – Web API (.http testkall, CORS, routing) +- `src/MinAttest.Domain/` – domene‑modeller (POCOs, enums) +- `src/MinAttest.Infrastructure/` – EF Core (DbContext, migrasjoner, design‑time factory) +- `MinAttest.AppHost/` – .NET Aspire AppHost (Docker orkestrering: SQL Server + API) +- `MinAttest.ServiceDefaults/` – felles konfig (telemetri, health, m.m.) + +**Forutsetninger** +- .NET SDK 9.x (`dotnet --info`) +- Docker Desktop i gang (for AppHost/SQL Server) + +**Kjøre lokalt (anbefalt: Aspire)** +- Start alt (API + SQL Server i Docker): + - `dotnet run --project backend/MinAttest.AppHost` +- AppHost eksponerer API og oppretter databasen. API kjører `Migrate()` ved oppstart. + +**Kjøre kun API (uten Aspire)** +- Sørg for en kjørende SQL Server lokalt/ekstern. +- Sett connection string i `src/MinAttest.Api/appsettings.Development.json` (felt `ConnectionStrings:Default`). +- Start API: `dotnet run --project backend/src/MinAttest.Api/MinAttest.Api.csproj` + +**EF Core (migrasjoner)** +- Verktøy er installert lokalt via tool‑manifest i `backend/.config`. +- Kjør fra `backend/` mappen: + - Legg til migrasjon (prosjekt: Infrastructure, startup: Api): + - `dotnet tool run dotnet-ef migrations add --project src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj --startup-project src/MinAttest.Api/MinAttest.Api.csproj --output-dir Data/Migrations` + - Oppdater database: + - `dotnet tool run dotnet-ef database update --project src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj --startup-project src/MinAttest.Api/MinAttest.Api.csproj` + +**Viktige endepunkter (POC)** +- Health: `GET /api/v1/health` – svarer `200 OK` når API og database er tilgjengelig, ellers `503 Service Unavailable`. +- Persons: `POST /api/v1/persons`, `GET /api/v1/persons/{id}` +- Employers: `POST /api/v1/employers`, `GET /api/v1/employers`, `GET /api/v1/employers/{id}` +- Attests (lagring/lesing): + - `GET /api/v1/attests?personId=&employerId=&take=` + - `GET /api/v1/attests/{id}` + - `GET /api/v1/attests/{id}/download` + - `POST /api/v1/attests/uploads` (POC) + - `POST /api/v1/attests/uploads/{uploadId}/complete` (skaper attest i DB) +- Shares: `POST/GET/DELETE /api/v1/attests/{id}/shares...` +- Verify: `GET /api/v1/verify/{code}` + +Se også `src/MinAttest.Api/MinAttest.Api.http` for eksempelkall. + +**Health checks** +- I tillegg til API‑endepunktet over, eksponeres også standard health‑endepunkter via ServiceDefaults i utviklingsmiljø: + - Readiness: `GET /health` – inkluderer DB‑sjekk (EF Core `AddDbContextCheck`). + - Liveness: `GET /alive` – enkel "self"‑sjekk. + +**Navngiving av endpoints (refaktorering)** +- Extension‑metodene for routing bruker ikke lenger postfixen "Endpoints". + - `MapHealthEndpoints` → `MapHealth` + - `MapPersonsEndpoints` → `MapPersons` + - `MapEmployerEndpoints` → `MapEmployer` + - `MapShareLinksEndpoints` → `MapShareLinks` + +**Domenemodell (kjerne)** +- Person (Persons) ↔ Attest (Attests): 1:n (obligatorisk FK `PersonId`) +- Employer (Employers) ↔ Attest: 1:n (valgfri FK `EmployerId`) +- Uverifisert attest: `EmployerId = null` og `Status = Unverified` +- Verifisert: `EmployerId != null` og `Status = Issued` (eller `Revoked` ved tilbaketrekking) +- DB‑constraint sikrer konsistente kombinasjoner av `EmployerId`/`Status`. + +**CORS (dev)** +- Dev‑origins er konfigurert i `src/MinAttest.Api/appsettings.Development.json` under `Cors:AllowedOrigins`. +- Hvis ingen origins er satt, er CORS av (ingen cross‑origin tillatt). + +**Videre** +- Auth (BankID/Entra via B2C) legges på senere. +- Blob Storage (SAS) for reelle nedlastingslenker. +- Prod‑CORS settes eksplisitt når frontend‑domene er klart. diff --git a/backend/src/MinAttest.Api/Features/Employer/Employer.cs b/backend/src/MinAttest.Api/Features/Employer/Employer.cs new file mode 100644 index 0000000..cb4e3a0 --- /dev/null +++ b/backend/src/MinAttest.Api/Features/Employer/Employer.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.Employers; +using MediatR; +using MinAttest.Application.Features.Employers.Commands; +using MinAttest.Application.Features.Employers.Queries; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.Attests.Queries; + +namespace MinAttest.Api.Features.Employer; + +public static class Employers +{ + public static RouteGroupBuilder MapEmployer(this RouteGroupBuilder group) + { + var employers = group.MapGroup("/employers"); + + employers.MapPost("/", UpsertEmployer) + .WithName("UpsertEmployer") + .WithOpenApi(); + + employers.MapGet("/", ListEmployers) + .WithName("ListEmployers") + .WithOpenApi(); + + employers.MapGet("/{id:guid}", GetEmployer) + .WithName("GetEmployer") + .WithOpenApi(); + + employers.MapGet("/{employerId:guid}/users", ListEmployerUsers) + .WithName("ListEmployerUsers") + .WithOpenApi(); + + employers.MapGet("/{employerId:guid}/users/{userId:guid}", GetEmployerUser) + .WithName("GetEmployerUser") + .WithOpenApi(); + + employers.MapPost("/{employerId:guid}/users", UpsertEmployerUser) + .WithName("UpsertEmployerUser") + .WithOpenApi(); + + employers.MapDelete("/{employerId:guid}/users/{userId:guid}", DeleteEmployerUser) + .WithName("DeleteEmployerUser") + .WithOpenApi(); + + employers.MapPost("/{id:guid}/attests", IssueAttestForEmployer) + .WithName("IssueAttestForEmployer") + .WithOpenApi(); + + employers.MapGet("/{id:guid}/attests", ListAttestsForEmployer) + .WithName("ListAttestsForEmployer") + .WithOpenApi(); + + employers.MapGet("/{id:guid}/attests/{attestId:guid}/download", DownloadAttestForEmployer) + .WithName("DownloadAttestForEmployer") + .WithOpenApi(); + + return group; + } + + private static async Task IssueAttestForEmployer(Guid id, EmployerAttestUploadRequest req, IMediator mediator, CancellationToken ct) + { + var createdId = await mediator.Send(new EmployerIssueAttestCommand(id, req), ct); + if (createdId is null) return Results.NotFound(); + return Results.Created($"/api/v1/employers/{id}/attests/{createdId}", new { attestId = createdId }); + } + + private static async Task ListAttestsForEmployer(Guid id, int? take, IMediator mediator, CancellationToken ct) + { + var items = await mediator.Send(new ListEmployerAttestsQuery(id, take), ct); + return Results.Ok(items); + } + + private static async Task DownloadAttestForEmployer(Guid id, Guid attestId, bool? inline, IMediator mediator, CancellationToken ct) + { + var content = await mediator.Send(new GetAttestContentQuery(attestId), ct); + if (content is null) return Results.NotFound(); + var contentType = NormalizeContentType(content.Content, content.ContentType); + var fileName = NormalizeFileName(content.FileName, attestId, contentType); + if (inline == true) + { + return Results.File(content.Content, contentType); + } + return Results.File(content.Content, contentType, fileName); + } + + private static string NormalizeContentType(byte[] data, string? original) + { + var ct = string.IsNullOrWhiteSpace(original) || string.Equals(original, "application/octet-stream", StringComparison.OrdinalIgnoreCase) + ? null + : original; + if (ct is null) + { + if (data is { Length: >= 4 } && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46) + { + return "application/pdf"; + } + } + return ct ?? "application/pdf"; + } + + private static string NormalizeFileName(string? name, Guid attestId, string contentType) + { + var n = string.IsNullOrWhiteSpace(name) ? $"attest-{attestId}" : name!; + if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase) && !n.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + { + n += ".pdf"; + } + return n; + } + + private static async Task UpsertEmployer(EmployerUpsertRequest req, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new UpsertEmployerCommand(req.OrgNumber, req.Name), ct); + return Results.Ok(resp); + } + + private static async Task ListEmployers(IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new ListEmployersQuery(), ct); + return Results.Ok(resp); + } + + private static async Task, NotFound>> GetEmployer(Guid id, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new GetEmployerQuery(id), ct); + if (resp is null) return TypedResults.NotFound(); + return TypedResults.Ok(resp); + } + + private static async Task ListEmployerUsers(Guid employerId, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new ListEmployerUsersQuery(employerId), ct); + return Results.Ok(resp); + } + + private static async Task, NotFound>> GetEmployerUser(Guid employerId, Guid userId, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new GetEmployerUserQuery(employerId, userId), ct); + if (resp is null) return TypedResults.NotFound(); + return TypedResults.Ok(resp); + } + + private static async Task, NotFound, BadRequest>> UpsertEmployerUser(Guid employerId, EmployerUserUpsertRequest req, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new UpsertEmployerUserCommand(employerId, req.ExternalObjectId, req.Email, req.Name, req.Role), ct); + if (resp is null) return TypedResults.NotFound(); + return TypedResults.Ok(resp); + } + + private static async Task> DeleteEmployerUser(Guid employerId, Guid userId, IMediator mediator, CancellationToken ct) + { + var ok = await mediator.Send(new DeleteEmployerUserCommand(employerId, userId), ct); + if (!ok) return TypedResults.NotFound(); + return TypedResults.NoContent(); + } +} diff --git a/backend/src/MinAttest.Api/Features/Health/Health.cs b/backend/src/MinAttest.Api/Features/Health/Health.cs new file mode 100644 index 0000000..e143fd8 --- /dev/null +++ b/backend/src/MinAttest.Api/Features/Health/Health.cs @@ -0,0 +1,27 @@ +namespace MinAttest.Api.Features.Health; + +using MinAttest.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +public static class Health +{ + public static RouteGroupBuilder MapHealth(this RouteGroupBuilder group) + { + group.MapGet("/health", GetHealth) + .WithName("Health") + .WithOpenApi(); + return group; + } + + private static async Task GetHealth(AppDbContext db, CancellationToken ct) + { + var canConnect = await db.Database.CanConnectAsync(ct); + var payload = new + { + status = canConnect ? "OK" : "DEGRADED", + database = canConnect ? "Up" : "Down", + time = DateTimeOffset.UtcNow + }; + return canConnect ? Results.Ok(payload) : Results.Json(payload, statusCode: StatusCodes.Status503ServiceUnavailable); + } +} diff --git a/backend/src/MinAttest.Api/Features/Persons/Persons.cs b/backend/src/MinAttest.Api/Features/Persons/Persons.cs new file mode 100644 index 0000000..0d8134c --- /dev/null +++ b/backend/src/MinAttest.Api/Features/Persons/Persons.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using MinAttest.Contracts.Persons; +using MinAttest.Contracts.Attests; +using MediatR; +using MinAttest.Application.Features.Persons.Commands; +using MinAttest.Application.Features.Persons.Queries; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.Attests.Queries; + +namespace MinAttest.Api.Features.Persons; + +public static class Persons +{ + public static RouteGroupBuilder MapPersons(this RouteGroupBuilder group) + { + var persons = group.MapGroup("/persons"); + + persons.MapPost("/", UpsertPerson) + .WithName("UpsertPerson") + .WithOpenApi(); + + persons.MapGet("/{id:guid}", GetPerson) + .WithName("GetPerson") + .WithOpenApi(); + + persons.MapPost("/{id:guid}/attests", UploadAttestForPerson) + .WithName("UploadAttestForPerson") + .WithOpenApi(); + + persons.MapGet("/{id:guid}/attests", ListAttestsForPerson) + .WithName("ListAttestsForPerson") + .WithOpenApi(); + + persons.MapGet("/{id:guid}/attests/{attestId:guid}/download", DownloadAttestForPerson) + .WithName("DownloadAttestForPerson") + .WithOpenApi(); + + return group; + } + + private static async Task UpsertPerson(PersonUpsertRequest req, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new UpsertPersonCommand(req.NationalIdHash, req.Email, req.Phone), ct); + return Results.Ok(resp); + } + + private static async Task, NotFound>> GetPerson(Guid id, IMediator mediator, CancellationToken ct) + { + var resp = await mediator.Send(new GetPersonQuery(id), ct); + if (resp is null) return TypedResults.NotFound(); + return TypedResults.Ok(resp); + } + + private static async Task UploadAttestForPerson(Guid id, PersonAttestUploadRequest req, IMediator mediator, CancellationToken ct) + { + var createdId = await mediator.Send(new PersonUploadAttestCommand(id, req), ct); + if (createdId is null) return Results.NotFound(); + return Results.Created($"/api/v1/persons/{id}/attests/{createdId}", new { attestId = createdId }); + } + + private static async Task ListAttestsForPerson(Guid id, int? take, IMediator mediator, CancellationToken ct) + { + var items = await mediator.Send(new ListPersonAttestsQuery(id, take), ct); + return Results.Ok(items); + } + + private static async Task DownloadAttestForPerson(Guid id, Guid attestId, bool? inline, IMediator mediator, CancellationToken ct) + { + var content = await mediator.Send(new GetAttestContentQuery(attestId), ct); + if (content is null) return Results.NotFound(); + var contentType = NormalizeContentType(content.Content, content.ContentType); + var fileName = NormalizeFileName(content.FileName, attestId, contentType); + if (inline == true) + { + // Inline view: omit download filename to avoid attachment disposition + return Results.File(content.Content, contentType); + } + return Results.File(content.Content, contentType, fileName); + } + + private static string NormalizeContentType(byte[] data, string? original) + { + var ct = string.IsNullOrWhiteSpace(original) || string.Equals(original, "application/octet-stream", StringComparison.OrdinalIgnoreCase) + ? null + : original; + // Sniff PDF signature: %PDF- + if (ct is null) + { + if (data is { Length: >= 4 } && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46) + { + return "application/pdf"; + } + } + return ct ?? "application/pdf"; // prefer PDF when unknown + } + + private static string NormalizeFileName(string? name, Guid attestId, string contentType) + { + var n = string.IsNullOrWhiteSpace(name) ? $"attest-{attestId}" : name!; + if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase) && !n.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + { + n += ".pdf"; + } + return n; + } +} diff --git a/backend/src/MinAttest.Api/Features/ShareLinks/ShareLinks.cs b/backend/src/MinAttest.Api/Features/ShareLinks/ShareLinks.cs new file mode 100644 index 0000000..3457c7e --- /dev/null +++ b/backend/src/MinAttest.Api/Features/ShareLinks/ShareLinks.cs @@ -0,0 +1,47 @@ +using MediatR; +using MinAttest.Application.Features.ShareLinks.Commands; +using MinAttest.Application.Features.ShareLinks.Queries; +using MinAttest.Contracts.ShareLinks; + +namespace MinAttest.Api.Features.ShareLinks; + +public static class ShareLinks +{ + public static RouteGroupBuilder MapShareLinks(this RouteGroupBuilder group) + { + group.MapPost("/attests/{id:guid}/sharelinks", CreateShareLink) + .WithName("CreateShareLinks") + .WithOpenApi(); + + group.MapGet("/attests/{id:guid}/sharelinks", ListShareLinks) + .WithName("ListShareLinks") + .WithOpenApi(); + + group.MapDelete("/attests/{id:guid}/sharelinks/{shareLinkId:guid}", RevokeShareLink) + .WithName("RevokeShareLinks") + .WithOpenApi(); + + return group; + } + + private static async Task CreateShareLink(Guid id, CreateShareLinkRequest req, HttpContext http, IMediator mediator, CancellationToken ct) + { + var baseUrl = $"{http.Request.Scheme}://{http.Request.Host}"; + var resp = await mediator.Send(new CreateShareLinkCommand(id, req, baseUrl), ct); + if (resp is null) return Results.NotFound(); + return Results.Ok(resp); + } + + private static async Task ListShareLinks(Guid id, IMediator mediator, CancellationToken ct) + { + var items = await mediator.Send(new ListShareLinksQuery(id), ct); + return Results.Ok(items); + } + + private static async Task RevokeShareLink(Guid id, Guid shareLinkId, IMediator mediator, CancellationToken ct) + { + var ok = await mediator.Send(new RevokeShareLinkCommand(id, shareLinkId), ct); + if (!ok) return Results.NotFound(); + return Results.NoContent(); + } +} diff --git a/backend/src/MinAttest.Api/MinAttest.Api.csproj b/backend/src/MinAttest.Api/MinAttest.Api.csproj new file mode 100644 index 0000000..9946a2b --- /dev/null +++ b/backend/src/MinAttest.Api/MinAttest.Api.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + enable + MinAttest.Api + MinAttest.Api + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/MinAttest.Api/MinAttest.Api.http b/backend/src/MinAttest.Api/MinAttest.Api.http new file mode 100644 index 0000000..1cadd87 --- /dev/null +++ b/backend/src/MinAttest.Api/MinAttest.Api.http @@ -0,0 +1,75 @@ +@host = http://localhost:5072 + +### Health +GET {{host}}/api/v1/health + +### Persons +POST {{host}}/api/v1/persons +Content-Type: application/json + +{ + "nationalIdHash": "hash123", + "email": "user@example.com", + "phone": "+4712345678" +} + +### Employers +POST {{host}}/api/v1/employers +Content-Type: application/json + +{ + "orgNumber": "123456789", + "name": "Bedrift AS" +} + +### Attests - list +GET {{host}}/api/v1/persons/{{personId}}/attests + +### Person - upload attest (inline content) +POST {{host}}/api/v1/persons/{{personId}}/attests +Content-Type: application/json + +{ + "title": "Utvikler", + "from": "2021-01-01", + "to": "2023-12-31", + "summary": "Arbeidet med .NET", + "blobPath": "", + "blobHash": null, + "contentBase64": "", + "contentType": "application/pdf" +} + +### Person - download attest +GET {{host}}/api/v1/persons/{{personId}}/attests/{{attestId}}/download + +### Employer - upsert employer +POST {{host}}/api/v1/employers +Content-Type: application/json + +{ + "orgNumber": "123456789", + "name": "Bedrift AS" +} + +### Employer - issue attest for person +POST {{host}}/api/v1/employers/{{employerId}}/attests +Content-Type: application/json + +{ + "personId": "{{personId}}", + "title": "Konsulent", + "from": "2022-01-01", + "to": "2024-01-01", + "summary": "Prosjektarbeid", + "blobPath": "", + "blobHash": null, + "contentBase64": "", + "contentType": "application/pdf" +} + +### Employer - list attests +GET {{host}}/api/v1/employers/{{employerId}}/attests + +### Employer - download attest +GET {{host}}/api/v1/employers/{{employerId}}/attests/{{attestId}}/download diff --git a/backend/src/MinAttest.Api/Program.cs b/backend/src/MinAttest.Api/Program.cs new file mode 100644 index 0000000..6f79afe --- /dev/null +++ b/backend/src/MinAttest.Api/Program.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using Serilog; +using Microsoft.OpenApi.Models; +using MinAttest.Api.Features.Employer; +using MinAttest.Api.Features.Health; +using MinAttest.Api.Features.ShareLinks; +using MinAttest.Infrastructure.Data; +using MinAttest.Api.Features.Persons; +using MediatR; +using MinAttest.Application; +using MinAttest.Application.Abstractions; +using FluentValidation; +using MinAttest.Application.Common.Behaviors; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +Directory.CreateDirectory("logs"); + +builder.Host.UseSerilog((ctx, services, cfg) => +{ + cfg.ReadFrom.Configuration(ctx.Configuration) + .Enrich.FromLogContext(); +}); + +builder.Services.AddOpenApi(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "MinAttest API", + Version = "v1" + }); +}); +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly)); +builder.Services.AddScoped(); +builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); +builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); +builder.Services.AddDbContext(options => +{ + var cs = builder.Configuration.GetConnectionString("Default") + ?? builder.Configuration.GetConnectionString("MinAttest") + ?? builder.Configuration.GetConnectionString("minattest") + ?? throw new InvalidOperationException("Missing connection string. Provide ConnectionStrings:Default or :MinAttest."); + options.UseSqlServer(cs, sql => sql.EnableRetryOnFailure()); +}); + +// Add DB health check to readiness probe +builder.Services.AddHealthChecks() + .AddDbContextCheck(name: "database"); + +builder.Services.AddCors(options => +{ + options.AddPolicy("Frontend", policy => + { + var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + if (origins.Length > 0) + { + policy.WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod(); + } + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + // Use relative path so it works with PathBase/proxies + c.SwaggerEndpoint("../openapi/v1.json", "MinAttest API v1"); + c.RoutePrefix = "swagger"; + }); +} + +app.UseHttpsRedirection(); +app.UseCors("Frontend"); + +app.UseExceptionHandler(errApp => +{ + errApp.Run(async context => + { + var feature = context.Features.Get(); + var ex = feature?.Error; + var status = ex is FluentValidation.ValidationException ? StatusCodes.Status400BadRequest : StatusCodes.Status500InternalServerError; + + var problem = new + { + type = status == 400 ? "https://datatracker.ietf.org/doc/html/rfc7807#section-3.1" : "about:blank", + title = status == 400 ? "One or more validation errors occurred." : "An unexpected error occurred.", + status, + detail = app.Environment.IsDevelopment() ? ex?.Message : null, + traceId = context.TraceIdentifier, + errors = ex is FluentValidation.ValidationException vex + ? vex.Errors.GroupBy(e => e.PropertyName).ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()) + : null + }; + + context.Response.StatusCode = status; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(problem); + }); +}); + +var apiV1 = app.MapGroup("/api/v1"); + +apiV1.MapHealth(); +apiV1.MapEmployer(); +apiV1.MapPersons(); +apiV1.MapShareLinks(); + +app.MapDefaultEndpoints(); + + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + + var c = db.Database.GetDbConnection(); + db.Database.Migrate(); +} + +app.Run(); + +public partial class Program { } diff --git a/backend/src/MinAttest.Api/Properties/launchSettings.json b/backend/src/MinAttest.Api/Properties/launchSettings.json new file mode 100644 index 0000000..2fea2c4 --- /dev/null +++ b/backend/src/MinAttest.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5072", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7172;http://localhost:5072", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/MinAttest.Api/appsettings.Development.json b/backend/src/MinAttest.Api/appsettings.Development.json new file mode 100644 index 0000000..019fa17 --- /dev/null +++ b/backend/src/MinAttest.Api/appsettings.Development.json @@ -0,0 +1,39 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "Default": "Server=127.0.0.1,14335;Database=minattest-dev-sql;User Id=sa;Password=Your_password123;TrustServerCertificate=True;" + }, + "Cors": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://localhost:3000" + ] + }, + "Serilog": { + "Using": [ "Serilog.Sinks.File" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "WriteTo": [ + { + "Name": "File", + "Args": { + "path": "logs/api-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 7, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext" ] + } +} diff --git a/backend/src/MinAttest.Api/appsettings.json b/backend/src/MinAttest.Api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/backend/src/MinAttest.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/backend/src/MinAttest.Application/Abstractions/IAppDbContext.cs b/backend/src/MinAttest.Application/Abstractions/IAppDbContext.cs new file mode 100644 index 0000000..4622b9f --- /dev/null +++ b/backend/src/MinAttest.Application/Abstractions/IAppDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Abstractions; + +public interface IAppDbContext +{ + DbSet Persons { get; } + DbSet Employers { get; } + DbSet Attests { get; } + DbSet ShareLinks { get; } + DbSet AuditLogs { get; } + DbSet EmployerUsers { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/MinAttest.Application/Application.cs b/backend/src/MinAttest.Application/Application.cs new file mode 100644 index 0000000..d9bccd9 --- /dev/null +++ b/backend/src/MinAttest.Application/Application.cs @@ -0,0 +1,4 @@ +namespace MinAttest.Application; + +// Marker type for scanning application assembly (MediatR, validators) +public sealed class ApplicationAssembly { } diff --git a/backend/src/MinAttest.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/MinAttest.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..7aa6a57 --- /dev/null +++ b/backend/src/MinAttest.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace MinAttest.Application.Common.Behaviors; + +public class LoggingBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var sw = Stopwatch.StartNew(); + try + { + logger.LogInformation("Handling {RequestName}", requestName); + var response = await next(); + sw.Stop(); + logger.LogInformation("Handled {RequestName} in {ElapsedMs} ms", requestName, sw.ElapsedMilliseconds); + return response; + } + catch (Exception ex) + { + sw.Stop(); + logger.LogError(ex, "Error handling {RequestName} after {ElapsedMs} ms", requestName, sw.ElapsedMilliseconds); + throw; + } + } +} + diff --git a/backend/src/MinAttest.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/MinAttest.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..d27367c --- /dev/null +++ b/backend/src/MinAttest.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using MediatR; + +namespace MinAttest.Application.Common.Behaviors; + +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(); + + var context = new ValidationContext(request); + var failures = (await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)))) + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count != 0) + { + throw new ValidationException(failures); + } + + return await next(); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommand.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommand.cs new file mode 100644 index 0000000..01d6b99 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommand.cs @@ -0,0 +1,62 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Attests; +using MinAttest.Application.Abstractions; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Commands; + +public record CompleteUploadCommand(Guid UploadId, CompleteUploadRequest Request) : IRequest; + +public class CompleteUploadCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(CompleteUploadCommand request, CancellationToken cancellationToken) + { + var r = request.Request; + var personExists = await db.Persons.AnyAsync(p => p.Id == r.PersonId, cancellationToken); + if (!personExists) return null; + + if (r.EmployerId is Guid eid) + { + var exists = await db.Employers.AnyAsync(e => e.Id == eid, cancellationToken); + if (!exists) return null; + } + + byte[]? content = null; + long? length = null; + if (!string.IsNullOrWhiteSpace(r.ContentBase64)) + { + try + { + content = Convert.FromBase64String(r.ContentBase64); + length = content.LongLength; + } + catch (FormatException) + { + // invalid base64 -> treat as bad request + return null; + } + } + + var a = new Attest + { + Id = Guid.NewGuid(), + PersonId = r.PersonId, + EmployerId = r.EmployerId, + Title = r.Title, + From = r.From, + To = r.To, + Summary = r.Summary, + BlobPath = r.BlobPath, + BlobHash = r.BlobHash, + Content = content, + ContentType = r.ContentType, + ContentLength = length, + IssuedAt = DateTimeOffset.UtcNow + }; + + db.Attests.Add(a); + await db.SaveChangesAsync(cancellationToken); + return a.Id; + } +} diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommandValidator.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommandValidator.cs new file mode 100644 index 0000000..301bc93 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/CompleteUploadCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Attests.Commands; + +public class CompleteUploadCommandValidator : AbstractValidator +{ + public CompleteUploadCommandValidator() + { + RuleFor(x => x.Request.PersonId).NotEqual(Guid.Empty); + RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Request.BlobPath).NotEmpty().MaximumLength(500); + // Allow either inline content or external reference + RuleFor(x => x.Request) + .Must(r => !string.IsNullOrWhiteSpace(r.ContentBase64) || !string.IsNullOrWhiteSpace(r.BlobPath)) + .WithMessage("Either ContentBase64 or BlobPath must be provided"); + When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () => + { + RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255); + }); + RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To); + RuleFor(x => x.Request.Summary).MaximumLength(2000).When(x => !string.IsNullOrWhiteSpace(x.Request.Summary)); + } +} diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommand.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommand.cs new file mode 100644 index 0000000..216659a --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommand.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Attests; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Commands; + +public record EmployerIssueAttestCommand(Guid EmployerId, EmployerAttestUploadRequest Request) : IRequest; + +public class EmployerIssueAttestCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(EmployerIssueAttestCommand request, CancellationToken cancellationToken) + { + var r = request.Request; + var employerExists = await db.Employers.AnyAsync(e => e.Id == request.EmployerId, cancellationToken); + if (!employerExists) return null; + + var personExists = await db.Persons.AnyAsync(p => p.Id == r.PersonId, cancellationToken); + if (!personExists) return null; + + byte[]? content = null; + long? length = null; + if (!string.IsNullOrWhiteSpace(r.ContentBase64)) + { + try { content = Convert.FromBase64String(r.ContentBase64); length = content.LongLength; } + catch (FormatException) { return null; } + } + + var a = new Attest + { + Id = Guid.NewGuid(), + PersonId = r.PersonId, + EmployerId = request.EmployerId, + Title = r.Title, + From = r.From, + To = r.To, + Summary = r.Summary, + BlobPath = r.BlobPath, + BlobHash = r.BlobHash, + Content = content, + ContentType = r.ContentType, + ContentLength = length, + IssuedAt = DateTimeOffset.UtcNow + }; + + db.Attests.Add(a); + await db.SaveChangesAsync(cancellationToken); + return a.Id; + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommandValidator.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommandValidator.cs new file mode 100644 index 0000000..6eb0bfd --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/EmployerIssueAttestCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Attests.Commands; + +public class EmployerIssueAttestCommandValidator : AbstractValidator +{ + public EmployerIssueAttestCommandValidator() + { + RuleFor(x => x.EmployerId).NotEqual(Guid.Empty); + RuleFor(x => x.Request.PersonId).NotEqual(Guid.Empty); + RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Request.BlobPath).MaximumLength(500); + RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To); + RuleFor(x => x.Request) + .Must(r => !string.IsNullOrWhiteSpace(r.BlobPath) || !string.IsNullOrWhiteSpace(r.ContentBase64)) + .WithMessage("Either BlobPath or ContentBase64 must be provided"); + When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () => + { + RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255); + }); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommand.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommand.cs new file mode 100644 index 0000000..7169e1c --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommand.cs @@ -0,0 +1,49 @@ +using MediatR; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Attests; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Commands; + +public record PersonUploadAttestCommand(Guid PersonId, PersonAttestUploadRequest Request) : IRequest; + +public class PersonUploadAttestCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(PersonUploadAttestCommand request, CancellationToken cancellationToken) + { + var r = request.Request; + + var person = await db.Persons.FindAsync([request.PersonId], cancellationToken); + if (person is null) return null; + + byte[]? content = null; + long? length = null; + if (!string.IsNullOrWhiteSpace(r.ContentBase64)) + { + try { content = Convert.FromBase64String(r.ContentBase64); length = content.LongLength; } + catch (FormatException) { return null; } + } + + var a = new Attest + { + Id = Guid.NewGuid(), + PersonId = request.PersonId, + EmployerId = null, + Title = r.Title, + From = r.From, + To = r.To, + Summary = r.Summary, + BlobPath = r.BlobPath, + BlobHash = r.BlobHash, + Content = content, + ContentType = r.ContentType, + ContentLength = length, + IssuedAt = DateTimeOffset.UtcNow + }; + + db.Attests.Add(a); + await db.SaveChangesAsync(cancellationToken); + return a.Id; + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommandValidator.cs b/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommandValidator.cs new file mode 100644 index 0000000..c43ae94 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Commands/PersonUploadAttestCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Attests.Commands; + +public class PersonUploadAttestCommandValidator : AbstractValidator +{ + public PersonUploadAttestCommandValidator() + { + RuleFor(x => x.PersonId).NotEqual(Guid.Empty); + RuleFor(x => x.Request.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Request.BlobPath).MaximumLength(500); + RuleFor(x => x.Request.From).LessThanOrEqualTo(x => x.Request.To); + RuleFor(x => x.Request) + .Must(r => !string.IsNullOrWhiteSpace(r.BlobPath) || !string.IsNullOrWhiteSpace(r.ContentBase64)) + .WithMessage("Either BlobPath or ContentBase64 must be provided"); + When(x => !string.IsNullOrWhiteSpace(x.Request.ContentBase64), () => + { + RuleFor(x => x.Request.ContentType).NotEmpty().MaximumLength(255); + }); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestByIdQuery.cs b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestByIdQuery.cs new file mode 100644 index 0000000..0153df0 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestByIdQuery.cs @@ -0,0 +1,29 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Attests; +using MinAttest.Application.Abstractions; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Queries; + +public record GetAttestByIdQuery(Guid Id) : IRequest; + +public class GetAttestByIdQueryHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(GetAttestByIdQuery request, CancellationToken cancellationToken) + { + var a = await db.Attests.AsNoTracking().Include(x => x.Employer) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + if (a is null) return null; + + return new AttestDetails( + a.Id, + a.Employer?.Name ?? string.Empty, + a.Title, + a.From, + a.To, + a.Summary ?? string.Empty, + a.Status == AttestStatus.Issued + ); + } +} diff --git a/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestContentQuery.cs b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestContentQuery.cs new file mode 100644 index 0000000..26277a4 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestContentQuery.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Attests.Queries; + +public record GetAttestContentQuery(Guid AttestId) : IRequest; + +public record AttestContentResult(byte[] Content, string? ContentType, string FileName); + +public class GetAttestContentQueryHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(GetAttestContentQuery request, CancellationToken cancellationToken) + { + var result = await db.Attests.AsNoTracking() + .Where(a => a.Id == request.AttestId) + .Select(a => new { a.Content, a.ContentType, a.Title }) + .FirstOrDefaultAsync(cancellationToken); + + if (result is null || result.Content is null) + return null; + + var name = string.IsNullOrWhiteSpace(result.Title) ? "attest" : result.Title; + // crude filename, no extension without content type mapping + return new AttestContentResult(result.Content, result.ContentType, name); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestsQuery.cs b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestsQuery.cs new file mode 100644 index 0000000..eaf92e5 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Queries/GetAttestsQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Attests; +using MinAttest.Domain.Entities; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Attests.Queries; + +public record GetAttestsQuery(Guid? PersonId, Guid? EmployerId, int? Take) : IRequest>; + +public class GetAttestsQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetAttestsQuery request, CancellationToken cancellationToken) + { + var query = db.Attests.AsNoTracking().Include(a => a.Employer).AsQueryable(); + + if (request.PersonId is Guid pid && pid != Guid.Empty) + query = query.Where(a => a.PersonId == pid); + if (request.EmployerId is Guid eid && eid != Guid.Empty) + query = query.Where(a => a.EmployerId == eid); + + var items = await query + .OrderByDescending(a => a.IssuedAt) + .Take(Math.Clamp(request.Take ?? 50, 1, 200)) + .Select(a => new AttestSummary( + a.Id, + a.Employer != null ? a.Employer.Name : string.Empty, + a.Title, + a.From, + a.To, + a.Status == AttestStatus.Issued + )) + .ToListAsync(cancellationToken); + + return items; + } +} diff --git a/backend/src/MinAttest.Application/Features/Attests/Queries/ListEmployerAttestsQuery.cs b/backend/src/MinAttest.Application/Features/Attests/Queries/ListEmployerAttestsQuery.cs new file mode 100644 index 0000000..8d099f2 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Queries/ListEmployerAttestsQuery.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Attests; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Queries; + +public record ListEmployerAttestsQuery(Guid EmployerId, int? Take) : IRequest>; + +public class ListEmployerAttestsQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListEmployerAttestsQuery request, CancellationToken cancellationToken) + { + return await db.Attests.AsNoTracking().Include(a => a.Employer) + .Where(a => a.EmployerId == request.EmployerId) + .OrderByDescending(a => a.IssuedAt) + .Take(Math.Clamp(request.Take ?? 50, 1, 200)) + .Select(a => new AttestSummary(a.Id, (a.Employer != null ? a.Employer.Name : string.Empty), a.Title, a.From, a.To, a.Status == AttestStatus.Issued)) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/MinAttest.Application/Features/Attests/Queries/ListPersonAttestsQuery.cs b/backend/src/MinAttest.Application/Features/Attests/Queries/ListPersonAttestsQuery.cs new file mode 100644 index 0000000..601fb80 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Attests/Queries/ListPersonAttestsQuery.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Attests; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Attests.Queries; + +public record ListPersonAttestsQuery(Guid PersonId, int? Take) : IRequest>; + +public class ListPersonAttestsQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListPersonAttestsQuery request, CancellationToken cancellationToken) + { + return await db.Attests.AsNoTracking().Include(a => a.Employer) + .Where(a => a.PersonId == request.PersonId) + .OrderByDescending(a => a.IssuedAt) + .Take(Math.Clamp(request.Take ?? 50, 1, 200)) + .Select(a => new AttestSummary(a.Id, (a.Employer != null ? a.Employer.Name : string.Empty), a.Title, a.From, a.To, a.Status == AttestStatus.Issued)) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/MinAttest.Application/Features/Employers/Commands/DeleteEmployerUserCommand.cs b/backend/src/MinAttest.Application/Features/Employers/Commands/DeleteEmployerUserCommand.cs new file mode 100644 index 0000000..076f110 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Commands/DeleteEmployerUserCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Employers.Commands; + +public record DeleteEmployerUserCommand(Guid EmployerId, Guid UserId) : IRequest; + +public class DeleteEmployerUserCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteEmployerUserCommand request, CancellationToken cancellationToken) + { + var user = await db.EmployerUsers.FirstOrDefaultAsync(u => u.Id == request.UserId && u.EmployerId == request.EmployerId, cancellationToken); + if (user is null) return false; + db.EmployerUsers.Remove(user); + await db.SaveChangesAsync(cancellationToken); + return true; + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommand.cs b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommand.cs new file mode 100644 index 0000000..4493596 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommand.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Employers; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Employers.Commands; + +public record UpsertEmployerCommand(string OrgNumber, string Name) : IRequest; + +public class UpsertEmployerCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(UpsertEmployerCommand request, CancellationToken cancellationToken) + { + var existing = await db.Employers.FirstOrDefaultAsync(e => e.OrgNumber == request.OrgNumber, cancellationToken); + if (existing is null) + { + existing = new MinAttest.Domain.Entities.Employer { Id = Guid.NewGuid(), OrgNumber = request.OrgNumber, Name = request.Name }; + db.Employers.Add(existing); + } + else + { + existing.Name = request.Name; + } + + await db.SaveChangesAsync(cancellationToken); + return new EmployerResponse(existing.Id, existing.OrgNumber, existing.Name); + } +} diff --git a/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommandValidator.cs b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommandValidator.cs new file mode 100644 index 0000000..d49f382 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Employers.Commands; + +public class UpsertEmployerCommandValidator : AbstractValidator +{ + public UpsertEmployerCommandValidator() + { + RuleFor(x => x.OrgNumber).NotEmpty().MaximumLength(32); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommand.cs b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommand.cs new file mode 100644 index 0000000..ddd1669 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommand.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Employers; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Employers.Commands; + +public record UpsertEmployerUserCommand( + Guid EmployerId, + string ExternalObjectId, + string Email, + string? Name, + string Role +) : IRequest; + +public class UpsertEmployerUserCommandHandler(IAppDbContext db) + : IRequestHandler +{ + public async Task Handle(UpsertEmployerUserCommand request, CancellationToken cancellationToken) + { + var employer = await db.Employers.FirstOrDefaultAsync(e => e.Id == request.EmployerId, cancellationToken); + if (employer is null) return null; + + var roleParsed = Enum.TryParse(request.Role, ignoreCase: true, out var role) + ? role : EmployerUserRole.Issuer; + + var user = await db.EmployerUsers.FirstOrDefaultAsync(u => u.ExternalObjectId == request.ExternalObjectId, cancellationToken); + if (user is null) + { + user = new EmployerUser + { + Id = Guid.NewGuid(), + EmployerId = employer.Id, + ExternalObjectId = request.ExternalObjectId, + Email = request.Email, + Name = request.Name, + Role = roleParsed + }; + db.EmployerUsers.Add(user); + } + else + { + user.EmployerId = employer.Id; + user.Email = request.Email; + user.Name = request.Name; + user.Role = roleParsed; + } + + await db.SaveChangesAsync(cancellationToken); + + return new EmployerUserResponse(user.Id, user.EmployerId, user.ExternalObjectId, user.Email, user.Name, user.Role.ToString()); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommandValidator.cs b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommandValidator.cs new file mode 100644 index 0000000..6cd78e9 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Commands/UpsertEmployerUserCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Employers.Commands; + +public class UpsertEmployerUserCommandValidator : AbstractValidator +{ + public UpsertEmployerUserCommandValidator() + { + RuleFor(x => x.EmployerId).NotEqual(Guid.Empty); + RuleFor(x => x.ExternalObjectId).NotEmpty().MaximumLength(128); + RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress(); + RuleFor(x => x.Name).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.Name)); + RuleFor(x => x.Role).NotEmpty(); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerQuery.cs b/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerQuery.cs new file mode 100644 index 0000000..1330d88 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerQuery.cs @@ -0,0 +1,18 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Employers; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Employers.Queries; + +public record GetEmployerQuery(Guid Id) : IRequest; + +public class GetEmployerQueryHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(GetEmployerQuery request, CancellationToken cancellationToken) + { + var e = await db.Employers.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + if (e is null) return null; + return new EmployerResponse(e.Id, e.OrgNumber, e.Name); + } +} diff --git a/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerUserQuery.cs b/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerUserQuery.cs new file mode 100644 index 0000000..094b817 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Queries/GetEmployerUserQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Employers; + +namespace MinAttest.Application.Features.Employers.Queries; + +public record GetEmployerUserQuery(Guid EmployerId, Guid UserId) : IRequest; + +public class GetEmployerUserQueryHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(GetEmployerUserQuery request, CancellationToken cancellationToken) + { + var u = await db.EmployerUsers.FirstOrDefaultAsync(x => x.Id == request.UserId && x.EmployerId == request.EmployerId, cancellationToken); + if (u is null) return null; + return new EmployerUserResponse(u.Id, u.EmployerId, u.ExternalObjectId, u.Email, u.Name, u.Role.ToString()); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployerUsersQuery.cs b/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployerUsersQuery.cs new file mode 100644 index 0000000..9879072 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployerUsersQuery.cs @@ -0,0 +1,21 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.Employers; + +namespace MinAttest.Application.Features.Employers.Queries; + +public record ListEmployerUsersQuery(Guid EmployerId) : IRequest>; + +public class ListEmployerUsersQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListEmployerUsersQuery request, CancellationToken cancellationToken) + { + return await db.EmployerUsers + .Where(u => u.EmployerId == request.EmployerId) + .OrderBy(u => u.Email) + .Select(u => new EmployerUserResponse(u.Id, u.EmployerId, u.ExternalObjectId, u.Email, u.Name, u.Role.ToString())) + .ToListAsync(cancellationToken); + } +} + diff --git a/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployersQuery.cs b/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployersQuery.cs new file mode 100644 index 0000000..3501db8 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Employers/Queries/ListEmployersQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Employers; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Employers.Queries; + +public record ListEmployersQuery() : IRequest>; + +public class ListEmployersQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListEmployersQuery request, CancellationToken cancellationToken) + { + return await db.Employers + .OrderBy(e => e.Name) + .Select(e => new EmployerResponse(e.Id, e.OrgNumber, e.Name)) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommand.cs b/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommand.cs new file mode 100644 index 0000000..38dc2ce --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommand.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Persons; +using MinAttest.Application.Abstractions; +using MinAttest.Domain.Entities; + +namespace MinAttest.Application.Features.Persons.Commands; + +public record UpsertPersonCommand(string NationalIdHash, string? Email, string? Phone) : IRequest; + +public class UpsertPersonCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(UpsertPersonCommand request, CancellationToken cancellationToken) + { + var p = await db.Persons.FirstOrDefaultAsync(x => x.NationalIdHash == request.NationalIdHash, cancellationToken); + if (p is null) + { + p = new Person + { + Id = Guid.NewGuid(), + NationalIdHash = request.NationalIdHash, + Email = request.Email, + Phone = request.Phone + }; + db.Persons.Add(p); + } + else + { + p.Email = request.Email; + p.Phone = request.Phone; + } + + await db.SaveChangesAsync(cancellationToken); + return new PersonResponse(p.Id, p.NationalIdHash, p.Email, p.Phone, []); + } +} diff --git a/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommandValidator.cs b/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommandValidator.cs new file mode 100644 index 0000000..a6a17e1 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Persons/Commands/UpsertPersonCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace MinAttest.Application.Features.Persons.Commands; + +public class UpsertPersonCommandValidator : AbstractValidator +{ + public UpsertPersonCommandValidator() + { + RuleFor(x => x.NationalIdHash).NotEmpty().MaximumLength(200); + RuleFor(x => x.Email).MaximumLength(256).When(x => !string.IsNullOrWhiteSpace(x.Email)); + RuleFor(x => x.Phone).MaximumLength(50).When(x => !string.IsNullOrWhiteSpace(x.Phone)); + } +} \ No newline at end of file diff --git a/backend/src/MinAttest.Application/Features/Persons/Queries/GetPersonQuery.cs b/backend/src/MinAttest.Application/Features/Persons/Queries/GetPersonQuery.cs new file mode 100644 index 0000000..6cd3367 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/Persons/Queries/GetPersonQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.Persons; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.Persons.Queries; + +public record GetPersonQuery(Guid Id) : IRequest; + +public class GetPersonQueryHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(GetPersonQuery request, CancellationToken cancellationToken) + { + var person = await db.Persons + .Where(p => p.Id == request.Id) + .Select(p => new PersonResponse( + p.Id, + p.NationalIdHash, + p.Email, + p.Phone, + p.Attests + .Select(a => new AttestSummary( + a.Id, + a.Employer != null ? a.Employer.Name : string.Empty, + a.Title, + a.From, + a.To, + a.Status == Domain.Entities.AttestStatus.Issued + )) + .ToList() + )) + .FirstOrDefaultAsync(cancellationToken); + + return person; + } +} diff --git a/backend/src/MinAttest.Application/Features/ShareLinks/Commands/CreateShareLinkCommand.cs b/backend/src/MinAttest.Application/Features/ShareLinks/Commands/CreateShareLinkCommand.cs new file mode 100644 index 0000000..8a55cf6 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/ShareLinks/Commands/CreateShareLinkCommand.cs @@ -0,0 +1,48 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.ShareLinks; +using System.Security.Cryptography; + +namespace MinAttest.Application.Features.ShareLinks.Commands; + +public record CreateShareLinkCommand(Guid AttestId, CreateShareLinkRequest Request, string BaseUrl) : IRequest; + +public class CreateShareLinkCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(CreateShareLinkCommand request, CancellationToken cancellationToken) + { + var attestExists = await db.Attests.AnyAsync(a => a.Id == request.AttestId, cancellationToken); + if (!attestExists) return null; + + var code = GenerateCode(16); + var expiresAt = DateTimeOffset.UtcNow.Add(request.Request.ExpiresIn ?? TimeSpan.FromDays(7)); + var oneTime = request.Request.OneTime ?? false; + + var entity = new MinAttest.Domain.Entities.ShareLink + { + Id = Guid.NewGuid(), + AttestId = request.AttestId, + Code = code, + ExpiresAt = expiresAt, + OneTime = oneTime, + RevokedAt = null + }; + + db.ShareLinks.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + var url = new Uri($"{request.BaseUrl}/api/v1/verify/{code}"); + return new ShareLinkResponse(entity.Id, code, url, expiresAt, oneTime); + } + + private static string GenerateCode(int bytes) + { + Span buffer = stackalloc byte[bytes]; + RandomNumberGenerator.Fill(buffer); + var b64 = Convert.ToBase64String(buffer); + return b64.TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/backend/src/MinAttest.Application/Features/ShareLinks/Commands/RevokeShareLinkCommand.cs b/backend/src/MinAttest.Application/Features/ShareLinks/Commands/RevokeShareLinkCommand.cs new file mode 100644 index 0000000..ebe5e00 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/ShareLinks/Commands/RevokeShareLinkCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Application.Features.ShareLinks.Commands; + +public record RevokeShareLinkCommand(Guid AttestId, Guid ShareLinkId) : IRequest; + +public class RevokeShareLinkCommandHandler(IAppDbContext db) : IRequestHandler +{ + public async Task Handle(RevokeShareLinkCommand request, CancellationToken cancellationToken) + { + var link = await db.ShareLinks.FirstOrDefaultAsync(l => l.Id == request.ShareLinkId && l.AttestId == request.AttestId, cancellationToken); + if (link is null) return false; + if (link.RevokedAt is not null) return true; // already revoked + link.RevokedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + return true; + } +} + diff --git a/backend/src/MinAttest.Application/Features/ShareLinks/Queries/ListShareLinksQuery.cs b/backend/src/MinAttest.Application/Features/ShareLinks/Queries/ListShareLinksQuery.cs new file mode 100644 index 0000000..f74f309 --- /dev/null +++ b/backend/src/MinAttest.Application/Features/ShareLinks/Queries/ListShareLinksQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MinAttest.Application.Abstractions; +using MinAttest.Contracts.ShareLinks; + +namespace MinAttest.Application.Features.ShareLinks.Queries; + +public record ListShareLinksQuery(Guid AttestId) : IRequest>; + +public class ListShareLinksQueryHandler(IAppDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListShareLinksQuery request, CancellationToken cancellationToken) + { + return await db.ShareLinks.AsNoTracking() + .Where(l => l.AttestId == request.AttestId && l.RevokedAt == null) + .OrderBy(l => l.ExpiresAt) + .Select(l => new ShareLinkResponse(l.Id, l.Code, new Uri($"https://app.example/verify/{l.Code}"), l.ExpiresAt, l.OneTime)) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/MinAttest.Application/MinAttest.Application.csproj b/backend/src/MinAttest.Application/MinAttest.Application.csproj new file mode 100644 index 0000000..44f4d95 --- /dev/null +++ b/backend/src/MinAttest.Application/MinAttest.Application.csproj @@ -0,0 +1,17 @@ + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/backend/src/MinAttest.Contracts/Attests/AttestsContracts.cs b/backend/src/MinAttest.Contracts/Attests/AttestsContracts.cs new file mode 100644 index 0000000..50952ea --- /dev/null +++ b/backend/src/MinAttest.Contracts/Attests/AttestsContracts.cs @@ -0,0 +1,33 @@ +namespace MinAttest.Contracts.Attests; + +public record AttestSummary( + Guid Id, + string Employer, + string Title, + DateOnly From, + DateOnly To, + bool Verified +); + +public record AttestDetails( + Guid Id, + string Employer, + string Title, + DateOnly From, + DateOnly To, + string Summary, + bool Verified +); + +public record CompleteUploadRequest( + Guid PersonId, + Guid? EmployerId, + string Title, + DateOnly From, + DateOnly To, + string? Summary, + string BlobPath, + string? BlobHash, + string? ContentBase64, + string? ContentType +); diff --git a/backend/src/MinAttest.Contracts/Attests/EmployerAttestUploadRequest.cs b/backend/src/MinAttest.Contracts/Attests/EmployerAttestUploadRequest.cs new file mode 100644 index 0000000..01152a6 --- /dev/null +++ b/backend/src/MinAttest.Contracts/Attests/EmployerAttestUploadRequest.cs @@ -0,0 +1,14 @@ +namespace MinAttest.Contracts.Attests; + +public record EmployerAttestUploadRequest( + Guid PersonId, + string Title, + DateOnly From, + DateOnly To, + string? Summary, + string BlobPath, + string? BlobHash, + string? ContentBase64, + string? ContentType +); + diff --git a/backend/src/MinAttest.Contracts/Attests/PersonAttestUploadRequest.cs b/backend/src/MinAttest.Contracts/Attests/PersonAttestUploadRequest.cs new file mode 100644 index 0000000..54f2d02 --- /dev/null +++ b/backend/src/MinAttest.Contracts/Attests/PersonAttestUploadRequest.cs @@ -0,0 +1,13 @@ +namespace MinAttest.Contracts.Attests; + +public record PersonAttestUploadRequest( + string Title, + DateOnly From, + DateOnly To, + string? Summary, + string BlobPath, + string? BlobHash, + string? ContentBase64, + string? ContentType +); + diff --git a/backend/src/MinAttest.Contracts/Employers/EmployerUsersContracts.cs b/backend/src/MinAttest.Contracts/Employers/EmployerUsersContracts.cs new file mode 100644 index 0000000..9509606 --- /dev/null +++ b/backend/src/MinAttest.Contracts/Employers/EmployerUsersContracts.cs @@ -0,0 +1,18 @@ +namespace MinAttest.Contracts.Employers; + +public record EmployerUserUpsertRequest( + string ExternalObjectId, + string Email, + string? Name, + string Role +); + +public record EmployerUserResponse( + Guid Id, + Guid EmployerId, + string ExternalObjectId, + string Email, + string? Name, + string Role +); + diff --git a/backend/src/MinAttest.Contracts/Employers/EmployersContracts.cs b/backend/src/MinAttest.Contracts/Employers/EmployersContracts.cs new file mode 100644 index 0000000..acd0252 --- /dev/null +++ b/backend/src/MinAttest.Contracts/Employers/EmployersContracts.cs @@ -0,0 +1,12 @@ +namespace MinAttest.Contracts.Employers; + +public record EmployerUpsertRequest( + string OrgNumber, + string Name +); + +public record EmployerResponse( + Guid Id, + string OrgNumber, + string Name +); diff --git a/backend/src/MinAttest.Contracts/MinAttest.Contracts.csproj b/backend/src/MinAttest.Contracts/MinAttest.Contracts.csproj new file mode 100644 index 0000000..075963f --- /dev/null +++ b/backend/src/MinAttest.Contracts/MinAttest.Contracts.csproj @@ -0,0 +1,7 @@ + + + net9.0 + enable + enable + + diff --git a/backend/src/MinAttest.Contracts/Persons/PersonsContracts.cs b/backend/src/MinAttest.Contracts/Persons/PersonsContracts.cs new file mode 100644 index 0000000..5f8d1c5 --- /dev/null +++ b/backend/src/MinAttest.Contracts/Persons/PersonsContracts.cs @@ -0,0 +1,17 @@ +namespace MinAttest.Contracts.Persons; + +using MinAttest.Contracts.Attests; + +public record PersonUpsertRequest( + string NationalIdHash, + string? Email, + string? Phone +); + +public record PersonResponse( + Guid Id, + string NationalIdHash, + string? Email, + string? Phone, + IReadOnlyList Attests +); diff --git a/backend/src/MinAttest.Contracts/ShareLinks/ShareLinksContracts.cs b/backend/src/MinAttest.Contracts/ShareLinks/ShareLinksContracts.cs new file mode 100644 index 0000000..59b0c7b --- /dev/null +++ b/backend/src/MinAttest.Contracts/ShareLinks/ShareLinksContracts.cs @@ -0,0 +1,5 @@ +namespace MinAttest.Contracts.ShareLinks; + +public record CreateShareLinkRequest(TimeSpan? ExpiresIn, bool? OneTime); + +public record ShareLinkResponse(Guid ShareId, string Code, Uri Url, DateTimeOffset ExpiresAt, bool OneTime); diff --git a/backend/src/MinAttest.Domain/Entities/Attest.cs b/backend/src/MinAttest.Domain/Entities/Attest.cs new file mode 100644 index 0000000..d3743ab --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/Attest.cs @@ -0,0 +1,30 @@ +namespace MinAttest.Domain.Entities; + +public class Attest +{ + public Guid Id { get; set; } + public Guid PersonId { get; set; } + public Person Person { get; set; } = null!; + + public Guid? EmployerId { get; set; } + public Employer? Employer { get; set; } + + public string Title { get; set; } = string.Empty; + public DateOnly From { get; set; } + public DateOnly To { get; set; } + public string? Summary { get; set; } + + public string BlobPath { get; set; } = string.Empty; + public string? BlobHash { get; set; } + + // Temporary in-DB storage (to be replaced by Azure Blob) + public byte[]? Content { get; set; } + public string? ContentType { get; set; } + public long? ContentLength { get; set; } + + public AttestStatus Status { get; set; } = AttestStatus.Unverified; + public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; + public string? IssuedBy { get; set; } + + public ICollection ShareLinks { get; set; } = []; +} diff --git a/backend/src/MinAttest.Domain/Entities/AuditLog.cs b/backend/src/MinAttest.Domain/Entities/AuditLog.cs new file mode 100644 index 0000000..1e1d3ec --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/AuditLog.cs @@ -0,0 +1,13 @@ +namespace MinAttest.Domain.Entities; + +public class AuditLog +{ + public Guid Id { get; set; } + public ActorType ActorType { get; set; } + public Guid? ActorId { get; set; } + public string Action { get; set; } = string.Empty; + public string TargetType { get; set; } = string.Empty; + public Guid TargetId { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public string? Ip { get; set; } +} diff --git a/backend/src/MinAttest.Domain/Entities/Employer.cs b/backend/src/MinAttest.Domain/Entities/Employer.cs new file mode 100644 index 0000000..bcd82d7 --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/Employer.cs @@ -0,0 +1,11 @@ +namespace MinAttest.Domain.Entities; + +public class Employer +{ + public Guid Id { get; set; } + public string OrgNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + public ICollection Attests { get; set; } = []; + public ICollection Users { get; set; } = []; +} diff --git a/backend/src/MinAttest.Domain/Entities/EmployerUser.cs b/backend/src/MinAttest.Domain/Entities/EmployerUser.cs new file mode 100644 index 0000000..b5cd403 --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/EmployerUser.cs @@ -0,0 +1,17 @@ +namespace MinAttest.Domain.Entities; + +public class EmployerUser +{ + public Guid Id { get; set; } + public Guid EmployerId { get; set; } + public Employer Employer { get; set; } = null!; + + // Entra ID object id of the user + public string ExternalObjectId { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + public string? Name { get; set; } + + public EmployerUserRole Role { get; set; } = EmployerUserRole.Issuer; +} + diff --git a/backend/src/MinAttest.Domain/Entities/Enums.cs b/backend/src/MinAttest.Domain/Entities/Enums.cs new file mode 100644 index 0000000..49a3161 --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/Enums.cs @@ -0,0 +1,21 @@ +namespace MinAttest.Domain.Entities; + +public enum AttestStatus +{ + Issued = 1, + Unverified = 2, + Revoked = 3 +} + +public enum ActorType +{ + Person = 1, + Employer = 2, + Verifier = 3 +} + +public enum EmployerUserRole +{ + Issuer = 1, + Admin = 2 +} diff --git a/backend/src/MinAttest.Domain/Entities/Person.cs b/backend/src/MinAttest.Domain/Entities/Person.cs new file mode 100644 index 0000000..beb4016 --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/Person.cs @@ -0,0 +1,12 @@ +namespace MinAttest.Domain.Entities; + +public class Person +{ + public Guid Id { get; set; } + public string NationalIdHash { get; set; } = null!; + public string? NationalIdEncrypted { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + + public ICollection Attests { get; set; } = []; +} diff --git a/backend/src/MinAttest.Domain/Entities/ShareLink.cs b/backend/src/MinAttest.Domain/Entities/ShareLink.cs new file mode 100644 index 0000000..a9dc071 --- /dev/null +++ b/backend/src/MinAttest.Domain/Entities/ShareLink.cs @@ -0,0 +1,13 @@ +namespace MinAttest.Domain.Entities; + +public class ShareLink +{ + public Guid Id { get; set; } + public Guid AttestId { get; set; } + public Attest Attest { get; set; } = null!; + + public string Code { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public bool OneTime { get; set; } +} diff --git a/backend/src/MinAttest.Domain/MinAttest.Domain.csproj b/backend/src/MinAttest.Domain/MinAttest.Domain.csproj new file mode 100644 index 0000000..075963f --- /dev/null +++ b/backend/src/MinAttest.Domain/MinAttest.Domain.csproj @@ -0,0 +1,7 @@ + + + net9.0 + enable + enable + + diff --git a/backend/src/MinAttest.Infrastructure/Data/AppDbContext.cs b/backend/src/MinAttest.Infrastructure/Data/AppDbContext.cs new file mode 100644 index 0000000..314c402 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/AppDbContext.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore; +using MinAttest.Domain.Entities; +using MinAttest.Application.Abstractions; + +namespace MinAttest.Infrastructure.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options), IAppDbContext +{ + public DbSet Persons => Set(); + public DbSet Employers => Set(); + public DbSet Attests => Set(); + public DbSet ShareLinks => Set(); + public DbSet AuditLogs => Set(); + public DbSet EmployerUsers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.NationalIdHash).HasMaxLength(200); + b.HasIndex(x => x.NationalIdHash); + b.ToTable(t => t.IsTemporal(tt => tt + .UseHistoryTable("PersonsHistory") + )); + }); + + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.OrgNumber).HasMaxLength(32); + b.HasIndex(x => x.OrgNumber).IsUnique(false); + b.ToTable(t => t.IsTemporal(tt => tt + .UseHistoryTable("EmployersHistory") + )); + }); + + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Title).HasMaxLength(200); + b.Property(x => x.Summary).HasMaxLength(2000); + b.Property(x => x.BlobPath).HasMaxLength(500); + b.Property(x => x.BlobHash).HasMaxLength(128); + b.Property(x => x.Content).HasColumnType("varbinary(max)"); + b.Property(x => x.ContentType).HasMaxLength(255); + b.Property(x => x.ContentLength).HasColumnType("bigint"); + b.HasOne(x => x.Person) + .WithMany(x => x.Attests) + .HasForeignKey(x => x.PersonId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne(x => x.Employer) + .WithMany(x => x.Attests) + .HasForeignKey(x => x.EmployerId) + .OnDelete(DeleteBehavior.Restrict); + + // Consistency: EmployerId null <=> Unverified, EmployerId set => Issued/Revoked + b.ToTable(t => + { + t.HasCheckConstraint( + "CK_Attests_Verification", + "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + t.IsTemporal(tt => tt.UseHistoryTable("AttestsHistory")); + }); + + // Helpful composite indexes for common queries + b.HasIndex(x => new { x.PersonId, x.Status }); + b.HasIndex(x => new { x.EmployerId, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Code).HasMaxLength(200); + b.HasIndex(x => x.Code).IsUnique(); + b.HasOne(x => x.Attest) + .WithMany(x => x.ShareLinks) + .HasForeignKey(x => x.AttestId) + .OnDelete(DeleteBehavior.Cascade); + b.ToTable(t => t.IsTemporal(tt => tt + .UseHistoryTable("ShareLinksHistory") + )); + }); + + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.ExternalObjectId).HasMaxLength(128); + b.Property(x => x.Email).HasMaxLength(256); + b.Property(x => x.Name).HasMaxLength(200); + b.HasIndex(x => x.ExternalObjectId).IsUnique(); + b.HasIndex(x => x.EmployerId); + b.HasOne(x => x.Employer) + .WithMany(e => e.Users) + .HasForeignKey(x => x.EmployerId) + .OnDelete(DeleteBehavior.Cascade); + b.ToTable(t => t.IsTemporal(tt => tt + .UseHistoryTable("EmployerUsersHistory") + )); + }); + + modelBuilder.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Action).HasMaxLength(100); + b.Property(x => x.TargetType).HasMaxLength(100); + b.Property(x => x.Ip).HasMaxLength(64); + b.HasIndex(x => x.Timestamp); + }); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + NormalizeAttestStatuses(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + NormalizeAttestStatuses(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void NormalizeAttestStatuses() + { + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified)) continue; + var a = entry.Entity; + + if (a.EmployerId is null) + { + // No employer -> always Unverified + a.Status = AttestStatus.Unverified; + } + else if (a.Status == AttestStatus.Unverified) + { + // Has employer but Unverified -> default to Issued + a.Status = AttestStatus.Issued; + } + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/AppDbContextFactory.cs b/backend/src/MinAttest.Infrastructure/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..62f09b3 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/AppDbContextFactory.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace MinAttest.Infrastructure.Data; + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var basePath = Directory.GetCurrentDirectory(); + Directory.CreateDirectory(Path.Combine(basePath, "data")); + + var config = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var cs = config.GetConnectionString("Default") + ?? config.GetConnectionString("MinAttest") + ?? "Server=localhost,1433;Database=MinAttest;User Id=sa;Password=Your_password123;TrustServerCertificate=True;"; + + var options = new DbContextOptionsBuilder() + .UseSqlServer(cs) + .Options; + + return new AppDbContext(options); + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.Designer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.Designer.cs new file mode 100644 index 0000000..2f632b1 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.Designer.cs @@ -0,0 +1,249 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250913095523_InitialSqlServer")] + partial class InitialSqlServer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId"); + + b.HasIndex("PersonId"); + + b.ToTable("Attests"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.HasOne("MinAttest.Api.Data.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Api.Data.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Api.Data.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b => + { + b.Navigation("Attests"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.cs new file mode 100644 index 0000000..71165f3 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913095523_InitialSqlServer.cs @@ -0,0 +1,172 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + /// + public partial class InitialSqlServer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ActorType = table.Column(type: "int", nullable: false), + ActorId = table.Column(type: "uniqueidentifier", nullable: true), + Action = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + TargetType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + TargetId = table.Column(type: "uniqueidentifier", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false), + Ip = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Employers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrgNumber = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Employers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Persons", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + NationalIdHash = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + NationalIdEncrypted = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(max)", nullable: true), + Phone = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Persons", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Attests", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + PersonId = table.Column(type: "uniqueidentifier", nullable: false), + EmployerId = table.Column(type: "uniqueidentifier", nullable: true), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + From = table.Column(type: "date", nullable: false), + To = table.Column(type: "date", nullable: false), + Summary = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + BlobPath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + BlobHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + Status = table.Column(type: "int", nullable: false), + IssuedAt = table.Column(type: "datetimeoffset", nullable: false), + IssuedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Attests", x => x.Id); + table.ForeignKey( + name: "FK_Attests_Employers_EmployerId", + column: x => x.EmployerId, + principalTable: "Employers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Attests_Persons_PersonId", + column: x => x.PersonId, + principalTable: "Persons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ShareLinks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + AttestId = table.Column(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false), + RevokedAt = table.Column(type: "datetimeoffset", nullable: true), + OneTime = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShareLinks", x => x.Id); + table.ForeignKey( + name: "FK_ShareLinks_Attests_AttestId", + column: x => x.AttestId, + principalTable: "Attests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Attests_EmployerId", + table: "Attests", + column: "EmployerId"); + + migrationBuilder.CreateIndex( + name: "IX_Attests_PersonId", + table: "Attests", + column: "PersonId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Timestamp", + table: "AuditLogs", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_Employers_OrgNumber", + table: "Employers", + column: "OrgNumber"); + + migrationBuilder.CreateIndex( + name: "IX_Persons_NationalIdHash", + table: "Persons", + column: "NationalIdHash"); + + migrationBuilder.CreateIndex( + name: "IX_ShareLinks_AttestId", + table: "ShareLinks", + column: "AttestId"); + + migrationBuilder.CreateIndex( + name: "IX_ShareLinks_Code", + table: "ShareLinks", + column: "Code", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "ShareLinks"); + + migrationBuilder.DropTable( + name: "Attests"); + + migrationBuilder.DropTable( + name: "Employers"); + + migrationBuilder.DropTable( + name: "Persons"); + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.Designer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.Designer.cs new file mode 100644 index 0000000..41f5717 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.Designer.cs @@ -0,0 +1,252 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250913104919_AddAttestVerificationCheck")] + partial class AddAttestVerificationCheck + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId", "Status"); + + b.HasIndex("PersonId", "Status"); + + b.ToTable("Attests", t => + { + t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + }); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.HasOne("MinAttest.Api.Data.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Api.Data.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Api.Data.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Employer", b => + { + b.Navigation("Attests"); + }); + + modelBuilder.Entity("MinAttest.Api.Data.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.cs new file mode 100644 index 0000000..fda834d --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913104919_AddAttestVerificationCheck.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + /// + public partial class AddAttestVerificationCheck : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Attests_EmployerId", + table: "Attests"); + + migrationBuilder.DropIndex( + name: "IX_Attests_PersonId", + table: "Attests"); + + migrationBuilder.CreateIndex( + name: "IX_Attests_EmployerId_Status", + table: "Attests", + columns: new[] { "EmployerId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Attests_PersonId_Status", + table: "Attests", + columns: new[] { "PersonId", "Status" }); + + migrationBuilder.AddCheckConstraint( + name: "CK_Attests_Verification", + table: "Attests", + sql: "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Attests_EmployerId_Status", + table: "Attests"); + + migrationBuilder.DropIndex( + name: "IX_Attests_PersonId_Status", + table: "Attests"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Attests_Verification", + table: "Attests"); + + migrationBuilder.CreateIndex( + name: "IX_Attests_EmployerId", + table: "Attests", + column: "EmployerId"); + + migrationBuilder.CreateIndex( + name: "IX_Attests_PersonId", + table: "Attests", + column: "PersonId"); + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.Designer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.Designer.cs new file mode 100644 index 0000000..9446902 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.Designer.cs @@ -0,0 +1,336 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250913112309_EnableTemporalTables")] + partial class EnableTemporalTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId", "Status"); + + b.HasIndex("PersonId", "Status"); + + b.ToTable("Attests", t => + { + t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + }); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("AttestsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("PersonsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ShareLinksHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Domain.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Domain.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Navigation("Attests"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.cs new file mode 100644 index 0000000..0b7866b --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913112309_EnableTemporalTables.cs @@ -0,0 +1,187 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + /// + public partial class EnableTemporalTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterTable( + name: "ShareLinks") + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "ShareLinksHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Persons") + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "PersonsHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Employers") + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "EmployersHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Attests") + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "AttestsHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AddColumn( + name: "PeriodEnd", + table: "ShareLinks", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodStart", + table: "ShareLinks", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodEnd", + table: "Persons", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodStart", + table: "Persons", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodEnd", + table: "Employers", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodStart", + table: "Employers", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodEnd", + table: "Attests", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.AddColumn( + name: "PeriodStart", + table: "Attests", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)) + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PeriodEnd", + table: "ShareLinks") + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodStart", + table: "ShareLinks") + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodEnd", + table: "Persons") + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodStart", + table: "Persons") + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodEnd", + table: "Employers") + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodStart", + table: "Employers") + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodEnd", + table: "Attests") + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true); + + migrationBuilder.DropColumn( + name: "PeriodStart", + table: "Attests") + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true); + + migrationBuilder.AlterTable( + name: "ShareLinks") + .OldAnnotation("SqlServer:IsTemporal", true) + .OldAnnotation("SqlServer:TemporalHistoryTableName", "ShareLinksHistory") + .OldAnnotation("SqlServer:TemporalHistoryTableSchema", null) + .OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Persons") + .OldAnnotation("SqlServer:IsTemporal", true) + .OldAnnotation("SqlServer:TemporalHistoryTableName", "PersonsHistory") + .OldAnnotation("SqlServer:TemporalHistoryTableSchema", null) + .OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Employers") + .OldAnnotation("SqlServer:IsTemporal", true) + .OldAnnotation("SqlServer:TemporalHistoryTableName", "EmployersHistory") + .OldAnnotation("SqlServer:TemporalHistoryTableSchema", null) + .OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.AlterTable( + name: "Attests") + .OldAnnotation("SqlServer:IsTemporal", true) + .OldAnnotation("SqlServer:TemporalHistoryTableName", "AttestsHistory") + .OldAnnotation("SqlServer:TemporalHistoryTableSchema", null) + .OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.Designer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.Designer.cs new file mode 100644 index 0000000..c8bd5d5 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.Designer.cs @@ -0,0 +1,406 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250913122233_AddEmployerUser")] + partial class AddEmployerUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId", "Status"); + + b.HasIndex("PersonId", "Status"); + + b.ToTable("Attests", t => + { + t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + }); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("AttestsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExternalObjectId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId"); + + b.HasIndex("ExternalObjectId") + .IsUnique(); + + b.ToTable("EmployerUsers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployerUsersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("PersonsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ShareLinksHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Domain.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Users") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Domain.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Navigation("Attests"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.cs new file mode 100644 index 0000000..e1cb245 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913122233_AddEmployerUser.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + /// + public partial class AddEmployerUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmployerUsers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EmployerId = table.Column(type: "uniqueidentifier", nullable: false), + ExternalObjectId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Role = table.Column(type: "int", nullable: false), + PeriodEnd = table.Column(type: "datetime2", nullable: false) + .Annotation("SqlServer:TemporalIsPeriodEndColumn", true), + PeriodStart = table.Column(type: "datetime2", nullable: false) + .Annotation("SqlServer:TemporalIsPeriodStartColumn", true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmployerUsers", x => x.Id); + table.ForeignKey( + name: "FK_EmployerUsers_Employers_EmployerId", + column: x => x.EmployerId, + principalTable: "Employers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "EmployerUsersHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + + migrationBuilder.CreateIndex( + name: "IX_EmployerUsers_EmployerId", + table: "EmployerUsers", + column: "EmployerId"); + + migrationBuilder.CreateIndex( + name: "IX_EmployerUsers_ExternalObjectId", + table: "EmployerUsers", + column: "ExternalObjectId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmployerUsers") + .Annotation("SqlServer:IsTemporal", true) + .Annotation("SqlServer:TemporalHistoryTableName", "EmployerUsersHistory") + .Annotation("SqlServer:TemporalHistoryTableSchema", null) + .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") + .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.Designer.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.Designer.cs new file mode 100644 index 0000000..42214ee --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.Designer.cs @@ -0,0 +1,416 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250913124425_AddAttestContentColumns")] + partial class AddAttestContentColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Content") + .HasColumnType("varbinary(max)"); + + b.Property("ContentLength") + .HasColumnType("bigint"); + + b.Property("ContentType") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId", "Status"); + + b.HasIndex("PersonId", "Status"); + + b.ToTable("Attests", t => + { + t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + }); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("AttestsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExternalObjectId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId"); + + b.HasIndex("ExternalObjectId") + .IsUnique(); + + b.ToTable("EmployerUsers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployerUsersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("PersonsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ShareLinksHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Domain.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Users") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Domain.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Navigation("Attests"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.cs new file mode 100644 index 0000000..5cf5d02 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/20250913124425_AddAttestContentColumns.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + /// + public partial class AddAttestContentColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Content", + table: "Attests", + type: "varbinary(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ContentLength", + table: "Attests", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "ContentType", + table: "Attests", + type: "nvarchar(255)", + maxLength: 255, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Content", + table: "Attests"); + + migrationBuilder.DropColumn( + name: "ContentLength", + table: "Attests"); + + migrationBuilder.DropColumn( + name: "ContentType", + table: "Attests"); + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/MinAttest.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..b995fc7 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,413 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinAttest.Infrastructure.Data; + +#nullable disable + +namespace MinAttest.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobHash") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("BlobPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Content") + .HasColumnType("varbinary(max)"); + + b.Property("ContentLength") + .HasColumnType("bigint"); + + b.Property("ContentType") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("From") + .HasColumnType("date"); + + b.Property("IssuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IssuedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("To") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId", "Status"); + + b.HasIndex("PersonId", "Status"); + + b.ToTable("Attests", t => + { + t.HasCheckConstraint("CK_Attests_Verification", "(([EmployerId] IS NULL AND [Status] = 2) OR ([EmployerId] IS NOT NULL AND [Status] IN (1,3)))"); + }); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("AttestsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("ActorType") + .HasColumnType("int"); + + b.Property("Ip") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrgNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.HasKey("Id"); + + b.HasIndex("OrgNumber"); + + b.ToTable("Employers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmployerId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExternalObjectId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployerId"); + + b.HasIndex("ExternalObjectId") + .IsUnique(); + + b.ToTable("EmployerUsers"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("EmployerUsersHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdEncrypted") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NationalIdHash"); + + b.ToTable("Persons"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("PersonsHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttestId") + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("OneTime") + .HasColumnType("bit"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AttestId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ShareLinks"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ShareLinksHistory"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Attests") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MinAttest.Domain.Entities.Person", "Person") + .WithMany("Attests") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.EmployerUser", b => + { + b.HasOne("MinAttest.Domain.Entities.Employer", "Employer") + .WithMany("Users") + .HasForeignKey("EmployerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employer"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.ShareLink", b => + { + b.HasOne("MinAttest.Domain.Entities.Attest", "Attest") + .WithMany("ShareLinks") + .HasForeignKey("AttestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attest"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Attest", b => + { + b.Navigation("ShareLinks"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Employer", b => + { + b.Navigation("Attests"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("MinAttest.Domain.Entities.Person", b => + { + b.Navigation("Attests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj b/backend/src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj new file mode 100644 index 0000000..98909b2 --- /dev/null +++ b/backend/src/MinAttest.Infrastructure/MinAttest.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/backend/tests/MinAttest.Tests/Domain/ValidationTests.cs b/backend/tests/MinAttest.Tests/Domain/ValidationTests.cs new file mode 100644 index 0000000..59150ac --- /dev/null +++ b/backend/tests/MinAttest.Tests/Domain/ValidationTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.Employers.Commands; +using MinAttest.Application.Features.Persons.Commands; +using MinAttest.Contracts.Attests; +using Xunit; + +namespace MinAttest.Tests.Domain; + +public class ValidationTests +{ + [Fact] + public void UpsertPersonCommand_invalid_when_missing_hash() + { + var v = new UpsertPersonCommandValidator(); + var r = v.Validate(new UpsertPersonCommand("", null, null)); + r.IsValid.Should().BeFalse(); + } + + [Fact] + public void UpsertEmployerCommand_invalid_when_missing_fields() + { + var v = new UpsertEmployerCommandValidator(); + var r = v.Validate(new UpsertEmployerCommand("", "")); + r.IsValid.Should().BeFalse(); + } + + [Fact] + public void PersonUploadAttestCommand_requires_title_and_dates() + { + var v = new PersonUploadAttestCommandValidator(); + var cmd = new PersonUploadAttestCommand(Guid.NewGuid(), new PersonAttestUploadRequest( + Title: "", + From: new DateOnly(2024,12,31), + To: new DateOnly(2024,1,1), + Summary: null, + BlobPath: "", + BlobHash: null, + ContentBase64: null, + ContentType: null)); + var r = v.Validate(cmd); + r.IsValid.Should().BeFalse(); + } + + [Fact] + public void EmployerIssueAttestCommand_requires_person_title_and_dates() + { + var v = new EmployerIssueAttestCommandValidator(); + var cmd = new EmployerIssueAttestCommand(Guid.Empty, new EmployerAttestUploadRequest( + PersonId: Guid.Empty, + Title: "", + From: new DateOnly(2024,12,31), + To: new DateOnly(2024,1,1), + Summary: null, + BlobPath: "", + BlobHash: null, + ContentBase64: null, + ContentType: null)); + var r = v.Validate(cmd); + r.IsValid.Should().BeFalse(); + } +} + diff --git a/backend/tests/MinAttest.Tests/Integration/Api/EmployersApiTests.cs b/backend/tests/MinAttest.Tests/Integration/Api/EmployersApiTests.cs new file mode 100644 index 0000000..0e0e1f6 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/Api/EmployersApiTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.Employers; +using MinAttest.Contracts.Persons; +using MinAttest.Tests.Integration.Server; +using Xunit; + +namespace MinAttest.Tests.Integration.Api; + +[Collection("IntegrationCollection")] +public class EmployersApiTests : IClassFixture +{ + private readonly HttpClient _client; + public EmployersApiTests(ApiWebAppFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task Employer_issue_and_list_and_download_via_http() + { + // Upsert employer + var employerUpsert = new EmployerUpsertRequest("321654987", "ApiCo AS"); + var employerResp = await _client.PostAsJsonAsync("/api/v1/employers", employerUpsert); + employerResp.StatusCode.Should().Be(HttpStatusCode.OK); + var employer = await employerResp.Content.ReadFromJsonAsync(); + employer.Should().NotBeNull(); + + // Upsert person + var personResp = await _client.PostAsJsonAsync("/api/v1/persons", new PersonUpsertRequest("hash-api-emp-1", "emp-person@example.com", null)); + personResp.StatusCode.Should().Be(HttpStatusCode.OK); + var person = await personResp.Content.ReadFromJsonAsync(); + person.Should().NotBeNull(); + + // Issue attest for person + var issueReq = new EmployerAttestUploadRequest( + PersonId: person!.Id, + Title: "ApiEmployerAttest", + From: new DateOnly(2024,1,1), + To: new DateOnly(2024,6,1), + Summary: null, + BlobPath: string.Empty, + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[]{7,8,9}), + ContentType: "application/octet-stream"); + + var issueResp = await _client.PostAsJsonAsync($"/api/v1/employers/{employer!.Id}/attests", issueReq); + issueResp.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await issueResp.Content.ReadFromJsonAsync>(); + created.Should().NotBeNull(); + var createdId = Guid.Parse(created!["attestId"].ToString()!); + + // List employer attests + var listResp = await _client.GetAsync($"/api/v1/employers/{employer.Id}/attests"); + listResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Download + var dlResp = await _client.GetAsync($"/api/v1/employers/{employer.Id}/attests/{createdId}/download"); + dlResp.StatusCode.Should().Be(HttpStatusCode.OK); + (await dlResp.Content.ReadAsByteArrayAsync()).Length.Should().BeGreaterThan(0); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/Api/PersonsApiTests.cs b/backend/tests/MinAttest.Tests/Integration/Api/PersonsApiTests.cs new file mode 100644 index 0000000..791025d --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/Api/PersonsApiTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.Persons; +using MinAttest.Tests.Integration.Server; +using Xunit; + +namespace MinAttest.Tests.Integration.Api; + +[Collection("IntegrationCollection")] +public class PersonsApiTests : IClassFixture +{ + private readonly HttpClient _client; + public PersonsApiTests(ApiWebAppFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task Upsert_person_and_upload_and_list() + { + var upsertResp = await _client.PostAsJsonAsync("/api/v1/persons", new PersonUpsertRequest("hash-api-1", "api@example.com", null)); + upsertResp.StatusCode.Should().Be(HttpStatusCode.OK); + var person = await upsertResp.Content.ReadFromJsonAsync(); + person.Should().NotBeNull(); + + var uploadReq = new PersonAttestUploadRequest( + Title: "ApiAttest", + From: new DateOnly(2024,1,1), + To: new DateOnly(2024,6,1), + Summary: "", + BlobPath: "", + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[]{1,2,3}), + ContentType: "application/octet-stream"); + + var uploadResp = await _client.PostAsJsonAsync($"/api/v1/persons/{person!.Id}/attests", uploadReq); + uploadResp.StatusCode.Should().Be(HttpStatusCode.Created); + + var listResp = await _client.GetAsync($"/api/v1/persons/{person.Id}/attests"); + listResp.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/Api/ShareLinksApiTests.cs b/backend/tests/MinAttest.Tests/Integration/Api/ShareLinksApiTests.cs new file mode 100644 index 0000000..20e82e3 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/Api/ShareLinksApiTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.Persons; +using MinAttest.Contracts.ShareLinks; +using MinAttest.Tests.Integration.Server; +using Xunit; + +namespace MinAttest.Tests.Integration.Api; + +[Collection("IntegrationCollection")] +public class ShareLinksApiTests : IClassFixture +{ + private readonly HttpClient _client; + public ShareLinksApiTests(ApiWebAppFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task Create_list_revoke_sharelink_via_http() + { + // Upsert person + var personResp = await _client.PostAsJsonAsync("/api/v1/persons", new PersonUpsertRequest("hash-api-share-1", "share-person@example.com", null)); + personResp.StatusCode.Should().Be(HttpStatusCode.OK); + var person = await personResp.Content.ReadFromJsonAsync(); + + // Upload attest + var upload = new PersonAttestUploadRequest( + Title: "ShareAttest", + From: new DateOnly(2024,1,1), + To: new DateOnly(2024,6,1), + Summary: null, + BlobPath: string.Empty, + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[]{1,2,3,4}), + ContentType: "application/pdf"); + var upResp = await _client.PostAsJsonAsync($"/api/v1/persons/{person!.Id}/attests", upload); + upResp.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await upResp.Content.ReadFromJsonAsync>(); + var attestId = Guid.Parse(created!["attestId"].ToString()!); + + // Create share link + var createReq = new CreateShareLinkRequest(TimeSpan.FromDays(7), false); + var slResp = await _client.PostAsJsonAsync($"/api/v1/attests/{attestId}/sharelinks", createReq); + slResp.StatusCode.Should().Be(HttpStatusCode.OK); + var link = await slResp.Content.ReadFromJsonAsync(); + link.Should().NotBeNull(); + + // List + var listResp = await _client.GetAsync($"/api/v1/attests/{attestId}/sharelinks"); + listResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Revoke + var delResp = await _client.DeleteAsync($"/api/v1/attests/{attestId}/sharelinks/{link!.ShareId}"); + delResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/AttestsIntegrationTests.cs b/backend/tests/MinAttest.Tests/Integration/AttestsIntegrationTests.cs new file mode 100644 index 0000000..42e4300 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/AttestsIntegrationTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.Attests.Queries; +using MinAttest.Contracts.Attests; +using Xunit; + +namespace MinAttest.Tests.Integration; + +[Collection("IntegrationCollection")] +public class AttestsIntegrationTests +{ + private readonly IntegrationTestFixture _fixture; + + public AttestsIntegrationTests(IntegrationTestFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Person_upload_and_list_attest_success() + { + var sp = _fixture.Services; + var mediator = sp.GetRequiredService(); + + // Ensure person exists + var upsert = await mediator.Send(new MinAttest.Application.Features.Persons.Commands.UpsertPersonCommand("person-hash-123", "person@example.com", null)); + var personId = upsert.Id; + + var req = new PersonAttestUploadRequest( + Title: "TestAttest", + From: new DateOnly(2023,1,1), + To: new DateOnly(2023,12,31), + Summary: "Test summary", + BlobPath: string.Empty, + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[] {1,2,3}), + ContentType: "application/octet-stream" + ); + + var createdId = await mediator.Send(new PersonUploadAttestCommand(personId, req)); + createdId.Should().NotBeNull(); + + var list = await mediator.Send(new ListPersonAttestsQuery(personId, 10)); + list.Should().NotBeNull(); + list!.Any(a => a.Id == createdId).Should().BeTrue(); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/EmployerUsersIntegrationTests.cs b/backend/tests/MinAttest.Tests/Integration/EmployerUsersIntegrationTests.cs new file mode 100644 index 0000000..064724b --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/EmployerUsersIntegrationTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application.Features.Employers.Commands; +using MinAttest.Application.Features.Employers.Queries; +using Xunit; + +namespace MinAttest.Tests.Integration; + +[Collection("IntegrationCollection")] +public class EmployerUsersIntegrationTests +{ + private readonly IntegrationTestFixture _fixture; + public EmployerUsersIntegrationTests(IntegrationTestFixture fixture) => _fixture = fixture; + [Fact] + public async Task Upsert_list_get_delete_employer_user() + { + var mediator = _fixture.Services.GetRequiredService(); + + // Ensure employer exists + var employer = await mediator.Send(new UpsertEmployerCommand("999999999", "TestBedrift AS")); + + // Upsert user + var user = await mediator.Send(new UpsertEmployerUserCommand( + employer.Id, "oid-123", "hr@example.com", "Hr User", "Issuer")); + user.Should().NotBeNull(); + + // List + var list = await mediator.Send(new ListEmployerUsersQuery(employer.Id)); + list.Should().Contain(u => u.ExternalObjectId == "oid-123"); + + // Get + var got = await mediator.Send(new GetEmployerUserQuery(employer.Id, user!.Id)); + got.Should().NotBeNull(); + got!.Email.Should().Be("hr@example.com"); + + // Delete + var deleted = await mediator.Send(new DeleteEmployerUserCommand(employer.Id, user.Id)); + deleted.Should().BeTrue(); + + // Delete again -> false or idempotent? we return false when not found + var deletedAgain = await mediator.Send(new DeleteEmployerUserCommand(employer.Id, user.Id)); + deletedAgain.Should().BeFalse(); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/EmployersAttestsIntegrationTests.cs b/backend/tests/MinAttest.Tests/Integration/EmployersAttestsIntegrationTests.cs new file mode 100644 index 0000000..4a45955 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/EmployersAttestsIntegrationTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.Attests.Queries; +using MinAttest.Application.Features.Employers.Commands; +using MinAttest.Contracts.Attests; +using Xunit; + +namespace MinAttest.Tests.Integration; + +[Collection("IntegrationCollection")] +public class EmployersAttestsIntegrationTests +{ + private readonly IntegrationTestFixture _fixture; + public EmployersAttestsIntegrationTests(IntegrationTestFixture fixture) => _fixture = fixture; + [Fact] + public async Task Employer_issue_and_list_attests() + { + var mediator = _fixture.Services.GetRequiredService(); + + // Ensure employer & person + var employer = await mediator.Send(new UpsertEmployerCommand("555555555", "NewCo AS")); + var person = await mediator.Send(new MinAttest.Application.Features.Persons.Commands.UpsertPersonCommand("person-hash-456", "person2@example.com", null)); + + var req = new EmployerAttestUploadRequest( + PersonId: person.Id, + Title: "IssuedAttest", + From: new DateOnly(2023,1,1), + To: new DateOnly(2023,12,31), + Summary: null, + BlobPath: string.Empty, + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[]{4,5,6}), + ContentType: "application/octet-stream"); + + var createdId = await mediator.Send(new EmployerIssueAttestCommand(employer.Id, req)); + createdId.Should().NotBeNull(); + + var list = await mediator.Send(new ListEmployerAttestsQuery(employer.Id, 10)); + list.Should().Contain(a => a.Id == createdId); + + // Content + var content = await mediator.Send(new GetAttestContentQuery(createdId!.Value)); + content.Should().NotBeNull(); + content!.Content.Length.Should().BeGreaterThan(0); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/EmployersIntegrationTests.cs b/backend/tests/MinAttest.Tests/Integration/EmployersIntegrationTests.cs new file mode 100644 index 0000000..ed46f90 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/EmployersIntegrationTests.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application.Features.Employers.Commands; +using MinAttest.Application.Features.Employers.Queries; +using Xunit; + +namespace MinAttest.Tests.Integration; + +[Collection("IntegrationCollection")] +public class EmployersIntegrationTests +{ + private readonly IntegrationTestFixture _fixture; + public EmployersIntegrationTests(IntegrationTestFixture fixture) => _fixture = fixture; + [Fact] + public async Task Upsert_and_get_and_list_employers_work() + { + var mediator = _fixture.Services.GetRequiredService(); + + var upsert = await mediator.Send(new UpsertEmployerCommand("123456789", "Acme AS")); + upsert.OrgNumber.Should().Be("123456789"); + upsert.Name.Should().Be("Acme AS"); + + var list = await mediator.Send(new ListEmployersQuery()); + list.Should().Contain(e => e.OrgNumber == "123456789"); + + var get = await mediator.Send(new GetEmployerQuery(upsert.Id)); + get.Should().NotBeNull(); + get!.Name.Should().Be("Acme AS"); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/IntegrationCollection.cs b/backend/tests/MinAttest.Tests/Integration/IntegrationCollection.cs new file mode 100644 index 0000000..9bd39c5 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/IntegrationCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace MinAttest.Tests.Integration; + +[CollectionDefinition("IntegrationCollection")] +public class IntegrationCollection : ICollectionFixture +{ +} + diff --git a/backend/tests/MinAttest.Tests/Integration/IntegrationTestFixture.cs b/backend/tests/MinAttest.Tests/Integration/IntegrationTestFixture.cs new file mode 100644 index 0000000..1727ef9 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/IntegrationTestFixture.cs @@ -0,0 +1,67 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application; +using MinAttest.Application.Abstractions; +using MinAttest.Application.Common.Behaviors; +using MinAttest.Infrastructure.Data; +using Xunit; + +namespace MinAttest.Tests.Integration; + +public class IntegrationTestFixture : IAsyncLifetime +{ + public IServiceProvider Services { get; private set; } = default!; + public string ConnectionString { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + await Server.TestDb.StartAsync(); + ConnectionString = Server.TestDb.ConnectionString; + + var services = new ServiceCollection(); + + services.AddDbContext(opt => opt.UseSqlServer(ConnectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + Services = services.BuildServiceProvider(); + + // Migrate and seed + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + await SeedAsync(db); + } + + public async Task DisposeAsync() + { + await Server.TestDb.StopAsync(); + } + + private static async Task SeedAsync(AppDbContext db) + { + if (!db.Persons.Any()) + { + var personId = Guid.NewGuid(); + db.Persons.Add(new MinAttest.Domain.Entities.Person + { + Id = personId, + NationalIdHash = "person-hash-123", + Email = "person@example.com" + }); + + var employerId = Guid.NewGuid(); + db.Employers.Add(new MinAttest.Domain.Entities.Employer + { + Id = employerId, + OrgNumber = "999999999", + Name = "TestBedrift AS" + }); + await db.SaveChangesAsync(); + } + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/Server/ApiWebAppFactory.cs b/backend/tests/MinAttest.Tests/Integration/Server/ApiWebAppFactory.cs new file mode 100644 index 0000000..b9a1279 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/Server/ApiWebAppFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Infrastructure.Data; + +namespace MinAttest.Tests.Integration.Server; + +public class ApiWebAppFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor is not null) + { + services.Remove(descriptor); + } + services.AddDbContext(options => options.UseSqlServer(TestDb.ConnectionString)); + }); + } +} diff --git a/backend/tests/MinAttest.Tests/Integration/Server/TestDb.cs b/backend/tests/MinAttest.Tests/Integration/Server/TestDb.cs new file mode 100644 index 0000000..0614f83 --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/Server/TestDb.cs @@ -0,0 +1,42 @@ +using Testcontainers.MsSql; + +namespace MinAttest.Tests.Integration.Server; + +public static class TestDb +{ + private static MsSqlContainer? _container; + private static readonly object _lock = new(); + private static bool _started; + + public static string ConnectionString { get; private set; } = string.Empty; + + public static async Task StartAsync() + { + if (_started) return; + lock (_lock) + { + if (_container == null) + { + _container = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("Your_password123") + .WithPortBinding(14333, 1433) + .Build(); + } + } + await _container!.StartAsync(); + ConnectionString = _container.GetConnectionString() + ";Database=MinAttest_Integration"; + _started = true; + } + + public static async Task StopAsync() + { + if (_container is null) return; + await _container.StopAsync(); + await _container.DisposeAsync(); + _container = null; + _started = false; + ConnectionString = string.Empty; + } +} + diff --git a/backend/tests/MinAttest.Tests/Integration/ShareLinksIntegrationTests.cs b/backend/tests/MinAttest.Tests/Integration/ShareLinksIntegrationTests.cs new file mode 100644 index 0000000..eee074d --- /dev/null +++ b/backend/tests/MinAttest.Tests/Integration/ShareLinksIntegrationTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MinAttest.Application.Features.Attests.Commands; +using MinAttest.Application.Features.ShareLinks.Commands; +using MinAttest.Application.Features.ShareLinks.Queries; +using MinAttest.Contracts.Attests; +using MinAttest.Contracts.ShareLinks; +using Xunit; + +namespace MinAttest.Tests.Integration; + +[Collection("IntegrationCollection")] +public class ShareLinksIntegrationTests +{ + private readonly IntegrationTestFixture _fixture; + public ShareLinksIntegrationTests(IntegrationTestFixture fixture) => _fixture = fixture; + [Fact] + public async Task Create_list_revoke_sharelink_for_attest() + { + var mediator = _fixture.Services.GetRequiredService(); + + // Ensure person + var person = await mediator.Send(new MinAttest.Application.Features.Persons.Commands.UpsertPersonCommand("person-hash-123", "person@example.com", null)); + + // Create attest + var attestId = await mediator.Send(new PersonUploadAttestCommand(person.Id, new PersonAttestUploadRequest( + Title: "Test", + From: new DateOnly(2023,1,1), + To: new DateOnly(2023,12,31), + Summary: null, + BlobPath: string.Empty, + BlobHash: null, + ContentBase64: Convert.ToBase64String(new byte[]{1,2,3}), + ContentType: "application/octet-stream" + ))); + + attestId.Should().NotBeNull(); + + // Create sharelink + var created = await mediator.Send(new CreateShareLinkCommand(attestId!.Value, new CreateShareLinkRequest(TimeSpan.FromDays(7), false), "https://localhost")); + created.Should().NotBeNull(); + + // List + var list = await mediator.Send(new ListShareLinksQuery(attestId.Value)); + list.Should().Contain(sl => sl.ShareId == created!.ShareId); + + // Revoke + var revoked = await mediator.Send(new RevokeShareLinkCommand(attestId.Value, created!.ShareId)); + revoked.Should().BeTrue(); + } +} diff --git a/backend/tests/MinAttest.Tests/MinAttest.Tests.csproj b/backend/tests/MinAttest.Tests/MinAttest.Tests.csproj new file mode 100644 index 0000000..22dd260 --- /dev/null +++ b/backend/tests/MinAttest.Tests/MinAttest.Tests.csproj @@ -0,0 +1,30 @@ + + + net9.0 + false + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/tests/MinAttest.Tests/appsettings.Integration.json b/backend/tests/MinAttest.Tests/appsettings.Integration.json new file mode 100644 index 0000000..fcb9787 --- /dev/null +++ b/backend/tests/MinAttest.Tests/appsettings.Integration.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "Default": "Server=localhost,1433;Database=MinAttest_Integration;User Id=sa;Password=Your_password123;TrustServerCertificate=True;" + } +} diff --git a/frontend/minattest-app-host/MinAttest.AppHost.sln b/frontend/minattest-app-host/MinAttest.AppHost.sln new file mode 100644 index 0000000..6640c64 --- /dev/null +++ b/frontend/minattest-app-host/MinAttest.AppHost.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "minattest-app-host", "minattest-app-host.csproj", "{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x64.Build.0 = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x86.Build.0 = Debug|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|Any CPU.Build.0 = Release|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x64.ActiveCfg = Release|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x64.Build.0 = Release|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x86.ActiveCfg = Release|Any CPU + {DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/frontend/minattest-app-host/OpenApi/OpenApiExtensions.cs b/frontend/minattest-app-host/OpenApi/OpenApiExtensions.cs new file mode 100644 index 0000000..11cdb20 --- /dev/null +++ b/frontend/minattest-app-host/OpenApi/OpenApiExtensions.cs @@ -0,0 +1,16 @@ +namespace minattest_app_host.OpenApi +{ + public static class OpenApiExtensions + { + public static void AddSwagger(this IServiceCollection services) + { + services.AddSwaggerGen(); + } + + public static void UseAppSwagger(this WebApplication app) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + } +} diff --git a/frontend/minattest-app-host/Program.cs b/frontend/minattest-app-host/Program.cs new file mode 100644 index 0000000..856f7d9 --- /dev/null +++ b/frontend/minattest-app-host/Program.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using minattest_app_host.OpenApi; +using Serilog; +using Yarp.ReverseProxy.Forwarder; + +Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting minattest app host"); + + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services)); + + builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwagger(); + builder.Services.TryAddSingleton(); + + var app = builder.Build(); + app.UseSerilogRequestLogging(); + + app.Use((context, next) => + { + context.Request.Scheme = "https"; + return next(context); + }); + + app.UseAppSwagger(); + app.UseHttpsRedirection(); + + app.MapReverseProxy(configure => configure.Use(async (context, next) => + { + await next(); + var errorFeature = context.GetForwarderErrorFeature(); + if (errorFeature is not null && errorFeature.Error != ForwarderError.None && errorFeature.Exception != null) + throw errorFeature.Exception; + })); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/frontend/minattest-app-host/Properties/launchSettings.json b/frontend/minattest-app-host/Properties/launchSettings.json new file mode 100644 index 0000000..a4296a2 --- /dev/null +++ b/frontend/minattest-app-host/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "MinAttestApp": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:10001;http://localhost:10000", + "dotnetRunMessages": true + } + } +} diff --git a/frontend/minattest-app-host/README.md b/frontend/minattest-app-host/README.md new file mode 100644 index 0000000..3213005 --- /dev/null +++ b/frontend/minattest-app-host/README.md @@ -0,0 +1,26 @@ +minattest-app-host (BFF) + +Overview +- .NET 9 minimal app acting as a reverse proxy for both the React frontend and the API during development. +- Uses YARP to forward: + - `/api/*` to the API (`API_URL`, default `https://localhost:7172`). + - everything else to the frontend dev server (`FRONTEND_URL`, default `http://localhost:5173`). + +Configure +- Env vars: + - `FRONTEND_URL` (default: `http://localhost:5173`) + - `API_URL` (default: `https://localhost:7172`) +- `launchSettings.json` sets both for dev profiles. + +Run +- Start your API (e.g., at `https://localhost:7172`). +- Start your React dev server (e.g., `npm run dev` on port 5173). +- From repo root: `dotnet run --project frontend/minattest-app-host` +- Open the BFF URL shown in the console (e.g., `https://localhost:10001`). + - Requests to `/api/...` go to your API. + - All other paths (including `/`) go to the React dev server. + +Notes +- WebSockets are enabled to support HMR from Vite/CRA dev servers. +- If your API uses the ASP.NET Core dev certificate, ensure it's trusted: `dotnet dev-certs https --trust`. +- Later, add auth and API composition here while keeping the frontend origin hidden from browsers. diff --git a/frontend/minattest-app-host/appsettings.Development.json b/frontend/minattest-app-host/appsettings.Development.json new file mode 100644 index 0000000..7663cea --- /dev/null +++ b/frontend/minattest-app-host/appsettings.Development.json @@ -0,0 +1,42 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Verbose", + "Override": { + "Microsoft": "Information", + "System": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Literate, Serilog.Sinks.Console", + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}|{RequestId}] {Message:lj} {NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext" ] + }, + "ReverseProxy": { + "Routes": { + "userApiRoute": { + "ClusterId": "clusterUser", + "Match": { + "Path": "/api/{**catch-all}" + } + } + }, + "Clusters": { + "clusterUser": { + "Destinations": { + "destination1": { + "Address": "https://localhost:7172/" + } + } + } + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/frontend/minattest-app-host/appsettings.json b/frontend/minattest-app-host/appsettings.json new file mode 100644 index 0000000..7663cea --- /dev/null +++ b/frontend/minattest-app-host/appsettings.json @@ -0,0 +1,42 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Verbose", + "Override": { + "Microsoft": "Information", + "System": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Literate, Serilog.Sinks.Console", + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}|{RequestId}] {Message:lj} {NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext" ] + }, + "ReverseProxy": { + "Routes": { + "userApiRoute": { + "ClusterId": "clusterUser", + "Match": { + "Path": "/api/{**catch-all}" + } + } + }, + "Clusters": { + "clusterUser": { + "Destinations": { + "destination1": { + "Address": "https://localhost:7172/" + } + } + } + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/frontend/minattest-app-host/minattest-app-host.csproj b/frontend/minattest-app-host/minattest-app-host.csproj new file mode 100644 index 0000000..1a4eed3 --- /dev/null +++ b/frontend/minattest-app-host/minattest-app-host.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + minattest_app_host + + + + + + + + + + + + diff --git a/frontend/minattest-app/README.md b/frontend/minattest-app/README.md new file mode 100644 index 0000000..2a2c522 --- /dev/null +++ b/frontend/minattest-app/README.md @@ -0,0 +1,43 @@ +MinAttest React App (Vite) + +Overview + +- Vite + React app for MinAttest. +- Works with the .NET BFF at `frontend/minattest-app-host`. +- Calls the API through the BFF using relative `/api/...` paths. + +Dev setup + +- Start Api in `backend/minattest-app` +- Start BFF in `frontend/minattest-app-host` +- From `frontend/minattest-app`: + - Install deps: `npm ci` (or `npm install`) + - Start dev server: `npm run dev` (defaults to `http://localhost:5173`) + +Login flow (no real auth) + +- On the start page, choose one of: + - "Logg inn privat": enter a Person ID (GUID) + - "Logg inn bedrift": enter an Employer ID (GUID) +- The session is stored in localStorage and used until you click "Logg ut". + +Available features + +- Privat: + - Shows Person details + - Lists attester, with actions: Vis (inline preview), Last ned + - Upload PDF attest with title, period, summary +- Bedrift: + - Shows Employer details + - Lists attester, with actions: Vis (inline preview), Last ned + - Issue/upload PDF attest for a person with title, period, summary + +Notes + +- Deleting attester and toggling verification are present as UI controls but are not wired to API calls yet (endpoints missing). They currently show a notice. +- The app expects API routes: + - GET `/api/v1/persons/{id}` and `/api/v1/persons/{id}/attests` + - GET `/api/v1/employers/{id}` and `/api/v1/employers/{id}/attests` + - GET download endpoints under each entity: `.../attests/{attestId}/download` + - POST `/api/v1/persons/{id}/attests` with body matching `PersonAttestUploadRequest` + - POST `/api/v1/employers/{id}/attests` with body matching `EmployerAttestUploadRequest` diff --git a/frontend/minattest-app/checkDependencies.js b/frontend/minattest-app/checkDependencies.js new file mode 100644 index 0000000..d2b81e4 --- /dev/null +++ b/frontend/minattest-app/checkDependencies.js @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; + +const nodeModulesPath = './node_modules'; +const packageJsonPath = './package.json'; + +try { + if (!fs.existsSync(packageJsonPath)) { + console.error('package.json not found. Please make sure you are in a valid Node.js project.'); + process.exit(1); + } + + if (!fs.existsSync(nodeModulesPath)) { + console.log('node_modules not found. Please run "npm install" to install dependencies.'); + + execSync('npm install', { stdio: 'inherit' }); + + console.log('Dependencies installed successfully. You can now run "npm run dev".'); + process.exit(0); + } else { + console.log('node_modules found. Checking if dependencies are installed...'); + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; + + const missingDependencies = []; + + for (const depName of Object.keys(dependencies)) { + const depPath = path.join(nodeModulesPath, depName); + + if (!fs.existsSync(depPath)) { + missingDependencies.push(depName); + } + } + + if (missingDependencies.length === 0) { + console.log('All dependencies are installed.'); + } else { + console.log('Missing one or more dependencies:'); + missingDependencies.forEach((dep) => { + console.log(` - ${dep}`); + }); + console.log('Manually run "npm install" to install the missing dependencies.'); + process.exit(1); + } + } +} catch (error) { + console.error('Error checking dependencies:', error.message || error); + process.exit(1); +} + diff --git a/frontend/minattest-app/dist/index.html b/frontend/minattest-app/dist/index.html new file mode 100644 index 0000000..5213251 --- /dev/null +++ b/frontend/minattest-app/dist/index.html @@ -0,0 +1,14 @@ + + + + + + MinAttest + + + + +
+ + + diff --git a/frontend/minattest-app/index.html b/frontend/minattest-app/index.html new file mode 100644 index 0000000..2b3a6df --- /dev/null +++ b/frontend/minattest-app/index.html @@ -0,0 +1,13 @@ + + + + + + MinAttest + + +
+ + + + diff --git a/frontend/minattest-app/package-lock.json b/frontend/minattest-app/package-lock.json new file mode 100644 index 0000000..ae48098 --- /dev/null +++ b/frontend/minattest-app/package-lock.json @@ -0,0 +1,1789 @@ +{ + "name": "minattest-app", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minattest-app", + "version": "0.0.1", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.2", + "vite": "^7.1.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", + "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", + "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", + "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", + "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", + "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", + "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", + "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", + "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", + "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", + "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", + "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", + "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", + "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", + "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", + "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", + "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", + "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", + "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", + "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", + "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", + "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.3.tgz", + "integrity": "sha512-mcE+Wr2CAhHNWxXN/DdTI+n4gsPc5QpXpWnyCQWiQYIYZX+ZMJ8juXZgjRa/0/YPJo/NSsgW15/YgmI4nbysYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", + "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.2", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.218", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", + "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", + "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.1", + "@rollup/rollup-android-arm64": "4.50.1", + "@rollup/rollup-darwin-arm64": "4.50.1", + "@rollup/rollup-darwin-x64": "4.50.1", + "@rollup/rollup-freebsd-arm64": "4.50.1", + "@rollup/rollup-freebsd-x64": "4.50.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", + "@rollup/rollup-linux-arm-musleabihf": "4.50.1", + "@rollup/rollup-linux-arm64-gnu": "4.50.1", + "@rollup/rollup-linux-arm64-musl": "4.50.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", + "@rollup/rollup-linux-ppc64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-musl": "4.50.1", + "@rollup/rollup-linux-s390x-gnu": "4.50.1", + "@rollup/rollup-linux-x64-gnu": "4.50.1", + "@rollup/rollup-linux-x64-musl": "4.50.1", + "@rollup/rollup-openharmony-arm64": "4.50.1", + "@rollup/rollup-win32-arm64-msvc": "4.50.1", + "@rollup/rollup-win32-ia32-msvc": "4.50.1", + "@rollup/rollup-win32-x64-msvc": "4.50.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/minattest-app/package.json b/frontend/minattest-app/package.json new file mode 100644 index 0000000..db7c0a0 --- /dev/null +++ b/frontend/minattest-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "minattest-app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.2", + "vite": "^7.1.9" + } +} diff --git a/frontend/minattest-app/scripts/dev-https.ps1 b/frontend/minattest-app/scripts/dev-https.ps1 new file mode 100644 index 0000000..e5fa8fb --- /dev/null +++ b/frontend/minattest-app/scripts/dev-https.ps1 @@ -0,0 +1,35 @@ +param( + [string]$PfxPath = "$env:USERPROFILE\.aspnet\https\minattest-app.pfx", + [string]$PfxPass = "mypass", + [int]$BffHttpsPort = 7228, + [int]$Port = 5173 +) + +function Ensure-DevCertExported { + param([string]$Path, [string]$Pass) + if (-not (Test-Path -LiteralPath $Path)) { + Write-Host "Exporting .NET dev cert to: $Path" -ForegroundColor Cyan + dotnet dev-certs https -ep $Path -p $Pass | Out-Null + dotnet dev-certs https --trust | Out-Null + } +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Move to app root (one up from scripts folder) +Set-Location (Resolve-Path (Join-Path $PSScriptRoot '..')) + +Ensure-DevCertExported -Path $PfxPath -Pass $PfxPass + +$env:SSL_PFX_PATH = $PfxPath +$env:SSL_PFX_PASS = $PfxPass +$env:VITE_ASPNETCORE_HTTPS_PORT = "$BffHttpsPort" + +Write-Host "Launching Vite over HTTPS:" -ForegroundColor Green +Write-Host " SSL_PFX_PATH = $PfxPath" +Write-Host " VITE_ASPNETCORE_HTTPS_PORT = $BffHttpsPort" +Write-Host " Port = $Port" + +npm run dev -- --port $Port + diff --git a/frontend/minattest-app/src/api.ts b/frontend/minattest-app/src/api.ts new file mode 100644 index 0000000..d263d8b --- /dev/null +++ b/frontend/minattest-app/src/api.ts @@ -0,0 +1,89 @@ +import type { EmployerResponse, PersonResponse, Guid, AttestSummary } from './types' + +async function apiGet(path: string, signal?: AbortSignal): Promise { + const res = await fetch(path, { headers: { 'Accept': 'application/json' }, signal }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API GET ${path} failed: ${res.status} ${res.statusText} ${text}`) + } + return res.json() as Promise +} + +export const Api = { + getPerson: (id: Guid, signal?: AbortSignal) => apiGet(`/api/v1/persons/${id}`, signal), + listPersonAttests: (id: Guid, signal?: AbortSignal) => apiGet(`/api/v1/persons/${id}/attests`, signal), + getEmployer: (id: Guid, signal?: AbortSignal) => apiGet(`/api/v1/employers/${id}`, signal), + listEmployerAttests: (id: Guid, signal?: AbortSignal) => apiGet(`/api/v1/employers/${id}/attests`, signal), + personAttestDownloadUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download`, + employerAttestDownloadUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download`, + personAttestPreviewUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download?inline=true`, + employerAttestPreviewUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download?inline=true`, + uploadPersonAttest: async ( + personId: Guid, + req: { + title: string + from: string // YYYY-MM-DD + to: string // YYYY-MM-DD + summary?: string | null + contentBase64: string + contentType: string + } + ): Promise<{ attestId: Guid }> => { + const body = { + title: req.title, + from: req.from, + to: req.to, + summary: req.summary ?? null, + blobPath: '', + blobHash: null, + contentBase64: req.contentBase64, + contentType: req.contentType, + } + const path = `/api/v1/persons/${personId}/attests` + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`) + } + return res.json() + }, + uploadEmployerAttest: async ( + employerId: Guid, + req: { + personId: Guid + title: string + from: string // YYYY-MM-DD + to: string // YYYY-MM-DD + summary?: string | null + contentBase64: string + contentType: string + } + ): Promise<{ attestId: Guid }> => { + const body = { + personId: req.personId, + title: req.title, + from: req.from, + to: req.to, + summary: req.summary ?? null, + blobPath: '', + blobHash: null, + contentBase64: req.contentBase64, + contentType: req.contentType, + } + const path = `/api/v1/employers/${employerId}/attests` + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`) + } + return res.json() + }, +} diff --git a/frontend/minattest-app/src/main.tsx b/frontend/minattest-app/src/main.tsx new file mode 100644 index 0000000..1e86f51 --- /dev/null +++ b/frontend/minattest-app/src/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from 'react-dom/client' +import { App } from './ui/App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/frontend/minattest-app/src/styles.css b/frontend/minattest-app/src/styles.css new file mode 100644 index 0000000..d46d5e9 --- /dev/null +++ b/frontend/minattest-app/src/styles.css @@ -0,0 +1,120 @@ +:root { + --bg: #f7f7f8; + --panel: #ffffff; + --text: #111827; + --muted: #6b7280; + --brand: #c3002f; /* NAV red */ + --border: #e5e7eb; + --success: #16a34a; + --danger: #dc2626; + --warning: #d97706; +} + +html, body, #root { height: 100%; } +body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji"; } + +.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; } + +.app-header { + position: sticky; top: 0; z-index: 10; background: var(--panel); + border-bottom: 1px solid var(--border); +} +.app-header-inner { display: flex; align-items: center; gap: 16px; height: 64px; } +.logo { display: flex; align-items: center; gap: 10px; } +.logo img { height: 36px; width: auto; display: block; } +.logo strong { font-size: 18px; color: var(--brand); letter-spacing: 0.2px; } + +.role-toggle { display: inline-flex; background: #f1f5f9; border-radius: 9999px; padding: 4px; } +.role-toggle button { border: none; background: transparent; padding: 6px 12px; border-radius: 9999px; cursor: pointer; color: var(--muted); font-weight: 600; } +.role-toggle button[aria-pressed="true"] { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(16,24,40,0.06); } + +.spacer { flex: 1; } + +.login-area { display: flex; align-items: center; gap: 8px; } +.login-area input[type="text"] { height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 340px; } +.btn { height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; line-height: 1; } +.btn.primary { background: var(--brand); color: #fff; border-color: var(--brand); } +.btn.ghost { background: transparent; } +.btn.secondary { background: #fff; border-color: #cbd5e1; } +.btn.danger { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn.sm { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 14px; } +.btn:disabled { opacity: .6; cursor: default; } +.btn .icon { width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; } + +/* Icon-only buttons/links */ +.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; text-decoration: none; } +.icon-btn:hover { background: #f3f4f6; } +.icon-btn .icon { width: 16px; height: 16px; margin: 0; } + +.app-main { padding: 16px 0 40px; } +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; } +.cards { margin-top: 8px; } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; } + +.section { margin-bottom: 16px; } +.section-title { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; } +.toolbar { display: flex; align-items: center; gap: 8px; } + +.tag { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 9999px; padding: 2px 10px; font-size: 12px; color: var(--muted); background: #f8fafc; } +.tag.success { color: var(--success); border-color: #bbf7d0; background: #ecfdf5; } +.tag.muted { color: var(--muted); } + +.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 8px; } +.kpi { background: #fafafa; border: 1px dashed var(--border); border-radius: 12px; padding: 12px; } +.kpi strong { display: block; font-size: 20px; } +.kpi span { color: var(--muted); font-size: 12px; } + +.alert { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff7ed; color: #7c2d12; } +.alert.error { background: #fef2f2; color: #991b1b; } +.alert.success { background: #f0fdf4; color: #14532d; } + +/* Modal */ +.modal-backdrop { position: fixed; inset: 0; background: rgba(17,24,39,.5); display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; } +.modal { width: min(1100px, 96vw); background: var(--panel); border-radius: 12px; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; max-height: 92vh; } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.modal-body { padding: 0; } +.modal-body iframe { display: block; width: 100%; height: 70vh; border: none; } + +/* Forms */ +.form-grid { display: grid; gap: 10px; grid-template-columns: 1fr; } +@media (min-width: 720px) { .form-grid.cols-2 { grid-template-columns: 1fr 1fr; } } +.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.field label.btn { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 0; font-size: 14px; color: var(--text); } +.field input[type="text"], .field input[type="date"], .field textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; } +.help { color: var(--muted); font-size: 12px; } + +/* Accessible visually hidden input (screen-reader only) */ +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.file-row { display: flex; align-items: center; gap: 8px; } + +/* Loader */ +.spinner { width: 16px; height: 16px; border: 2px solid #fff; border-top-color: rgba(255,255,255,.2); border-radius: 9999px; display: inline-block; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg) } } + +/* Date picker */ +.date-input { position: relative; display: inline-block; } +.date-field { width: 150px; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; display: inline-flex; align-items: center; justify-content: space-between; cursor: pointer; } +.date-field:hover { background: #f9fafb; } +.date-field .value { color: var(--text); font-variant-numeric: tabular-nums; } +.date-field .placeholder { color: var(--muted); } +.date-field .icon { width: 16px; height: 16px; opacity: .7; } +.calendar-popover { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 10px 25px rgba(16,24,40,0.1); width: 280px; padding: 8px; z-index: 40; } +.cal-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px; } +.cal-title { font-weight: 600; text-transform: capitalize; } +.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 6px; } +.cal-cell { text-align: center; padding: 6px 0; font-size: 13px; color: var(--text); } +.cal-wd { color: var(--muted); text-transform: lowercase; } +.cal-day { border: 1px solid transparent; background: transparent; border-radius: 8px; cursor: pointer; } +.cal-day:hover { background: #f3f4f6; } +.cal-day.selected { background: #eef2ff; border-color: #c7d2fe; } + +/* Inline pair for date fields */ +.field-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; width: fit-content; } + +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; } + +.muted { color: var(--muted); } +.success { color: #16a34a; } +.error { color: #dc2626; } diff --git a/frontend/minattest-app/src/types.ts b/frontend/minattest-app/src/types.ts new file mode 100644 index 0000000..a881e24 --- /dev/null +++ b/frontend/minattest-app/src/types.ts @@ -0,0 +1,27 @@ +export type Guid = string + +export interface AttestSummary { + id: Guid + employer: string + title: string + from: string // DateOnly as ISO string + to: string // DateOnly as ISO string + verified: boolean +} + +export interface PersonResponse { + id: Guid + nationalIdHash: string + email?: string | null + phone?: string | null + attests: AttestSummary[] +} + +export interface EmployerResponse { + id: Guid + orgNumber: string + name: string +} + +export type Role = 'privat' | 'bedrift' + diff --git a/frontend/minattest-app/src/ui/App.tsx b/frontend/minattest-app/src/ui/App.tsx new file mode 100644 index 0000000..6923764 --- /dev/null +++ b/frontend/minattest-app/src/ui/App.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useState } from 'react' +import { EmployerView } from './EmployerView' +import { PersonView } from './PersonView' +import { HomeLanding } from './HomeLanding' +import type { Guid, Role } from '../types' +import { isGuidLike } from '../utils' +import { Api } from '../api' + +type Session = { role: Role; id: Guid } | null + +function loadSession(): Session { + try { + const text = localStorage.getItem('minattest.session') + if (!text) return null + const obj = JSON.parse(text) + if ((obj.role === 'privat' || obj.role === 'bedrift') && typeof obj.id === 'string') return obj + } catch { + // ignore + } + return null +} + +function saveSession(s: Session) { + if (s) localStorage.setItem('minattest.session', JSON.stringify(s)) + else localStorage.removeItem('minattest.session') +} + +export function App() { + const [session, setSession] = useState(() => loadSession()) + const [selectedRole, setSelectedRole] = useState('privat') + const [pendingId, setPendingId] = useState('') + const [loginErr, setLoginErr] = useState(null) + const [identity, setIdentity] = useState(null) + + const performLogin = () => { + setLoginErr(null) + if (!isGuidLike(pendingId)) { setLoginErr('Ugyldig GUID'); return } + const s = { role: selectedRole, id: pendingId as Guid } + setSession(s); saveSession(s) + } + + const onLogout = () => { + setSession(null) + saveSession(null) + } + + // Load identity label for header (email for privat, company name for bedrift) + useEffect(() => { + if (!session) { setIdentity(null); return } + const ac = new AbortController() + ;(async () => { + try { + if (session.role === 'privat') { + const p = await Api.getPerson(session.id, ac.signal) + setIdentity(p.email ?? 'Ukjent e‑post') + } else { + const e = await Api.getEmployer(session.id, ac.signal) + setIdentity(e.name || 'Ukjent bedrift') + } + } catch { + setIdentity(session.role === 'privat' ? 'Ukjent e‑post' : 'Ukjent bedrift') + } + })() + return () => ac.abort() + }, [session]) + + const body = useMemo(() => { + if (!session) return + if (session.role === 'privat') return + return + }, [session]) + + return ( +
+
+
+
+ MinAttest +
+
+ + +
+
+
+ {!session ? ( + <> + setPendingId(e.target.value)} + /> + + {loginErr && {loginErr}} + + ) : ( + <> + Innlogget som: {identity ?? ''} + + + )} +
+
+
+ +
{body}
+
+ ) +} diff --git a/frontend/minattest-app/src/ui/DatePicker.tsx b/frontend/minattest-app/src/ui/DatePicker.tsx new file mode 100644 index 0000000..a83e2dc --- /dev/null +++ b/frontend/minattest-app/src/ui/DatePicker.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +const nbMonths = ['januar','februar','mars','april','mai','juni','juli','august','september','oktober','november','desember'] +const nbWeekdays = ['man','tir','ons','tor','fre','lør','søn'] + +function clampMonth(year: number, month: number) { + if (month < 0) { year -= 1; month = 11 } + if (month > 11) { year += 1; month = 0 } + return { year, month } +} + +function parseDateOnly(value?: string): Date | null { + if (!value) return null + const m = value.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/) + if (!m) return null + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + return isNaN(d.getTime()) ? null : d +} + +function toDateOnly(d: Date): string { + const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0') + return `${y}-${m}-${day}` +} + +export function DatePicker({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) { + const selected = parseDateOnly(value) ?? new Date() + const [open, setOpen] = useState(false) + const [viewYear, setViewYear] = useState(selected.getFullYear()) + const [viewMonth, setViewMonth] = useState(selected.getMonth()) + const wrapperRef = useRef(null) + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (!wrapperRef.current) return + if (!wrapperRef.current.contains(e.target as Node)) setOpen(false) + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDocClick) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDocClick) + document.removeEventListener('keydown', onKey) + } + }, []) + + useEffect(() => { + // When value changes externally, sync calendar view + const d = parseDateOnly(value) + if (d) { setViewYear(d.getFullYear()); setViewMonth(d.getMonth()) } + }, [value]) + + const days = useMemo(() => { + const first = new Date(viewYear, viewMonth, 1) + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + const weekStartMondayIndex = (first.getDay() + 6) % 7 // 0=Mon ... 6=Sun + const cells: Array<{ d: number | null; date?: Date }> = [] + for (let i=0;i { + const d = parseDateOnly(value) + if (!d) return '—' + const dd = String(d.getDate()).padStart(2,'0') + const mm = String(d.getMonth()+1).padStart(2,'0') + const yyyy = d.getFullYear() + return `${dd}.${mm}.${yyyy}` + }, [value]) + + return ( +
+ + {open && ( +
+
+ +
{nbMonths[viewMonth]} {viewYear}
+ +
+
+ {nbWeekdays.map((w) =>
{w}
)} +
+
+ {days.map((c, i) => { + if (c.d === null) return
+ const isSelected = value && c.date && toDateOnly(c.date) === value + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/frontend/minattest-app/src/ui/EmployerView.tsx b/frontend/minattest-app/src/ui/EmployerView.tsx new file mode 100644 index 0000000..a51250e --- /dev/null +++ b/frontend/minattest-app/src/ui/EmployerView.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useState } from 'react' +import { Api } from '../api' +import { fileToBase64, isAbortError, isPdfFile, toDateOnlyString, formatRange, isGuidLike, toFileName } from '../utils' +import { DatePicker } from './DatePicker' +import type { AttestSummary, EmployerResponse, Guid } from '../types' + +export function EmployerView({ employerId }: { employerId: Guid }) { + const [employer, setEmployer] = useState(null) + const [attests, setAttests] = useState(null) + const [error, setError] = useState(null) + const [preview, setPreview] = useState(null) + const [uploading, setUploading] = useState(false) + const [success, setSuccess] = useState(null) + + useEffect(() => { + const ac = new AbortController() + setError(null) + Promise.all([ + Api.getEmployer(employerId, ac.signal), + Api.listEmployerAttests(employerId, ac.signal) + ]).then(([e, a]) => { setEmployer(e); setAttests(a) }).catch(err => { + if (isAbortError(err)) return + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + }) + return () => ac.abort() + }, [employerId]) + + const previewUrl = useMemo(() => preview ? Api.employerAttestPreviewUrl(employerId, preview) : null, [preview, employerId]) + + return ( +
+
+
+

Bedrift

+
+ +
+
+ {!employer && !error &&

Laster arbeidsgiver…

} + {error &&

{error}

} + {employer && ( +
+
{employer.name}Navn
+
{employer.orgNumber}Organisasjonsnummer
+
{attests?.length ?? 0}Attester
+
+ )} +
+ +
+
+

Utsted attest (PDF)

+
+ { + setError(null); setSuccess(null); setUploading(true) + try { + if (!data.file || !isPdfFile(data.file)) throw new Error('Kun PDF er tillatt.') + const contentBase64 = await fileToBase64(data.file) + const resp = await Api.uploadEmployerAttest(employerId, { + personId: data.personId, + title: data.title, + from: toDateOnlyString(data.from), + to: toDateOnlyString(data.to), + summary: data.summary || null, + contentBase64, + contentType: 'application/pdf', + }) + setSuccess('Attest utstedt.') + setPreview(resp.attestId) + const a = await Api.listEmployerAttests(employerId) + setAttests(a) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + setError(msg) + } finally { + setUploading(false) + } + }} + /> + {success &&

{success}

} +
+ +
+
+

Bedriftens attester

+
+ +
+
+ {!attests && !error &&

Laster attester…

} + {attests && attests.length === 0 &&

Ingen attester.

} + {attests && attests.length > 0 && ( + + + + + + + + + + + + {attests.map(a => ( + + + + + + + + ))} + +
TittelArbeidsgiverPeriodeStatusHandlinger
{a.title}{a.employer}{formatRange(a.from, a.to)} + + {a.verified ? 'Verifisert' : 'Ubekreftet'} + + +
+ + + + + + + + + +
+
+ )} +
+ + {previewUrl && ( +
setPreview(null)}> +
e.stopPropagation()}> +
+ ForhĂĄndsvisning + +
+
+