commit ecd89a68f837eb9a2e69f8c2a54f457f3f04b9e6 Author: Julian Lechner Date: Wed Jun 24 08:33:23 2026 +0200 Project to GitHub diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..0cfa2d1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": ["dotnet-ef"], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2747b03 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,razor}] +indent_style = space +indent_size = 4 + +[*.{css.json,yml,yaml,xml,csproj,props,targets,xaml}] +indent_style = space +indent_size = 2 + +[*.cs] +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..92be83e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53f1c64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,298 @@ +## 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/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# 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 + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# VS Code +.vscode/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.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 + +# 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 + +# 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 +# TODO: 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 +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/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 + +# 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 + +# 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 + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# 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 + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# Other +**/bin/ +**/obj/ +*.db +*.db-shm +*.db-wal +TestResults/ +.env diff --git a/Bsevita.Library.sln b/Bsevita.Library.sln new file mode 100644 index 0000000..486d7e0 --- /dev/null +++ b/Bsevita.Library.sln @@ -0,0 +1,56 @@ +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}") = "Bsevita.Library.Models", "src\Bsevita.Library.Models\Bsevita.Library.Models.csproj", "{85EC888E-0CA0-4966-9AFD-F5346F65BB02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Api", "src\Bsevita.Library.Api\Bsevita.Library.Api.csproj", "{14F5FA6C-0F68-4FEB-908A-7BF63201886F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Ui", "src\Bsevita.Library.Ui\Bsevita.Library.Ui.csproj", "{B8BB3BB0-6661-413B-A5FC-30C41E0607AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Web", "src\Bsevita.Library.Web\Bsevita.Library.Web.csproj", "{9A0841E9-CE16-4978-B74A-19ED20F9DD21}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Maui", "src\Bsevita.Library.Maui\Bsevita.Library.Maui.csproj", "{5579CB78-497F-4EE2-9F4A-38388CECF371}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Api.Tests", "tests\Bsevita.Library.Api.Tests\Bsevita.Library.Api.Tests.csproj", "{D3A8B599-8913-4D58-9C15-D22E4531CEFF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{679EE508-FCF4-4651-A99E-AA19E53BCCFA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Release|Any CPU.Build.0 = Release|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Release|Any CPU.Build.0 = Release|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Release|Any CPU.Build.0 = Release|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Release|Any CPU.Build.0 = Release|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Release|Any CPU.Build.0 = Release|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D3A8B599-8913-4D58-9C15-D22E4531CEFF} = {679EE508-FCF4-4651-A99E-AA19E53BCCFA} + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3f2bb79 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + 14.0 + enable + enable + latest + true + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..20cfdda --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + true + 10.0.71 + 10.0.9 + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dee934c --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# BSEVITA Library +dies ist ein Projekt das im Rahmen meiner Ausbildung in der Schule abgearbeitet wurde. + +Schulbibliotheksverwaltung mit .NET 10, ASP.NET Core API, EF Core/MS SQL Server und gemeinsamer Blazor-UI für Web und .NET MAUI. + +## Projekte + +- `src/Bsevita.Library.Api` - REST-API +- `src/Bsevita.Library.Ui` - gemeinsame Razor-UI +- `src/Bsevita.Library.Web` - Browser-Host +- `src/Bsevita.Library.Maui` - nativer MAUI-Host +- `src/Bsevita.Library.Models` - Request-/Response-Modelle +- `tests/Bsevita.Library.Api.Tests` - API-Tests + +## Start + +```bash +dotnet restore Bsevita.Library.sln +dotnet build Bsevita.Library.sln +dotnet test tests/Bsevita.Library.Api.Tests +``` + +Danach starten: + +- Datenbank: `database/create_database.sql` auf `(localdb)\MSSQLLocalDB` ausführen +- API: `Bsevita.Library.Api` auf `http://localhost:5180` +- Web: `Bsevita.Library.Web` auf `http://localhost:5190` +- MAUI: `Bsevita.Library.Maui` + +Android-Emulatoren verwenden `http://10.0.2.2:5180`; andere lokale Hosts verwenden `http://localhost:5180`. + +## Datenbank + +`database/create_database.sql` ist die Schema-Quelle. Standardverbindung: `(localdb)\MSSQLLocalDB`, Datenbank `BsevitaLibrary`. Es gibt keine EF-Core-Migrationshistorie; generierter EF-Code liegt unter `src/Bsevita.Library.Api/Data/Generated`. + +## Doku + +- [Architektur](docs/ARCHITECTURE.md) +- [API](docs/API.md) diff --git a/database/create_database.sql b/database/create_database.sql new file mode 100644 index 0000000..358f9de --- /dev/null +++ b/database/create_database.sql @@ -0,0 +1,126 @@ +IF DB_ID(N'BsevitaLibrary') IS NULL +BEGIN + EXEC(N'CREATE DATABASE [BsevitaLibrary]'); +END; +GO + +USE [BsevitaLibrary]; +GO + +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +SET XACT_ABORT ON; +GO + +IF OBJECT_ID(N'dbo.Students', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Students + ( + StudentId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Students_StudentId DEFAULT NEWSEQUENTIALID(), + CardNumber NVARCHAR(32) NOT NULL, + FirstName NVARCHAR(100) NOT NULL, + LastName NVARCHAR(100) NOT NULL, + ClassName NVARCHAR(32) NOT NULL, + Email NVARCHAR(254) NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Students_IsActive DEFAULT (1), + CreatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Students_CreatedAt DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Students_UpdatedAt DEFAULT SYSUTCDATETIME(), + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Students PRIMARY KEY CLUSTERED (StudentId), + CONSTRAINT CK_Students_CardNumber_NotBlank CHECK (LEN(LTRIM(RTRIM(CardNumber))) > 0), + CONSTRAINT CK_Students_FirstName_NotBlank CHECK (LEN(LTRIM(RTRIM(FirstName))) > 0), + CONSTRAINT CK_Students_LastName_NotBlank CHECK (LEN(LTRIM(RTRIM(LastName))) > 0), + CONSTRAINT CK_Students_ClassName_NotBlank CHECK (LEN(LTRIM(RTRIM(ClassName))) > 0) + ); + CREATE UNIQUE INDEX UX_Students_CardNumber ON dbo.Students(CardNumber); + CREATE INDEX IX_Students_IsActive_Name ON dbo.Students(IsActive, LastName, FirstName); +END; +GO + +IF OBJECT_ID(N'dbo.Books', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Books + ( + BookId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Books_BookId DEFAULT NEWSEQUENTIALID(), + BookNumber NVARCHAR(32) NOT NULL, + Isbn NVARCHAR(20) NULL, + Title NVARCHAR(240) NOT NULL, + Author NVARCHAR(160) NOT NULL, + Subject NVARCHAR(120) NOT NULL, + Publisher NVARCHAR(160) NULL, + PublicationYear SMALLINT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Books_IsActive DEFAULT (1), + CreatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Books_CreatedAt DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Books_UpdatedAt DEFAULT SYSUTCDATETIME(), + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Books PRIMARY KEY CLUSTERED (BookId), + CONSTRAINT CK_Books_BookNumber_NotBlank CHECK (LEN(LTRIM(RTRIM(BookNumber))) > 0), + CONSTRAINT CK_Books_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0), + CONSTRAINT CK_Books_Author_NotBlank CHECK (LEN(LTRIM(RTRIM(Author))) > 0), + CONSTRAINT CK_Books_Subject_NotBlank CHECK (LEN(LTRIM(RTRIM(Subject))) > 0), + CONSTRAINT CK_Books_PublicationYear CHECK (PublicationYear IS NULL OR PublicationYear BETWEEN 1000 AND 2200) + ); + CREATE UNIQUE INDEX UX_Books_BookNumber ON dbo.Books(BookNumber); + CREATE UNIQUE INDEX UX_Books_Isbn ON dbo.Books(Isbn) WHERE Isbn IS NOT NULL; + CREATE INDEX IX_Books_IsActive_Title ON dbo.Books(IsActive, Title); +END; +GO + +IF OBJECT_ID(N'dbo.Loans', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Loans + ( + LoanId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Loans_LoanId DEFAULT NEWSEQUENTIALID(), + StudentId UNIQUEIDENTIFIER NOT NULL, + BookId UNIQUEIDENTIFIER NOT NULL, + LoanedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Loans_LoanedAt DEFAULT SYSUTCDATETIME(), + DueAt DATETIMEOFFSET(0) NOT NULL, + ReturnedAt DATETIMEOFFSET(0) NULL, + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Loans PRIMARY KEY CLUSTERED (LoanId), + CONSTRAINT FK_Loans_Students FOREIGN KEY (StudentId) REFERENCES dbo.Students(StudentId), + CONSTRAINT FK_Loans_Books FOREIGN KEY (BookId) REFERENCES dbo.Books(BookId), + CONSTRAINT CK_Loans_DueAfterLoan CHECK (DueAt > LoanedAt), + CONSTRAINT CK_Loans_ReturnAfterLoan CHECK (ReturnedAt IS NULL OR ReturnedAt >= LoanedAt) + ); + CREATE INDEX IX_Loans_StudentId ON dbo.Loans(StudentId); + CREATE INDEX IX_Loans_Active_DueAt ON dbo.Loans(DueAt) WHERE ReturnedAt IS NULL; + CREATE UNIQUE INDEX UX_Loans_ActiveBook ON dbo.Loans(BookId) WHERE ReturnedAt IS NULL; +END; +GO + +-- Idempotent sample data for immediate manual testing. +IF NOT EXISTS (SELECT 1 FROM dbo.Students WHERE CardNumber = N'S-1001') +BEGIN + INSERT dbo.Students (StudentId, CardNumber, FirstName, LastName, ClassName, Email) + VALUES + ('11111111-1111-1111-1111-111111111111', N'S-1001', N'Max', N'Mustermann', N'3AHIT', N'max.mustermann@schule.local'), + ('22222222-2222-2222-2222-222222222222', N'S-1002', N'Anna', N'Berger', N'2BHIF', N'anna.berger@schule.local'), + ('33333333-3333-3333-3333-333333333333', N'S-1003', N'Leon', N'Fischer', N'1AHIT', NULL); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.Books WHERE BookNumber = N'B-1001') +BEGIN + INSERT dbo.Books (BookId, BookNumber, Isbn, Title, Author, Subject, Publisher, PublicationYear) + VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', N'B-1001', N'978-3-8362-9500-1', N'C# Grundlagen', N'Ralph Steyer', N'Informatik', N'Rheinwerk', 2024), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', N'B-1002', N'978-0-13-235088-4', N'Clean Code', N'Robert C. Martin', N'Softwareentwicklung', N'Prentice Hall', 2008), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3', N'B-1003', N'978-3-86680-192-0', N'Die Welt der Physik', N'Andrea Lenz', N'Physik', N'Verita', 2022), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4', N'B-1004', NULL, N'Mathematik kompakt', N'Sabine Kern', N'Mathematik', N'Schulbuchverlag', 2023), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5', N'B-1005', NULL, N'Geschichte Europas', N'Paul Winter', N'Geschichte', N'Verita', 2021); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.Loans WHERE LoanId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1') +BEGIN + INSERT dbo.Loans (LoanId, StudentId, BookId, LoanedAt, DueAt, ReturnedAt) + VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', DATEADD(DAY, -20, SYSUTCDATETIME()), DATEADD(DAY, -6, SYSUTCDATETIME()), NULL), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3', DATEADD(DAY, -4, SYSUTCDATETIME()), DATEADD(DAY, 10, SYSUTCDATETIME()), NULL), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb3', '33333333-3333-3333-3333-333333333333', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4', DATEADD(DAY, -30, SYSUTCDATETIME()), DATEADD(DAY, -16, SYSUTCDATETIME()), DATEADD(DAY, -15, SYSUTCDATETIME())); +END; +GO + +PRINT N'BsevitaLibrary database is ready.'; +GO diff --git a/database/smoke_test.sql b/database/smoke_test.sql new file mode 100644 index 0000000..c115466 --- /dev/null +++ b/database/smoke_test.sql @@ -0,0 +1,16 @@ +USE [BsevitaLibrary]; +GO +SET NOCOUNT ON; + +SELECT 'Students' AS Entity, COUNT(*) AS Total FROM dbo.Students WHERE IsActive = 1 +UNION ALL SELECT 'Books', COUNT(*) FROM dbo.Books WHERE IsActive = 1 +UNION ALL SELECT 'ActiveLoans', COUNT(*) FROM dbo.Loans WHERE ReturnedAt IS NULL +UNION ALL SELECT 'OverdueLoans', COUNT(*) FROM dbo.Loans WHERE ReturnedAt IS NULL AND DueAt < SYSUTCDATETIME(); + +SELECT l.LoanId, s.CardNumber, CONCAT(s.FirstName, ' ', s.LastName) AS StudentName, + b.BookNumber, b.Title, l.LoanedAt, l.DueAt, l.ReturnedAt +FROM dbo.Loans l +JOIN dbo.Students s ON s.StudentId = l.StudentId +JOIN dbo.Books b ON b.BookId = l.BookId +ORDER BY l.LoanedAt DESC; +GO diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6b092d6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,51 @@ +# REST-API + +Basisroute: `/api` + +Die genaue Endpoint-, Request- und Response-Dokumentation steht im Development in der Swagger UI unter `/swagger`. + +## Schüler + +- `GET /students?skip=0&take=100` +- `GET /students/{id}` +- `GET /students/by-card/{nr}` +- `GET /students/search?name=` +- `POST /students` +- `PUT /students/{id}` +- `DELETE /students/{id}` + +## Bücher + +- `GET /books?availableOnly=&skip=0&take=100` +- `GET /books/{id}` +- `GET /books/by-number/{nr}` +- `GET /books/search?title=` +- `GET /books/search?author=` +- `GET /books/search?subject=` +- `POST /books` +- `PUT /books/{id}` +- `DELETE /books/{id}` + +Buch- und Schülersuche werden datenbankseitig gefiltert. + +## Ausleihe + +- `POST /loans/verify` prüft Schüler, Buch und Verfügbarkeit ohne Datenänderung. +- `POST /loans` prüft erneut und legt die Ausleihe transaktional an. + +## Rückgabe + +- `GET /returns/verify/{nr}` liefert die aktive Ausleihe und den Überfälligkeitsstatus. +- `POST /returns` schließt die aktive Ausleihe transaktional ab. + +## Reports + +- `GET /reports` +- `GET /reports/active-loans?skip=0&take=100` +- `GET /reports/active-students?skip=0&take=100` +- `GET /reports/overdue?skip=0&take=100` +- `GET /reports/statistics` + +`GET /reports` liefert die ersten 100 Einträge je Liste plus vollständige Statistiken. + +Fehler verwenden `application/problem+json` mit `status`, `title`, `detail` und `instance`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8b16dd5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,50 @@ +# Architektur + +Die Anwendung trennt Hosts, UI, API, Verträge und Datenbankzugriff strikt. + +```text +Bsevita.Library.Maui -> Bsevita.Library.Ui -> Bsevita.Library.Models +Bsevita.Library.Web -> Bsevita.Library.Ui -> Bsevita.Library.Models + +Bsevita.Library.Api -> Bsevita.Library.Models +Bsevita.Library.Api -> EF Core -> SQL Server LocalDB +``` + +## Projekte + +- `Bsevita.Library.Models`: gemeinsame Request-/Response-Modelle, keine Datenbankabhängigkeit. +- `Bsevita.Library.Ui`: Razor Class Library mit Seiten, Layouts, Komponenten und API-Clients. +- `Bsevita.Library.Web`: Browser-Host für die UI; registriert `HttpClient` zur lokalen API. +- `Bsevita.Library.Maui`: nativer Host für dieselbe UI; löst die API-Adresse je Plattform auf. +- `Bsevita.Library.Api`: Controller, Services, EF Core und zentrale Fehlerbehandlung. +- `Bsevita.Library.Api.Tests`: API-Workflowtests mit Testdatenbank. + +## Datenfluss + +1. UI-Seiten rufen typed API-Clients aus `Bsevita.Library.Ui.Services` auf. +2. API-Clients senden HTTP-Requests an `/api/...` und lesen JSON oder ProblemDetails. +3. Controller delegieren an Services; Fachlogik liegt nicht im Controller. +4. Services verwenden `LibraryDbContext` und mappen Entities auf Models. +5. SQL Server speichert das Database-First-Schema aus `database/create_database.sql`. + +## Datenbank + +`database/create_database.sql` ist die Schema-Quelle. Der generierte EF-Code liegt unter `src/Bsevita.Library.Api/Data/Generated` und wird nicht für Fachlogik erweitert. + +Wichtige Regeln liegen zusätzlich in der Datenbank: + +- `UX_Students_CardNumber`: eindeutige Ausweisnummern. +- `UX_Books_BookNumber`: eindeutige Buchnummern. +- `UX_Loans_ActiveBook`: höchstens eine aktive Ausleihe pro Buch. + +## Transaktionen und Fehler + +Ausleihe, Rückgabe und Löschoperationen laufen in serialisierbaren Transaktionen, wenn eine relationale Datenbank verwendet wird. Vor dem Schreiben wird der aktuelle Zustand erneut geprüft. + +Fachfehler werden zentral in `application/problem+json` übersetzt. Die UI zeigt diese Fehler über die gemeinsamen API-Clients an. + +## Grenzen + +- Die UI referenziert weder `Bsevita.Library.Api` noch `LibraryDbContext`. +- API-Verträge sind nicht die EF-Entities. +- Löschen deaktiviert Schüler und Bücher, damit historische Ausleihen erhalten bleiben. diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj b/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj new file mode 100644 index 0000000..3b45db4 --- /dev/null +++ b/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj @@ -0,0 +1,20 @@ + + + net10.0 + Bsevita.Library.Api + bsevita-library-api + true + $(NoWarn);1573;1591 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Bsevita.Library.Api/Controllers/BooksController.cs b/src/Bsevita.Library.Api/Controllers/BooksController.cs new file mode 100644 index 0000000..188eefb --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/BooksController.cs @@ -0,0 +1,136 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Verwaltet Bücher im Bibliotheksbestand. +[ApiController] +[Route("api/books")] +public sealed class BooksController(BookService service) : ControllerBase +{ + /// Liefert Bücher seitenweise. + /// Filtert optional nach verfügbaren oder ausgeliehenen Büchern. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Bücherliste. + /// Bücher wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll( + [FromQuery] bool? availableOnly, + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetAllAsync(availableOnly, skip, take, cancellationToken)); + + /// Liefert ein Buch anhand der ID. + /// Buch-ID. + /// Gefundenes Buch. + /// Buch wurde gefunden. + /// ID ist ungültig. + /// Buch wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) => + Ok(await service.GetByIdAsync(id, cancellationToken)); + + /// Liefert ein Buch anhand der Buchnummer. + /// Buchnummer. + /// Gefundenes Buch. + /// Buch wurde gefunden. + /// Buchnummer ist ungültig. + /// Buch wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("by-number/{bookNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetByNumber(string bookNumber, CancellationToken cancellationToken) => + Ok(await service.GetByNumberAsync(bookNumber, cancellationToken)); + + /// Sucht aktive Bücher nach Titel, Autor oder Fach. + /// Optionaler Titelfilter. + /// Optionaler Autorfilter. + /// Optionaler Fachfilter. + /// Passende Bücher. + /// Suche wurde ausgeführt. + /// Mindestens ein Suchfilter fehlt oder ist ungültig. + /// Interner Serverfehler. + [HttpGet("search")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Search( + [FromQuery] string? title, + [FromQuery] string? author, + [FromQuery] string? subject, + CancellationToken cancellationToken) => + Ok(await service.SearchAsync(title, author, subject, cancellationToken)); + + /// Legt ein Buch an. + /// Buchdaten im Body. + /// Angelegtes Buch. + /// Buch wurde angelegt. + /// Body ist ungültig. + /// Buchnummer oder ISBN ist bereits vergeben. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(SaveBookRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// Aktualisiert ein Buch. + /// Buch-ID. + /// Neue Buchdaten im Body. + /// Aktualisiertes Buch. + /// Buch wurde aktualisiert. + /// ID oder Body ist ungültig. + /// Buch wurde nicht gefunden. + /// Buchnummer oder ISBN ist bereits vergeben. + /// Interner Serverfehler. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, SaveBookRequest request, CancellationToken cancellationToken) => + Ok(await service.UpdateAsync(id, request, cancellationToken)); + + /// Deaktiviert ein Buch. + /// Buch-ID. + /// Kein Inhalt. + /// Buch wurde deaktiviert. + /// ID ist ungültig. + /// Buch wurde nicht gefunden. + /// Buch ist noch ausgeliehen. + /// Interner Serverfehler. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await service.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/Bsevita.Library.Api/Controllers/LoansController.cs b/src/Bsevita.Library.Api/Controllers/LoansController.cs new file mode 100644 index 0000000..8114c22 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/LoansController.cs @@ -0,0 +1,48 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Loans; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Prüft und erstellt Ausleihen. +[ApiController] +[Route("api/loans")] +public sealed class LoansController(LoanService service) : ControllerBase +{ + /// Prüft, ob ein Buch ausgeliehen werden kann. + /// Ausweis- und Buchnummer im Body. + /// Ergebnis der Ausleihpruefung. + /// Ausleihe ist möglich. + /// Body ist ungültig. + /// Schüler oder Buch wurde nicht gefunden. + /// Buch ist bereits ausgeliehen. + /// Interner Serverfehler. + [HttpPost("verify")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Verify(VerifyLoanRequest request, CancellationToken cancellationToken) => + Ok(await service.VerifyAsync(request, cancellationToken)); + + /// Erstellt eine Ausleihe. + /// Ausleihdaten im Body. + /// Angelegte Ausleihe. + /// Ausleihe wurde angelegt. + /// Body ist ungültig. + /// Schüler oder Buch wurde nicht gefunden. + /// Buch ist bereits ausgeliehen. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(CreateLoanRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } +} diff --git a/src/Bsevita.Library.Api/Controllers/ReportsController.cs b/src/Bsevita.Library.Api/Controllers/ReportsController.cs new file mode 100644 index 0000000..3c9a346 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/ReportsController.cs @@ -0,0 +1,84 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Reports; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Liefert Auswertungen zum Bibliotheksbetrieb. +[ApiController] +[Route("api/reports")] +public sealed class ReportsController(ReportService service) : ControllerBase +{ + /// Liefert die wichtigsten Reports gebündelt. + /// Dashboard-Reports. + /// Reports wurden geliefert. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Reports(CancellationToken cancellationToken) => + Ok(await service.GetReportsAsync(cancellationToken)); + + /// Liefert aktive Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Aktive Ausleihen. + /// Aktive Ausleihen wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("active-loans")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> ActiveLoans( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetActiveLoansAsync(skip, take, cancellationToken)); + + /// Liefert Schüler mit aktiven Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Schüler mit aktiven Ausleihen. + /// Schüler wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("active-students")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> ActiveStudents( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetActiveStudentsAsync(skip, take, cancellationToken)); + + /// Liefert überfällige Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Ueberfällige Ausleihen. + /// Ueberfällige Ausleihen wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("overdue")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Overdue( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetOverdueAsync(skip, take, cancellationToken)); + + /// Liefert Bestands- und Ausleihstatistiken. + /// Bibliotheksstatistiken. + /// Statistiken wurden geliefert. + /// Interner Serverfehler. + [HttpGet("statistics")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Statistics(CancellationToken cancellationToken) => + Ok(await service.GetStatisticsAsync(cancellationToken)); +} diff --git a/src/Bsevita.Library.Api/Controllers/ReturnsController.cs b/src/Bsevita.Library.Api/Controllers/ReturnsController.cs new file mode 100644 index 0000000..4d19423 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/ReturnsController.cs @@ -0,0 +1,43 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Returns; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Prüft und verarbeitet Rückgaben. +[ApiController] +[Route("api/returns")] +public sealed class ReturnsController(ReturnService service) : ControllerBase +{ + /// Prüft die aktive Ausleihe eines Buchs. + /// Buchnummer. + /// Aktive Ausleihe mit Rückgabestatus. + /// Aktive Ausleihe wurde gefunden. + /// Buchnummer ist ungültig. + /// Keine aktive Ausleihe gefunden. + /// Interner Serverfehler. + [HttpGet("verify/{bookNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Verify(string bookNumber, CancellationToken cancellationToken) => + Ok(await service.VerifyAsync(bookNumber, cancellationToken)); + + /// Schließt die aktive Ausleihe eines Buchs ab. + /// Buchnummer im Body. + /// Ergebnis der Rückgabe. + /// Rückgabe wurde abgeschlossen. + /// Body ist ungültig. + /// Keine aktive Ausleihe gefunden. + /// Datenbankkonflikt bei paralleler Änderung. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Return(ReturnBookRequest request, CancellationToken cancellationToken) => + Ok(await service.ReturnAsync(request, cancellationToken)); +} diff --git a/src/Bsevita.Library.Api/Controllers/StudentsController.cs b/src/Bsevita.Library.Api/Controllers/StudentsController.cs new file mode 100644 index 0000000..2008fab --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/StudentsController.cs @@ -0,0 +1,128 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Verwaltet Schüler und ihre Bibliotheksausweise. +[ApiController] +[Route("api/students")] +public sealed class StudentsController(StudentService service) : ControllerBase +{ + /// Liefert Schüler seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Schülerliste. + /// Schüler wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetAllAsync(skip, take, cancellationToken)); + + /// Liefert einen Schüler anhand der ID. + /// Schüler-ID. + /// Gefundener Schüler. + /// Schüler wurde gefunden. + /// ID ist ungültig. + /// Schüler wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) => + Ok(await service.GetByIdAsync(id, cancellationToken)); + + /// Liefert einen Schüler anhand der Ausweisnummer. + /// Ausweisnummer. + /// Gefundener Schüler. + /// Schüler wurde gefunden. + /// Ausweisnummer ist ungültig. + /// Schüler wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("by-card/{cardNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetByCard(string cardNumber, CancellationToken cancellationToken) => + Ok(await service.GetByCardAsync(cardNumber, cancellationToken)); + + /// Sucht aktive Schüler nach Name. + /// Suchbegriff. + /// Passende Schüler. + /// Suche wurde ausgeführt. + /// Suchbegriff fehlt oder ist ungültig. + /// Interner Serverfehler. + [HttpGet("search")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Search([FromQuery] string name, CancellationToken cancellationToken) => + Ok(await service.SearchAsync(name, cancellationToken)); + + /// Legt einen Schüler an. + /// Schülerdaten im Body. + /// Angelegter Schüler. + /// Schüler wurde angelegt. + /// Body ist ungültig. + /// Ausweisnummer ist bereits vergeben. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(SaveStudentRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// Aktualisiert einen Schüler. + /// Schüler-ID. + /// Neue Schülerdaten im Body. + /// Aktualisierter Schüler. + /// Schüler wurde aktualisiert. + /// ID oder Body ist ungültig. + /// Schüler wurde nicht gefunden. + /// Ausweisnummer ist bereits vergeben. + /// Interner Serverfehler. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, SaveStudentRequest request, CancellationToken cancellationToken) => + Ok(await service.UpdateAsync(id, request, cancellationToken)); + + /// Deaktiviert einen Schüler. + /// Schüler-ID. + /// Kein Inhalt. + /// Schüler wurde deaktiviert. + /// ID ist ungültig. + /// Schüler wurde nicht gefunden. + /// Schüler hat aktive Ausleihen. + /// Interner Serverfehler. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await service.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs new file mode 100644 index 0000000..81083b9 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs @@ -0,0 +1,20 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Book +{ + public Guid BookId { get; set; } + public string BookNumber { get; set; } = null!; + public string? Isbn { get; set; } + public string Title { get; set; } = null!; + public string Author { get; set; } = null!; + public string Subject { get; set; } = null!; + public string? Publisher { get; set; } + public short? PublicationYear { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual ICollection Loans { get; set; } = new List(); +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs new file mode 100644 index 0000000..9bedf05 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs @@ -0,0 +1,16 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Loan +{ + public Guid LoanId { get; set; } + public Guid StudentId { get; set; } + public Guid BookId { get; set; } + public DateTimeOffset LoanedAt { get; set; } + public DateTimeOffset DueAt { get; set; } + public DateTimeOffset? ReturnedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual Book Book { get; set; } = null!; + public virtual Student Student { get; set; } = null!; +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs new file mode 100644 index 0000000..671da0a --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs @@ -0,0 +1,18 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Student +{ + public Guid StudentId { get; set; } + public string CardNumber { get; set; } = null!; + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public string ClassName { get; set; } = null!; + public string? Email { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual ICollection Loans { get; set; } = new List(); +} diff --git a/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs b/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs new file mode 100644 index 0000000..eba4d52 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs @@ -0,0 +1,68 @@ +// Generated from database/001_create_database.sql. +using Bsevita.Library.Api.Data.Generated.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Data.Generated; + +public partial class LibraryDbContext(DbContextOptions options) : DbContext(options) +{ + public virtual DbSet Books { get; set; } = null!; + public virtual DbSet Loans { get; set; } = null!; + public virtual DbSet Students { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.BookId).HasName("PK_Books"); + entity.HasIndex(e => e.BookNumber).IsUnique().HasDatabaseName("UX_Books_BookNumber"); + entity.HasIndex(e => e.Isbn).IsUnique().HasFilter("[Isbn] IS NOT NULL").HasDatabaseName("UX_Books_Isbn"); + entity.HasIndex(e => new { e.IsActive, e.Title }).HasDatabaseName("IX_Books_IsActive_Title"); + entity.Property(e => e.BookId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.BookNumber).HasMaxLength(32); + entity.Property(e => e.Isbn).HasMaxLength(20); + entity.Property(e => e.Title).HasMaxLength(240); + entity.Property(e => e.Author).HasMaxLength(160); + entity.Property(e => e.Subject).HasMaxLength(120); + entity.Property(e => e.Publisher).HasMaxLength(160); + entity.Property(e => e.IsActive).HasDefaultValue(true); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.StudentId).HasName("PK_Students"); + entity.HasIndex(e => e.CardNumber).IsUnique().HasDatabaseName("UX_Students_CardNumber"); + entity.HasIndex(e => new { e.IsActive, e.LastName, e.FirstName }).HasDatabaseName("IX_Students_IsActive_Name"); + entity.Property(e => e.StudentId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.CardNumber).HasMaxLength(32); + entity.Property(e => e.FirstName).HasMaxLength(100); + entity.Property(e => e.LastName).HasMaxLength(100); + entity.Property(e => e.ClassName).HasMaxLength(32); + entity.Property(e => e.Email).HasMaxLength(254); + entity.Property(e => e.IsActive).HasDefaultValue(true); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.LoanId).HasName("PK_Loans"); + entity.HasIndex(e => e.StudentId).HasDatabaseName("IX_Loans_StudentId"); + entity.HasIndex(e => e.DueAt).HasFilter("[ReturnedAt] IS NULL").HasDatabaseName("IX_Loans_Active_DueAt"); + entity.HasIndex(e => e.BookId).IsUnique().HasFilter("[ReturnedAt] IS NULL").HasDatabaseName("UX_Loans_ActiveBook"); + entity.Property(e => e.LoanId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.LoanedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + entity.HasOne(e => e.Book).WithMany(e => e.Loans).HasForeignKey(e => e.BookId).OnDelete(DeleteBehavior.Restrict).HasConstraintName("FK_Loans_Books"); + entity.HasOne(e => e.Student).WithMany(e => e.Loans).HasForeignKey(e => e.StudentId).OnDelete(DeleteBehavior.Restrict).HasConstraintName("FK_Loans_Students"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs b/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs new file mode 100644 index 0000000..250ad64 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs @@ -0,0 +1,20 @@ +using Bsevita.Library.Api.Data.Generated; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Bsevita.Library.Api.Data; + +public sealed class LibraryDbContextFactory : IDesignTimeDbContextFactory +{ + public LibraryDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("BSEVITA_DB_CONNECTION") + ?? @"Server=(localdb)\MSSQLLocalDB;Database=BsevitaLibrary;Trusted_Connection=True;TrustServerCertificate=True"; + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + return new LibraryDbContext(options); + } +} diff --git a/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs b/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs new file mode 100644 index 0000000..60632b2 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Infrastructure; + +public sealed class ApiExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + (int status, string title, string detail) = exception switch + { + ApiException apiException => (apiException.StatusCode, apiException.Title, apiException.Message), + DbUpdateConcurrencyException => ( + StatusCodes.Status409Conflict, + "Paralleländerung erkannt", + "Der Datensatz wurde zwischenzeitlich geändert. Bitte Daten neu laden."), + DbUpdateException => ( + StatusCodes.Status409Conflict, + "Datenbankkonflikt", + "Die Änderung verletzt eine Datenbankregel. Prüfen Sie eindeutige Nummern und aktive Ausleihen."), + _ => ( + StatusCodes.Status500InternalServerError, + "Interner Serverfehler", + "Die Anfrage konnte nicht verarbeitet werden.") + }; + + if (status >= 500) + { + logger.LogError(exception, "Unhandled exception for {Method} {Path}", httpContext.Request.Method, httpContext.Request.Path); + } + else + { + logger.LogWarning(exception, "Request failed with {StatusCode} for {Method} {Path}", status, httpContext.Request.Method, httpContext.Request.Path); + } + + httpContext.Response.StatusCode = status; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = new ProblemDetails + { + Status = status, + Title = title, + Detail = detail, + Instance = httpContext.Request.Path + }, + Exception = exception + }); + } +} diff --git a/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs b/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs new file mode 100644 index 0000000..dbdbb09 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Api.Infrastructure; + +public abstract class ApiException(int statusCode, string title, string detail) : Exception(detail) +{ + public int StatusCode { get; } = statusCode; + public string Title { get; } = title; +} + +public sealed class ResourceNotFoundException(string detail) + : ApiException(StatusCodes.Status404NotFound, "Nicht gefunden", detail) +{ } + +public sealed class ResourceConflictException(string detail) + : ApiException(StatusCodes.Status409Conflict, "Konflikt", detail) +{ } + +public sealed class RequestValidationException(string detail) + : ApiException(StatusCodes.Status400BadRequest, "Ungültige Anfrage", detail) +{ } diff --git a/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs b/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs new file mode 100644 index 0000000..c5cfab7 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs @@ -0,0 +1,23 @@ +namespace Bsevita.Library.Api.Infrastructure; + +public static class StringNormalizer +{ + public static string Required(string value, string fieldName) + { + var normalized = value.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new RequestValidationException($"{fieldName} darf nicht leer sein."); + } + + return normalized; + } + + public static string? Optional(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + public static string Key(string value, string fieldName) => Required(value, fieldName).ToUpperInvariant(); +} diff --git a/src/Bsevita.Library.Api/Program.cs b/src/Bsevita.Library.Api/Program.cs new file mode 100644 index 0000000..01c4e84 --- /dev/null +++ b/src/Bsevita.Library.Api/Program.cs @@ -0,0 +1,86 @@ +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("LibraryDatabase") + ?? throw new InvalidOperationException("Connection string 'LibraryDatabase' is missing."); + +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + foreach (var xmlFile in Directory.GetFiles(AppContext.BaseDirectory, "Bsevita.Library.*.xml")) + { + options.IncludeXmlComments(xmlFile); + } +}); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(options => +{ + options.AddPolicy("Frontend", policy => + { + var origins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? []; + if (origins.Length > 0) + { + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod(); + } + }); +}); +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5, TimeSpan.FromSeconds(5), null))); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +app.UseExceptionHandler(); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} +app.UseCors("Frontend"); +app.MapControllers(); +app.MapHealthChecks("/health") + .WithName("Health") + .WithSummary("Prüft den API-Status.") + .WithDescription("Liefert den Health-Status der API.") + .WithMetadata( + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), ["text/plain"]), + new ProducesResponseTypeMetadata(StatusCodes.Status503ServiceUnavailable, typeof(string), ["text/plain"])); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (app.Configuration.GetValue("Database:ValidateOnStartup", true)) +{ + await ValidateDatabaseAsync(app.Services, app.Logger, app.Lifetime.ApplicationStopping); +} + +await app.RunAsync(); + +static async Task ValidateDatabaseAsync(IServiceProvider services, ILogger logger, CancellationToken cancellationToken) +{ + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + if (!await dbContext.Database.CanConnectAsync(cancellationToken)) + { + var connection = dbContext.Database.GetDbConnection(); + throw new InvalidOperationException( + $"Database is unavailable at '{connection.DataSource}' for database '{connection.Database}'. " + + "Start SQL Server and initialize it with 'docker compose up -d sqlserver database-init'."); + } + + logger.LogInformation("Database connection validated successfully."); +} + +public partial class Program { } diff --git a/src/Bsevita.Library.Api/Services/BookService.cs b/src/Bsevita.Library.Api/Services/BookService.cs new file mode 100644 index 0000000..8daf818 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/BookService.cs @@ -0,0 +1,231 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class BookService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public Task> GetAllAsync( + bool? availableOnly, + int skip, + int take, + CancellationToken cancellationToken) => + QueryAsync(null, null, null, availableOnly, skip, take, cancellationToken); + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var book = await FindActiveByIdAsync(id, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking().AnyAsync(loan => loan.BookId == id && loan.ReturnedAt == null, cancellationToken); + return book.ToDto(!isBorrowed); + } + + public async Task GetByNumberAsync(string bookNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + var book = await dbContext.Books.AsNoTracking().SingleOrDefaultAsync(item => item.IsActive && item.BookNumber == key, cancellationToken); + if (book is null) + { + throw new ResourceNotFoundException($"Kein aktives Buch mit Buchnummer '{bookNumber}' gefunden."); + } + + var isBorrowed = await dbContext.Loans.AsNoTracking().AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + return book.ToDto(!isBorrowed); + } + + public async Task> SearchAsync( + string? title, + string? author, + string? subject, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(author) && string.IsNullOrWhiteSpace(subject)) + { + throw new RequestValidationException("Mindestens ein Suchfilter muss angegeben werden."); + } + + return await QueryAsync(title, author, subject, null, 0, LibraryRules.DefaultPageSize, cancellationToken); + } + + public async Task CreateAsync(SaveBookRequest request, CancellationToken cancellationToken) + { + var bookNumber = StringNormalizer.Key(request.BookNumber, "Buchnummer"); + var isbn = StringNormalizer.Optional(request.Isbn); + await EnsureIdentifiersAvailableAsync(bookNumber, isbn, null, cancellationToken); + var now = timeProvider.GetUtcNow(); + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = bookNumber, + Isbn = isbn, + Title = StringNormalizer.Required(request.Title, "Titel"), + Author = StringNormalizer.Required(request.Author, "Autor"), + Subject = StringNormalizer.Required(request.Subject, "Sachgebiet"), + Publisher = StringNormalizer.Optional(request.Publisher), + PublicationYear = request.PublicationYear is int publicationYear ? checked((short)publicationYear) : null, + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + + dbContext.Books.Add(book); + await dbContext.SaveChangesAsync(cancellationToken); + return book.ToDto(true); + } + + public async Task UpdateAsync(Guid id, SaveBookRequest request, CancellationToken cancellationToken) + { + var book = await FindActiveByIdAsync(id, cancellationToken); + var bookNumber = StringNormalizer.Key(request.BookNumber, "Buchnummer"); + var isbn = StringNormalizer.Optional(request.Isbn); + await EnsureIdentifiersAvailableAsync(bookNumber, isbn, id, cancellationToken); + + book.BookNumber = bookNumber; + book.Isbn = isbn; + book.Title = StringNormalizer.Required(request.Title, "Titel"); + book.Author = StringNormalizer.Required(request.Author, "Autor"); + book.Subject = StringNormalizer.Required(request.Subject, "Sachgebiet"); + book.Publisher = StringNormalizer.Optional(request.Publisher); + book.PublicationYear = request.PublicationYear is int publicationYear ? checked((short)publicationYear) : null; + book.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return await GetByIdAsync(id, cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var book = await FindActiveByIdAsync(id, cancellationToken); + var hasActiveLoan = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == id && loan.ReturnedAt == null, cancellationToken); + if (hasActiveLoan) + { + throw new ResourceConflictException("Ein ausgeliehenes Buch kann nicht gelöscht werden."); + } + + book.IsActive = false; + book.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + }); + } + + private async Task> QueryAsync( + string? title, + string? author, + string? subject, + bool? availableOnly, + int skip, + int take, + CancellationToken cancellationToken) + { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, LibraryRules.MaxPageSize); + var query = dbContext.Books.AsNoTracking().Where(book => book.IsActive); + if (!string.IsNullOrWhiteSpace(title)) + { + query = ApplyTextSearch(query, SearchField.Title, title, "Titel"); + } + if (!string.IsNullOrWhiteSpace(author)) + { + query = ApplyTextSearch(query, SearchField.Author, author, "Autor"); + } + if (!string.IsNullOrWhiteSpace(subject)) + { + query = ApplyTextSearch(query, SearchField.Subject, subject, "Sachgebiet"); + } + if (availableOnly is true) + { + query = query.Where(book => !book.Loans.Any(loan => loan.ReturnedAt == null)); + } + else if (availableOnly is false) + { + query = query.Where(book => book.Loans.Any(loan => loan.ReturnedAt == null)); + } + + var books = await query + .OrderBy(book => book.Title) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + var bookIds = books.Select(book => book.BookId).ToList(); + var borrowedBookIds = await dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null && bookIds.Contains(loan.BookId)) + .Select(loan => loan.BookId) + .ToListAsync(cancellationToken); + var borrowed = borrowedBookIds.ToHashSet(); + return books.Select(book => book.ToDto(!borrowed.Contains(book.BookId))).ToList(); + } + + private IQueryable ApplyTextSearch( + IQueryable query, + SearchField field, + string value, + string fieldName) + { + var term = StringNormalizer.Required(value, fieldName); + if (!dbContext.Database.IsRelational()) + { + var fallbackTerm = term.ToUpperInvariant(); + return field switch + { + SearchField.Title => query.Where(book => book.Title.ToUpper().Contains(fallbackTerm)), + SearchField.Author => query.Where(book => book.Author.ToUpper().Contains(fallbackTerm)), + _ => query.Where(book => book.Subject.ToUpper().Contains(fallbackTerm)) + }; + } + + return field switch + { + SearchField.Title => query.Where(book => book.Title.Contains(term)), + SearchField.Author => query.Where(book => book.Author.Contains(term)), + _ => query.Where(book => book.Subject.Contains(term)) + }; + } + + private enum SearchField + { + Title, + Author, + Subject + } + + private async Task FindActiveByIdAsync(Guid id, CancellationToken cancellationToken) => + await dbContext.Books.SingleOrDefaultAsync(book => book.BookId == id && book.IsActive, cancellationToken) + ?? throw new ResourceNotFoundException("Buch wurde nicht gefunden."); + + private async Task EnsureIdentifiersAvailableAsync(string bookNumber, string? isbn, Guid? ignoredId, CancellationToken cancellationToken) + { + var numberExists = await dbContext.Books.AsNoTracking().AnyAsync( + book => book.BookNumber == bookNumber && (!ignoredId.HasValue || book.BookId != ignoredId.Value), + cancellationToken); + if (numberExists) + { + throw new ResourceConflictException($"Die Buchnummer '{bookNumber}' ist bereits vergeben."); + } + + if (isbn is not null) + { + var isbnExists = await dbContext.Books.AsNoTracking().AnyAsync( + book => book.Isbn == isbn && (!ignoredId.HasValue || book.BookId != ignoredId.Value), + cancellationToken); + if (isbnExists) + { + throw new ResourceConflictException($"Die ISBN '{isbn}' ist bereits vergeben."); + } + } + } +} diff --git a/src/Bsevita.Library.Api/Services/ContractMapper.cs b/src/Bsevita.Library.Api/Services/ContractMapper.cs new file mode 100644 index 0000000..1ba04da --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ContractMapper.cs @@ -0,0 +1,55 @@ +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Api.Services; + +internal static class ContractMapper +{ + public static StudentDto ToDto(this Student student) => new( + student.StudentId, + student.CardNumber, + student.FirstName, + student.LastName, + $"{student.FirstName} {student.LastName}", + student.ClassName, + student.Email, + student.IsActive, + student.CreatedAt, + student.UpdatedAt); + + public static BookDto ToDto(this Book book, bool isAvailable) => new( + book.BookId, + book.BookNumber, + book.Isbn, + book.Title, + book.Author, + book.Subject, + book.Publisher, + book.PublicationYear, + isAvailable, + book.IsActive, + book.CreatedAt, + book.UpdatedAt); + + public static LoanDto ToDto(this Loan loan, DateTimeOffset now) + { + var isOverdue = loan.ReturnedAt is null && loan.DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + + return new LoanDto( + loan.LoanId, + loan.StudentId, + $"{loan.Student.FirstName} {loan.Student.LastName}", + loan.Student.CardNumber, + loan.BookId, + loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + isOverdue, + daysOverdue); + } +} diff --git a/src/Bsevita.Library.Api/Services/LoanService.cs b/src/Bsevita.Library.Api/Services/LoanService.cs new file mode 100644 index 0000000..0736ce4 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/LoanService.cs @@ -0,0 +1,88 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class LoanService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task VerifyAsync(VerifyLoanRequest request, CancellationToken cancellationToken) + { + var (student, book) = await ResolveAsync(request.CardNumber, request.BookNumber, asTracking: false, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + if (isBorrowed) + { + throw new ResourceConflictException($"Das Buch '{book.Title}' ist bereits ausgeliehen."); + } + + var dueAt = timeProvider.GetUtcNow().AddDays(LibraryRules.DefaultLoanDays); + return new LoanVerificationDto(student.ToDto(), book.ToDto(true), dueAt, true, "Schüler und Buch wurden erfolgreich verifiziert."); + } + + public async Task CreateAsync(CreateLoanRequest request, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var (student, book) = await ResolveAsync(request.CardNumber, request.BookNumber, asTracking: true, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + if (isBorrowed) + { + throw new ResourceConflictException($"Das Buch '{book.Title}' wurde zwischenzeitlich ausgeliehen."); + } + + var now = timeProvider.GetUtcNow(); + var dueAt = request.DueAt ?? now.AddDays(LibraryRules.DefaultLoanDays); + if (dueAt < now) + { + throw new RequestValidationException("Das Fälligkeitsdatum darf nicht in der Vergangenheit liegen."); + } + + var loan = new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = book.BookId, + LoanedAt = now, + DueAt = dueAt, + Student = student, + Book = book + }; + dbContext.Loans.Add(loan); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return loan.ToDto(now); + }); + } + + private async Task<(Student Student, Book Book)> ResolveAsync( + string cardNumber, + string bookNumber, + bool asTracking, + CancellationToken cancellationToken) + { + var cardKey = StringNormalizer.Key(cardNumber, "Ausweisnummer"); + var bookKey = StringNormalizer.Key(bookNumber, "Buchnummer"); + var students = asTracking ? dbContext.Students : dbContext.Students.AsNoTracking(); + var books = asTracking ? dbContext.Books : dbContext.Books.AsNoTracking(); + var student = await students.SingleOrDefaultAsync( + item => item.IsActive && item.CardNumber == cardKey, + cancellationToken) ?? throw new ResourceNotFoundException($"Schüler mit Ausweisnummer '{cardNumber}' wurde nicht gefunden."); + var book = await books.SingleOrDefaultAsync( + item => item.IsActive && item.BookNumber == bookKey, + cancellationToken) ?? throw new ResourceNotFoundException($"Buch mit Buchnummer '{bookNumber}' wurde nicht gefunden."); + return (student, book); + } +} diff --git a/src/Bsevita.Library.Api/Services/ReportService.cs b/src/Bsevita.Library.Api/Services/ReportService.cs new file mode 100644 index 0000000..3ea087d --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ReportService.cs @@ -0,0 +1,159 @@ +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Reports; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class ReportService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task GetReportsAsync(CancellationToken cancellationToken) + { + var activeLoans = await GetActiveLoansAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + var overdueLoans = await GetOverdueAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + return new ReportsDto( + activeLoans, + overdueLoans, + await GetActiveStudentsAsync(0, LibraryRules.DefaultPageSize, cancellationToken), + await GetStatisticsAsync(cancellationToken)); + } + + public async Task> GetActiveLoansAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var query = dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null) + .OrderBy(loan => loan.DueAt) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)); + var loans = await query + .Select(loan => new + { + loan.LoanId, + loan.StudentId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + loan.BookId, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt + }) + .ToListAsync(cancellationToken); + return loans.Select(loan => ToLoanDto( + loan.LoanId, + loan.StudentId, + loan.StudentName, + loan.CardNumber, + loan.BookId, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + now)).ToList(); + } + + public Task> GetActiveLoansAsync(CancellationToken cancellationToken) => + GetActiveLoansAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task> GetOverdueAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var query = dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null && loan.DueAt < now) + .OrderBy(loan => loan.DueAt) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)); + var loans = await query + .Select(loan => new + { + loan.LoanId, + loan.StudentId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + loan.BookId, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt + }) + .ToListAsync(cancellationToken); + return loans.Select(loan => ToLoanDto( + loan.LoanId, + loan.StudentId, + loan.StudentName, + loan.CardNumber, + loan.BookId, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + now)).ToList(); + } + + public Task> GetOverdueAsync(CancellationToken cancellationToken) => + GetOverdueAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task> GetActiveStudentsAsync( + int skip, + int take, + CancellationToken cancellationToken) => + await dbContext.Students.AsNoTracking() + .Where(student => student.IsActive && student.Loans.Any(loan => loan.ReturnedAt == null)) + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)) + .Select(student => new ActiveStudentDto( + student.StudentId, + student.FirstName + " " + student.LastName, + student.CardNumber, + student.ClassName, + student.Loans.Count(loan => loan.ReturnedAt == null))) + .ToListAsync(cancellationToken); + + public Task> GetActiveStudentsAsync(CancellationToken cancellationToken) => + GetActiveStudentsAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task GetStatisticsAsync(CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var totalBooks = await dbContext.Books.CountAsync(book => book.IsActive, cancellationToken); + var activeLoans = await dbContext.Loans.CountAsync(loan => loan.ReturnedAt == null, cancellationToken); + var availableBooks = await dbContext.Books.CountAsync( + book => book.IsActive && !book.Loans.Any(loan => loan.ReturnedAt == null), + cancellationToken); + var totalStudents = await dbContext.Students.CountAsync(student => student.IsActive, cancellationToken); + var overdueLoans = await dbContext.Loans.CountAsync(loan => loan.ReturnedAt == null && loan.DueAt < now, cancellationToken); + return new LibraryStatisticsDto(totalBooks, availableBooks, totalStudents, activeLoans, overdueLoans); + } + + private static LoanDto ToLoanDto( + Guid Id, + Guid StudentId, + string StudentName, + string CardNumber, + Guid BookId, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + DateTimeOffset? ReturnedAt, + DateTimeOffset now) + { + var isOverdue = ReturnedAt is null && DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - DueAt).TotalDays)) : 0; + return new LoanDto(Id, StudentId, StudentName, CardNumber, BookId, BookTitle, BookNumber, LoanedAt, DueAt, ReturnedAt, isOverdue, daysOverdue); + } +} diff --git a/src/Bsevita.Library.Api/Services/ReturnService.cs b/src/Bsevita.Library.Api/Services/ReturnService.cs new file mode 100644 index 0000000..899d66d --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ReturnService.cs @@ -0,0 +1,89 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Returns; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class ReturnService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task VerifyAsync(string bookNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + var loan = await dbContext.Loans + .AsNoTracking() + .Where(loan => loan.Book.BookNumber == key && loan.Book.IsActive && loan.ReturnedAt == null) + .Select(loan => new + { + loan.LoanId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt + }) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new ResourceNotFoundException($"Für Buchnummer '{bookNumber}' wurde keine aktive Ausleihe gefunden."); + var now = timeProvider.GetUtcNow(); + var isOverdue = loan.DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + return new ReturnVerificationDto( + loan.LoanId, + loan.StudentName, + loan.CardNumber, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + isOverdue, + daysOverdue); + } + + public async Task ReturnAsync(ReturnBookRequest request, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var loan = await FindActiveLoanAsync(request.BookNumber, asTracking: true, cancellationToken); + var now = timeProvider.GetUtcNow(); + var wasOverdue = loan.DueAt < now; + var daysOverdue = wasOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + loan.ReturnedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new ReturnResultDto( + loan.LoanId, + $"{loan.Student.FirstName} {loan.Student.LastName}", + loan.Book.Title, + loan.Book.BookNumber, + now, + wasOverdue, + daysOverdue); + }); + } + + private async Task FindActiveLoanAsync(string bookNumber, bool asTracking, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + IQueryable query = dbContext.Loans + .Include(loan => loan.Student) + .Include(loan => loan.Book); + if (!asTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync( + loan => loan.Book.BookNumber == key && loan.Book.IsActive && loan.ReturnedAt == null, + cancellationToken) ?? throw new ResourceNotFoundException($"Für Buchnummer '{bookNumber}' wurde keine aktive Ausleihe gefunden."); + } +} diff --git a/src/Bsevita.Library.Api/Services/StudentService.cs b/src/Bsevita.Library.Api/Services/StudentService.cs new file mode 100644 index 0000000..dfd4194 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/StudentService.cs @@ -0,0 +1,162 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class StudentService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task> GetAllAsync(int skip, int take, CancellationToken cancellationToken) => + await ActiveStudents() + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)) + .Select(ToDto()) + .ToListAsync(cancellationToken); + + private IQueryable ActiveStudents() => + dbContext.Students + .AsNoTracking() + .Where(student => student.IsActive); + + private static System.Linq.Expressions.Expression> ToDto() => + student => new StudentDto( + student.StudentId, + student.CardNumber, + student.FirstName, + student.LastName, + student.FirstName + " " + student.LastName, + student.ClassName, + student.Email, + student.IsActive, + student.CreatedAt, + student.UpdatedAt); + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + await ActiveStudents() + .Where(student => student.StudentId == id) + .Select(ToDto()) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new ResourceNotFoundException("Schüler wurde nicht gefunden."); + + public async Task GetByCardAsync(string cardNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(cardNumber, "Ausweisnummer"); + var student = await dbContext.Students.AsNoTracking() + .SingleOrDefaultAsync(item => item.IsActive && item.CardNumber == key, cancellationToken); + return student?.ToDto() ?? throw new ResourceNotFoundException($"Kein aktiver Schüler mit Ausweisnummer '{cardNumber}' gefunden."); + } + + public async Task> SearchAsync(string name, CancellationToken cancellationToken) + { + var term = StringNormalizer.Required(name, "Suchbegriff"); + var query = ActiveStudents(); + if (dbContext.Database.IsRelational()) + { + query = query.Where(student => + student.FirstName.Contains(term) || + student.LastName.Contains(term) || + (student.FirstName + " " + student.LastName).Contains(term)); + } + else + { + var fallbackTerm = term.ToUpperInvariant(); + query = query.Where(student => student.FirstName.ToUpper().Contains(fallbackTerm) || + student.LastName.ToUpper().Contains(fallbackTerm) || + (student.FirstName + " " + student.LastName).ToUpper().Contains(fallbackTerm)); + } + + return await query + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Take(LibraryRules.DefaultPageSize) + .Select(ToDto()) + .ToListAsync(cancellationToken); + } + + public async Task CreateAsync(SaveStudentRequest request, CancellationToken cancellationToken) + { + var cardNumber = StringNormalizer.Key(request.CardNumber, "Ausweisnummer"); + await EnsureCardNumberAvailableAsync(cardNumber, null, cancellationToken); + var now = timeProvider.GetUtcNow(); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = cardNumber, + FirstName = StringNormalizer.Required(request.FirstName, "Vorname"), + LastName = StringNormalizer.Required(request.LastName, "Nachname"), + ClassName = StringNormalizer.Required(request.ClassName, "Klasse"), + Email = StringNormalizer.Optional(request.Email), + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + + dbContext.Students.Add(student); + await dbContext.SaveChangesAsync(cancellationToken); + return student.ToDto(); + } + + public async Task UpdateAsync(Guid id, SaveStudentRequest request, CancellationToken cancellationToken) + { + var student = await FindActiveByIdAsync(id, cancellationToken); + var cardNumber = StringNormalizer.Key(request.CardNumber, "Ausweisnummer"); + await EnsureCardNumberAvailableAsync(cardNumber, id, cancellationToken); + + student.CardNumber = cardNumber; + student.FirstName = StringNormalizer.Required(request.FirstName, "Vorname"); + student.LastName = StringNormalizer.Required(request.LastName, "Nachname"); + student.ClassName = StringNormalizer.Required(request.ClassName, "Klasse"); + student.Email = StringNormalizer.Optional(request.Email); + student.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return student.ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var student = await FindActiveByIdAsync(id, cancellationToken); + var hasActiveLoans = await dbContext.Loans.AsNoTracking().AnyAsync( + loan => loan.StudentId == id && loan.ReturnedAt == null, + cancellationToken); + if (hasActiveLoans) + { + throw new ResourceConflictException("Der Schüler hat noch aktive Ausleihen und kann nicht gelöscht werden."); + } + + student.IsActive = false; + student.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + }); + } + + private async Task FindActiveByIdAsync(Guid id, CancellationToken cancellationToken) => + await dbContext.Students.SingleOrDefaultAsync(student => student.StudentId == id && student.IsActive, cancellationToken) + ?? throw new ResourceNotFoundException("Schüler wurde nicht gefunden."); + + private async Task EnsureCardNumberAvailableAsync(string cardNumber, Guid? ignoredId, CancellationToken cancellationToken) + { + var exists = await dbContext.Students.AsNoTracking().AnyAsync( + student => student.CardNumber == cardNumber && (!ignoredId.HasValue || student.StudentId != ignoredId.Value), + cancellationToken); + if (exists) + { + throw new ResourceConflictException($"Die Ausweisnummer '{cardNumber}' ist bereits vergeben."); + } + } +} diff --git a/src/Bsevita.Library.Api/appsettings.Development.json b/src/Bsevita.Library.Api/appsettings.Development.json new file mode 100644 index 0000000..9f8419c --- /dev/null +++ b/src/Bsevita.Library.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } +} diff --git a/src/Bsevita.Library.Api/appsettings.json b/src/Bsevita.Library.Api/appsettings.json new file mode 100644 index 0000000..f0263bd --- /dev/null +++ b/src/Bsevita.Library.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "ConnectionStrings": { + "LibraryDatabase": "Server=(localdb)\\MSSQLLocalDB;Database=BsevitaLibrary;Trusted_Connection=True;TrustServerCertificate=True" + }, + "AllowedOrigins": [ + "http://localhost:5190", + "https://localhost:7190" + ], + "Database": { + "ValidateOnStartup": true + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Bsevita.Library.Maui/App.xaml b/src/Bsevita.Library.Maui/App.xaml new file mode 100644 index 0000000..1074842 --- /dev/null +++ b/src/Bsevita.Library.Maui/App.xaml @@ -0,0 +1,10 @@ + + + + + #F6F4EF + + + diff --git a/src/Bsevita.Library.Maui/App.xaml.cs b/src/Bsevita.Library.Maui/App.xaml.cs new file mode 100644 index 0000000..2294894 --- /dev/null +++ b/src/Bsevita.Library.Maui/App.xaml.cs @@ -0,0 +1,24 @@ +namespace Bsevita.Library.Maui; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + var window = new Window(new MainPage()) { Title = "BSEVITA Library" }; + + if (DeviceInfo.Platform == DevicePlatform.WinUI) + { + window.Width = 1280; + window.Height = 860; + window.MinimumWidth = 980; + window.MinimumHeight = 640; + } + + return window; + } +} diff --git a/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj b/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj new file mode 100644 index 0000000..b8a9793 --- /dev/null +++ b/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj @@ -0,0 +1,43 @@ + + + net10.0-android + net10.0-windows10.0.19041.0 + net10.0-ios;net10.0-maccatalyst + Exe + Bsevita.Library.Maui + false + true + true + enable + enable + BSEVITA Library + top.lechner.bsevita.library + 1.0 + 1 + None + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + Platforms/MacCatalyst/Entitlements.plist + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\Program Files\dotnet\packs\Microsoft.Android.Ref.36\36.1.30\ref\net10.0\Mono.Android.dll + + + diff --git a/src/Bsevita.Library.Maui/MainPage.xaml b/src/Bsevita.Library.Maui/MainPage.xaml new file mode 100644 index 0000000..3dbd04b --- /dev/null +++ b/src/Bsevita.Library.Maui/MainPage.xaml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/src/Bsevita.Library.Maui/MainPage.xaml.cs b/src/Bsevita.Library.Maui/MainPage.xaml.cs new file mode 100644 index 0000000..ee8c505 --- /dev/null +++ b/src/Bsevita.Library.Maui/MainPage.xaml.cs @@ -0,0 +1,9 @@ +namespace Bsevita.Library.Maui; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + InitializeComponent(); + } +} diff --git a/src/Bsevita.Library.Maui/MauiProgram.cs b/src/Bsevita.Library.Maui/MauiProgram.cs new file mode 100644 index 0000000..8058dd1 --- /dev/null +++ b/src/Bsevita.Library.Maui/MauiProgram.cs @@ -0,0 +1,31 @@ +using Bsevita.Library.Maui.Services; +using Bsevita.Library.Ui.Services; +using Microsoft.Extensions.Logging; + +namespace Bsevita.Library.Maui; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder.UseMauiApp(); + builder.Services.AddMauiBlazorWebView(); + builder.Services.AddLibraryUi(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(serviceProvider => + { + var resolver = serviceProvider.GetRequiredService(); + return new HttpClient + { + BaseAddress = resolver.GetBaseAddress(), + Timeout = TimeSpan.FromSeconds(20) + }; + }); +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + return builder.Build(); + } +} diff --git a/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml b/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..edd00ae --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs b/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..e6e8966 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace Bsevita.Library.Maui; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, + ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | + ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity { } diff --git a/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs b/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..be3f2b4 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Runtime; + +namespace Bsevita.Library.Maui; + +[Application] +public class MainApplication(nint handle, JniHandleOwnership ownership) : MauiApplication(handle, ownership) +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml b/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 0000000..5123ff7 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,2 @@ + +#3157D5#172554#6941C6 diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 0000000..0918c81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Bsevita.Library.Maui; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 0000000..65a5b81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,3 @@ + + +com.apple.security.app-sandboxcom.apple.security.network.client diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist new file mode 100644 index 0000000..06f5639 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,7 @@ + + + +UIDeviceFamily2 +UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +NSAppTransportSecurityNSAllowsLocalNetworking + diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs new file mode 100644 index 0000000..4e96795 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace Bsevita.Library.Maui; + +public static class Program +{ + public static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml new file mode 100644 index 0000000..4f1b1b2 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml @@ -0,0 +1,4 @@ + diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..39588e7 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Maui.WinUI; + +public partial class App : MauiWinUIApplication +{ + public App() => InitializeComponent(); + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest b/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest new file mode 100644 index 0000000..48b02d8 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,9 @@ + + + + + BSEVITA LibraryJulian Lechner$placeholder$.png + + + + diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs b/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs new file mode 100644 index 0000000..0918c81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Bsevita.Library.Maui; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist b/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist new file mode 100644 index 0000000..d9ad011 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist @@ -0,0 +1,9 @@ + + + +LSRequiresIPhoneOS +UIDeviceFamily12 +UIRequiredDeviceCapabilitiesarm64 +UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +NSAppTransportSecurityNSAllowsLocalNetworking + diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs b/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs new file mode 100644 index 0000000..4e96795 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace Bsevita.Library.Maui; + +public static class Program +{ + public static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg b/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..7a18a99 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg b/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..7e3c119 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Resources/Raw/README.txt b/src/Bsevita.Library.Maui/Resources/Raw/README.txt new file mode 100644 index 0000000..6b35c6b --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/Raw/README.txt @@ -0,0 +1 @@ +This folder is reserved for MAUI raw assets. diff --git a/src/Bsevita.Library.Maui/Resources/Splash/splash.svg b/src/Bsevita.Library.Maui/Resources/Splash/splash.svg new file mode 100644 index 0000000..446ede5 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/Splash/splash.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs b/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs new file mode 100644 index 0000000..da1a3b2 --- /dev/null +++ b/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Maui.Services; + +public sealed class ApiEndpointResolver +{ + public Uri GetBaseAddress() + { + var configured = Environment.GetEnvironmentVariable("BSEVITA_API_BASE_URL"); + if (Uri.TryCreate(configured, UriKind.Absolute, out var customUri)) + { + return EnsureTrailingSlash(customUri); + } + + var host = DeviceInfo.Platform == DevicePlatform.Android ? "10.0.2.2" : "localhost"; + return new Uri($"http://{host}:5180/"); + } + + private static Uri EnsureTrailingSlash(Uri uri) => + uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/"); +} diff --git a/src/Bsevita.Library.Maui/wwwroot/index.html b/src/Bsevita.Library.Maui/wwwroot/index.html new file mode 100644 index 0000000..0a51181 --- /dev/null +++ b/src/Bsevita.Library.Maui/wwwroot/index.html @@ -0,0 +1,17 @@ + + + + + + BSEVITA Library + + + + +
+
Anwendung wird gestartet …
+
+ + + + diff --git a/src/Bsevita.Library.Models/Books/BookContracts.cs b/src/Bsevita.Library.Models/Books/BookContracts.cs new file mode 100644 index 0000000..43b19bc --- /dev/null +++ b/src/Bsevita.Library.Models/Books/BookContracts.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Books; + +/// Buch mit Bestands- und Verfügbarkeitsdaten. +/// Eindeutige Buch-ID. +/// Interne Buchnummer. +/// Optionale ISBN. +/// Titel. +/// Autor. +/// Fach oder Kategorie. +/// Optionaler Verlag. +/// Optionales Erscheinungsjahr. +/// Gibt an, ob das Buch ausleihbar ist. +/// Gibt an, ob das Buch aktiv im Bestand ist. +/// Erstellzeitpunkt. +/// Letzter Änderungszeitpunkt. +public sealed record BookDto( + Guid Id, + string BookNumber, + string? Isbn, + string Title, + string Author, + string Subject, + string? Publisher, + int? PublicationYear, + bool IsAvailable, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +/// Daten zum Anlegen oder Aktualisieren eines Buchs. +public sealed class SaveBookRequest +{ + /// Interne Buchnummer. + [Required, StringLength(LibraryRules.MaxBookNumberLength, MinimumLength = 1)] + public string BookNumber { get; set; } = string.Empty; + + /// Optionale ISBN. + [StringLength(20)] + public string? Isbn { get; set; } + + /// Titel. + [Required, StringLength(LibraryRules.MaxTitleLength, MinimumLength = 1)] + public string Title { get; set; } = string.Empty; + + /// Autor. + [Required, StringLength(LibraryRules.MaxAuthorLength, MinimumLength = 1)] + public string Author { get; set; } = string.Empty; + + /// Fach oder Kategorie. + [Required, StringLength(LibraryRules.MaxSubjectLength, MinimumLength = 1)] + public string Subject { get; set; } = string.Empty; + + /// Optionaler Verlag. + [StringLength(160)] + public string? Publisher { get; set; } + + /// Optionales Erscheinungsjahr. + [Range(1000, 2200)] + public int? PublicationYear { get; set; } +} diff --git a/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj b/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj new file mode 100644 index 0000000..d5a251a --- /dev/null +++ b/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj @@ -0,0 +1,8 @@ + + + net10.0 + Bsevita.Library.Models + true + $(NoWarn);1591 + + diff --git a/src/Bsevita.Library.Models/Common/LibraryRules.cs b/src/Bsevita.Library.Models/Common/LibraryRules.cs new file mode 100644 index 0000000..dbcb97f --- /dev/null +++ b/src/Bsevita.Library.Models/Common/LibraryRules.cs @@ -0,0 +1,15 @@ +namespace Bsevita.Library.Models.Common; + +public static class LibraryRules +{ + public const int DefaultLoanDays = 14; + public const int DefaultPageSize = 100; + public const int MaxPageSize = 500; + public const int MaxCardNumberLength = 32; + public const int MaxBookNumberLength = 32; + public const int MaxNameLength = 100; + public const int MaxClassNameLength = 32; + public const int MaxTitleLength = 240; + public const int MaxAuthorLength = 160; + public const int MaxSubjectLength = 120; +} diff --git a/src/Bsevita.Library.Models/Loans/LoanContracts.cs b/src/Bsevita.Library.Models/Loans/LoanContracts.cs new file mode 100644 index 0000000..a0a9235 --- /dev/null +++ b/src/Bsevita.Library.Models/Loans/LoanContracts.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Models.Loans; + +/// Daten zur Prüfung einer Ausleihe. +public sealed class VerifyLoanRequest +{ + /// Bibliotheksausweisnummer des Schülers. + [Required, StringLength(LibraryRules.MaxCardNumberLength)] + public string CardNumber { get; set; } = string.Empty; + + /// Buchnummer des auszuleihenden Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; +} + +/// Daten zum Erstellen einer Ausleihe. +public sealed class CreateLoanRequest +{ + /// Bibliotheksausweisnummer des Schülers. + [Required, StringLength(LibraryRules.MaxCardNumberLength)] + public string CardNumber { get; set; } = string.Empty; + + /// Buchnummer des auszuleihenden Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; + + /// Optionale Fälligkeit; ohne Wert wird die Standardfrist verwendet. + public DateTimeOffset? DueAt { get; set; } +} + +/// Ergebnis einer Ausleihpruefung. +/// Gefundener Schüler. +/// Gefundenes Buch. +/// Berechnetes Rückgabedatum. +/// Gibt an, ob die Ausleihe erlaubt ist. +/// Lesbare Statusmeldung. +public sealed record LoanVerificationDto( + StudentDto Student, + BookDto Book, + DateTimeOffset DueAt, + bool CanBorrow, + string StatusMessage); + +/// Ausleihe mit Schüler-, Buch- und Fristdaten. +/// Eindeutige Ausleih-ID. +/// ID des Schülers. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// ID des Buchs. +/// Titel des Buchs. +/// Buchnummer. +/// Ausleihzeitpunkt. +/// Fälligkeit der Rückgabe. +/// Rückgabezeitpunkt, falls abgeschlossen. +/// Gibt an, ob die Ausleihe überfällig ist. +/// Anzahl überfälliger Tage. +public sealed record LoanDto( + Guid Id, + Guid StudentId, + string StudentName, + string CardNumber, + Guid BookId, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + DateTimeOffset? ReturnedAt, + bool IsOverdue, + int DaysOverdue); diff --git a/src/Bsevita.Library.Models/Reports/ReportContracts.cs b/src/Bsevita.Library.Models/Reports/ReportContracts.cs new file mode 100644 index 0000000..e2f4c95 --- /dev/null +++ b/src/Bsevita.Library.Models/Reports/ReportContracts.cs @@ -0,0 +1,40 @@ +using Bsevita.Library.Models.Loans; + +namespace Bsevita.Library.Models.Reports; + +/// Schüler mit aktiven Ausleihen. +/// ID des Schülers. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// Klasse oder Kurs. +/// Anzahl aktiver Ausleihen. +public sealed record ActiveStudentDto( + Guid StudentId, + string StudentName, + string CardNumber, + string ClassName, + int ActiveLoanCount); + +/// Aggregierte Bibliotheksstatistiken. +/// Anzahl aktiver Bücher. +/// Anzahl verfügbarer Bücher. +/// Anzahl aktiver Schüler. +/// Anzahl aktiver Ausleihen. +/// Anzahl überfälliger Ausleihen. +public sealed record LibraryStatisticsDto( + int TotalBooks, + int AvailableBooks, + int TotalStudents, + int ActiveLoans, + int OverdueLoans); + +/// Gebundelte Reports für das Dashboard. +/// Aktive Ausleihen. +/// Ueberfällige Ausleihen. +/// Schüler mit aktiven Ausleihen. +/// Aggregierte Statistiken. +public sealed record ReportsDto( + IReadOnlyList ActiveLoans, + IReadOnlyList OverdueLoans, + IReadOnlyList ActiveStudents, + LibraryStatisticsDto Statistics); diff --git a/src/Bsevita.Library.Models/Returns/ReturnContracts.cs b/src/Bsevita.Library.Models/Returns/ReturnContracts.cs new file mode 100644 index 0000000..442e251 --- /dev/null +++ b/src/Bsevita.Library.Models/Returns/ReturnContracts.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Returns; + +/// Daten zum Abschließen einer Rückgabe. +public sealed class ReturnBookRequest +{ + /// Buchnummer des zurückgegebenen Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; +} + +/// Ergebnis einer Rückgabeprüfung. +/// ID der aktiven Ausleihe. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// Titel des Buchs. +/// Buchnummer. +/// Ausleihzeitpunkt. +/// Fälligkeit der Rückgabe. +/// Gibt an, ob die Ausleihe überfällig ist. +/// Anzahl überfälliger Tage. +public sealed record ReturnVerificationDto( + Guid LoanId, + string StudentName, + string CardNumber, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + bool IsOverdue, + int DaysOverdue); + +/// Ergebnis einer abgeschlossenen Rückgabe. +/// ID der abgeschlossenen Ausleihe. +/// Name des Schülers. +/// Titel des Buchs. +/// Buchnummer. +/// Rückgabezeitpunkt. +/// Gibt an, ob die Ausleihe überfällig war. +/// Anzahl überfälliger Tage. +public sealed record ReturnResultDto( + Guid LoanId, + string StudentName, + string BookTitle, + string BookNumber, + DateTimeOffset ReturnedAt, + bool WasOverdue, + int DaysOverdue); diff --git a/src/Bsevita.Library.Models/Students/StudentContracts.cs b/src/Bsevita.Library.Models/Students/StudentContracts.cs new file mode 100644 index 0000000..a43c932 --- /dev/null +++ b/src/Bsevita.Library.Models/Students/StudentContracts.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Students; + +/// Schüler mit Ausweis- und Kontaktdaten. +/// Eindeutige Schüler-ID. +/// Bibliotheksausweisnummer. +/// Vorname. +/// Nachname. +/// Anzeigename aus Vor- und Nachname. +/// Klasse oder Kurs. +/// Optionale E-Mail-Adresse. +/// Gibt an, ob der Schüler aktiv ist. +/// Erstellzeitpunkt. +/// Letzter Änderungszeitpunkt. +public sealed record StudentDto( + Guid Id, + string CardNumber, + string FirstName, + string LastName, + string FullName, + string ClassName, + string? Email, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +/// Daten zum Anlegen oder Aktualisieren eines Schülers. +public sealed class SaveStudentRequest +{ + /// Bibliotheksausweisnummer. + [Required, StringLength(LibraryRules.MaxCardNumberLength, MinimumLength = 2)] + public string CardNumber { get; set; } = string.Empty; + + /// Vorname. + [Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)] + public string FirstName { get; set; } = string.Empty; + + /// Nachname. + [Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)] + public string LastName { get; set; } = string.Empty; + + /// Klasse oder Kurs. + [Required, StringLength(LibraryRules.MaxClassNameLength, MinimumLength = 1)] + public string ClassName { get; set; } = string.Empty; + + /// Optionale E-Mail-Adresse. + [EmailAddress, StringLength(254)] + public string? Email { get; set; } +} diff --git a/src/Bsevita.Library.Ui/AppRoutes.razor b/src/Bsevita.Library.Ui/AppRoutes.razor new file mode 100644 index 0000000..94c2192 --- /dev/null +++ b/src/Bsevita.Library.Ui/AppRoutes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj b/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj new file mode 100644 index 0000000..a996214 --- /dev/null +++ b/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Bsevita.Library.Ui + true + + + + + + + + diff --git a/src/Bsevita.Library.Ui/Components/AlertMessage.razor b/src/Bsevita.Library.Ui/Components/AlertMessage.razor new file mode 100644 index 0000000..e945e08 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/AlertMessage.razor @@ -0,0 +1,11 @@ +@if (!string.IsNullOrWhiteSpace(Message)) +{ + +} diff --git a/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs b/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs new file mode 100644 index 0000000..2368569 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class AlertMessage +{ + [Parameter] public string? Message { get; set; } + [Parameter] public string Kind { get; set; } = "error"; + [Parameter] public bool Dismissible { get; set; } = true; + [Parameter] public EventCallback OnDismiss { get; set; } + private Task Dismiss() => OnDismiss.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor new file mode 100644 index 0000000..7a6b179 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor @@ -0,0 +1,18 @@ +@if (Visible) +{ + +} diff --git a/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs new file mode 100644 index 0000000..b2bf78c --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs @@ -0,0 +1,13 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class ConfirmDialog +{ + [Parameter] public bool Visible { get; set; } + [Parameter] public string Title { get; set; } = "Aktion bestätigen"; + [Parameter] public string Message { get; set; } = "Soll die Aktion wirklich ausgeführt werden?"; + [Parameter] public string ConfirmText { get; set; } = "Löschen"; + [Parameter] public EventCallback OnConfirm { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + private Task Confirm() => OnConfirm.InvokeAsync(); + private Task Cancel() => OnCancel.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Components/Icon.razor b/src/Bsevita.Library.Ui/Components/Icon.razor new file mode 100644 index 0000000..fbf92f1 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/Icon.razor @@ -0,0 +1,27 @@ + + +@code { + [Parameter, EditorRequired] public string Name { get; set; } = string.Empty; + [Parameter] public string? Class { get; set; } + + private string CssClass => string.IsNullOrWhiteSpace(Class) ? "icon" : $"icon {Class}"; + + private string Path => Name switch + { + "alert-triangle" => """""", + "arrow-left-right" => """""", + "book-open" => """""", + "check" => """""", + "clipboard-list" => """""", + "graduation-cap" => """""", + "house" => """""", + "plus" => """""", + "rotate-ccw" => """""", + "search" => """""", + "x" => """""", + "x-circle" => """""", + _ => """""" + }; +} diff --git a/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor new file mode 100644 index 0000000..151f275 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor @@ -0,0 +1,4 @@ +
+ + @Text +
diff --git a/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs new file mode 100644 index 0000000..a71f3e8 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs @@ -0,0 +1,6 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class LoadingIndicator +{ + [Parameter] public string Text { get; set; } = "Daten werden geladen …"; +} diff --git a/src/Bsevita.Library.Ui/Components/PageHeader.razor b/src/Bsevita.Library.Ui/Components/PageHeader.razor new file mode 100644 index 0000000..81872ee --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/PageHeader.razor @@ -0,0 +1,9 @@ + diff --git a/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs b/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs new file mode 100644 index 0000000..59ae6b8 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class PageHeader +{ + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public RenderFragment? Actions { get; set; } +} diff --git a/src/Bsevita.Library.Ui/Components/StatCard.razor b/src/Bsevita.Library.Ui/Components/StatCard.razor new file mode 100644 index 0000000..139a837 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/StatCard.razor @@ -0,0 +1,7 @@ +
+
+
+
@Value
+
@Label
+
+
diff --git a/src/Bsevita.Library.Ui/Components/StatCard.razor.cs b/src/Bsevita.Library.Ui/Components/StatCard.razor.cs new file mode 100644 index 0000000..c267e50 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/StatCard.razor.cs @@ -0,0 +1,9 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class StatCard +{ + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Value { get; set; } = string.Empty; + [Parameter] public string Icon { get; set; } = "#"; + [Parameter] public string Tone { get; set; } = "primary"; +} diff --git a/src/Bsevita.Library.Ui/GlobalUsings.cs b/src/Bsevita.Library.Ui/GlobalUsings.cs new file mode 100644 index 0000000..5200d7e --- /dev/null +++ b/src/Bsevita.Library.Ui/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System.Net; +global using Bsevita.Library.Models.Books; +global using Bsevita.Library.Models.Loans; +global using Bsevita.Library.Models.Reports; +global using Bsevita.Library.Models.Returns; +global using Bsevita.Library.Models.Students; +global using Bsevita.Library.Ui.Services; +global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.Components.Web; diff --git a/src/Bsevita.Library.Ui/Layout/MainLayout.razor b/src/Bsevita.Library.Ui/Layout/MainLayout.razor new file mode 100644 index 0000000..77e5b62 --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/MainLayout.razor @@ -0,0 +1,22 @@ +@inherits LayoutComponentBase + +
+ + @if (_mobileMenuOpen) + { + + } +
+
+
+ Library System +
+ Bereit +
+
+ @Body +
+
+
diff --git a/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs b/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs new file mode 100644 index 0000000..edc2871 --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs @@ -0,0 +1,8 @@ +namespace Bsevita.Library.Ui.Layout; + +public partial class MainLayout +{ + private bool _mobileMenuOpen; + private void ToggleMenu() => _mobileMenuOpen = !_mobileMenuOpen; + private void CloseMenu() => _mobileMenuOpen = false; +} diff --git a/src/Bsevita.Library.Ui/Layout/NavMenu.razor b/src/Bsevita.Library.Ui/Layout/NavMenu.razor new file mode 100644 index 0000000..e91e70e --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/NavMenu.razor @@ -0,0 +1,27 @@ +
+
B
+
+
BSEVITA
+
+
+ + diff --git a/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs b/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs new file mode 100644 index 0000000..04ad62e --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Ui.Layout; + +public partial class NavMenu +{ + [Parameter] public EventCallback OnNavigate { get; set; } + private Task Navigate() => OnNavigate.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Pages/Books.razor b/src/Bsevita.Library.Ui/Pages/Books.razor new file mode 100644 index 0000000..7f1417a --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Books.razor @@ -0,0 +1,86 @@ +@page "/books" +@inject BookApiClient BooksApi + +Bücher - Library + + + + + + + +
+
+
+ + + + + + @if (HasSearchText) { } +
+ @_books.Count Bücher +
+ + @if (_isLoading) + { + + } + else if (_books.Count == 0) + { +
+
+

Keine Bücher gefunden

+
+ } + else + { +
+ + + + @foreach (var book in _books) + { + + + + + + + + + } + +
TitelAutorSachgebietBuchnummerStatusAktionen
@book.Title@if (HasIsbn(book)) { ISBN @book.Isbn }@book.Author@book.Subject@book.BookNumber@(book.IsAvailable ? "Verfügbar" : "Ausgeliehen") + + +
+
+ } +
+ +@if (_showEditor) +{ + +} + + diff --git a/src/Bsevita.Library.Ui/Pages/Books.razor.cs b/src/Bsevita.Library.Ui/Pages/Books.razor.cs new file mode 100644 index 0000000..e4c314f --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Books.razor.cs @@ -0,0 +1,199 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Books +{ + [SupplyParameterFromQuery(Name = "focus")] + public string? Focus { get; set; } + + private List _books = []; + private string _searchText = string.Empty; + private string _searchFilter = "title"; + private bool _isLoading = true; + private bool _isSaving; + private bool _showEditor; + private Guid? _editingId; + private SaveBookRequest _editModel = new(); + private BookDto? _bookToDelete; + private string? _errorMessage; + private string? _successMessage; + + private bool HasSearchText => !string.IsNullOrWhiteSpace(_searchText); + private bool ShouldFocusSearch => string.Equals(Focus, "search", StringComparison.OrdinalIgnoreCase); + private string EditorTitle => _editingId is null ? "Neu anlegen" : "Bearbeiten"; + private string SaveButtonText => _isSaving ? "Speichert ..." : "Speichern"; + private string DeleteMessage => _bookToDelete is null + ? string.Empty + : $"Soll '{_bookToDelete.Title}' wirklich gelöscht werden? Ausgeliehene Bücher können nicht gelöscht werden."; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + try + { + _books = (await BooksApi.GetAllAsync()).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _books = []; + } + finally + { + _isLoading = false; + } + } + + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(_searchText)) + { + await LoadAsync(); + return; + } + + _isLoading = true; + try + { + var searchText = _searchText.Trim(); + if (_searchFilter == "number") + { + var book = await BooksApi.GetByNumberAsync(searchText); + _books = [book]; + } + else + { + _books = (await BooksApi.SearchAsync(_searchFilter, searchText)).ToList(); + } + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _books = []; + } + finally + { + _isLoading = false; + } + } + + private async Task ResetSearchAsync() + { + _searchText = string.Empty; + await LoadAsync(); + } + + private async Task HandleSearchKey(KeyboardEventArgs args) + { + if (args.Key == "Enter") + { + await SearchAsync(); + } + } + + private void OpenCreate() + { + _editingId = null; + _editModel = new SaveBookRequest(); + _showEditor = true; + ClearMessages(); + } + + private async Task OpenEditAsync(Guid id) + { + ClearMessages(); + try + { + var book = await BooksApi.GetByIdAsync(id); + _editingId = book.Id; + _editModel = new SaveBookRequest + { + BookNumber = book.BookNumber, + Isbn = book.Isbn, + Title = book.Title, + Author = book.Author, + Subject = book.Subject, + Publisher = book.Publisher, + PublicationYear = book.PublicationYear + }; + _showEditor = true; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private void CloseEditor() + { + if (!_isSaving) + { + _showEditor = false; + } + } + + private async Task SaveAsync() + { + _isSaving = true; + try + { + if (_editingId is Guid id) + { + await BooksApi.UpdateAsync(id, _editModel); + } + else + { + await BooksApi.CreateAsync(_editModel); + } + + _showEditor = false; + _successMessage = _editingId is null ? "Buch wurde angelegt." : "Buch wurde gespeichert."; + await LoadAsync(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + finally + { + _isSaving = false; + } + } + + private void AskDelete(BookDto book) => _bookToDelete = book; + + private void CancelDelete() => _bookToDelete = null; + + private async Task DeleteAsync() + { + if (_bookToDelete is null) + { + return; + } + + try + { + await BooksApi.DeleteBookAsync(_bookToDelete.Id); + _successMessage = $"'{_bookToDelete.Title}' wurde gelöscht."; + _bookToDelete = null; + await LoadAsync(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _bookToDelete = null; + } + } + + private void ClearMessages() + { + _errorMessage = null; + _successMessage = null; + } + + private static bool HasIsbn(BookDto book) => !string.IsNullOrWhiteSpace(book.Isbn); + + private static string Error(Exception exception) => + exception is ApiProblemException api ? api.Message : "Die Buchdaten konnten nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Dashboard.razor b/src/Bsevita.Library.Ui/Pages/Dashboard.razor new file mode 100644 index 0000000..352b1db --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Dashboard.razor @@ -0,0 +1,82 @@ +@page "/" +@inject ReportApiClient Reports + +Übersicht - Library + + + + +@if (_isLoading) +{ + +} +else if (_statistics is not null) +{ +
+ + + + +
+ +
+ + +
+
+
+

Überfällige Bücher

+
+ Alle Listen +
+ @if (_overdueLoans.Count == 0) + { +

Alles im grünen Bereich.

+ } + else + { +
+ @foreach (var loan in VisibleOverdueLoans) + { +
+
@loan.BookTitle@loan.StudentName · @loan.BookNumber
+ @loan.DaysOverdue Tag@(loan.DaysOverdue == 1 ? "" : "e") +
+ } +
+ } +
+
+} +else +{ +
+
+

Übersicht konnte nicht geladen werden

+ +
+} diff --git a/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs b/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs new file mode 100644 index 0000000..7c38e94 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs @@ -0,0 +1,36 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Dashboard +{ + private LibraryStatisticsDto? _statistics; + private IReadOnlyList _overdueLoans = []; + private bool _isLoading = true; + private string? _errorMessage; + private IEnumerable VisibleOverdueLoans => _overdueLoans.Take(5); + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + _errorMessage = null; + _statistics = null; + try + { + var reports = await Reports.GetReportsAsync(); + _statistics = reports.Statistics; + _overdueLoans = reports.OverdueLoans; + } + catch (Exception exception) + { + _errorMessage = GetMessage(exception); + } + finally + { + _isLoading = false; + } + } + + private void ClearError() => _errorMessage = null; + private static string GetMessage(Exception exception) => exception is ApiProblemException api ? api.Message : "Übersicht konnte nicht geladen werden. Bitte Verbindung prüfen."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Loans.razor b/src/Bsevita.Library.Ui/Pages/Loans.razor new file mode 100644 index 0000000..95ccecd --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Loans.razor @@ -0,0 +1,81 @@ +@page "/loans" +@inject LoanApiClient LoansApi +@inject StudentApiClient StudentsApi +@inject BookApiClient BooksApi + +Ausleihe - Library + + + + + + +
+ @foreach (var step in _steps) + { +
@step.Number@step.Label
+ } +
+ +
+ @if (_verification is null) + { +

Wer leiht welches Buch aus?

+ + +
+
+ + + + @if (CardSuggestions.Count > 0) + { +
+ @foreach (var student in CardSuggestions) + { + + } +
+ } +
+
+ + + + @if (BookSuggestions.Count > 0) + { +
+ @foreach (var book in BookSuggestions) + { + + } +
+ } +
+
+
+
+ } + else + { +

Stimmt alles?

+
+
Bereit zum Ausleihen
+
+
Schüler
@_verification.Student.FullName
+
Klasse / Ausweis
@_verification.Student.ClassName · @_verification.Student.CardNumber
+
Buch
@_verification.Book.Title
+
Autor / Nummer
@_verification.Book.Author · @_verification.Book.BookNumber
+
Fällig am
+
Status
Verfügbar
+
+
+
+ } +
diff --git a/src/Bsevita.Library.Ui/Pages/Loans.razor.cs b/src/Bsevita.Library.Ui/Pages/Loans.razor.cs new file mode 100644 index 0000000..498041b --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Loans.razor.cs @@ -0,0 +1,127 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Loans +{ + private readonly (int Number, string Label)[] _steps = [(1, "Angaben"), (2, "Bestätigen")]; + private VerifyLoanRequest _verifyModel = new(); + private LoanVerificationDto? _verification; + private List _students = []; + private List _availableBooks = []; + private bool _isWorking; + private string? _errorMessage; + private string? _warningMessage; + private string? _successMessage; + private string _dueDate = string.Empty; + private string _defaultDueDate = string.Empty; + private int CurrentStep => _verification is null ? 1 : 2; + private List CardSuggestions => _students + .Where(student => Matches(_verifyModel.CardNumber, student.CardNumber, student.FullName, student.ClassName)) + .Where(student => !string.Equals(student.CardNumber, _verifyModel.CardNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + private List BookSuggestions => _availableBooks + .Where(book => Matches(_verifyModel.BookNumber, book.BookNumber, book.Title, book.Author)) + .Where(book => !string.Equals(book.BookNumber, _verifyModel.BookNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + + protected override async Task OnInitializedAsync() + { + try + { + _students = (await StudentsApi.GetAllAsync()).ToList(); + _availableBooks = (await BooksApi.GetAllAsync(availableOnly: true)).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private async Task VerifyAsync() + { + ClearMessages(); _isWorking = true; + try + { + _verification = await LoansApi.VerifyAsync(_verifyModel); + _dueDate = _defaultDueDate = ToDateInputValue(_verification.DueAt); + } + catch (ApiProblemException exception) when (exception.StatusCode == (int)HttpStatusCode.Conflict) + { + _warningMessage = exception.Message; + _verification = null; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _verification = null; + } + finally + { + _isWorking = false; + } + } + + private async Task ConfirmAsync() + { + if (_verification is null) return; + _isWorking = true; + try + { + var customDueAt = (DateTimeOffset?)null; + if (!string.Equals(_dueDate, _defaultDueDate, StringComparison.Ordinal)) + { + if (!TryGetDueAt(out var parsedDueAt)) + { + _errorMessage = "Bitte ein gültiges Fälligkeitsdatum wählen."; + return; + } + + customDueAt = parsedDueAt; + } + + var loan = await LoansApi.CreateAsync(new CreateLoanRequest { CardNumber = _verifyModel.CardNumber, BookNumber = _verifyModel.BookNumber, DueAt = customDueAt }); + _successMessage = $"{loan.BookTitle} wurde erfolgreich an {loan.StudentName} ausgeliehen. Fällig am {loan.DueAt.ToLocalTime():dd.MM.yyyy}."; + _availableBooks.RemoveAll(book => book.BookNumber == loan.BookNumber); + _verifyModel = new VerifyLoanRequest(); + _verification = null; + _dueDate = _defaultDueDate = string.Empty; + } + catch (ApiProblemException exception) when (exception.StatusCode == (int)HttpStatusCode.Conflict) + { + _warningMessage = exception.Message; + _verification = null; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _verification = null; + } + finally + { + _isWorking = false; + } + } + + private void OnCardInput(ChangeEventArgs args) => _verifyModel.CardNumber = args.Value?.ToString() ?? string.Empty; + private void OnBookInput(ChangeEventArgs args) => _verifyModel.BookNumber = args.Value?.ToString() ?? string.Empty; + private void OnDueDateChanged(ChangeEventArgs args) => _dueDate = args.Value?.ToString() ?? string.Empty; + private void SelectStudent(StudentDto student) => _verifyModel.CardNumber = student.CardNumber; + private void SelectBook(BookDto book) => _verifyModel.BookNumber = book.BookNumber; + private void ResetVerification() => _verification = null; + private void ClearMessages() { _errorMessage = null; _warningMessage = null; _successMessage = null; } + private static bool Matches(string input, params string[] values) => !string.IsNullOrWhiteSpace(input) && values.Any(value => value.Contains(input.Trim(), StringComparison.OrdinalIgnoreCase)); + private bool TryGetDueAt(out DateTimeOffset dueAt) + { + if (DateOnly.TryParseExact(_dueDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date)) + { + dueAt = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Local)); + return true; + } + + dueAt = default; + return false; + } + private static string ToDateInputValue(DateTimeOffset value) => value.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Ausleihe konnte nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/NotFound.razor b/src/Bsevita.Library.Ui/Pages/NotFound.razor new file mode 100644 index 0000000..5d0e3c2 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/NotFound.razor @@ -0,0 +1,11 @@ +@page "/not-found" +@layout MainLayout + +Seite nicht gefunden + +
+
404
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht.

+ Zur Übersicht +
diff --git a/src/Bsevita.Library.Ui/Pages/Reports.razor b/src/Bsevita.Library.Ui/Pages/Reports.razor new file mode 100644 index 0000000..257a6e2 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Reports.razor @@ -0,0 +1,92 @@ +@page "/reports" +@inject ReportApiClient ReportsApi + +Listen - Library + + + + +@if (_isLoading) +{ + +} +else if (_statistics is not null) +{ +
+ + + + +
+ +
+
+

Offene Ausleihen

@_activeLoans.Count
+
+ + @if (_activeLoans.Count == 0) + { + + } + else + { + @foreach (var loan in _activeLoans) + { + + } + } +
SchülerBuchAusgeliehenFälligStatus
Keine aktiven Ausleihen.
@loan.StudentName@loan.CardNumber@loan.BookTitle@loan.BookNumber@loan.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")@(loan.IsOverdue ? $"{loan.DaysOverdue} Tage überfällig" : "Aktiv")
+
+
+ +
+

Schüler mit Ausleihen

+ @if (_activeStudents.Count == 0) + { +

Keine aktiven Schüler.

+ } + else + { +
+ @foreach (var student in _activeStudents) + { +
+
+ @student.StudentName + @student.ClassName · @student.CardNumber +
+ @student.ActiveLoanCount +
+ } +
+ } +
+ +
+

Überfällige Ausleihen

@_overdueLoans.Count offen
+
+ + @if (_overdueLoans.Count == 0) + { + + } + else + { + @foreach (var loan in _overdueLoans) + { + + } + } +
VerzugSchülerBuchFällig seit
Keine überfälligen Ausleihen.
@loan.DaysOverdue Tage@loan.StudentName@loan.CardNumber@loan.BookTitle@loan.BookNumber@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")
+
+
+
+} +else +{ +
+
+

Listen konnten nicht geladen werden

+ +
+} diff --git a/src/Bsevita.Library.Ui/Pages/Reports.razor.cs b/src/Bsevita.Library.Ui/Pages/Reports.razor.cs new file mode 100644 index 0000000..3a09ff5 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Reports.razor.cs @@ -0,0 +1,31 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Reports +{ + private IReadOnlyList _activeLoans = []; + private IReadOnlyList _overdueLoans = []; + private IReadOnlyList _activeStudents = []; + private LibraryStatisticsDto? _statistics; + private bool _isLoading = true; + private string? _errorMessage; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + _errorMessage = null; + _statistics = null; + try + { + var reports = await ReportsApi.GetReportsAsync(); + _activeLoans = reports.ActiveLoans; + _overdueLoans = reports.OverdueLoans; + _activeStudents = reports.ActiveStudents; + _statistics = reports.Statistics; + } + catch (Exception e) { _errorMessage = e is ApiProblemException api ? api.Message : "Listen konnten nicht geladen werden."; } + finally { _isLoading = false; } + } + private void ClearError() => _errorMessage = null; +} diff --git a/src/Bsevita.Library.Ui/Pages/Returns.razor b/src/Bsevita.Library.Ui/Pages/Returns.razor new file mode 100644 index 0000000..c510536 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Returns.razor @@ -0,0 +1,65 @@ +@page "/returns" +@inject ReturnApiClient ReturnsApi +@inject BookApiClient BooksApi + +Rückgabe - Library + + + + + +
+
1Buchnummer
+
2Details
+
3Speichern
+
+ +
+ + + @if (_verification is not null) + { +
+
@(_verification.IsOverdue ? "Überfällige Ausleihe" : "Aktive Ausleihe")
+
+
Schüler
@_verification.StudentName
+
Ausweisnummer
@_verification.CardNumber
+
Buch
@_verification.BookTitle
+
Ausgeliehen am
@_verification.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")
+
Fällig am
@_verification.DueAt.ToLocalTime().ToString("dd.MM.yyyy")
+
+
Status
+
+ @if (_verification.IsOverdue) + { + @_verification.DaysOverdue Tage überfällig + } + else + { + Fristgerecht + } +
+
+
+
+
+ } +
diff --git a/src/Bsevita.Library.Ui/Pages/Returns.razor.cs b/src/Bsevita.Library.Ui/Pages/Returns.razor.cs new file mode 100644 index 0000000..b01eb10 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Returns.razor.cs @@ -0,0 +1,61 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Returns +{ + private string _bookNumber = string.Empty; + private ReturnVerificationDto? _verification; + private List _borrowedBooks = []; + private bool _isWorking; + private string? _errorMessage; + private string? _successMessage; + private List BookSuggestions => _borrowedBooks + .Where(book => Matches(_bookNumber, book.BookNumber, book.Title, book.Author)) + .Where(book => !string.Equals(book.BookNumber, _bookNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + + protected override async Task OnInitializedAsync() + { + try + { + _borrowedBooks = (await BooksApi.GetAllAsync(availableOnly: false)).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private async Task VerifyAsync() + { + ClearMessages(); _verification = null; + if (string.IsNullOrWhiteSpace(_bookNumber)) { _errorMessage = "Bitte eine Buchnummer eingeben."; return; } + _isWorking = true; + try { _verification = await ReturnsApi.VerifyAsync(_bookNumber.Trim()); } + catch (Exception e) { _errorMessage = Error(e); } + finally { _isWorking = false; } + } + + private async Task ConfirmAsync() + { + if (_verification is null) return; + _isWorking = true; + try + { + var result = await ReturnsApi.ReturnAsync(new ReturnBookRequest { BookNumber = _verification.BookNumber }); + _successMessage = $"{result.BookTitle} wurde erfolgreich zurückgegeben." + (result.WasOverdue ? $" Die Ausleihe war {result.DaysOverdue} Tage überfällig." : string.Empty); + _borrowedBooks.RemoveAll(book => book.BookNumber == result.BookNumber); + _bookNumber = string.Empty; + _verification = null; + } + catch (Exception e) { _errorMessage = Error(e); _verification = null; } + finally { _isWorking = false; } + } + + private void OnBookInput(ChangeEventArgs args) => _bookNumber = args.Value?.ToString() ?? string.Empty; + private void SelectBook(BookDto book) => _bookNumber = book.BookNumber; + private async Task HandleKey(KeyboardEventArgs args) { if (args.Key == "Enter") await VerifyAsync(); } + private void ClearMessages() { _errorMessage = null; _successMessage = null; } + private static bool Matches(string input, params string[] values) => !string.IsNullOrWhiteSpace(input) && values.Any(value => value.Contains(input.Trim(), StringComparison.OrdinalIgnoreCase)); + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Rückgabe konnte nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Students.razor b/src/Bsevita.Library.Ui/Pages/Students.razor new file mode 100644 index 0000000..26340e1 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Students.razor @@ -0,0 +1,92 @@ +@page "/students" +@inject StudentApiClient StudentsApi + +Schüler - Library + + + + + + + + + +
+
+
+ + + + + + @if (HasSearchText) + { + + } +
+ @_students.Count gefunden +
+ + @if (_isLoading) + { + + } + else if (_students.Count == 0) + { +

Keine Schüler gefunden

+ } + else + { +
+ + + + @foreach (var student in _students) + { + + + + + + + + } + +
NameKlasseAusweisnummerE-MailAktionen
@student.FullName@student.ClassName@student.CardNumber@(student.Email ?? "–") + + +
+
+ } +
+ +@if (_showEditor) +{ + +} + + diff --git a/src/Bsevita.Library.Ui/Pages/Students.razor.cs b/src/Bsevita.Library.Ui/Pages/Students.razor.cs new file mode 100644 index 0000000..b778f2e --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Students.razor.cs @@ -0,0 +1,117 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Students +{ + [SupplyParameterFromQuery(Name = "focus")] public string? Focus { get; set; } + private List _students = []; + private string _searchText = string.Empty; + private string _searchMode = "name"; + private bool _isLoading = true; + private bool _isSaving; + private bool _showEditor; + private Guid? _editingId; + private SaveStudentRequest _editModel = new(); + private StudentDto? _studentToDelete; + private string? _errorMessage; + private string? _successMessage; + private bool HasSearchText => !string.IsNullOrWhiteSpace(_searchText); + private bool ShouldFocusSearch => string.Equals(Focus, "search", StringComparison.OrdinalIgnoreCase); + private string EditorTitle => _editingId is null ? "Neu anlegen" : "Bearbeiten"; + private string SaveButtonText => _isSaving ? "Speichert …" : "Speichern"; + private string DeleteMessage => _studentToDelete is null ? string.Empty : $"Soll {_studentToDelete.FullName} wirklich gelöscht werden? Dieser Schritt deaktiviert den Datensatz."; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + try + { + _students = (await StudentsApi.GetAllAsync()).ToList(); + } + catch (Exception exception) { _errorMessage = Error(exception); _students = []; } + finally { _isLoading = false; } + } + + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(_searchText)) { await LoadAsync(); return; } + _isLoading = true; + try + { + if (_searchMode == "card") + { + var student = await StudentsApi.GetByCardAsync(_searchText.Trim()); + _students = [student]; + } + else + { + _students = (await StudentsApi.SearchAsync(_searchText.Trim())).ToList(); + } + } + catch (Exception exception) { _errorMessage = Error(exception); _students = []; } + finally { _isLoading = false; } + } + + private async Task ResetSearchAsync() { _searchText = string.Empty; await LoadAsync(); } + private async Task HandleSearchKey(KeyboardEventArgs args) { if (args.Key == "Enter") await SearchAsync(); } + + private void OpenCreate() + { + _editingId = null; + _editModel = new SaveStudentRequest(); + _showEditor = true; + ClearMessages(); + } + + private async Task OpenEditAsync(Guid id) + { + ClearMessages(); + try + { + var student = await StudentsApi.GetByIdAsync(id); + _editingId = student.Id; + _editModel = new SaveStudentRequest { CardNumber = student.CardNumber, FirstName = student.FirstName, LastName = student.LastName, ClassName = student.ClassName, Email = student.Email }; + _showEditor = true; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private void CloseEditor() { if (!_isSaving) _showEditor = false; } + + private async Task SaveAsync() + { + _isSaving = true; + try + { + if (_editingId is { } id) await StudentsApi.UpdateAsync(id, _editModel); else await StudentsApi.CreateAsync(_editModel); + _showEditor = false; + _successMessage = _editingId is null ? "Schüler wurde angelegt." : "Schüler wurde gespeichert."; + await LoadAsync(); + } + catch (Exception exception) { _errorMessage = Error(exception); } + finally { _isSaving = false; } + } + + private void AskDelete(StudentDto student) => _studentToDelete = student; + private void CancelDelete() => _studentToDelete = null; + + private async Task DeleteAsync() + { + if (_studentToDelete is null) return; + try + { + await StudentsApi.DeleteStudentAsync(_studentToDelete.Id); + _successMessage = $"{_studentToDelete.FullName} wurde gelöscht."; + _studentToDelete = null; + await LoadAsync(); + } + catch (Exception exception) { _errorMessage = Error(exception); _studentToDelete = null; } + } + + private void ClearMessages() { _errorMessage = null; _successMessage = null; } + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Schülerdaten konnten nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Services/ApiClientBase.cs b/src/Bsevita.Library.Ui/Services/ApiClientBase.cs new file mode 100644 index 0000000..147ab26 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ApiClientBase.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Ui.Services; + +public abstract class ApiClientBase(HttpClient httpClient) +{ + private HttpClient HttpClient { get; } = httpClient; + + protected async Task GetAsync(string uri, CancellationToken cancellationToken = default) => + await SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken); + + protected async Task PostAsync(string uri, TRequest request, CancellationToken cancellationToken = default) => + await SendAsync(CreateJsonRequest(HttpMethod.Post, uri, request), cancellationToken); + + protected async Task PutAsync(string uri, TRequest request, CancellationToken cancellationToken = default) => + await SendAsync(CreateJsonRequest(HttpMethod.Put, uri, request), cancellationToken); + + protected async Task DeleteResourceAsync(string uri, CancellationToken cancellationToken = default) + { + using var response = await HttpClient.DeleteAsync(uri, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + private static HttpRequestMessage CreateJsonRequest(HttpMethod method, string uri, TRequest request) => + new(method, uri) { Content = JsonContent.Create(request) }; + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using (request) + using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + { + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? throw new ApiProblemException((int)response.StatusCode, "Die API hat eine leere Antwort geliefert."); + } + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var problem = (ProblemDetails?)null; + try + { + problem = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + catch (JsonException) + { + // Fall back to a generic message when a proxy returns non-JSON content. + } + + var message = problem?.Detail ?? problem?.Title ?? $"API-Fehler ({(int)response.StatusCode})."; + throw new ApiProblemException((int)response.StatusCode, message, problem); + } +} diff --git a/src/Bsevita.Library.Ui/Services/ApiProblemException.cs b/src/Bsevita.Library.Ui/Services/ApiProblemException.cs new file mode 100644 index 0000000..e2a1305 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ApiProblemException.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Ui.Services; + +public sealed class ApiProblemException(int statusCode, string message, ProblemDetails? problem = null) + : Exception(message) +{ + public int StatusCode { get; } = statusCode; + public ProblemDetails? Problem { get; } = problem; +} diff --git a/src/Bsevita.Library.Ui/Services/BookApiClient.cs b/src/Bsevita.Library.Ui/Services/BookApiClient.cs new file mode 100644 index 0000000..9399791 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/BookApiClient.cs @@ -0,0 +1,37 @@ +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Ui.Services; + +public sealed class BookApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task> GetAllAsync(bool? availableOnly = null, CancellationToken cancellationToken = default) + { + var separator = availableOnly is null ? "?" : "&"; + var query = availableOnly is null + ? string.Empty + : $"?availableOnly={availableOnly.Value.ToString().ToLowerInvariant()}"; + query += $"{separator}take={LibraryRules.MaxPageSize}"; + return GetAsync>($"api/books{query}", cancellationToken); + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + GetAsync($"api/books/{id}", cancellationToken); + + public Task GetByNumberAsync(string bookNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/books/by-number/{Uri.EscapeDataString(bookNumber)}", cancellationToken); + + public Task> SearchAsync(string filter, string value, CancellationToken cancellationToken = default) + { + var query = $"{Uri.EscapeDataString(filter)}={Uri.EscapeDataString(value)}"; + return GetAsync>($"api/books/search?{query}", cancellationToken); + } + + public Task CreateAsync(SaveBookRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/books", request, cancellationToken); + + public Task UpdateAsync(Guid id, SaveBookRequest request, CancellationToken cancellationToken = default) => + PutAsync($"api/books/{id}", request, cancellationToken); + + public Task DeleteBookAsync(Guid id, CancellationToken cancellationToken = default) => + DeleteResourceAsync($"api/books/{id}", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/LoanApiClient.cs b/src/Bsevita.Library.Ui/Services/LoanApiClient.cs new file mode 100644 index 0000000..099e798 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/LoanApiClient.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class LoanApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task VerifyAsync(VerifyLoanRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/loans/verify", request, cancellationToken); + + public Task CreateAsync(CreateLoanRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/loans", request, cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ReportApiClient.cs b/src/Bsevita.Library.Ui/Services/ReportApiClient.cs new file mode 100644 index 0000000..fe7b63d --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ReportApiClient.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class ReportApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task GetReportsAsync(CancellationToken cancellationToken = default) => + GetAsync("api/reports", cancellationToken); + + public Task> GetActiveLoansAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/active-loans", cancellationToken); + + public Task> GetOverdueAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/overdue", cancellationToken); + + public Task> GetActiveStudentsAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/active-students", cancellationToken); + + public Task GetStatisticsAsync(CancellationToken cancellationToken = default) => + GetAsync("api/reports/statistics", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs b/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs new file mode 100644 index 0000000..3c5ff73 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class ReturnApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task VerifyAsync(string bookNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/returns/verify/{Uri.EscapeDataString(bookNumber)}", cancellationToken); + + public Task ReturnAsync(ReturnBookRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/returns", request, cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs b/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d4fc3ca --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bsevita.Library.Ui.Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddLibraryUi(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Bsevita.Library.Ui/Services/StudentApiClient.cs b/src/Bsevita.Library.Ui/Services/StudentApiClient.cs new file mode 100644 index 0000000..4420923 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/StudentApiClient.cs @@ -0,0 +1,27 @@ +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Ui.Services; + +public sealed class StudentApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task> GetAllAsync(CancellationToken cancellationToken = default) => + GetAsync>($"api/students?take={LibraryRules.MaxPageSize}", cancellationToken); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + GetAsync($"api/students/{id}", cancellationToken); + + public Task GetByCardAsync(string cardNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/students/by-card/{Uri.EscapeDataString(cardNumber)}", cancellationToken); + + public Task> SearchAsync(string name, CancellationToken cancellationToken = default) => + GetAsync>($"api/students/search?name={Uri.EscapeDataString(name)}", cancellationToken); + + public Task CreateAsync(SaveStudentRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/students", request, cancellationToken); + + public Task UpdateAsync(Guid id, SaveStudentRequest request, CancellationToken cancellationToken = default) => + PutAsync($"api/students/{id}", request, cancellationToken); + + public Task DeleteStudentAsync(Guid id, CancellationToken cancellationToken = default) => + DeleteResourceAsync($"api/students/{id}", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/_Imports.razor b/src/Bsevita.Library.Ui/_Imports.razor new file mode 100644 index 0000000..ac1023c --- /dev/null +++ b/src/Bsevita.Library.Ui/_Imports.razor @@ -0,0 +1,15 @@ +@using System.ComponentModel.DataAnnotations +@using System.Net +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Bsevita.Library.Models.Books +@using Bsevita.Library.Models.Loans +@using Bsevita.Library.Models.Reports +@using Bsevita.Library.Models.Returns +@using Bsevita.Library.Models.Students +@using Bsevita.Library.Ui.Components +@using Bsevita.Library.Ui.Layout +@using Bsevita.Library.Ui.Services diff --git a/src/Bsevita.Library.Ui/wwwroot/css/app.css b/src/Bsevita.Library.Ui/wwwroot/css/app.css new file mode 100644 index 0000000..5fc4a5f --- /dev/null +++ b/src/Bsevita.Library.Ui/wwwroot/css/app.css @@ -0,0 +1,1140 @@ +:root { + --bg: #f6f9fc; + --surface: #ffffff; + --surface-muted: #f8fafc; + --text: #172033; + --text-muted: #64748b; + --border: #dbe5f0; + --primary: #2563eb; + --primary-dark: #1d4ed8; + --primary-soft: #eff6ff; + --success: #15803d; + --success-soft: #ecfdf3; + --danger: #dc2626; + --danger-soft: #fef2f2; + --warning: #b45309; + --warning-soft: #fffbeb; + --shadow: 0 12px 34px rgba(15, 23, 42, .07); + --radius: 8px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} +* { + box-sizing: border-box; +} +html, body, #app { + width: 100%; + height: 100%; + margin: 0; +} +html, body { + overflow: hidden; + overscroll-behavior: none; +} +body { + background: var(--bg); + color: var(--text); +} +a { + color: inherit; + text-decoration: none; +} +button, input, select { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: .62; +} +code { + font: 700 .84rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #334155; + background: #eef2f7; + border-radius: 6px; + padding: .18rem .4rem; +} +.icon { + width: 1em; + height: 1em; + display: block; + flex: 0 0 auto; +} +.app-shell { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: 250px minmax(0, 1fr); +} +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 250px; + z-index: 30; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 22px 16px; + color: var(--text); + background: #fff; + border-right: 1px solid var(--border); +} +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 24px; +} +.brand__mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 10px; + color: #fff; + background: var(--primary); + font-weight: 800; + font-size: 1.15rem; +} +.brand__name { + font-weight: 800; + letter-spacing: 0; +} +.nav-list { + display: grid; + gap: 5px; +} +.nav-item { + display: flex; + align-items: center; + gap: 11px; + min-height: 44px; + padding: 9px 11px; + border-radius: var(--radius); + color: #475569; + font-weight: 700; + transition: background .14s ease, color .14s ease; +} +.nav-item:hover { + color: var(--primary); + background: var(--primary-soft); +} +.nav-item.active { + color: var(--primary); + background: var(--primary-soft); +} +.nav-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 8px; + color: var(--primary); + background: #e0ecff; + font-size: .78rem; + font-weight: 800; +} +.nav-icon .icon { + width: 17px; + height: 17px; +} +.app-main { + grid-column: 2; + min-width: 0; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} +.topbar { + position: sticky; + top: 0; + z-index: 20; + min-height: 64px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 32px; + background: rgba(255,255,255,.94); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); +} +.topbar strong { + display: block; + font-size: .96rem; +} +.api-indicator { + margin-left: auto; + border: 1px solid #bfdbfe; + border-radius: 999px; + padding: 5px 10px; + color: var(--primary); + background: var(--primary-soft); + font-size: .72rem; + font-weight: 800; +} +.content { + width: min(1480px, 100%); + margin: 0 auto; + padding: 30px 32px 42px; +} +.mobile-menu-button, .sidebar-backdrop { + display: none; +} +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} +.page-header h1 { + margin: 0; + font-size: clamp(1.55rem, 2vw, 2.05rem); + line-height: 1.15; + letter-spacing: 0; +} +.page-header__actions { + display: flex; + gap: 10px; +} +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.panel { + padding: 20px; +} +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} +.panel__header h2, .process-card__header h2 { + margin: 0; + font-size: 1.08rem; + letter-spacing: 0; +} +.panel__header p { + margin: 0; +} +.btn { + min-height: 39px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 8px 13px; + font-weight: 750; + transition: background .12s ease, border-color .12s ease, box-shadow .12s ease; +} +.btn .icon { + width: 16px; + height: 16px; +} +.btn:hover:not(:disabled) { + box-shadow: 0 8px 18px rgba(15, 23, 42, .08); +} +.btn-primary { + color: #fff; + background: var(--primary); +} +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); +} +.btn-secondary { + color: #334155; + background: #fff; + border-color: #cbd5e1; +} +.btn-secondary:hover:not(:disabled) { + background: #f8fafc; + border-color: #94a3b8; +} +.btn-danger { + color: #fff; + background: var(--danger); +} +.btn-danger-outline { + color: var(--danger); + background: #fff; + border-color: #fecaca; +} +.btn-danger-outline:hover:not(:disabled) { + background: var(--danger-soft); +} +.btn-ghost { + color: var(--text-muted); + background: transparent; +} +.btn-ghost:hover:not(:disabled) { + color: var(--primary); + background: var(--primary-soft); + box-shadow: none; +} +.btn-small { + min-height: 33px; + padding: 6px 10px; + font-size: .82rem; +} +.btn-large { + min-height: 48px; + padding: 10px 18px; +} +.icon-button { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius); + color: #475569; + background: transparent; + font-size: 1.25rem; +} +.icon-button .icon { + width: 20px; + height: 20px; +} +.icon-button:hover { + background: #eef2f7; +} +.text-link { + color: var(--primary); + font-weight: 800; + font-size: .85rem; +} +.text-danger { + color: var(--danger) !important; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.stat-card { + display: flex; + align-items: center; + gap: 14px; + min-height: 104px; + padding: 18px; +} +.stat-card__icon { + width: 44px; + height: 44px; + flex: 0 0 44px; + display: grid; + place-items: center; + border-radius: 10px; + font-size: 1rem; + font-weight: 900; +} +.stat-card__icon .icon { + width: 22px; + height: 22px; +} +.stat-card__icon--primary { + color: var(--primary); + background: var(--primary-soft); +} +.stat-card__icon--danger { + color: var(--danger); + background: var(--danger-soft); +} +.stat-card__icon--success { + color: var(--success); + background: var(--success-soft); +} +.stat-card__icon--violet { + color: #334155; + background: #eef2f7; +} +.stat-card__value { + font-size: 1.62rem; + line-height: 1; + font-weight: 850; + letter-spacing: 0; +} +.stat-card__label { + margin-top: 6px; + color: var(--text-muted); + font-size: .84rem; +} +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(340px, .9fr); + gap: 20px; +} +.quick-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 12px; +} +.quick-action { + display: flex; + align-items: center; + gap: 12px; + min-height: 72px; + padding: 13px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + transition: border .12s ease, background .12s ease; +} +.quick-action:hover { + border-color: #93c5fd; + background: var(--primary-soft); +} +.quick-action__icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 9px; + color: var(--primary); + background: #dbeafe; + font-size: .95rem; + font-weight: 900; +} +.quick-action__icon .icon { + width: 18px; + height: 18px; +} +.quick-action strong, .quick-action small { + display: block; +} +.quick-action small { + margin-top: 3px; + color: var(--text-muted); +} +.compact-list { + display: grid; + gap: 9px; +} +.compact-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-muted); +} +.compact-list__item--danger { + border-color: #fecaca; + background: var(--danger-soft); +} +.compact-list__item strong, .compact-list__item small { + display: block; +} +.compact-list__item small { + margin-top: 3px; + color: var(--text-muted); +} +.empty-inline { + min-height: 140px; + display: grid; + place-items: center; + align-content: center; + gap: 6px; + color: var(--text-muted); + text-align: center; +} +.empty-inline span { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 10px; + color: var(--success); + background: var(--success-soft); + font-weight: 900; +} +.empty-inline .icon { + width: 19px; + height: 19px; +} +.empty-inline p { + margin: 0; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 17px; +} +.toolbar--wrap { + flex-wrap: wrap; +} +.search-group { + display: flex; + align-items: center; + gap: 9px; + min-width: min(100%, 620px); +} +.search-group .form-control:not(.form-select) { + min-width: 280px; +} +.search-group--books { + min-width: min(100%, 760px); +} +.search-group .form-select { + flex: 0 0 145px; +} +.result-count { + color: var(--text-muted); + font-size: .83rem; + font-weight: 800; + white-space: nowrap; +} +.form-control { + width: 100%; + min-height: 42px; + border: 1px solid #cbd5e1; + border-radius: var(--radius); + padding: 9px 11px; + color: var(--text); + background: #fff; + outline: none; + transition: border .12s ease, box-shadow .12s ease; +} +.form-control:focus { + border-color: #60a5fa; + box-shadow: 0 0 0 4px rgba(37,99,235,.12); +} +.form-control--large { + min-height: 49px; + padding: 11px 13px; + font-size: 1rem; +} +.form-select { + appearance: auto; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 15px; +} +.form-field { + display: grid; + align-content: start; + gap: 6px; +} +.form-field--full { + grid-column: 1 / -1; +} +.form-field label { + font-size: .83rem; + font-weight: 800; +} +.search-field { + min-width: 0; +} +.suggestion-list { + display: grid; + gap: 6px; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px; + background: var(--surface-muted); +} +.suggestion-item { + display: grid; + gap: 2px; + width: 100%; + border: 1px solid transparent; + border-radius: 7px; + padding: 10px 11px; + color: var(--text); + background: #fff; + text-align: left; +} +.suggestion-item:focus, .suggestion-item:hover { + border-color: #93c5fd; + background: var(--primary-soft); + outline: none; +} +.suggestion-item strong { + font-size: .9rem; +} +.suggestion-item span { + color: var(--text-muted); + font-size: .8rem; + overflow-wrap: anywhere; +} +.validation-message { + color: var(--danger); + font-size: .78rem; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} +.table-responsive { + width: 100%; + overflow-x: auto; +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: .88rem; +} +.data-table th { + padding: 11px 12px; + color: #64748b; + background: #f8fafc; + border-bottom: 1px solid var(--border); + text-align: left; + font-size: .74rem; + letter-spacing: 0; + text-transform: uppercase; +} +.data-table td { + padding: 13px 12px; + border-bottom: 1px solid #e8eef6; + vertical-align: middle; +} +.data-table tbody tr:hover { + background: #f8fbff; +} +.data-table tbody tr:last-child td { + border-bottom: 0; +} +.table-actions { + text-align: right !important; + white-space: nowrap; +} +.table-actions .btn + .btn { + margin-left: 6px; +} +.cell-subtitle { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: .76rem; +} +.table-empty { + padding: 30px !important; + color: var(--text-muted); + text-align: center; +} +.row-overdue { + background: var(--danger-soft); +} +.row-overdue:hover { + background: #fee2e2 !important; +} +.badge { + display: inline-flex; + align-items: center; + min-height: 25px; + border-radius: 999px; + padding: 4px 9px; + font-size: .73rem; + font-weight: 850; + line-height: 1; + white-space: nowrap; +} +.badge-success { + color: #166534; + background: #dcfce7; +} +.badge-danger { + color: #991b1b; + background: #fee2e2; +} +.badge-primary { + color: #1d4ed8; + background: #dbeafe; +} +.badge-neutral { + color: #475569; + background: #eef2f7; +} +.alert { + position: relative; + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 16px; + border: 1px solid; + border-radius: var(--radius); + padding: 12px 42px 12px 13px; + line-height: 1.45; +} +.alert-error { + color: #991b1b; + border-color: #fecaca; + background: var(--danger-soft); +} +.alert-success { + color: #166534; + border-color: #bbf7d0; + background: var(--success-soft); +} +.alert-warning { + color: #92400e; + border-color: #fde68a; + background: var(--warning-soft); +} +.alert__icon { + width: 22px; + height: 22px; + flex: 0 0 22px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid currentColor; + font-weight: 900; +} +.alert__icon .icon { + width: 13px; + height: 13px; +} +.alert__close { + position: absolute; + top: 7px; + right: 7px; + width: 30px; + height: 30px; + border: 0; + border-radius: 7px; + color: currentColor; + background: transparent; + font-size: 1.1rem; +} +.alert__close:hover { + background: rgba(15,23,42,.06); +} +.loading-panel { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); +} +.spinner { + width: 26px; + height: 26px; + border: 3px solid #dbeafe; + border-top-color: var(--primary); + border-radius: 50%; + animation: spin .75s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.empty-state { + min-height: 250px; + display: grid; + place-items: center; + align-content: center; + color: var(--text-muted); + text-align: center; +} +.empty-state h1, .empty-state h2 { + margin: 8px 0 2px; + color: var(--text); +} +.empty-state p { + margin: 4px 0 16px; +} +.empty-state__icon { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: 14px; + color: var(--primary); + background: var(--primary-soft); + font-weight: 900; + font-size: 1.1rem; +} +.empty-state__icon .icon { + width: 26px; + height: 26px; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15,23,42,.44); + backdrop-filter: blur(4px); +} +.modal { + width: min(720px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + overscroll-behavior: contain; + border-radius: 12px; + background: #fff; + box-shadow: 0 24px 60px rgba(15,23,42,.24); +} +.modal--small { + width: min(460px,100%); +} +.modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} +.modal__header h2 { + margin: 2px 0 0; + font-size: 1.2rem; +} +.modal__body { + padding: 22px; +} +.modal__body p { + margin: 0; + line-height: 1.55; +} +.modal__footer { + display: flex; + justify-content: flex-end; + gap: 9px; + padding: 16px 22px; + background: #f8fafc; + border-top: 1px solid var(--border); +} +.process-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0,1fr)); + gap: 8px; + margin-bottom: 18px; +} +.process-steps--two { + grid-template-columns: repeat(2, minmax(0,1fr)); + max-width: 520px; + margin-inline: auto; +} +.process-steps--three { + grid-template-columns: repeat(3, minmax(0,1fr)); +} +.process-step { + min-height: 54px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #94a3b8; + background: #eef2f7; + border-radius: var(--radius); + text-align: center; +} +.process-step span { + width: 27px; + height: 27px; + display: grid; + place-items: center; + border-radius: 50%; + background: #fff; + font-weight: 900; +} +.process-step small { + font-weight: 800; +} +.process-step--active { + color: var(--primary); + background: var(--primary-soft); + box-shadow: inset 0 0 0 1px #bfdbfe; +} +.process-step--active span { + color: #fff; + background: var(--primary); +} +.process-card { + max-width: 940px; + margin: 0 auto; + padding: 26px; +} +.process-card__header { + margin-bottom: 20px; +} +.process-card__header p:last-child { + max-width: 700px; + margin: 8px 0 0; + color: var(--text-muted); + line-height: 1.55; +} +.process-form { + margin-bottom: 22px; +} +.process-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 21px; +} +.process-actions--split { + justify-content: space-between; +} +.confirmation-card { + border: 1px solid #bbf7d0; + border-radius: var(--radius); + padding: 19px; + background: #f7fef9; +} +.confirmation-card--overdue { + border-color: #fecaca; + background: var(--danger-soft); +} +.confirmation-card__status { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + color: var(--success); +} +.confirmation-card--overdue .confirmation-card__status { + color: var(--danger); +} +.confirmation-card__status span { + width: 33px; + height: 33px; + display: grid; + place-items: center; + border-radius: 50%; + color: #fff; + background: var(--success); + font-weight: 900; +} +.confirmation-card__status .icon { + width: 17px; + height: 17px; +} +.confirmation-card--overdue .confirmation-card__status span { + background: var(--danger); +} +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 0; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} +.detail-list div { + padding: 13px 15px; + border-bottom: 1px solid var(--border); +} +.detail-list div:nth-child(odd) { + border-right: 1px solid var(--border); +} +.detail-list div:nth-last-child(-n+2) { + border-bottom: 0; +} +.detail-list dt { + color: var(--text-muted); + font-size: .73rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; +} +.detail-list dd { + margin: 5px 0 0; + font-weight: 750; +} +.return-search { + display: grid; + grid-template-columns: minmax(0,1fr) auto; + align-items: end; + gap: 12px; +} +.report-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(300px, .55fr); + gap: 20px; +} +.report-grid .report-panel:last-child { + grid-column: 1 / -1; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + transition-duration: .01ms !important; + } +} +@media (max-width: 1180px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0,1fr)); + } + .dashboard-grid, .report-grid { + grid-template-columns: 1fr; + } + .report-grid .report-panel:last-child { + grid-column: auto; + } +} +@media (max-width: 820px) { + .app-shell { + display: block; + } + .app-main { + min-width: 0; + } + .sidebar { + transform: translateX(-105%); + transition: transform .2s ease; + box-shadow: 12px 0 30px rgba(15,23,42,.12); + } + .sidebar--open { + transform: translateX(0); + } + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 25; + border: 0; + background: rgba(15,23,42,.38); + } + .topbar { + padding: 0 18px; + } + .mobile-menu-button { + display: grid; + } + .content { + padding: 24px 18px 36px; + } + .page-header { + align-items: stretch; + flex-direction: column; + gap: 14px; + } + .page-header__actions .btn { + width: 100%; + } + .toolbar { + align-items: stretch; + flex-direction: column; + } + .search-group, .search-group--books { + min-width: 0; + width: 100%; + flex-wrap: wrap; + } + .search-group .form-control:not(.form-select) { + min-width: 180px; + flex: 1 1 230px; + } + .search-group--books .form-select { + flex: 1 1 130px; + } + .result-count { + align-self: flex-end; + } + .process-steps { + grid-template-columns: repeat(5, minmax(62px,1fr)); + overflow-x: auto; + padding-bottom: 4px; + } + .process-steps--two { + grid-template-columns: repeat(2, minmax(120px,1fr)); + } + .process-steps--three { + grid-template-columns: repeat(3, minmax(95px,1fr)); + } +} +@media (max-width: 640px) { + .stats-grid, .quick-actions, .form-grid, .detail-list { + grid-template-columns: 1fr; + } + .detail-list div:nth-child(odd) { + border-right: 0; + } + .detail-list div:nth-last-child(-n+2) { + border-bottom: 1px solid var(--border); + } + .detail-list div:last-child { + border-bottom: 0; + } + .return-search { + grid-template-columns: 1fr; + } + .process-card { + padding: 18px; + } + .process-actions, .process-actions--split { + flex-direction: column-reverse; + } + .process-actions .btn { + width: 100%; + } + .data-table thead { + display: none; + } + .data-table, .data-table tbody, .data-table tr, .data-table td { + display: block; + width: 100%; + } + .data-table tr { + margin-bottom: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: #fff !important; + } + .data-table td { + display: grid; + grid-template-columns: minmax(92px, .38fr) minmax(0, .62fr); + gap: 10px; + padding: 10px 12px; + text-align: left !important; + } + .data-table td::before { + content: attr(data-label); + color: var(--text-muted); + font-size: .72rem; + font-weight: 850; + text-transform: uppercase; + } + .table-actions .btn { + width: 100%; + margin: 0 0 6px !important; + } + .table-actions .btn:last-child { + margin-bottom: 0 !important; + } + .modal-backdrop { + padding: 0; + align-items: end; + } + .modal { + max-height: 94vh; + border-radius: 14px 14px 0 0; + } +} diff --git a/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj b/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj new file mode 100644 index 0000000..af5fcdc --- /dev/null +++ b/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Bsevita.Library.Web + + + + + diff --git a/src/Bsevita.Library.Web/Components/App.razor b/src/Bsevita.Library.Web/Components/App.razor new file mode 100644 index 0000000..4a803f2 --- /dev/null +++ b/src/Bsevita.Library.Web/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Bsevita.Library.Web/Components/_Imports.razor b/src/Bsevita.Library.Web/Components/_Imports.razor new file mode 100644 index 0000000..faaedbb --- /dev/null +++ b/src/Bsevita.Library.Web/Components/_Imports.razor @@ -0,0 +1,6 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Bsevita.Library.Ui +@using Bsevita.Library.Web.Components diff --git a/src/Bsevita.Library.Web/Program.cs b/src/Bsevita.Library.Web/Program.cs new file mode 100644 index 0000000..d1a76f0 --- /dev/null +++ b/src/Bsevita.Library.Web/Program.cs @@ -0,0 +1,30 @@ +using Bsevita.Library.Ui; +using Bsevita.Library.Ui.Services; +using Bsevita.Library.Web.Components; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddLibraryUi(); +builder.Services.AddScoped(_ => new HttpClient +{ + BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5180/"), + Timeout = TimeSpan.FromSeconds(20) +}); + +var app = builder.Build(); +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler(); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(AppRoutes).Assembly); + +await app.RunAsync(); diff --git a/src/Bsevita.Library.Web/appsettings.json b/src/Bsevita.Library.Web/appsettings.json new file mode 100644 index 0000000..fea44a5 --- /dev/null +++ b/src/Bsevita.Library.Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "ApiBaseUrl": "http://localhost:5180/", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs b/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs new file mode 100644 index 0000000..c481b63 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Models.Students; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bsevita.Library.Api.Tests; + +public sealed class ApiIntegrationTests +{ + [Fact] + public async Task SwaggerUi_AndSwaggerJson_AreAvailableInDevelopment() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var ui = await client.GetAsync("/swagger"); + var document = await client.GetFromJsonAsync("/swagger/v1/swagger.json"); + + Assert.Equal(HttpStatusCode.OK, ui.StatusCode); + Assert.Equal("Bsevita.Library.Api", document?["info"]?["title"]?.GetValue()); + Assert.NotNull(document?["paths"]?["/api/students"]); + Assert.NotNull(document?["paths"]?["/api/books"]); + Assert.NotNull(document?["paths"]?["/api/loans"]); + Assert.NotNull(document?["paths"]?["/api/returns/verify/{bookNumber}"]); + Assert.NotNull(document?["paths"]?["/api/reports/statistics"]); + } + + [Fact] + public async Task PostStudent_ReturnsCreatedStudent() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/students", new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }); + var body = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("S-1", body?.CardNumber); + Assert.Equal("Ada Lovelace", body?.FullName); + } + + [Fact] + public async Task InvalidBody_ReturnsValidationProblemDetails() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/students", new { cardNumber = "" }); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(400, problem?["status"]?.GetValue()); + Assert.NotNull(problem?["errors"]); + } + + [Fact] + public async Task MissingResource_ReturnsProblemDetails() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/students/{Guid.NewGuid()}"); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(404, problem?["status"]?.GetValue()); + Assert.Equal("Nicht gefunden", problem?["title"]?.GetValue()); + } + + [Fact] + public async Task UnknownRoute_ReturnsNotFound() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/does-not-exist"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Health_ReturnsOk() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task LoanConflict_ReturnsConflictProblemDetails() + { + await using var factory = new ApiFactory(); + await factory.SeedBorrowedBookAsync(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/loans", new + { + cardNumber = "S-2", + bookNumber = "B-1" + }); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(409, problem?["status"]?.GetValue()); + } + + private sealed class ApiFactory : WebApplicationFactory + { + private readonly string _databaseName = $"Bsevita.Library.Api.Tests.{Guid.NewGuid():N}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("Database:ValidateOnStartup", "false"); + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + services.AddDbContext(options => options.UseInMemoryDatabase(_databaseName)); + services.AddSingleton(new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero))); + }); + } + + public async Task SeedBorrowedBookAsync() + { + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var firstStudent = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var secondStudent = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-2", + FirstName = "Grace", + LastName = "Hopper", + ClassName = "5BHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-1", + Title = "Borrowed", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + dbContext.AddRange(firstStudent, secondStudent, book, new Loan + { + LoanId = Guid.NewGuid(), + StudentId = firstStudent.StudentId, + BookId = book.BookId, + LoanedAt = now, + DueAt = now.AddDays(14) + }); + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj b/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj new file mode 100644 index 0000000..6342902 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + false + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs b/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs new file mode 100644 index 0000000..2006927 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs @@ -0,0 +1,6 @@ +namespace Bsevita.Library.Api.Tests; + +internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => utcNow; +} diff --git a/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs b/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs new file mode 100644 index 0000000..c48f5cd --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs @@ -0,0 +1,645 @@ +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Returns; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Api.Tests; + +public sealed class LibraryWorkflowTests +{ + [Fact] + public async Task LoanAndReturn_UpdatesReportsAndAvailability() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var returns = new ReturnService(database.Context, clock); + var reports = new ReportService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest + { + BookNumber = "B-1", + Title = "Clean Architecture", + Author = "Robert C. Martin", + Subject = "Informatik" + }, CancellationToken.None); + + var verification = await loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + Assert.True(verification.CanBorrow); + + var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + Assert.Equal(clock.GetUtcNow().AddDays(14), loan.DueAt); + Assert.Single(await reports.GetActiveLoansAsync(CancellationToken.None)); + Assert.False((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable); + + var returned = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + Assert.Equal(loan.Id, returned.LoanId); + Assert.Empty(await reports.GetActiveLoansAsync(CancellationToken.None)); + Assert.True((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable); + } + + [Fact] + public async Task CreateLoan_UsesCustomDueDate_WhenProvided() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest + { + BookNumber = "B-1", + Title = "Clean Architecture", + Author = "Robert C. Martin", + Subject = "Informatik" + }, CancellationToken.None); + + var dueAt = new DateTimeOffset(2026, 7, 20, 0, 0, 0, TimeSpan.Zero); + var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = dueAt }, CancellationToken.None); + + Assert.Equal(dueAt, loan.DueAt); + } + + [Fact] + public async Task DuplicateCardNumber_IsRejected() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var service = new StudentService(database.Context, new FixedTimeProvider(DateTimeOffset.UtcNow)); + var request = new SaveStudentRequest { CardNumber = "same", FirstName = "A", LastName = "B", ClassName = "1A" }; + await service.CreateAsync(request, CancellationToken.None); + await Assert.ThrowsAsync( + () => service.CreateAsync(new SaveStudentRequest { CardNumber = "SAME", FirstName = "C", LastName = "D", ClassName = "1B" }, CancellationToken.None)); + } + + [Fact] + public async Task Statistics_CountsAvailableActiveBooks() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var activeBook = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-1", + Title = "Active", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var inactiveBook = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-2", + Title = "Inactive", + Author = "A", + Subject = "S", + IsActive = false, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.AddRange(student, activeBook, inactiveBook, new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = inactiveBook.BookId, + LoanedAt = now.AddDays(-1), + DueAt = now.AddDays(13) + }); + await database.Context.SaveChangesAsync(); + + var statistics = await new ReportService(database.Context, new FixedTimeProvider(now)).GetStatisticsAsync(CancellationToken.None); + + Assert.Equal(1, statistics.TotalBooks); + Assert.Equal(1, statistics.AvailableBooks); + Assert.Equal(1, statistics.ActiveLoans); + } + + [Fact] + public async Task BookList_CanFilterAvailabilityAndLimitRows() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Available", Author = "A", Subject = "S" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Borrowed", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-2" }, CancellationToken.None); + + var available = await books.GetAllAsync(availableOnly: true, skip: 0, take: 1, CancellationToken.None); + var borrowed = await books.GetAllAsync(availableOnly: false, skip: 0, take: 10, CancellationToken.None); + + Assert.Single(available); + Assert.Equal("B-1", available[0].BookNumber); + Assert.Collection(borrowed, book => Assert.Equal("B-2", book.BookNumber)); + } + + [Fact] + public async Task Reports_ReturnsBundledReportData() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Overdue", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None); + + var reports = await new ReportService(database.Context, new FixedTimeProvider(clock.GetUtcNow().AddDays(2))).GetReportsAsync(CancellationToken.None); + + Assert.Single(reports.ActiveLoans); + Assert.Single(reports.OverdueLoans); + Assert.Single(reports.ActiveStudents); + Assert.Equal(1, reports.Statistics.ActiveLoans); + Assert.Equal(0, reports.Statistics.AvailableBooks); + } + + [Fact] + public async Task Reports_LimitsListsButKeepsFullStatistics() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.Students.Add(student); + for (var i = 0; i < 101; i++) + { + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = $"B-{i}", + Title = $"Book {i}", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.Add(book); + database.Context.Loans.Add(new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = book.BookId, + LoanedAt = now.AddDays(-1), + DueAt = now.AddDays(13) + }); + } + await database.Context.SaveChangesAsync(); + + var reports = await new ReportService(database.Context, new FixedTimeProvider(now)).GetReportsAsync(CancellationToken.None); + + Assert.Equal(100, reports.ActiveLoans.Count); + Assert.Equal(101, reports.Statistics.ActiveLoans); + } + + [Fact] + public async Task Search_FindsPartialMatches() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Informatik" }, CancellationToken.None); + + Assert.Single(await students.SearchAsync("love", CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: "arch", author: null, subject: null, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsNotFound_WhenStudentOrBookIsMissing() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await Assert.ThrowsAsync( + () => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "missing", BookNumber = "missing" }, CancellationToken.None)); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "missing" }, CancellationToken.None)); + + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "missing", BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsConflict_WhenBookIsAlreadyBorrowed() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await students.CreateAsync(Student("S-2"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None)); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsValidation_WhenDueDateIsInPast() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest + { + CardNumber = "S-1", + BookNumber = "B-1", + DueAt = clock.GetUtcNow().AddSeconds(-1) + }, CancellationToken.None)); + } + + [Fact] + public async Task Return_ThrowsNotFound_WhenNoActiveLoanExists() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var returns = new ReturnService(database.Context, NewClock()); + + await Assert.ThrowsAsync( + () => returns.VerifyAsync("missing", CancellationToken.None)); + await Assert.ThrowsAsync( + () => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "missing" }, CancellationToken.None)); + } + + [Fact] + public async Task Return_ReportsOverdueDays() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var start = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var overdueClock = new FixedTimeProvider(start.GetUtcNow().AddDays(3)); + var students = new StudentService(database.Context, start); + var books = new BookService(database.Context, start); + var loans = new LoanService(database.Context, start); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest + { + CardNumber = "S-1", + BookNumber = "B-1", + DueAt = start.GetUtcNow().AddDays(1) + }, CancellationToken.None); + + var returns = new ReturnService(database.Context, overdueClock); + var verification = await returns.VerifyAsync("B-1", CancellationToken.None); + var result = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + + Assert.True(verification.IsOverdue); + Assert.Equal(2, verification.DaysOverdue); + Assert.True(result.WasOverdue); + Assert.Equal(2, result.DaysOverdue); + } + + [Fact] + public async Task Delete_ThrowsConflict_WhenActiveLoanExists() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync(() => students.DeleteAsync(student.Id, CancellationToken.None)); + await Assert.ThrowsAsync(() => books.DeleteAsync(book.Id, CancellationToken.None)); + } + + [Fact] + public async Task Delete_DeactivatesStudentAndBook() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await students.DeleteAsync(student.Id, CancellationToken.None); + await books.DeleteAsync(book.Id, CancellationToken.None); + + Assert.Empty(await students.GetAllAsync(0, 10, CancellationToken.None)); + Assert.Empty(await books.GetAllAsync(null, 0, 10, CancellationToken.None)); + await Assert.ThrowsAsync(() => students.GetByIdAsync(student.Id, CancellationToken.None)); + await Assert.ThrowsAsync(() => books.GetByIdAsync(book.Id, CancellationToken.None)); + } + + [Fact] + public async Task BookIdentifiers_AreUnique() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var books = new BookService(database.Context, NewClock()); + + await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => books.CreateAsync(Book("b-1"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.CreateAsync(Book("B-2", isbn: "978-1"), CancellationToken.None)); + } + + [Fact] + public async Task Update_ThrowsConflict_WhenIdentifiersAlreadyExist() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + var secondStudent = await students.CreateAsync(Student("S-2"), CancellationToken.None); + await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None); + var secondBook = await books.CreateAsync(Book("B-2", isbn: "978-2"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => students.UpdateAsync(secondStudent.Id, Student("S-1"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.UpdateAsync(secondBook.Id, Book("B-1", isbn: "978-2"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.UpdateAsync(secondBook.Id, Book("B-2", isbn: "978-1"), CancellationToken.None)); + } + + [Fact] + public async Task Search_ThrowsValidation_WhenSearchInputIsMissing() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await Assert.ThrowsAsync( + () => students.SearchAsync(" ", CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.SearchAsync(null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task Reads_ThrowNotFound_WhenResourcesDoNotExist() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await Assert.ThrowsAsync( + () => students.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + await Assert.ThrowsAsync( + () => students.GetByCardAsync("missing", CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.GetByNumberAsync("missing", CancellationToken.None)); + } + + [Fact] + public async Task Paging_ClampsInvalidSkipAndTake() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + + Assert.Single(await students.GetAllAsync(-10, 0, CancellationToken.None)); + Assert.Single(await books.GetAllAsync(null, -10, 0, CancellationToken.None)); + } + + [Fact] + public async Task Update_ChangesStudentAndBookValues() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + var updatedStudent = await students.UpdateAsync(student.Id, new SaveStudentRequest + { + CardNumber = "S-2", + FirstName = "Grace", + LastName = "Hopper", + ClassName = "5BHIT", + Email = "grace@example.test" + }, CancellationToken.None); + var updatedBook = await books.UpdateAsync(book.Id, new SaveBookRequest + { + BookNumber = "B-2", + Isbn = "978-2", + Title = "Compiler Design", + Author = "Grace Hopper", + Subject = "Informatik", + Publisher = "ACM", + PublicationYear = 1952 + }, CancellationToken.None); + + Assert.Equal("Grace Hopper", updatedStudent.FullName); + Assert.Equal("S-2", updatedStudent.CardNumber); + Assert.Equal("grace@example.test", updatedStudent.Email); + Assert.Equal("Compiler Design", updatedBook.Title); + Assert.Equal("978-2", updatedBook.Isbn); + Assert.Equal(1952, updatedBook.PublicationYear); + } + + [Fact] + public async Task Search_CoversStudentFullNameAndBookAuthorAndSubject() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Software Engineering" }, CancellationToken.None); + + Assert.Single(await students.SearchAsync("ada love", CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: null, author: "martin", subject: null, CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: null, author: null, subject: "engineering", CancellationToken.None)); + Assert.Empty(await books.SearchAsync(title: "missing", author: null, subject: null, CancellationToken.None)); + } + + [Fact] + public async Task Lists_AreSortedAndPaged() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var reports = new ReportService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "B", LastName = "Beta", ClassName = "1A" }, CancellationToken.None); + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-2", FirstName = "A", LastName = "Alpha", ClassName = "1A" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Zulu", Author = "A", Subject = "S" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Alpha", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(5) }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-2", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None); + + var orderedStudents = await students.GetAllAsync(0, 10, CancellationToken.None); + var pagedBooks = await books.GetAllAsync(null, 1, 1, CancellationToken.None); + var orderedLoans = await reports.GetActiveLoansAsync(0, 10, CancellationToken.None); + + Assert.Equal(["Alpha", "Beta"], orderedStudents.Select(student => student.LastName).ToArray()); + Assert.Equal("Zulu", pagedBooks.Single().Title); + Assert.Equal(["Alpha", "Zulu"], orderedLoans.Select(loan => loan.BookTitle).ToArray()); + } + + [Fact] + public async Task Return_ThrowsNotFound_WhenBookIsReturnedTwice() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var returns = new ReturnService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task SoftDeletedResources_AreHiddenFromSearchAndLookup() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await students.DeleteAsync(student.Id, CancellationToken.None); + await books.DeleteAsync(book.Id, CancellationToken.None); + + Assert.Empty(await students.SearchAsync("Ada", CancellationToken.None)); + Assert.Empty(await books.SearchAsync(title: "Book", author: null, subject: null, CancellationToken.None)); + await Assert.ThrowsAsync(() => students.GetByCardAsync("S-1", CancellationToken.None)); + await Assert.ThrowsAsync(() => books.GetByNumberAsync("B-1", CancellationToken.None)); + } + + private static FixedTimeProvider NewClock() => + new(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + + private static SaveStudentRequest Student(string cardNumber) => + new() + { + CardNumber = cardNumber, + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }; + + private static SaveBookRequest Book(string bookNumber, string? isbn = null) => + new() + { + BookNumber = bookNumber, + Isbn = isbn, + Title = $"Book {bookNumber}", + Author = "Author", + Subject = "Subject" + }; +} diff --git a/tests/Bsevita.Library.Api.Tests/TestDatabase.cs b/tests/Bsevita.Library.Api.Tests/TestDatabase.cs new file mode 100644 index 0000000..380342f --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/TestDatabase.cs @@ -0,0 +1,23 @@ +using Bsevita.Library.Api.Data.Generated; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Tests; + +internal sealed class TestDatabase : IAsyncDisposable +{ + public LibraryDbContext Context { get; private set; } = null!; + + public Task InitializeAsync() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"Bsevita.Library.Tests.{Guid.NewGuid():N}") + .Options; + Context = new LibraryDbContext(options); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await Context.DisposeAsync(); + } +} diff --git a/tests/Bsevita.Library.Api.Tests/Usings.cs b/tests/Bsevita.Library.Api.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;