From 39c5a89ca750b528bfaa93f74b2370413b92a4df Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Mon, 31 Aug 2026 00:11:48 -0400 Subject: [PATCH] Add user avatar and profile feature, consolidate Auth and UserManagement features (#257) --- docs/architecture.md | 64 +++---- docs/website/database.md | 17 +- docs/website/diagrams-out/architecture.svg | 2 +- docs/website/diagrams-out/database-schema.svg | 2 +- docs/website/diagrams-out/deployment.svg | 2 +- docs/website/diagrams-src/architecture.puml | 43 ++--- .../website/diagrams-src/database-schema.puml | 25 ++- docs/website/diagrams-src/deployment.puml | 6 +- docs/website/docker.md | 2 +- docs/website/testing.md | 39 ++-- docs/website/token-validation.md | 32 ++-- web/backend/API/API.Core/API.Core.csproj | 3 +- web/backend/API/API.Core/Dockerfile | 6 +- web/backend/API/API.Core/Program.cs | 31 +-- web/backend/API/API.Specs/Dockerfile | 6 +- web/backend/Biergarten.API.slnx | 19 +- .../Database/Database.Migrations/schema.dbml | 23 ++- .../scripts/01-schema/01-UserAccount.sql | 2 + .../scripts/01-schema/03-UserAvatar.sql | 29 --- ...rification.sql => 03-UserVerification.sql} | 0 ...erCredential.sql => 04-UserCredential.sql} | 0 .../{06-UserFollow.sql => 05-UserFollow.sql} | 0 .../{07-Country.sql => 06-Country.sql} | 0 ...StateProvince.sql => 07-StateProvince.sql} | 0 .../01-schema/{09-City.sql => 08-City.sql} | 0 ...{10-BreweryPost.sql => 09-BreweryPost.sql} | 0 ...ocation.sql => 10-BreweryPostLocation.sql} | 0 ...yPostPhoto.sql => 11-BreweryPostPhoto.sql} | 0 .../{13-BeerStyle.sql => 12-BeerStyle.sql} | 0 .../{14-BeerPost.sql => 13-BeerPost.sql} | 0 ...BeerPostPhoto.sql => 14-BeerPostPhoto.sql} | 0 ...PostComment.sql => 15-BeerPostComment.sql} | 0 .../scripts/01-schema/16-UserProfile.sql | 22 +++ .../scripts/01-schema/17-UserAvatar.sql | 43 +++++ .../Database.Seed/Database.Seed.csproj | 12 +- .../DatabaseHelpers/AvatarGenerator.cs | 27 +++ .../DatabaseHelpers/NoOpTokenService.cs | 21 ++ web/backend/Database/Database.Seed/Dockerfile | 2 + web/backend/Database/Database.Seed/Program.cs | 179 +++++++++++------- .../Domain.Entities/Entities/UserAvatar.cs | 20 ++ .../Domain.Entities/Entities/UserProfile.cs | 16 ++ .../SendRegistrationEmailHandler.cs | 2 +- .../SendResendConfirmationEmailHandler.cs | 2 +- .../GetOrCreateCity/GetOrCreateCityCommand.cs | 12 ++ .../GetOrCreateCity/GetOrCreateCityHandler.cs | 12 ++ .../UploadPhoto/UploadPhotoHandler.cs | 7 +- .../Repository/PhotoUploadRepository.cs | 8 +- .../Commands/UpdateUserHandlerTests.cs | 22 --- .../Features.UserManagement.Tests.csproj | 25 --- .../Commands/UpdateUser/UpdateUserCommand.cs | 10 - .../Commands/UpdateUser/UpdateUserHandler.cs | 13 -- ...erManagementServiceCollectionExtensions.cs | 20 -- .../Features.UserManagement.csproj | 20 -- .../Repository/IUserAccountRepository.cs | 33 ---- .../Repository/UserAccountRepository.cs | 118 ------------ .../Commands/ConfirmUserHandlerTests.cs | 2 +- .../Commands/DeleteAccountHandlerTests.cs | 2 +- .../Commands/RefreshTokenHandlerTests.cs | 2 +- .../Commands/RegisterUserHandlerTests.cs | 2 +- .../ResendConfirmationEmailHandlerTests.cs | 2 +- .../Commands/UpdateEmailHandlerTests.cs | 2 +- .../Commands/UpdatePasswordHandlerTests.cs | 2 +- .../Commands/UpdateProfileHandlerTests.cs | 2 +- .../Commands/UpdateUsernameHandlerTests.cs | 2 +- .../Features.Users.Tests.csproj} | 2 +- .../Queries/GetAllUsersHandlerTests.cs | 8 +- .../Queries/GetUserByIdHandlerTests.cs | 8 +- .../Queries/LoginHandlerTests.cs | 2 +- .../Services/TokenServiceRefreshTests.cs | 0 .../Services/TokenServiceValidationTests.cs | 0 .../TestSupport/UserManagerMockFactory.cs | 0 .../DeleteAccount/DeleteAccountCommand.cs | 2 +- .../DeleteAccount/DeleteAccountHandler.cs | 2 +- .../UpdateEmail/UpdateEmailCommand.cs | 2 +- .../UpdateEmail/UpdateEmailHandler.cs | 4 +- .../UpdateEmail/UpdateEmailValidator.cs | 2 +- .../UpdatePassword/UpdatePasswordCommand.cs | 2 +- .../UpdatePassword/UpdatePasswordHandler.cs | 2 +- .../UpdatePassword/UpdatePasswordValidator.cs | 2 +- .../UpdateUsername/UpdateUsernameCommand.cs | 2 +- .../UpdateUsername/UpdateUsernameHandler.cs | 2 +- .../UpdateUsername/UpdateUsernameValidator.cs | 2 +- .../ConfirmUser/ConfirmUserCommand.cs | 2 +- .../ConfirmUser/ConfirmUserHandler.cs | 2 +- .../Authentication}/Login/LoginCommand.cs | 2 +- .../Authentication}/Login/LoginHandler.cs | 2 +- .../Authentication}/Login/LoginValidator.cs | 2 +- .../RefreshToken/RefreshTokenCommand.cs | 2 +- .../RefreshToken/RefreshTokenHandler.cs | 2 +- .../RefreshToken/RefreshTokenValidator.cs | 2 +- .../RegisterUser/RegisterUserCommand.cs | 2 +- .../RegisterUser/RegisterUserHandler.cs | 5 +- .../RegisterUser/RegisterUserValidator.cs | 2 +- .../ResendConfirmationEmailCommand.cs | 2 +- .../ResendConfirmationEmailHandler.cs | 2 +- .../CreateUserProfileCommand.cs | 6 + .../CreateUserProfileHandler.cs | 19 ++ .../UpdateBiography/UpdateBiographyCommand.cs | 10 + .../UpdateBiography/UpdateBiographyHandler.cs | 19 ++ .../UpdateProfile/UpdateProfileCommand.cs | 2 +- .../UpdateProfile/UpdateProfileHandler.cs | 2 +- .../UpdateProfile/UpdateProfileValidator.cs | 2 +- .../UploadAvatar/UploadAvatarCommand.cs | 7 + .../UploadAvatar/UploadAvatarHandler.cs | 42 ++++ .../Controllers/AuthController.cs | 20 +- .../Controllers/UserController.cs | 6 +- ...aturesUsersServiceCollectionExtensions.cs} | 17 +- .../Dtos/AuthPayloads.cs | 0 .../Features.Users.csproj} | 5 +- .../Identity/ApplicationUser.cs | 2 +- .../Identity/Argon2PasswordHasher.cs | 0 .../Identity/DapperUserStore.cs | 5 +- .../Identity/UserAccountExtensions.cs | 2 +- .../Queries/GetAllUsers/GetAllUsersHandler.cs | 6 +- .../Queries/GetAllUsers/GetAllUsersQuery.cs | 2 +- .../Queries/GetUserById/GetUserByIdHandler.cs | 6 +- .../Queries/GetUserById/GetUserByIdQuery.cs | 2 +- .../Repository/IUserListRepository.cs | 21 ++ .../Repository/IUserProfileRepository.cs | 41 ++++ .../Repository/UserListRepository.cs | 50 +++++ .../Repository/UserProfileRepository.cs | 139 ++++++++++++++ .../Services/ITokenService.cs | 0 .../Services/TokenService.cs | 0 .../Emails/SendRegistrationEmailCommand.cs | 2 +- .../SendResendConfirmationEmailCommand.cs | 2 +- web/docker-compose.db.yaml | 28 +++ web/docker-compose.dev.yaml | 6 + web/docker-compose.min.yaml | 28 +++ web/docker-compose.test.yaml | 22 +++ web/generate-env.sh | 7 + 130 files changed, 1005 insertions(+), 613 deletions(-) delete mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql rename web/backend/Database/Database.Migrations/scripts/01-schema/{04-UserVerification.sql => 03-UserVerification.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{05-UserCredential.sql => 04-UserCredential.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{06-UserFollow.sql => 05-UserFollow.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{07-Country.sql => 06-Country.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{08-StateProvince.sql => 07-StateProvince.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{09-City.sql => 08-City.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{10-BreweryPost.sql => 09-BreweryPost.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{11-BreweryPostLocation.sql => 10-BreweryPostLocation.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{12-BreweryPostPhoto.sql => 11-BreweryPostPhoto.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{13-BeerStyle.sql => 12-BeerStyle.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{14-BeerPost.sql => 13-BeerPost.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{15-BeerPostPhoto.sql => 14-BeerPostPhoto.sql} (100%) rename web/backend/Database/Database.Migrations/scripts/01-schema/{16-BeerPostComment.sql => 15-BeerPostComment.sql} (100%) create mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/16-UserProfile.sql create mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/17-UserAvatar.sql create mode 100644 web/backend/Database/Database.Seed/DatabaseHelpers/AvatarGenerator.cs create mode 100644 web/backend/Database/Database.Seed/DatabaseHelpers/NoOpTokenService.cs create mode 100644 web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs create mode 100644 web/backend/Domain/Domain.Entities/Entities/UserProfile.cs create mode 100644 web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs create mode 100644 web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityHandler.cs delete mode 100644 web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs delete mode 100644 web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj delete mode 100644 web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs delete mode 100644 web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs delete mode 100644 web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs delete mode 100644 web/backend/Features/Features.UserManagement/Features.UserManagement.csproj delete mode 100644 web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs delete mode 100644 web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/ConfirmUserHandlerTests.cs (98%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/DeleteAccountHandlerTests.cs (97%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/RefreshTokenHandlerTests.cs (94%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/RegisterUserHandlerTests.cs (99%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/ResendConfirmationEmailHandlerTests.cs (97%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/UpdateEmailHandlerTests.cs (98%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/UpdatePasswordHandlerTests.cs (98%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/UpdateProfileHandlerTests.cs (97%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Commands/UpdateUsernameHandlerTests.cs (98%) rename web/backend/Features/{Features.Auth.Tests/Features.Auth.Tests.csproj => Features.Users.Tests/Features.Users.Tests.csproj} (92%) rename web/backend/Features/{Features.UserManagement.Tests => Features.Users.Tests}/Queries/GetAllUsersHandlerTests.cs (75%) rename web/backend/Features/{Features.UserManagement.Tests => Features.Users.Tests}/Queries/GetUserByIdHandlerTests.cs (84%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Queries/LoginHandlerTests.cs (98%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Services/TokenServiceRefreshTests.cs (100%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/Services/TokenServiceValidationTests.cs (100%) rename web/backend/Features/{Features.Auth.Tests => Features.Users.Tests}/TestSupport/UserManagerMockFactory.cs (100%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/DeleteAccount/DeleteAccountCommand.cs (85%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/DeleteAccount/DeleteAccountHandler.cs (96%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateEmail/UpdateEmailCommand.cs (90%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateEmail/UpdateEmailHandler.cs (93%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateEmail/UpdateEmailValidator.cs (91%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdatePassword/UpdatePasswordCommand.cs (89%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdatePassword/UpdatePasswordHandler.cs (96%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdatePassword/UpdatePasswordValidator.cs (95%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateUsername/UpdateUsernameCommand.cs (87%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateUsername/UpdateUsernameHandler.cs (96%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Account}/UpdateUsername/UpdateUsernameValidator.cs (92%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/ConfirmUser/ConfirmUserCommand.cs (78%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/ConfirmUser/ConfirmUserHandler.cs (95%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/Login/LoginCommand.cs (85%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/Login/LoginHandler.cs (96%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/Login/LoginValidator.cs (88%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RefreshToken/RefreshTokenCommand.cs (83%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RefreshToken/RefreshTokenHandler.cs (94%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RefreshToken/RefreshTokenValidator.cs (84%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RegisterUser/RegisterUserCommand.cs (94%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RegisterUser/RegisterUserHandler.cs (93%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/RegisterUser/RegisterUserValidator.cs (97%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs (73%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Authentication}/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs (95%) create mode 100644 web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileCommand.cs create mode 100644 web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileHandler.cs create mode 100644 web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyCommand.cs create mode 100644 web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyHandler.cs rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Profile}/UpdateProfile/UpdateProfileCommand.cs (90%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Profile}/UpdateProfile/UpdateProfileHandler.cs (96%) rename web/backend/Features/{Features.Auth/Commands => Features.Users/Commands/Profile}/UpdateProfile/UpdateProfileValidator.cs (94%) create mode 100644 web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarCommand.cs create mode 100644 web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarHandler.cs rename web/backend/Features/{Features.Auth => Features.Users}/Controllers/AuthController.cs (94%) rename web/backend/Features/{Features.UserManagement => Features.Users}/Controllers/UserController.cs (89%) rename web/backend/Features/{Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs => Features.Users/DependencyInjection/FeaturesUsersServiceCollectionExtensions.cs} (71%) rename web/backend/Features/{Features.Auth => Features.Users}/Dtos/AuthPayloads.cs (100%) rename web/backend/Features/{Features.Auth/Features.Auth.csproj => Features.Users/Features.Users.csproj} (93%) rename web/backend/Features/{Features.Auth => Features.Users}/Identity/ApplicationUser.cs (88%) rename web/backend/Features/{Features.Auth => Features.Users}/Identity/Argon2PasswordHasher.cs (100%) rename web/backend/Features/{Features.Auth => Features.Users}/Identity/DapperUserStore.cs (98%) rename web/backend/Features/{Features.Auth => Features.Users}/Identity/UserAccountExtensions.cs (87%) rename web/backend/Features/{Features.UserManagement => Features.Users}/Queries/GetAllUsers/GetAllUsersHandler.cs (66%) rename web/backend/Features/{Features.UserManagement => Features.Users}/Queries/GetAllUsers/GetAllUsersQuery.cs (86%) rename web/backend/Features/{Features.UserManagement => Features.Users}/Queries/GetUserById/GetUserByIdHandler.cs (78%) rename web/backend/Features/{Features.UserManagement => Features.Users}/Queries/GetUserById/GetUserByIdQuery.cs (67%) create mode 100644 web/backend/Features/Features.Users/Repository/IUserListRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/UserListRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/UserProfileRepository.cs rename web/backend/Features/{Features.Auth => Features.Users}/Services/ITokenService.cs (100%) rename web/backend/Features/{Features.Auth => Features.Users}/Services/TokenService.cs (100%) diff --git a/docs/architecture.md b/docs/architecture.md index ad886d46..6a905811 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,13 +34,12 @@ For visual representations, see: ### Vertical-slice architecture pattern The backend organizes business capabilities as feature slices instead of -technical layers. Each feature (`Features.Auth`, `Features.Breweries`, -`Features.UserManagement`, `Features.Emails`, `Features.Locations`, -`Features.PhotoUpload`) is a single project that owns its own MediatR -commands/queries/handlers, validators, and repository end to end. -`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no -`Controllers/` folder — all three are invoked internally by other slices via -MediatR/DI, never over HTTP: +technical layers. Each feature (`Features.Users`, `Features.Breweries`, +`Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single +project that owns its own MediatR commands/queries/handlers, validators, and +repository end to end. `Features.Emails`, `Features.Locations`, and +`Features.PhotoUpload` have no `Controllers/` folder — all three are invoked +internally by other slices via MediatR/DI, never over HTTP: ``` ┌───────────────────────────────────────────────────────────────────────┐ @@ -49,15 +48,15 @@ MediatR/DI, never over HTTP: │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ └───────────────────────────────────────────────────────────────────────┘ ↓ discovers controllers via AddApplicationPart -┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┐ -│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ Features. │ -│ │Breweries │ UserManagement │ (no controller, │ Locations │ PhotoUpload │ -│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ (no controller, │ -│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │ -│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │ -│ Handlers │ Handlers │ Handlers │ │ │ Handlers │ -│ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │ -└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┴──────────────────┘ +┌────────────────┬───────────────┬──────────────────┬──────────────────┬──────────────────┐ +│ Features.Users │Features. │ Features.Emails │ Features. │ Features. │ +│ │Breweries │ (no controller, │ Locations │ PhotoUpload │ +│ Controllers │ Controller │ internal only) │ (no controller, │ (no controller, │ +│ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │ +│ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │ +│ Handlers │ Handlers │ │ │ Handlers │ +│ Repository │ Repository │ EmailDispatcher │ │ Repository │ +└────────────────┴───────────────┴──────────────────┴──────────────────┴──────────────────┘ ↓ each slice depends only on shared/domain/infra, never on another slice ┌─────────────────────────┬─────────────────────────┬────────────────────┐ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │ @@ -77,7 +76,7 @@ MediatR/DI, never over HTTP: ``` Slices never reference each other's project. The one cross-slice interaction -(`Features.Auth` triggering a confirmation email handled by `Features.Emails`) +(`Features.Users` triggering a confirmation email handled by `Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract lives in `Shared.Application`, so neither slice takes a project reference on the other. @@ -110,7 +109,7 @@ own - No controllers, no business logic, no feature-specific contracts - Exists purely to host and wire up the feature slices -#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) +#### Feature slices (`Features.Users`, `Features.Breweries`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) **Purpose**: Each slice is the complete vertical for one business capability @@ -145,7 +144,7 @@ for exercising the upload path end to end. - `Domain.Entities`, `Domain.Exceptions` - `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure project the slice needs (`Infrastructure.Jwt`/ - `Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ + `Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/ `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for PhotoUpload) - `Shared.Contracts`, `Shared.Application` @@ -277,9 +276,9 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl **Implementation**: each slice owns its own repository, scoped to that feature only: -- `Features.Auth/Repository/IAuthRepository.cs` +- `Features.Users/Repository/IUserListRepository.cs`, + `Features.Users/Repository/IUserProfileRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs` -- `Features.UserManagement/Repository/IUserAccountRepository.cs` - `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs` - `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection factory every slice's repository builds on @@ -297,10 +296,10 @@ only: **Example**: ```csharp -public interface IAuthRepository +public interface IUserListRepository { - Task GetUserCredentialAsync(string username); - Task CreateUserAccountAsync(UserAccount user, UserCredential credential); + Task GetByIdAsync(Guid id); + Task> GetAllAsync(int? limit, int? offset); } ``` @@ -427,7 +426,7 @@ for reference only. Active product and engineering documentation should point to 2. **Email confirmation**: - User follows the confirmation link/token from the email - - `Features.Auth`'s `ConfirmUser` command marks the account verified + - `Features.Users`' `ConfirmUser` command marks the account verified 3. **Login**: - User submits credentials @@ -437,7 +436,7 @@ for reference only. Active product and engineering documentation should point to 4. **Token refresh**: - Client exchanges a valid, unexpired refresh token for a new access/refresh - pair via `Features.Auth`'s `RefreshToken` command + pair via `Features.Users`' `RefreshToken` command - Refresh tokens are stateless JWTs, not tracked server-side: the previous refresh token is not invalidated and remains usable until its own expiration @@ -521,7 +520,8 @@ scripts/ ├── 01-UserAccount.sql ├── 02-Photo.sql ├── ... - └── 16-BeerPostComment.sql # one script per CREATE TABLE + ├── 16-UserProfile.sql + └── 17-UserAvatar.sql # one script per CREATE TABLE ``` Each table has its own script, numbered so a table's foreign keys always point @@ -541,7 +541,7 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md)) - Countries, state/provinces, and cities (via `Features.Locations`' `ILocationRepository`, which creates `Country`/`StateProvince` rows on demand while resolving a city) -- User accounts (via `Features.Auth`'s repository) +- User accounts (via `Features.Users`' repository) - Brewery posts with locations (via `Features.Breweries`' repository) `Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`, @@ -595,10 +595,10 @@ healthcheck: │ Integration │ ← API.Specs (Reqnroll) │ Tests │ ├──────────────┤ - │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, - │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests, - │ handler │ Features.PhotoUpload.Tests (commands/queries/handlers, - │ tests) │ with repository/service dependencies mocked via Moq) + │ Unit Tests │ ← Features.Users.Tests, Features.Breweries.Tests, + │ (per slice, │ Features.Emails.Tests, Features.PhotoUpload.Tests + │ handler │ (commands/queries/handlers, with repository/service + │ tests) │ dependencies mocked via Moq) └──────────────┘ ``` diff --git a/docs/website/database.md b/docs/website/database.md index 5906bad3..e98dcf10 100644 --- a/docs/website/database.md +++ b/docs/website/database.md @@ -25,9 +25,11 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`). | Table | Backed by | Notes | | --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique | -| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | -| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email | +| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | +| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | +| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email | +| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` | +| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` | | `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | @@ -42,7 +44,6 @@ repository or seed data yet: | Table | Purpose (from schema) | | ------------------ | -------------------------------------------------------------------------------------------------------- | -| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | | `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet | @@ -80,7 +81,7 @@ repository (`Features.PhotoUpload`) but no seed data. 409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or constraint-violation failure) maps to 503. - - One repository-level exception: `Features.Auth`'s `AuthRepository` does - catch `SqlException` directly, to detect a duplicate-key race (SQL Server - error 2601 or 2627) when two requests try to verify the same account - concurrently — see `Auth Repository.IsDuplicateVerificationViolation`. + - One exception: `Features.Users`' `DapperUserStore` does catch + `SqlException` directly, to detect a duplicate-key race (SQL Server error + 2601 or 2627) when two requests try to verify the same account + concurrently — see `DapperUserStore.IsDuplicateKeyViolation`. diff --git a/docs/website/diagrams-out/architecture.svg b/docs/website/diagrams-out/architecture.svg index b92d9990..b07f8b34 100644 --- a/docs/website/diagrams-out/architecture.svg +++ b/docs/website/diagrams-out/architecture.svg @@ -1 +1 @@ -The Biergarten App - Vertical Slice ArchitectureThe Biergarten App - Vertical Slice ArchitectureAPI.Core (thin host)Feature SlicesSharedInfrastructure LayerDomain LayerSQL ServerAPI.CoreASP.NET Core Web API host- Program.cs wiring only- MediatR + AddApplicationPartper Features.* assembly- Swagger/OpenAPI, health checks- JWT auth middleware- Global exception filterFeatures.AuthFeatures.BreweriesFeatures.UserManagementFeatures.EmailsFeatures.LocationsEach slice owns its own:- Controller (HTTP endpoints)- Commands/Queries + Handlers- Validators- Repository (Dapper)No controller - invoked onlyvia MediatR commands sentfrom other slicesNo controller and no MediatRhandlers - exposes ILocationRepositorydirectly as a plain DI service.Not registered by API.Core at all;only Database.Seed depends on it todayShared.ContractsShared.Application- ValidationBehavior(MediatR pipeline)- Cross-slice email commands(SendRegistrationEmailCommand,SendResendConfirmationEmailCommand)Infrastructure.SqlInfrastructure.JwtInfrastructure.PasswordHashingInfrastructure.EmailInfrastructure.Email.TemplatesDomain.EntitiesDomain.Exceptions- UserAccount, UserCredential, UserVerification- BreweryPost, BreweryPostLocation- City, StateProvince, Country- BeerPost, BeerStyle(schema tables exist; no repository yet)TablesDatabase.Seed(separate console app)SQL-first approachEach slice's own repository issuesinline SQL via Dapper - no ORM,no stored proceduresNo Features.* projectever references anotherFeatures.* project directlyAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanMediatR scan only(no controller)AddFeaturesAuth()(DI only, no API/MediatR)AddFeaturesBreweries()AddFeaturesLocations()sendsSendRegistrationEmailCommandhandlesSendRegistrationEmailCommandDapper SQLDapper SQLDapper SQLDapper SQL \ No newline at end of file +The Biergarten App - Vertical Slice ArchitectureAPI.Core (thin host)Feature SlicesSharedInfrastructure LayerDomain LayerSQL ServerAPI.CoreASP.NET Core Web API host- Program.cs wiring only- MediatR + AddApplicationPartper Features.* assembly- Swagger/OpenAPI, health checks- JWT auth middleware- Global exception filterFeatures.UsersFeatures.BreweriesFeatures.EmailsFeatures.LocationsEach slice owns its own:- Controller (HTTP endpoints)- Commands/Queries + Handlers- Validators- Repository (Dapper) Features.Users covers auth(Commands/Authentication),account management(Commands/Account), andprofile/avatar (Commands/Profile)No controller - invoked onlyvia MediatR commands sentfrom other slicesNo controller and no MediatRhandlers - exposes ILocationRepositorydirectly as a plain DI service.Not registered by API.Core at all;only Database.Seed depends on it todayShared.ContractsShared.Application- ValidationBehavior(MediatR pipeline)- Cross-slice email commands(SendRegistrationEmailCommand,SendResendConfirmationEmailCommand)Infrastructure.SqlInfrastructure.JwtInfrastructure.PasswordHashingInfrastructure.EmailInfrastructure.Email.TemplatesDomain.EntitiesDomain.Exceptions- UserAccount, UserCredential, UserVerification- UserProfile, UserAvatar- BreweryPost, BreweryPostLocation- City, StateProvince, Country- BeerPost, BeerStyle(schema tables exist; no repository yet)TablesDatabase.Seed(separate console app)SQL-first approachEach slice's own repository issuesinline SQL via Dapper - no ORM,no stored proceduresNo Features.* projectever references anotherFeatures.* project directlyAddApplicationPart+ MediatR scanAddApplicationPart+ MediatR scanMediatR scan only(no controller)AddFeaturesUsers()(DI only, no API/MediatR)AddFeaturesBreweries()AddFeaturesLocations()sendsSendRegistrationEmailCommandhandlesSendRegistrationEmailCommandDapper SQLDapper SQLDapper SQL \ No newline at end of file diff --git a/docs/website/diagrams-out/database-schema.svg b/docs/website/diagrams-out/database-schema.svg index e2105a63..2c9e70c5 100644 --- a/docs/website/diagrams-out/database-schema.svg +++ b/docs/website/diagrams-out/database-schema.svg @@ -1 +1 @@ -Database SchemaDatabase SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserAvatar (*)UserAvatarID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»PhotoID: UNIQUEIDENTIFIER «FK»UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEAuth reads/writes (registration, login, credentialrotation, verification) go through ASP.NET CoreIdentity's UserManager<ApplicationUser>, backed byFeatures.Auth's DapperUserStore - inline SQL, nostored procedures. Profile data still goes throughFeatures.UserManagement's UserAccountRepository.Backed by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashasrefers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file +Database SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserProfileUserProfileID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»Biography: NVARCHAR(MAX)UserAvatarUserAvatarID: UNIQUEIDENTIFIER «PK»UserProfileID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»ValidFrom: DATETIME2(3)ValidTo: DATETIME2(3)At most one row per UserProfileIDhas ValidTo IS NULL (active avatar)UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEAuth reads/writes (registration, login, credentialrotation, verification) go through ASP.NET CoreIdentity's UserManager<ApplicationUser>, backed byFeatures.Users' DapperUserStore - inline SQL, nostored procedures. Profile/avatar data goes throughFeatures.Users' UserProfileRepository.Backed by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashashas (history)refers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file diff --git a/docs/website/diagrams-out/deployment.svg b/docs/website/diagrams-out/deployment.svg index fa36fab5..a0ca5751 100644 --- a/docs/website/diagrams-out/deployment.svg +++ b/docs/website/diagrams-out/deployment.svg @@ -1 +1 @@ -Docker Deployment ArchitectureDocker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Mailpit(axllent/mailpit)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)Production Environment(docker-compose.prod.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)Migrations(run-once)API Container(API.Core)Database-only Environment(docker-compose.db.yaml)SQL ServerMinimal Environment(docker-compose.min.yaml)SQL ServerMailpittest-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrations8025 (Web UI)1025 (SMTP)Local SMTP catcher for dev email testing.Environment:- MP_SMTP_AUTH_ACCEPT_ANY=1- MP_SMTP_AUTH_ALLOW_INSECURE=1Fresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Auth.Tests- Features.Breweries.Tests- Features.UserManagement.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededProd Network (bridge: prodnet)Isolated from dev/test networks.Volumes: sqlserverdata-prod,nuget-cache-prodBiergartenDatabaseEnvironment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Express Volumes:- sqlserverdata-prodNo host port published.Database.MigrationsDepends on: sql-server (healthy)No seed step in production.ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Production- DB_SERVER/NAME/USER/PASSWORD- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET- WEBSITE_BASE_URL Depends on: sql-server (healthy) -does not wait on migrations/seed.Same devnet volumes/images asdocker-compose.dev.yaml, minusapi.core and mailpit - for workingagainst the database withoutrunning the API.BiergartenDatabaseMigrations(run-once)Seed(run-once)Database + mailpit only, no APIcontainer - for frontend developmentagainst an API.Core process runoutside Docker.BiergartenDatabaseMigrations(run-once)8025 / 1025Seed(run-once)api-specs/results.trxFeatures.Auth.Tests.trxFeatures.Breweries.Tests.trxFeatures.UserManagement.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregated1. Run migrationsdepends_on2. Connect & servedepends_on1. Run migrationsdepends_on2. Seed datadepends_on1. Run migrationsdepends_on2. Seed datadepends_ondocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & serve4. SMTP_HOST/PORTdepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file +Docker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Mailpit(axllent/mailpit)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)Production Environment(docker-compose.prod.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)Migrations(run-once)API Container(API.Core)Database-only Environment(docker-compose.db.yaml)SQL ServerMinimal Environment(docker-compose.min.yaml)SQL ServerMailpittest-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrations8025 (Web UI)1025 (SMTP)Local SMTP catcher for dev email testing.Environment:- MP_SMTP_AUTH_ACCEPT_ANY=1- MP_SMTP_AUTH_ALLOW_INSECURE=1Fresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Users.Tests- Features.Breweries.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededProd Network (bridge: prodnet)Isolated from dev/test networks.Volumes: sqlserverdata-prod,nuget-cache-prodBiergartenDatabaseEnvironment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Express Volumes:- sqlserverdata-prodNo host port published.Database.MigrationsDepends on: sql-server (healthy)No seed step in production.ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Production- DB_SERVER/NAME/USER/PASSWORD- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET- WEBSITE_BASE_URL Depends on: sql-server (healthy) -does not wait on migrations/seed.Same devnet volumes/images asdocker-compose.dev.yaml, minusapi.core and mailpit - for workingagainst the database withoutrunning the API.BiergartenDatabaseMigrations(run-once)Seed(run-once)Database + mailpit only, no APIcontainer - for frontend developmentagainst an API.Core process runoutside Docker.BiergartenDatabaseMigrations(run-once)8025 / 1025Seed(run-once)api-specs/results.trxFeatures.Users.Tests.trxFeatures.Breweries.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregated1. Run migrationsdepends_on2. Connect & servedepends_on1. Run migrationsdepends_on2. Seed datadepends_on1. Run migrationsdepends_on2. Seed datadepends_ondocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & serve4. SMTP_HOST/PORTdepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml index aa183798..e634f6c2 100644 --- a/docs/website/diagrams-src/architecture.puml +++ b/docs/website/diagrams-src/architecture.puml @@ -19,17 +19,22 @@ package "API.Core (thin host)" #E3F2FD { } package "Feature Slices" #F3E5F5 { - [Features.Auth] as AuthSlice + [Features.Users] as UsersSlice [Features.Breweries] as BrewerySlice - [Features.UserManagement] as UserSlice [Features.Emails] as EmailsSlice [Features.Locations] as LocationsSlice - note right of AuthSlice + note right of UsersSlice Each slice owns its own: - Controller (HTTP endpoints) - Commands/Queries + Handlers - Validators - Repository (Dapper) + + Features.Users covers auth + (Commands/Authentication), + account management + (Commands/Account), and + profile/avatar (Commands/Profile) end note note right of EmailsSlice No controller - invoked only @@ -70,6 +75,7 @@ package "Domain Layer" #E8F5E9 { [Domain.Exceptions] as DomainExceptions note right of Domain - UserAccount, UserCredential, UserVerification + - UserProfile, UserAvatar - BreweryPost, BreweryPostLocation - City, StateProvince, Country - BeerPost, BeerStyle @@ -84,29 +90,24 @@ database "SQL Server" { node "Database.Seed\n(separate console app)" as Seed ' Relationships -API ..> AuthSlice : AddApplicationPart\n+ MediatR scan +API ..> UsersSlice : AddApplicationPart\n+ MediatR scan API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan -API ..> UserSlice : AddApplicationPart\n+ MediatR scan API ..> EmailsSlice : MediatR scan only\n(no controller) -Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR) +Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR) Seed ..> BrewerySlice : AddFeaturesBreweries() Seed ..> LocationsSlice : AddFeaturesLocations() -AuthSlice --> Sql -AuthSlice --> JWT -AuthSlice --> PwdHash -AuthSlice --> SharedContracts -AuthSlice --> SharedApp +UsersSlice --> Sql +UsersSlice --> JWT +UsersSlice --> PwdHash +UsersSlice --> SharedContracts +UsersSlice --> SharedApp BrewerySlice --> Sql BrewerySlice --> SharedContracts BrewerySlice --> SharedApp -UserSlice --> Sql -UserSlice --> SharedContracts -UserSlice --> SharedApp - LocationsSlice --> Sql EmailsSlice --> Email @@ -114,21 +115,19 @@ EmailsSlice --> EmailTemplates EmailsSlice --> SharedApp ' The one cross-slice interaction: a MediatR command contract, not a project reference -AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand +UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand Sql --> Domain -AuthSlice --> Tables : Dapper SQL +UsersSlice --> Tables : Dapper SQL BrewerySlice --> Tables : Dapper SQL -UserSlice --> Tables : Dapper SQL LocationsSlice --> Tables : Dapper SQL -AuthSlice --> Domain +UsersSlice --> Domain BrewerySlice --> Domain -UserSlice --> Domain LocationsSlice --> Domain -AuthSlice --> DomainExceptions +UsersSlice --> DomainExceptions ' Notes note left of Sql @@ -138,7 +137,7 @@ note left of Sql no stored procedures end note -note bottom of AuthSlice +note bottom of UsersSlice No Features.* project ever references another Features.* project directly diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml index 11524e91..37a1269a 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -39,11 +39,23 @@ package "User & Auth" { * VerificationDateTime: DATETIME } - entity "UserAvatar (*)" as Avatar { - * UserAvatarID: UNIQUEIDENTIFIER <> + entity "UserProfile" as Profile { + * UserProfileID: UNIQUEIDENTIFIER <> -- * UserAccountID: UNIQUEIDENTIFIER <> + * Biography: NVARCHAR(MAX) + } + + entity "UserAvatar" as Avatar { + * UserAvatarID: UNIQUEIDENTIFIER <> + -- + * UserProfileID: UNIQUEIDENTIFIER <> * PhotoID: UNIQUEIDENTIFIER <> + * ValidFrom: DATETIME2(3) + ValidTo: DATETIME2(3) + .. + At most one row per UserProfileID + has ValidTo IS NULL (active avatar) } entity "UserFollow (*)" as Follow { @@ -163,7 +175,8 @@ package "Beer (*)" { ' Relationships: User & Auth User ||--o{ Cred : "has" User ||--o| Verify : "has" -User ||--o| Avatar : "has" +User ||--o| Profile : "has" +Profile ||--o{ Avatar : "has (history)" Avatar }o--|| Photo : "refers to" User ||--o{ Follow : "follows (UserAccountID)" User ||--o{ Follow : "followed by (FollowingID)" @@ -193,9 +206,9 @@ note bottom of User Auth reads/writes (registration, login, credential rotation, verification) go through ASP.NET Core Identity's UserManager, backed by - Features.Auth's DapperUserStore - inline SQL, no - stored procedures. Profile data still goes through - Features.UserManagement's UserAccountRepository. + Features.Users' DapperUserStore - inline SQL, no + stored procedures. Profile/avatar data goes through + Features.Users' UserProfileRepository. end note note bottom of Brewery diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml index 7b31d115..cdaa2e64 100644 --- a/docs/website/diagrams-src/deployment.puml +++ b/docs/website/diagrams-src/deployment.puml @@ -130,9 +130,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 { restores/builds against Core.slnx then runs `dotnet test` for every Features/*.Tests project in turn: - - Features.Auth.Tests + - Features.Users.Tests - Features.Breweries.Tests - - Features.UserManagement.Tests - Features.Emails.Tests Uses: Moq to mock each handler's @@ -261,9 +260,8 @@ package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 { folder "test-results/\n(mounted volume)" as Results { file "api-specs/\n results.trx" as Result1 - file "Features.Auth.Tests.trx" as Result2 + file "Features.Users.Tests.trx" as Result2 file "Features.Breweries.Tests.trx" as Result3 - file "Features.UserManagement.Tests.trx" as Result4 file "Features.Emails.Tests.trx" as Result5 note bottom diff --git a/docs/website/docker.md b/docs/website/docker.md index 844644f1..fba01399 100644 --- a/docs/website/docker.md +++ b/docs/website/docker.md @@ -101,7 +101,7 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abo # View results ls -la test-results/ cat test-results/api-specs/results.trx -cat test-results/Features.Auth.Tests.trx +cat test-results/Features.Users.Tests.trx # Clean up docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v diff --git a/docs/website/testing.md b/docs/website/testing.md index 6ae8c94f..e05e7eff 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -10,10 +10,10 @@ The project uses a multi-layered testing approach across backend and frontend: - **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database - **Features.\*.Tests** - One unit test project per backend feature slice - (`Features.Auth.Tests`, `Features.Breweries.Tests`, - `Features.UserManagement.Tests`, `Features.Emails.Tests`, - `Features.PhotoUpload.Tests`), covering that slice's command/query - handlers, with dependencies mocked via Moq (no real database required) + (`Features.Users.Tests`, `Features.Breweries.Tests`, + `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that + slice's command/query handlers, with dependencies mocked via Moq (no real + database required) - **Storybook Vitest project** - Browser-based interaction tests for shared website stories - **Storybook Playwright suite** - Browser checks against Storybook-rendered @@ -45,9 +45,8 @@ ls -la test-results/ # View specific test results cat test-results/api-specs/results.trx -cat test-results/Features.Auth.Tests.trx +cat test-results/Features.Users.Tests.trx cat test-results/Features.Breweries.Tests.trx -cat test-results/Features.UserManagement.Tests.trx cat test-results/Features.Emails.Tests.trx cat test-results/Features.PhotoUpload.Tests.trx ``` @@ -83,9 +82,8 @@ handlers: ```bash cd web/backend -dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj +dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj -dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj @@ -150,7 +148,7 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. ### Current coverage -**Features.Auth.Tests**: +**Features.Users.Tests**: - User registration with validation - User login with JWT token generation @@ -162,6 +160,7 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. - Account management: username, email, password, and profile updates, and account deletion (blocked while dependent posts, comments, photos, or follows exist) +- User get-by-id/get-all queries **Features.Breweries.Tests**: @@ -169,10 +168,6 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. - Brewery location queries: all locations, and locations within a given range of a coordinate -**Features.UserManagement.Tests**: - -- User get-by-id/get-all queries and the (currently unrouted) update command - **Features.Emails.Tests**: - Registration and resend-confirmation email dispatch handlers @@ -264,10 +259,10 @@ Scenario: Successful user registration And my account should be created ``` -### Features.Auth.Tests +### Features.Users.Tests ``` -Features.Auth.Tests/ +Features.Users.Tests/ ├── Commands/ │ ├── RegisterUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs @@ -279,14 +274,16 @@ Features.Auth.Tests/ │ ├── UpdateProfileHandlerTests.cs │ └── DeleteAccountHandlerTests.cs ├── Queries/ -│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name -└── Services/ - ├── TokenServiceRefreshTests.cs - └── TokenServiceValidationTests.cs +│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name +│ ├── GetAllUsersHandlerTests.cs +│ └── GetUserByIdHandlerTests.cs +├── Services/ +│ ├── TokenServiceRefreshTests.cs +│ └── TokenServiceValidationTests.cs +└── TestSupport/ # shared test fixtures/helpers ``` -Each of the other slices (`Features.Breweries.Tests`, -`Features.UserManagement.Tests`, `Features.Emails.Tests`, +Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a validator test file alongside its handler test). diff --git a/docs/website/token-validation.md b/docs/website/token-validation.md index 55756252..7cd3e769 100644 --- a/docs/website/token-validation.md +++ b/docs/website/token-validation.md @@ -30,9 +30,9 @@ Low-level JWT operations. - Algorithm: HS256 (HMAC-SHA256) - Validates token lifetime, signature, and well-formedness -### Features.Auth slice +### Features.Users slice -#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs) +#### [ITokenService](../../web/backend/Features/Features.Users/Services/ITokenService.cs) Both token generation and validation live on the same slice-internal service (there is no separate validation service/class). @@ -57,31 +57,32 @@ Both token generation and validation live on the same slice-internal service - `Principal` (ClaimsPrincipal) - Full JWT claims **Implementation:** -[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs) +[TokenService.cs](../../web/backend/Features/Features.Users/Services/TokenService.cs) - Reads token secrets from environment variables - Extracts and validates claims (Sub, UniqueName) - Throws `UnauthorizedException` on validation failure -`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()` +`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` extension method, except for the lower-level `ITokenInfrastructure` (JWT -signing/verification) it depends on, which is registered by the host -(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth -middleware) also depends on it directly. +signing/verification) it +depends on, which is registered by the host (`API.Core/Program.cs`) since +`JwtAuthenticationHandler` (host-level auth middleware) also depends on it +directly. ### Integration points -#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs) +#### [ConfirmUserHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs) **Flow:** 1. Receives confirmation token from user via `ConfirmUserCommand` 2. Calls `ITokenService.ValidateConfirmationTokenAsync()` -3. Extracts user ID from validated token -4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database +3. Extracts user ID from validated token and looks it up via `UserManager` +4. Marks the account confirmed via `IUserEmailStore.SetEmailConfirmedAsync()` 5. Returns confirmation result -#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs) +#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs) **Flow:** @@ -92,17 +93,16 @@ middleware) also depends on it directly. `Features.Emails` slice, with no direct project reference between the two slices -#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs) +#### [RefreshTokenHandler](../../web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs) **Flow:** 1. Receives a refresh token via `RefreshTokenCommand` 2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token, - retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and - issues a new access/refresh token pair + retrieves the user account, and issues a new access/refresh token pair 3. Maps the result onto `LoginPayload` -#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs) +#### [AuthController](../../web/backend/Features/Features.Users/Controllers/AuthController.cs) **Endpoints:** @@ -193,7 +193,7 @@ Validation failures return HTTP 401 Unauthorized: ## Testing -All of the following live in `Features.Auth.Tests`. +All of the following live in `Features.Users.Tests`. ### Unit tests diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 36e47d4c..09478802 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -22,8 +22,7 @@ - - + diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index 21132363..4ba26f84 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -22,8 +22,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] -COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"] -COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] @@ -32,8 +30,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] -COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] -COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] +COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] +COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 80912563..0ad30d04 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -2,15 +2,13 @@ using API.Core; using API.Core.Authentication; using Database.Connection.DependencyInjection; using Features.Auth.Controllers; -using Features.Auth.DependencyInjection; +using Features.Users.DependencyInjection; using Features.Breweries.Controllers; using Features.Breweries.DependencyInjection; using Features.Emails.DependencyInjection; using Features.Emails.Services; using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.DependencyInjection; -using Features.UserManagement.Controllers; -using Features.UserManagement.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; using Infrastructure.FileUpload; @@ -25,12 +23,8 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add current controllers to the exception filter builder - .Services.AddControllers(options => - { - options.Filters.Add(); - }) + .Services.AddControllers(options => { options.Filters.Add(); }) .AddApplicationPart(typeof(BreweryController).Assembly) - .AddApplicationPart(typeof(UserController).Assembly) .AddApplicationPart(typeof(AuthController).Assembly); builder.Services.AddEndpointsApiExplorer(); @@ -69,7 +63,6 @@ builder.Services.AddOpenApi(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); -builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly); builder.Services.AddFluentValidationAutoValidation(); @@ -80,7 +73,6 @@ builder.Services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblyContaining(); cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); - cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); @@ -98,8 +90,7 @@ if (!builder.Environment.IsProduction()) builder.Services.AddDatabaseConnection(); builder.Services.AddFeaturesBreweries(); -builder.Services.AddFeaturesUserManagement(); -builder.Services.AddFeaturesAuth(); +builder.Services.AddFeaturesUsers(); builder.Services.AddFeaturesEmails(); builder.Services.AddFeaturesPhotoUpload(); @@ -130,20 +121,6 @@ app.MapHealthChecks("/health"); app.MapControllers(); -app.MapPost( - "/upload-test", - async (IFormFile file, ClaimsPrincipal user, IMediator mediator) => - { - Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value); - Guid photoId = await mediator.Send( - new UploadPhotoCommand(uploadedById, "upload-test", file) - ); - return Results.Ok(photoId); - } - ) - .RequireAuthorization() - .DisableAntiforgery(); - app.MapFallbackToController("Handle404", "NotFound"); IHostApplicationLifetime lifetime = app.Services.GetRequiredService(); @@ -152,4 +129,4 @@ lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Application is shutting down gracefully..."); }); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index 4ac4114d..124425d4 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -12,8 +12,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] -COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"] -COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] @@ -22,8 +20,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] -COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] -COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] +COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] +COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx index e62246bc..b339fc59 100644 --- a/web/backend/Biergarten.API.slnx +++ b/web/backend/Biergarten.API.slnx @@ -12,6 +12,10 @@ + + + + @@ -20,18 +24,21 @@ - + + - - - - + + + + - + + + diff --git a/web/backend/Database/Database.Migrations/schema.dbml b/web/backend/Database/Database.Migrations/schema.dbml index f11e88fc..40b4bf50 100644 --- a/web/backend/Database/Database.Migrations/schema.dbml +++ b/web/backend/Database/Database.Migrations/schema.dbml @@ -31,8 +31,24 @@ Table Photo { Table UserAvatar { UserAvatarID uniqueidentifier [pk, default: `newid()`] - UserAccountID uniqueidentifier [not null, unique] + UserProfileID uniqueidentifier [not null] PhotoID uniqueidentifier [not null] + ValidFrom "datetime2(3)" [not null, default: `sysutcdatetime()`] + ValidTo "datetime2(3)" [note: 'NULL = currently active'] + RowVersion rowversion + + Note: 'Deleted when the user profile is deleted. At most one row per profile has ValidTo = NULL.' + + Indexes { + UserProfileID [unique, name: 'UX_UserAvatar_ActiveOne', note: 'filtered: WHERE ValidTo IS NULL'] + PhotoID [name: 'IX_UserAvatar_Photo'] + } +} + +Table UserProfile { + UserProfileID uniqueidentifier [pk, default: `newid()`] + Biography "nvarchar(max)" [not null] + UserAccountID uniqueidentifier [not null, unique] RowVersion rowversion Note: 'Deleted when the user account is deleted' @@ -216,8 +232,9 @@ Table BeerPostComment { } Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action] -Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade] -Ref: UserAvatar.PhotoID > Photo.PhotoID +Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action] +Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade] +Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade] Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade] Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade] Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action] diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql index 0d482939..024df8c2 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql @@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount CONSTRAINT AK_Email UNIQUE (Email) ); + + diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql deleted file mode 100644 index 7af3198d..00000000 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted -( - UserAvatarID UNIQUEIDENTIFIER - CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), - - UserAccountID UNIQUEIDENTIFIER NOT NULL, - - PhotoID UNIQUEIDENTIFIER NOT NULL, - - RowVersion ROWVERSION, - - CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), - - CONSTRAINT FK_UserAvatar_UserAccount - FOREIGN KEY (UserAccountID) - REFERENCES UserAccount (UserAccountID) - ON DELETE CASCADE, - - CONSTRAINT FK_UserAvatar_PhotoID - FOREIGN KEY (PhotoID) - REFERENCES Photo (PhotoID), - - CONSTRAINT AK_UserAvatar_UserAccountID - UNIQUE (UserAccountID) -); - -CREATE -NONCLUSTERED INDEX IX_UserAvatar_UserAccount - ON UserAvatar(UserAccountID); diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/04-UserVerification.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserVerification.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/04-UserVerification.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/03-UserVerification.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/05-UserCredential.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/04-UserCredential.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/05-UserCredential.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/04-UserCredential.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/06-UserFollow.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/05-UserFollow.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/06-UserFollow.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/05-UserFollow.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/07-Country.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/06-Country.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/07-Country.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/06-Country.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/08-StateProvince.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/07-StateProvince.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/08-StateProvince.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/07-StateProvince.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/09-City.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/08-City.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/09-City.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/08-City.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/10-BreweryPost.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/09-BreweryPost.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/10-BreweryPost.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/09-BreweryPost.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/10-BreweryPostLocation.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/10-BreweryPostLocation.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/12-BreweryPostPhoto.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostPhoto.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/12-BreweryPostPhoto.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostPhoto.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/13-BeerStyle.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/12-BeerStyle.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/13-BeerStyle.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/12-BeerStyle.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/14-BeerPost.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/13-BeerPost.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/14-BeerPost.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/13-BeerPost.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/15-BeerPostPhoto.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/14-BeerPostPhoto.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/15-BeerPostPhoto.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/14-BeerPostPhoto.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/16-BeerPostComment.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/15-BeerPostComment.sql similarity index 100% rename from web/backend/Database/Database.Migrations/scripts/01-schema/16-BeerPostComment.sql rename to web/backend/Database/Database.Migrations/scripts/01-schema/15-BeerPostComment.sql diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/16-UserProfile.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/16-UserProfile.sql new file mode 100644 index 00000000..956ac67e --- /dev/null +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/16-UserProfile.sql @@ -0,0 +1,22 @@ +CREATE TABLE dbo.UserProfile +( + UserProfileID UNIQUEIDENTIFIER + CONSTRAINT DF_UserProfileID DEFAULT NEWID(), + + Biography NVARCHAR(MAX) NOT NULL, + + UserAccountID UNIQUEIDENTIFIER NOT NULL, + + RowVersion ROWVERSION, + + CONSTRAINT PK_UserProfile + PRIMARY KEY (UserProfileID), + + CONSTRAINT AK_UserProfile_UserAccountID + UNIQUE (UserAccountID), + + CONSTRAINT FK_UserProfile_UserAccount + FOREIGN KEY (UserAccountID) + REFERENCES UserAccount (UserAccountID) + ON DELETE CASCADE +); diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserAvatar.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserAvatar.sql new file mode 100644 index 00000000..8c7c09be --- /dev/null +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserAvatar.sql @@ -0,0 +1,43 @@ +CREATE TABLE dbo.UserAvatar +( + UserAvatarID UNIQUEIDENTIFIER + CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), + + UserProfileID UNIQUEIDENTIFIER NOT NULL, + + PhotoID UNIQUEIDENTIFIER NOT NULL, + + ValidFrom DATETIME2(3) NOT NULL + CONSTRAINT DF_UserAvatar_ValidFrom DEFAULT SYSUTCDATETIME(), + + ValidTo DATETIME2(3) NULL, -- NULL = currently active + + RowVersion ROWVERSION, + + CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), + + CONSTRAINT FK_UserAvatar_UserProfile + FOREIGN KEY (UserProfileID) + REFERENCES dbo.UserProfile (UserProfileID) + ON DELETE CASCADE, + + CONSTRAINT FK_UserAvatar_Photo + FOREIGN KEY (PhotoID) + REFERENCES dbo.Photo (PhotoID) + ON DELETE NO ACTION + ON UPDATE NO ACTION, + + CONSTRAINT CK_UserAvatar_ValidRange + CHECK (ValidTo IS NULL OR ValidTo > ValidFrom) +); + +-- At most one active avatar per profile +CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne + ON dbo.UserAvatar (UserProfileID) + WHERE ValidTo IS NULL; + +CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo + ON dbo.UserAvatar (PhotoID); + +CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile + ON dbo.UserAvatar (UserProfileID) diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index a40bea94..f4209e0f 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -9,8 +9,11 @@ + + + @@ -18,13 +21,12 @@ - + + + - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/AvatarGenerator.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/AvatarGenerator.cs new file mode 100644 index 00000000..00b1857f --- /dev/null +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/AvatarGenerator.cs @@ -0,0 +1,27 @@ +using SkiaSharp; + +namespace Database.Seed.DatabaseHelpers; + +/// Generates minimal solid-color square avatars for seeded users. +public static class AvatarGenerator +{ + private const int SizePx = 128; + + /// + /// Generates a deterministic solid-color PNG avatar, with the color derived from + /// . + /// + public static byte[] GeneratePng(Guid userId) + { + byte[] idBytes = userId.ToByteArray(); + SKColor color = new(idBytes[0], idBytes[1], idBytes[2]); + + using SKBitmap bitmap = new(SizePx, SizePx); + bitmap.Erase(color); + + using SKImage image = SKImage.FromBitmap(bitmap); + using SKData data = image.Encode(SKEncodedImageFormat.Png, quality: 100); + + return data.ToArray(); + } +} diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/NoOpTokenService.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/NoOpTokenService.cs new file mode 100644 index 00000000..5720b30c --- /dev/null +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/NoOpTokenService.cs @@ -0,0 +1,21 @@ +using Features.Auth.Services; + +namespace Database.Seed.DatabaseHelpers; + +public class NoOpTokenService : ITokenService +{ + public string GenerateAccessToken(Guid userId, string username) => string.Empty; + + public string GenerateRefreshToken(Guid userId, string username) => string.Empty; + + public string GenerateConfirmationToken(Guid userId, string username) => string.Empty; + + public Task ValidateRefreshTokenAsync(string token) => + throw new NotSupportedException("Token validation is not supported during seeding."); + + public Task ValidateConfirmationTokenAsync(string token) => + throw new NotSupportedException("Token validation is not supported during seeding."); + + public Task RefreshTokenAsync(string refreshTokenString) => + throw new NotSupportedException("Token validation is not supported during seeding."); +} diff --git a/web/backend/Database/Database.Seed/Dockerfile b/web/backend/Database/Database.Seed/Dockerfile index 90846b20..318fbcb3 100644 --- a/web/backend/Database/Database.Seed/Dockerfile +++ b/web/backend/Database/Database.Seed/Dockerfile @@ -11,6 +11,8 @@ ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "Database.Seed.dll"] diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index aad3444e..a2643f78 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -3,15 +3,23 @@ using Database.Seed.DatabaseHelpers; using Database.Seed.PipelineData; using Database.Seed.Sqlite; using Domain.Entities; -using Features.Auth.DependencyInjection; -using Features.Auth.Identity; +using Features.Auth.Commands.Authentication.RegisterUser; +using Features.Auth.Commands.Profile.UpdateBiography; +using Features.Auth.Commands.Profile.UploadAvatar; +using Features.Users.DependencyInjection; +using Features.Auth.Dtos; +using Features.Auth.Services; +using Features.Breweries.Commands.CreateBrewery; using Features.Breweries.DependencyInjection; -using Features.Breweries.Repository; +using Features.ImageUploads.Commands.UploadPhoto; +using Features.ImageUploads.DependencyInjection; +using Features.Locations.Commands.GetOrCreateCity; using Features.Locations.DependencyInjection; using Features.Locations.Dtos; -using Features.Locations.Repository; using idunno.Password; -using Microsoft.AspNetCore.Identity; +using Infrastructure.FileUpload; +using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; @@ -24,20 +32,24 @@ static async Task RunAsync() { IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); - ServiceCollection services = []; - services.AddSingleton(configuration); - services.AddDatabaseConnection(); - services.AddFeaturesBreweries(); - services.AddFeaturesLocations(); - services.AddFeaturesAuth(); + IServiceCollection services = new ServiceCollection() + .AddSingleton(configuration) + .AddDatabaseConnection() + .AddFeaturesBreweries() + .AddFeaturesLocations() + .AddFeaturesUsers() + .AddFeaturesPhotoUpload() + .AddSingleton() + .AddScoped() + .AddMediatR(cfg => + cfg.RegisterServicesFromAssemblyContaining() + .RegisterServicesFromAssemblyContaining() + .RegisterServicesFromAssemblyContaining() + .RegisterServicesFromAssemblyContaining() + ); await using ServiceProvider provider = services.BuildServiceProvider(); - - IBreweryRepository breweryRepository = provider.GetRequiredService(); - ILocationRepository locationRepository = provider.GetRequiredService(); - UserManager userManager = provider.GetRequiredService< - UserManager - >(); + IMediator mediator = provider.GetRequiredService(); AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); @@ -46,7 +58,7 @@ static async Task RunAsync() PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); SeedData seedData = null!; - IReadOnlyList postedByIds = []; + IReadOnlyList userIds = []; await AnsiConsole .Status() @@ -87,11 +99,24 @@ static async Task RunAsync() "Loading user data into target database...", async ctx => { - postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users); + userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users); ctx.Status("User data loaded into target database."); } ); + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading avatars into target database...", + async ctx => + { + await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx); + ctx.Status("Avatar data loaded into target database."); + } + ); + await AnsiConsole .Status() .Spinner(Spinner.Known.Dots) @@ -101,10 +126,9 @@ static async Task RunAsync() async ctx => { await LoadBreweriesIntoDatabaseAsync( - breweryRepository, - locationRepository, + mediator, seedData.Breweries, - postedByIds, + userIds, ctx ); ctx.Status("Brewery data loaded into target database."); @@ -122,7 +146,7 @@ static async Task RunAsync() } static async Task> LoadUsersIntoDatabaseAsync( - UserManager userManager, + IMediator mediator, IReadOnlyList users ) { @@ -130,37 +154,57 @@ static async Task> LoadUsersIntoDatabaseAsync( foreach (UserRecord userRecord in users) { - ApplicationUser user = new() - { - FirstName = userRecord.User.FirstName, - LastName = userRecord.User.LastName, - DateOfBirth = DateTime.Parse(userRecord.DateOfBirth), - UserName = userRecord.User.Username, - Email = userRecord.Email, - }; - // allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible // digit characters (0-9), which idunno.Password rejects outright. - IdentityResult result = await userManager.CreateAsync( - user, - PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true) + string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true); + + RegistrationPayload registration = await mediator.Send( + new RegisterUserCommand( + userRecord.User.Username, + userRecord.User.FirstName, + userRecord.User.LastName, + userRecord.Email, + DateTime.Parse(userRecord.DateOfBirth), + password + ) ); - if (!result.Succeeded) - throw new InvalidOperationException( - $"Failed to seed user '{userRecord.User.Username}': " - + string.Join("; ", result.Errors.Select(e => e.Description)) - ); - - userAccountIds.Add(user.Id); + userAccountIds.Add(registration.UserAccountId); } return userAccountIds; } +static async Task LoadAvatarsIntoDatabaseAsync( + IMediator mediator, + IReadOnlyList userIds, + IReadOnlyList users, + StatusContext ctx +) +{ + for (int i = 0; i < userIds.Count; i++) + { + ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database..."); + + Guid userId = userIds[i]; + + await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio)); + + byte[] avatarPng = AvatarGenerator.GeneratePng(userId); + + await using MemoryStream stream = new(avatarPng); + IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png") + { + Headers = new HeaderDictionary(), + ContentType = "image/png", + }; + + await mediator.Send(new UploadAvatarCommand(userId, file)); + } +} + static async Task LoadBreweriesIntoDatabaseAsync( - IBreweryRepository breweryRepository, - ILocationRepository locationRepository, + IMediator mediator, IReadOnlyList breweries, IReadOnlyList posterUserIds, StatusContext ctx @@ -175,35 +219,34 @@ static async Task LoadBreweriesIntoDatabaseAsync( ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database..."); - Guid cityId = await locationRepository.GetOrCreateCityIdAsync( - new CityLocation( - breweryRecord.Address.City.CityName, - breweryRecord.Address.City.StateProvince, - breweryRecord.Address.City.Iso31662, - breweryRecord.Address.City.Country, - breweryRecord.Address.City.Iso31661 + Guid cityId = await mediator.Send( + new GetOrCreateCityCommand( + new CityLocation( + breweryRecord.Address.City.CityName, + breweryRecord.Address.City.StateProvince, + breweryRecord.Address.City.Iso31662, + breweryRecord.Address.City.Country, + breweryRecord.Address.City.Iso31661 + ) ) ); - await breweryRepository.CreateAsync( - new BreweryPost - { - BreweryPostId = Guid.NewGuid(), - BreweryName = breweryRecord.Brewery.NameEn, - Description = breweryRecord.Brewery.DescriptionEn, - PostedById = posterUserIds[i % posterUserIds.Count], - Location = new BreweryPostLocation - { - BreweryPostLocationId = Guid.NewGuid(), - CityId = cityId, - AddressLine1 = breweryRecord.Address.AddressLine1, - PostalCode = breweryRecord.Address.PostalCode, - Coordinates = new CoordinateData( + await mediator.Send( + new CreateBreweryCommand( + posterUserIds[i % posterUserIds.Count], + breweryRecord.Brewery.NameEn, + breweryRecord.Brewery.DescriptionEn, + new CreateBreweryLocation( + cityId, + breweryRecord.Address.AddressLine1, + null, + breweryRecord.Address.PostalCode, + new CoordinateData( breweryRecord.Address.Latitude, breweryRecord.Address.Longitude - ), - }, - } + ) + ) + ) ); } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs b/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs new file mode 100644 index 00000000..67f2891c --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs @@ -0,0 +1,20 @@ +namespace Domain.Entities; + +public sealed class UserAvatar +{ + // PK + public Guid UserAvatarId { get; set; } + + // FK References + public Guid UserProfileId { get; set; } + public Guid PhotoId { get; set; } + + // Attributes + public DateTime ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + + // Navigation Field + public Photo? Photo { get; set; } + // Audit Fields + public byte[]? RowVersion { get; set; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs b/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs new file mode 100644 index 00000000..b7a432a0 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs @@ -0,0 +1,16 @@ +namespace Domain.Entities; + +public sealed class UserProfile +{ + // PK + public Guid UserProfileId { get; set; } + + // FK References + public Guid UserAccountId { get; set; } + + // Attributes + public string Biography { get; set; } = string.Empty; + + // Audit Fields + public byte[]? RowVersion { get; set; } +} diff --git a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs index 31981b69..6bd22d78 100644 --- a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs +++ b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs @@ -5,7 +5,7 @@ using Shared.Application.Emails; namespace Features.Emails.Commands.SendRegistrationEmail; /// -/// Handles , the cross-slice command sent by Features.Auth +/// Handles , the cross-slice command sent by Features.Users /// after a new user registers. /// public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) diff --git a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs index d04419b9..c37551e0 100644 --- a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs @@ -5,7 +5,7 @@ using Shared.Application.Emails; namespace Features.Emails.Commands.SendResendConfirmationEmail; /// -/// Handles , the cross-slice command sent by Features.Auth +/// Handles , the cross-slice command sent by Features.Users /// when a user requests a fresh confirmation link. /// public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) diff --git a/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs b/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs new file mode 100644 index 00000000..95ddb918 --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs @@ -0,0 +1,12 @@ +using Features.Locations.Dtos; +using MediatR; + +namespace Features.Locations.Commands.GetOrCreateCity; + +/// +/// Resolves to a City ID, creating the Country, StateProvince, and/or City rows +/// if any part of the chain doesn't exist yet. Sent by other features' handlers (e.g. brewery +/// creation) rather than bound from a request directly, since this feature exposes no controller of +/// its own. +/// +public record GetOrCreateCityCommand(CityLocation City) : IRequest; diff --git a/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityHandler.cs b/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityHandler.cs new file mode 100644 index 00000000..a5f2a95a --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityHandler.cs @@ -0,0 +1,12 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Commands.GetOrCreateCity; + +/// Handles by delegating to . +public class GetOrCreateCityHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(GetOrCreateCityCommand request, CancellationToken cancellationToken) => + repository.GetOrCreateCityIdAsync(request.City); +} diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs index 959a85fe..d857f2cd 100644 --- a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs +++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs @@ -25,7 +25,12 @@ public class UploadPhotoHandler( cancellationToken ); - Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById }; + Photo photo = new() + { + PhotoId = photoId, + Hyperlink = key, + UploadedById = request.UploadedById, + }; await photoUploadRepository.CreateAsync(photo, cancellationToken); diff --git a/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs index 76a0d475..0c449fe0 100644 --- a/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs +++ b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs @@ -18,7 +18,7 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory) public async Task CreateAsync(Photo photo, CancellationToken cancellationToken) { await using DbConnection connection = await CreateConnection(); - await using DbTransaction transaction = await connection.BeginTransactionAsync(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); try { @@ -44,10 +44,10 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteAsync( new CommandDefinition( """ - INSERT INTO dbo.Photo (Hyperlink, UploadedByID) - VALUES (@Hyperlink, @UploadedById) + INSERT INTO dbo.Photo (PhotoID, Hyperlink, UploadedByID) + VALUES (@PhotoId, @Hyperlink, @UploadedById) """, - new { photo.Hyperlink, photo.UploadedById }, + new { photo.PhotoId, photo.Hyperlink, photo.UploadedById }, transaction ) ); diff --git a/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs b/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs deleted file mode 100644 index 53f3a800..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Domain.Entities; -using Features.UserManagement.Commands.UpdateUser; -using Features.UserManagement.Repository; -using Moq; - -namespace Features.UserManagement.Tests.Commands; - -public class UpdateUserHandlerTests -{ - [Fact] - public async Task Handle_DelegatesToRepository() - { - Mock repoMock = new(); - UpdateUserHandler handler = new(repoMock.Object); - UserAccount user = new() { UserAccountId = Guid.NewGuid() }; - repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask); - - await handler.Handle(new UpdateUserCommand(user), CancellationToken.None); - - repoMock.Verify(r => r.UpdateAsync(user), Times.Once); - } -} diff --git a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj deleted file mode 100644 index 1777547c..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - net10.0 - enable - enable - false - Features.UserManagement.Tests - - - - - - - - - - - - - - - - - - diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs deleted file mode 100644 index 73d9c503..00000000 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Domain.Entities; -using MediatR; - -namespace Features.UserManagement.Commands.UpdateUser; - -/// -/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward -/// from IUserService.UpdateAsync as-is. -/// -public record UpdateUserCommand(UserAccount UserAccount) : IRequest; diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs deleted file mode 100644 index 4967b127..00000000 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Features.UserManagement.Repository; -using MediatR; - -namespace Features.UserManagement.Commands.UpdateUser; - -public class UpdateUserHandler(IUserAccountRepository repository) - : IRequestHandler -{ - public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) - { - return repository.UpdateAsync(request.UserAccount); - } -} diff --git a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs b/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs deleted file mode 100644 index caff349d..00000000 --- a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Features.UserManagement.Repository; -using Microsoft.Extensions.DependencyInjection; - -namespace Features.UserManagement.DependencyInjection; - -/// -/// Registers the services used by the Features.UserManagement slice. -/// -public static class FeaturesUserManagementServiceCollectionExtensions -{ - /// - /// Registers and its Dapper-based implementation. - /// - /// The same instance, for chaining. - public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services) - { - services.AddScoped(); - return services; - } -} diff --git a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj b/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj deleted file mode 100644 index 77bfb16b..00000000 --- a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - net10.0 - enable - enable - Features.UserManagement - - - - - - - - - - - - - - diff --git a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs deleted file mode 100644 index 25ebf13d..00000000 --- a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; - -namespace Features.UserManagement.Repository; - -/// -/// Repository for CRUD operations on user account records. -/// -public interface IUserAccountRepository -{ - /// Returns null if no user account exists with the given ID. - Task GetByIdAsync(Guid id); - - /// Returns user accounts ordered by creation date descending. - /// null for no limit. - /// null for no offset. - Task> GetAllAsync(int? limit, int? offset); - - /// Updates the mutable fields of an existing user account. - /// Must have a valid UserAccountId. - /// Thrown when no user account exists with the given ID. - Task UpdateAsync(UserAccount userAccount); - - /// Deletes the user account with the given ID. - /// Thrown when no user account exists with the given ID. - Task DeleteAsync(Guid id); - - /// Returns null if no user account exists with the given username. - Task GetByUsernameAsync(string username); - - /// Returns null if no user account exists with the given email address. - Task GetByEmailAsync(string email); -} diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs deleted file mode 100644 index 13d56011..00000000 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Data.Common; -using Dapper; -using Database.Connection; -using Domain.Entities; -using Domain.Exceptions; - -namespace Features.UserManagement.Repository; - -/// -/// Dapper-based implementation of . -/// -public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : DapperRepository(connectionFactory), - IUserAccountRepository -{ - /// - public async Task GetByIdAsync(Guid id) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId - """, - new { UserAccountId = id } - ); - } - - /// - /// - /// Always applies pagination via OFFSET/FETCH, ordered by creation date descending. - /// A null / is treated as "no limit"/"no offset". - /// - public async Task> GetAllAsync(int? limit, int? offset) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - ORDER BY CreatedAt DESC - OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY - """, - new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue } - ); - } - - /// - public async Task UpdateAsync(UserAccount userAccount) - { - await using DbConnection connection = await CreateConnection(); - int rows = await connection.ExecuteAsync( - """ - UPDATE dbo.UserAccount - SET Username = @Username, FirstName = @FirstName, LastName = @LastName, - DateOfBirth = @DateOfBirth, Email = @Email - WHERE UserAccountId = @UserAccountId - """, - new - { - userAccount.UserAccountId, - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.DateOfBirth, - userAccount.Email, - } - ); - - if (rows == 0) - throw new NotFoundException("User account not found."); - } - - /// - public async Task DeleteAsync(Guid id) - { - await using DbConnection connection = await CreateConnection(); - int rows = await connection.ExecuteAsync( - """ - DELETE FROM dbo.UserAccount - WHERE UserAccountId = @UserAccountId - """, - new { UserAccountId = id } - ); - - if (rows == 0) - throw new NotFoundException("User account not found."); - } - - /// - public async Task GetByUsernameAsync(string username) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE Username = @Username - """, - new { Username = username } - ); - } - - /// - public async Task GetByEmailAsync(string email) - { - await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( - """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM dbo.UserAccount - WHERE Email = @Email - """, - new { Email = email } - ); - } -} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/ConfirmUserHandlerTests.cs similarity index 98% rename from web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/ConfirmUserHandlerTests.cs index 3e2f63e5..db8d2f0a 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/ConfirmUserHandlerTests.cs @@ -1,7 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Domain.Exceptions; -using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Commands.Authentication.ConfirmUser; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Services; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/DeleteAccountHandlerTests.cs similarity index 97% rename from web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/DeleteAccountHandlerTests.cs index ea7a7cc6..7a51820e 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/DeleteAccountHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.DeleteAccount; +using Features.Auth.Commands.Account.DeleteAccount; using Features.Auth.Identity; using Features.Auth.Tests.TestSupport; using FluentAssertions; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RefreshTokenHandlerTests.cs similarity index 94% rename from web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/RefreshTokenHandlerTests.cs index bb006d4b..11325fc3 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/RefreshTokenHandlerTests.cs @@ -1,4 +1,4 @@ -using Features.Auth.Commands.RefreshToken; +using Features.Auth.Commands.Authentication.RefreshToken; using Features.Auth.Dtos; using Features.Auth.Services; using FluentAssertions; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs similarity index 99% rename from web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs index 367b50cc..d2ee91bf 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.RegisterUser; +using Features.Auth.Commands.Authentication.RegisterUser; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Services; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs similarity index 97% rename from web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs index eae3ab0f..974b94d9 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs @@ -1,4 +1,4 @@ -using Features.Auth.Commands.ResendConfirmationEmail; +using Features.Auth.Commands.Authentication.ResendConfirmationEmail; using Features.Auth.Identity; using Features.Auth.Services; using Features.Auth.Tests.TestSupport; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateEmailHandlerTests.cs similarity index 98% rename from web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/UpdateEmailHandlerTests.cs index 13123fc2..2844430c 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateEmailHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.UpdateEmail; +using Features.Auth.Commands.Account.UpdateEmail; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Tests.TestSupport; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordHandlerTests.cs similarity index 98% rename from web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordHandlerTests.cs index 24da76b5..728ffb3b 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.UpdatePassword; +using Features.Auth.Commands.Account.UpdatePassword; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Tests.TestSupport; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateProfileHandlerTests.cs similarity index 97% rename from web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/UpdateProfileHandlerTests.cs index 6202cbf2..76db5804 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateProfileHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.UpdateProfile; +using Features.Auth.Commands.Profile.UpdateProfile; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Tests.TestSupport; diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameHandlerTests.cs similarity index 98% rename from web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameHandlerTests.cs index 28d37132..7005c3fb 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.UpdateUsername; +using Features.Auth.Commands.Account.UpdateUsername; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Tests.TestSupport; diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj similarity index 92% rename from web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj rename to web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj index aa3c8bff..53d61628 100644 --- a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj +++ b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj @@ -20,7 +20,7 @@ - + diff --git a/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs similarity index 75% rename from web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs index 83b42d1c..4f2aeb06 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs @@ -1,17 +1,17 @@ using Domain.Entities; -using Features.UserManagement.Queries.GetAllUsers; -using Features.UserManagement.Repository; +using Features.Auth.Queries.GetAllUsers; +using Features.Auth.Repository; using FluentAssertions; using Moq; -namespace Features.UserManagement.Tests.Queries; +namespace Features.Auth.Tests.Queries; public class GetAllUsersHandlerTests { [Fact] public async Task Handle_PassesLimitAndOffset_ToRepository() { - Mock repoMock = new(); + Mock repoMock = new(); GetAllUsersHandler handler = new(repoMock.Object); repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty()); diff --git a/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs similarity index 84% rename from web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs index e79b05b4..d3d7fbdf 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs @@ -1,16 +1,16 @@ using Domain.Entities; using Domain.Exceptions; -using Features.UserManagement.Queries.GetUserById; -using Features.UserManagement.Repository; +using Features.Auth.Queries.GetUserById; +using Features.Auth.Repository; using FluentAssertions; using Moq; -namespace Features.UserManagement.Tests.Queries; +namespace Features.Auth.Tests.Queries; public class GetUserByIdHandlerTests { private readonly GetUserByIdHandler _handler; - private readonly Mock _repoMock = new(); + private readonly Mock _repoMock = new(); public GetUserByIdHandlerTests() { diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/LoginHandlerTests.cs similarity index 98% rename from web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs rename to web/backend/Features/Features.Users.Tests/Queries/LoginHandlerTests.cs index 157b3bdb..fe852da6 100644 --- a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/LoginHandlerTests.cs @@ -1,5 +1,5 @@ using Domain.Exceptions; -using Features.Auth.Commands.Login; +using Features.Auth.Commands.Authentication.Login; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Services; diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs b/web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs similarity index 100% rename from web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs rename to web/backend/Features/Features.Users.Tests/Services/TokenServiceRefreshTests.cs diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs b/web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs similarity index 100% rename from web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs rename to web/backend/Features/Features.Users.Tests/Services/TokenServiceValidationTests.cs diff --git a/web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs b/web/backend/Features/Features.Users.Tests/TestSupport/UserManagerMockFactory.cs similarity index 100% rename from web/backend/Features/Features.Auth.Tests/TestSupport/UserManagerMockFactory.cs rename to web/backend/Features/Features.Users.Tests/TestSupport/UserManagerMockFactory.cs diff --git a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs b/web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountCommand.cs similarity index 85% rename from web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs rename to web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountCommand.cs index 086f665b..059d4ff2 100644 --- a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountCommand.cs @@ -1,6 +1,6 @@ using MediatR; -namespace Features.Auth.Commands.DeleteAccount; +namespace Features.Auth.Commands.Account.DeleteAccount; /// Permanently deletes the given user account. /// diff --git a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs b/web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountHandler.cs similarity index 96% rename from web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs rename to web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountHandler.cs index c99fa263..36b632c4 100644 --- a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Account/DeleteAccount/DeleteAccountHandler.cs @@ -3,7 +3,7 @@ using Features.Auth.Identity; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.DeleteAccount; +namespace Features.Auth.Commands.Account.DeleteAccount; /// Handles by deleting the user via . public class DeleteAccountHandler(UserManager userManager) diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailCommand.cs similarity index 90% rename from web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailCommand.cs index b8818c81..a20fc44a 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.UpdateEmail; +namespace Features.Auth.Commands.Account.UpdateEmail; /// /// Changes the email address of the given user account. The account's EmailConfirmed status diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailHandler.cs similarity index 93% rename from web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailHandler.cs index 8ef0613d..7e81c150 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailHandler.cs @@ -4,13 +4,13 @@ using Features.Auth.Identity; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.UpdateEmail; +namespace Features.Auth.Commands.Account.UpdateEmail; /// /// Handles via . /// also resets EmailConfirmed to /// , so the user must re-confirm the new address (see -/// Features.Auth.Commands.ResendConfirmationEmail). +/// Features.Users.Commands.ResendConfirmationEmail). /// public class UpdateEmailHandler(UserManager userManager) : IRequestHandler diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailValidator.cs similarity index 91% rename from web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailValidator.cs index 3ddb80fc..ff988562 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateEmail/UpdateEmailValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.UpdateEmail; +namespace Features.Auth.Commands.Account.UpdateEmail; /// Validates , mirroring the email rules in RegisterUserValidator. public class UpdateEmailValidator : AbstractValidator diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordCommand.cs similarity index 89% rename from web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordCommand.cs index 9bcf0f23..b43fdcdc 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.UpdatePassword; +namespace Features.Auth.Commands.Account.UpdatePassword; /// Changes the password of the given user account, verifying the current password first. /// diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordHandler.cs similarity index 96% rename from web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordHandler.cs index a0a9e8ec..e34d96dd 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordHandler.cs @@ -4,7 +4,7 @@ using Features.Auth.Identity; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.UpdatePassword; +namespace Features.Auth.Commands.Account.UpdatePassword; /// /// Handles via , diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordValidator.cs similarity index 95% rename from web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordValidator.cs index 14b210b6..153aff7b 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdatePassword/UpdatePasswordValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.UpdatePassword; +namespace Features.Auth.Commands.Account.UpdatePassword; /// Validates , mirroring the password rules in RegisterUserValidator. public class UpdatePasswordValidator : AbstractValidator diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameCommand.cs similarity index 87% rename from web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameCommand.cs index 57cb68db..8733f379 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.UpdateUsername; +namespace Features.Auth.Commands.Account.UpdateUsername; /// Changes the username of the given user account. /// diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameHandler.cs similarity index 96% rename from web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameHandler.cs index 22af13d3..4f179ec8 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameHandler.cs @@ -4,7 +4,7 @@ using Features.Auth.Identity; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.UpdateUsername; +namespace Features.Auth.Commands.Account.UpdateUsername; /// Handles via . public class UpdateUsernameHandler(UserManager userManager) diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameValidator.cs similarity index 92% rename from web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs rename to web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameValidator.cs index 175ad4f5..3baea3c5 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Account/UpdateUsername/UpdateUsernameValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.UpdateUsername; +namespace Features.Auth.Commands.Account.UpdateUsername; /// Validates , mirroring the username rules in RegisterUserValidator. public class UpdateUsernameValidator : AbstractValidator diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs b/web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserCommand.cs similarity index 78% rename from web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs rename to web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserCommand.cs index 841392fa..e72583b9 100644 --- a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.ConfirmUser; +namespace Features.Auth.Commands.Authentication.ConfirmUser; /// Validates a confirmation token and confirms the corresponding user account. public record ConfirmUserCommand(string Token) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs similarity index 95% rename from web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs rename to web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs index a64495a7..524ff749 100644 --- a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs @@ -5,7 +5,7 @@ using Features.Auth.Services; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.ConfirmUser; +namespace Features.Auth.Commands.Authentication.ConfirmUser; /// /// Handles by validating the confirmation token and marking the diff --git a/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginCommand.cs similarity index 85% rename from web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs rename to web/backend/Features/Features.Users/Commands/Authentication/Login/LoginCommand.cs index 56132e14..883f251f 100644 --- a/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.Login; +namespace Features.Auth.Commands.Authentication.Login; /// /// Authenticates a user using their username and password and issues new tokens. Bound directly diff --git a/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginHandler.cs similarity index 96% rename from web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs rename to web/backend/Features/Features.Users/Commands/Authentication/Login/LoginHandler.cs index aee0a4c9..95aeaead 100644 --- a/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginHandler.cs @@ -5,7 +5,7 @@ using Features.Auth.Services; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.Login; +namespace Features.Auth.Commands.Authentication.Login; /// /// Handles by verifying credentials via diff --git a/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginValidator.cs similarity index 88% rename from web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs rename to web/backend/Features/Features.Users/Commands/Authentication/Login/LoginValidator.cs index ed0703a3..d7ea5468 100644 --- a/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/Login/LoginValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.Login; +namespace Features.Auth.Commands.Authentication.Login; /// Validates that and are present. public class LoginValidator : AbstractValidator diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenCommand.cs similarity index 83% rename from web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenCommand.cs index 453ba306..05ea189e 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.RefreshToken; +namespace Features.Auth.Commands.Authentication.RefreshToken; /// /// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs similarity index 94% rename from web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs index 386ae836..9ee3c144 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs @@ -2,7 +2,7 @@ using Features.Auth.Dtos; using Features.Auth.Services; using MediatR; -namespace Features.Auth.Commands.RefreshToken; +namespace Features.Auth.Commands.Authentication.RefreshToken; /// /// Handles by validating the refresh token and issuing a new diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenValidator.cs similarity index 84% rename from web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenValidator.cs index d25bcf68..dd1dcfa6 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.RefreshToken; +namespace Features.Auth.Commands.Authentication.RefreshToken; /// Validates that is present. public class RefreshTokenValidator : AbstractValidator diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserCommand.cs similarity index 94% rename from web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserCommand.cs index c6d7d05c..128c6086 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.RegisterUser; +namespace Features.Auth.Commands.Authentication.RegisterUser; /// /// Registers a new user account. Bound directly from the request body of POST /api/auth/register. diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs similarity index 93% rename from web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs index 889b9b3c..8d22714e 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs @@ -1,5 +1,6 @@ using Domain.Entities; using Domain.Exceptions; +using Features.Auth.Commands.Profile.CreateUserProfile; using Features.Auth.Dtos; using Features.Auth.Identity; using Features.Auth.Services; @@ -7,7 +8,7 @@ using MediatR; using Microsoft.AspNetCore.Identity; using Shared.Application.Emails; -namespace Features.Auth.Commands.RegisterUser; +namespace Features.Auth.Commands.Authentication.RegisterUser; /// /// Handles : creates the user via @@ -48,6 +49,8 @@ public class RegisterUserHandler( ); } + await mediator.Send(new CreateUserProfileCommand(user.Id, string.Empty), cancellationToken); + string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName); string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName); string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserValidator.cs similarity index 97% rename from web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs rename to web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserValidator.cs index 10e1075a..219f674c 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.RegisterUser; +namespace Features.Auth.Commands.Authentication.RegisterUser; /// /// Validates the field-level constraints on : username format and diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs similarity index 73% rename from web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs rename to web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs index 216371fe..d9fd25c2 100644 --- a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs @@ -1,6 +1,6 @@ using MediatR; -namespace Features.Auth.Commands.ResendConfirmationEmail; +namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail; /// Resends the account confirmation email to a user, generating a fresh confirmation token. public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs similarity index 95% rename from web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs rename to web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs index 9b8ef647..eabc4a65 100644 --- a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs @@ -4,7 +4,7 @@ using MediatR; using Microsoft.AspNetCore.Identity; using Shared.Application.Emails; -namespace Features.Auth.Commands.ResendConfirmationEmail; +namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail; /// /// Handles by generating a fresh confirmation token and diff --git a/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileCommand.cs b/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileCommand.cs new file mode 100644 index 00000000..cb60b7bd --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileCommand.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace Features.Auth.Commands.Profile.CreateUserProfile; + +/// Creates a new user profile for a user account. +public record CreateUserProfileCommand(Guid UserAccountId, string Biography) : IRequest; diff --git a/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileHandler.cs b/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileHandler.cs new file mode 100644 index 00000000..3c8c12a0 --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/CreateUserProfile/CreateUserProfileHandler.cs @@ -0,0 +1,19 @@ +using Features.Auth.Repository; +using MediatR; + +namespace Features.Auth.Commands.Profile.CreateUserProfile; + +/// Handles by inserting the new profile record. +public class CreateUserProfileHandler(IUserProfileRepository userProfileRepository) + : IRequestHandler +{ + public Task Handle( + CreateUserProfileCommand request, + CancellationToken cancellationToken + ) => + userProfileRepository.CreateProfileAsync( + request.UserAccountId, + request.Biography, + cancellationToken + ); +} diff --git a/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyCommand.cs b/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyCommand.cs new file mode 100644 index 00000000..1c1f06bd --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace Features.Auth.Commands.Profile.UpdateBiography; + +/// Updates the biography of the given user account's profile. +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record UpdateBiographyCommand(Guid UserAccountId, string Biography) : IRequest; diff --git a/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyHandler.cs b/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyHandler.cs new file mode 100644 index 00000000..e55048c6 --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/UpdateBiography/UpdateBiographyHandler.cs @@ -0,0 +1,19 @@ +using Features.Auth.Repository; +using MediatR; + +namespace Features.Auth.Commands.Profile.UpdateBiography; + +/// Handles by updating the stored biography. +public class UpdateBiographyHandler(IUserProfileRepository userProfileRepository) + : IRequestHandler +{ + /// + /// Thrown when no user profile exists for the given user account. + /// + public Task Handle(UpdateBiographyCommand request, CancellationToken cancellationToken) => + userProfileRepository.UpdateBiographyAsync( + request.UserAccountId, + request.Biography, + cancellationToken + ); +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileCommand.cs similarity index 90% rename from web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs rename to web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileCommand.cs index 9ce21423..b91d1366 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs +++ b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileCommand.cs @@ -1,7 +1,7 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Commands.UpdateProfile; +namespace Features.Auth.Commands.Profile.UpdateProfile; /// /// Updates the non-credential profile fields (first name, last name, date of birth) of the given diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileHandler.cs similarity index 96% rename from web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs rename to web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileHandler.cs index 55282ab1..04a80d4e 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs +++ b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileHandler.cs @@ -4,7 +4,7 @@ using Features.Auth.Identity; using MediatR; using Microsoft.AspNetCore.Identity; -namespace Features.Auth.Commands.UpdateProfile; +namespace Features.Auth.Commands.Profile.UpdateProfile; /// /// Handles via . First/last name diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileValidator.cs similarity index 94% rename from web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs rename to web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileValidator.cs index 6b35ef5b..09530360 100644 --- a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs +++ b/web/backend/Features/Features.Users/Commands/Profile/UpdateProfile/UpdateProfileValidator.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace Features.Auth.Commands.UpdateProfile; +namespace Features.Auth.Commands.Profile.UpdateProfile; /// Validates , mirroring the profile rules in RegisterUserValidator. public class UpdateProfileValidator : AbstractValidator diff --git a/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarCommand.cs b/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarCommand.cs new file mode 100644 index 00000000..09a63922 --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarCommand.cs @@ -0,0 +1,7 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Features.Auth.Commands.Profile.UploadAvatar; + +/// Uploads a new avatar photo for a user account, replacing any existing avatar. +public record UploadAvatarCommand(Guid UserAccountId, IFormFile File) : IRequest; diff --git a/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarHandler.cs b/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarHandler.cs new file mode 100644 index 00000000..99c05bb6 --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/Profile/UploadAvatar/UploadAvatarHandler.cs @@ -0,0 +1,42 @@ +using Domain.Entities; +using Features.Auth.Repository; +using Features.ImageUploads.Commands.UploadPhoto; +using MediatR; + +namespace Features.Auth.Commands.Profile.UploadAvatar; + +/// +/// Handles : uploads the photo via , +/// then saves the record, replacing any existing avatar for the user. +/// +public class UploadAvatarHandler(IMediator mediator, IUserProfileRepository userProfileRepository) + : IRequestHandler +{ + public async Task Handle(UploadAvatarCommand request, CancellationToken cancellationToken) + { + Guid photoId = await mediator.Send( + new UploadPhotoCommand( + request.UserAccountId, + $"avatars/{request.UserAccountId}", + request.File + ), + cancellationToken + ); + + Guid profileId = await userProfileRepository.GetProfileIdAsync( + request.UserAccountId, + cancellationToken + ); + + UserAvatar avatar = new() + { + UserAvatarId = Guid.NewGuid(), + UserProfileId = profileId, + PhotoId = photoId, + }; + + await userProfileRepository.SaveAvatarAsync(avatar, cancellationToken); + + return photoId; + } +} diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Users/Controllers/AuthController.cs similarity index 94% rename from web/backend/Features/Features.Auth/Controllers/AuthController.cs rename to web/backend/Features/Features.Users/Controllers/AuthController.cs index f49ebc49..70a4dfbf 100644 --- a/web/backend/Features/Features.Auth/Controllers/AuthController.cs +++ b/web/backend/Features/Features.Users/Controllers/AuthController.cs @@ -1,15 +1,15 @@ using System.IdentityModel.Tokens.Jwt; using Domain.Exceptions; -using Features.Auth.Commands.ConfirmUser; -using Features.Auth.Commands.DeleteAccount; -using Features.Auth.Commands.Login; -using Features.Auth.Commands.RefreshToken; -using Features.Auth.Commands.RegisterUser; -using Features.Auth.Commands.ResendConfirmationEmail; -using Features.Auth.Commands.UpdateEmail; -using Features.Auth.Commands.UpdatePassword; -using Features.Auth.Commands.UpdateProfile; -using Features.Auth.Commands.UpdateUsername; +using Features.Auth.Commands.Account.DeleteAccount; +using Features.Auth.Commands.Account.UpdateEmail; +using Features.Auth.Commands.Account.UpdatePassword; +using Features.Auth.Commands.Account.UpdateUsername; +using Features.Auth.Commands.Authentication.ConfirmUser; +using Features.Auth.Commands.Authentication.Login; +using Features.Auth.Commands.Authentication.RefreshToken; +using Features.Auth.Commands.Authentication.RegisterUser; +using Features.Auth.Commands.Authentication.ResendConfirmationEmail; +using Features.Auth.Commands.Profile.UpdateProfile; using Features.Auth.Dtos; using MediatR; using Microsoft.AspNetCore.Authorization; diff --git a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs b/web/backend/Features/Features.Users/Controllers/UserController.cs similarity index 89% rename from web/backend/Features/Features.UserManagement/Controllers/UserController.cs rename to web/backend/Features/Features.Users/Controllers/UserController.cs index 669d4f5e..86d4827c 100644 --- a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs +++ b/web/backend/Features/Features.Users/Controllers/UserController.cs @@ -1,11 +1,11 @@ using Domain.Entities; using Domain.Exceptions; -using Features.UserManagement.Queries.GetAllUsers; -using Features.UserManagement.Queries.GetUserById; +using Features.Auth.Queries.GetAllUsers; +using Features.Auth.Queries.GetUserById; using MediatR; using Microsoft.AspNetCore.Mvc; -namespace Features.UserManagement.Controllers; +namespace Features.Auth.Controllers; /// /// Exposes read endpoints for user accounts. diff --git a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Users/DependencyInjection/FeaturesUsersServiceCollectionExtensions.cs similarity index 71% rename from web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs rename to web/backend/Features/Features.Users/DependencyInjection/FeaturesUsersServiceCollectionExtensions.cs index 97d632a0..b4031ff8 100644 --- a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Users/DependencyInjection/FeaturesUsersServiceCollectionExtensions.cs @@ -1,22 +1,27 @@ using Features.Auth.Identity; +using Features.Auth.Repository; using Features.Auth.Services; using Infrastructure.PasswordHashing; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; -namespace Features.Auth.DependencyInjection; +namespace Features.Users.DependencyInjection; -/// Registers the services required by Features.Auth with the dependency injection container. -public static class FeaturesAuthServiceCollectionExtensions +/// Registers the services required by Features.Users with the dependency injection container. +public static class FeaturesUsersServiceCollectionExtensions { /// - /// Adds and ASP.NET Core Identity ( - /// backed by and ) as scoped services. + /// Adds , , + /// , and ASP.NET Core Identity + /// ( backed by and + /// ) as scoped services. /// - public static IServiceCollection AddFeaturesAuth(this IServiceCollection services) + public static IServiceCollection AddFeaturesUsers(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // AddDefaultTokenProviders() is deliberately not called: it registers // DataProtectorTokenProvider, which UserManager's constructor resolves eagerly and which diff --git a/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs b/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs similarity index 100% rename from web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs rename to web/backend/Features/Features.Users/Dtos/AuthPayloads.cs diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Users/Features.Users.csproj similarity index 93% rename from web/backend/Features/Features.Auth/Features.Auth.csproj rename to web/backend/Features/Features.Users/Features.Users.csproj index 53379c51..b7439890 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Users/Features.Users.csproj @@ -18,11 +18,8 @@ + - - - - diff --git a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs b/web/backend/Features/Features.Users/Identity/ApplicationUser.cs similarity index 88% rename from web/backend/Features/Features.Auth/Identity/ApplicationUser.cs rename to web/backend/Features/Features.Users/Identity/ApplicationUser.cs index e0dd41d5..9b015bc6 100644 --- a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs +++ b/web/backend/Features/Features.Users/Identity/ApplicationUser.cs @@ -2,7 +2,7 @@ namespace Features.Auth.Identity; /// /// The user type driving for -/// Features.Auth. A plain POCO (not IdentityUser<TKey>) backed by +/// Features.Users. A plain POCO (not IdentityUser<TKey>) backed by /// rather than EF Core, matching the rest of the solution's Dapper-only persistence. /// public sealed class ApplicationUser diff --git a/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs b/web/backend/Features/Features.Users/Identity/Argon2PasswordHasher.cs similarity index 100% rename from web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs rename to web/backend/Features/Features.Users/Identity/Argon2PasswordHasher.cs diff --git a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs b/web/backend/Features/Features.Users/Identity/DapperUserStore.cs similarity index 98% rename from web/backend/Features/Features.Auth/Identity/DapperUserStore.cs rename to web/backend/Features/Features.Users/Identity/DapperUserStore.cs index fb2780a9..1e8965cb 100644 --- a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs +++ b/web/backend/Features/Features.Users/Identity/DapperUserStore.cs @@ -186,8 +186,9 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) /// /// /// A plain DELETE against dbo.UserAccount: its foreign keys from - /// dbo.UserCredential, dbo.UserVerification, and dbo.UserAvatar cascade, so no - /// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the + /// dbo.UserCredential, dbo.UserVerification, and dbo.UserProfile cascade + /// (dbo.UserAvatar cascades transitively through dbo.UserProfile), so no manual + /// child cleanup is needed. Other tables (posts, comments, photos, follows) reference the /// account with ON DELETE NO ACTION by design; deleting an account that still has any of /// those fails with a foreign key violation, which is surfaced as a failed /// rather than an unhandled . diff --git a/web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs b/web/backend/Features/Features.Users/Identity/UserAccountExtensions.cs similarity index 87% rename from web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs rename to web/backend/Features/Features.Users/Identity/UserAccountExtensions.cs index 67c270bb..24c11c5a 100644 --- a/web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs +++ b/web/backend/Features/Features.Users/Identity/UserAccountExtensions.cs @@ -5,7 +5,7 @@ namespace Features.Auth.Identity; /// /// Converts the persistence-agnostic domain entity into the /// shape required by . -/// Kept here, in Features.Auth, rather than on itself so the domain layer has +/// Kept here, in Features.Users, rather than on itself so the domain layer has /// no dependency on ASP.NET Core Identity. /// public static class UserAccountExtensions diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs similarity index 66% rename from web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs rename to web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs index 4e8afe82..201c35be 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs +++ b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs @@ -1,10 +1,10 @@ using Domain.Entities; -using Features.UserManagement.Repository; +using Features.Auth.Repository; using MediatR; -namespace Features.UserManagement.Queries.GetAllUsers; +namespace Features.Auth.Queries.GetAllUsers; -public class GetAllUsersHandler(IUserAccountRepository repository) +public class GetAllUsersHandler(IUserListRepository repository) : IRequestHandler> { public Task> Handle( diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs similarity index 86% rename from web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs rename to web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs index 54e8d4cf..e000063e 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs +++ b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs @@ -1,7 +1,7 @@ using Domain.Entities; using MediatR; -namespace Features.UserManagement.Queries.GetAllUsers; +namespace Features.Auth.Queries.GetAllUsers; /// /// Requests a page of user accounts, ordered by creation date descending. diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs similarity index 78% rename from web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs rename to web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs index d664310e..8d83c112 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs @@ -1,11 +1,11 @@ using Domain.Entities; using Domain.Exceptions; -using Features.UserManagement.Repository; +using Features.Auth.Repository; using MediatR; -namespace Features.UserManagement.Queries.GetUserById; +namespace Features.Auth.Queries.GetUserById; -public class GetUserByIdHandler(IUserAccountRepository repository) +public class GetUserByIdHandler(IUserListRepository repository) : IRequestHandler { /// Thrown when no user account exists with the given ID. diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs similarity index 67% rename from web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs rename to web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs index 8337b7a9..80328852 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs @@ -1,6 +1,6 @@ using Domain.Entities; using MediatR; -namespace Features.UserManagement.Queries.GetUserById; +namespace Features.Auth.Queries.GetUserById; public record GetUserByIdQuery(Guid UserAccountId) : IRequest; diff --git a/web/backend/Features/Features.Users/Repository/IUserListRepository.cs b/web/backend/Features/Features.Users/Repository/IUserListRepository.cs new file mode 100644 index 00000000..954d37ab --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/IUserListRepository.cs @@ -0,0 +1,21 @@ +using Domain.Entities; + +namespace Features.Auth.Repository; + +/// +/// Read access for user account listing. Everything else on user accounts (lookup by +/// username/email, create/update/delete) already goes through +/// ; this repository only covers +/// what IUserStore has no equivalent for: fetching by ID with full audit fields, and +/// paginated listing. +/// +public interface IUserListRepository +{ + /// Returns null if no user account exists with the given ID. + Task GetByIdAsync(Guid id); + + /// Returns user accounts ordered by creation date descending. + /// null for no limit. + /// null for no offset. + Task> GetAllAsync(int? limit, int? offset); +} diff --git a/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs b/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs new file mode 100644 index 00000000..9472b9b5 --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs @@ -0,0 +1,41 @@ +using Domain.Entities; + +namespace Features.Auth.Repository; + +/// +/// Repository for user profile records and their avatars. +/// +public interface IUserProfileRepository +{ + /// + /// Creates a new user profile for with the given + /// and returns its UserProfileID. + /// + Task CreateProfileAsync( + Guid userAccountId, + string biography, + CancellationToken cancellationToken + ); + + /// + /// Returns the UserProfileID for + /// + Task GetProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken); + + /// + /// Updates the biography of the user profile belonging to . + /// + /// + /// Thrown when no user profile exists for . + /// + Task UpdateBiographyAsync( + Guid userAccountId, + string biography, + CancellationToken cancellationToken + ); + + /// + /// Persists , replacing any existing avatar for the same user profile. + /// + Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken); +} diff --git a/web/backend/Features/Features.Users/Repository/UserListRepository.cs b/web/backend/Features/Features.Users/Repository/UserListRepository.cs new file mode 100644 index 00000000..8f90054a --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/UserListRepository.cs @@ -0,0 +1,50 @@ +using System.Data.Common; +using Dapper; +using Database.Connection; +using Domain.Entities; + +namespace Features.Auth.Repository; + +/// +/// Dapper-based implementation of . +/// +public class UserListRepository(ISqlConnectionFactory connectionFactory) + : DapperRepository(connectionFactory), + IUserListRepository +{ + /// + public async Task GetByIdAsync(Guid id) + { + await using DbConnection connection = await CreateConnection(); + return await connection.QueryFirstOrDefaultAsync( + """ + SELECT + UserAccountID, + Username, + FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = id } + ); + } + + /// + /// + /// Always applies pagination via OFFSET/FETCH, ordered by creation date descending. + /// A null / is treated as "no limit"/"no offset". + /// + public async Task> GetAllAsync(int? limit, int? offset) + { + await using DbConnection connection = await CreateConnection(); + return await connection.QueryAsync( + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + ORDER BY CreatedAt DESC + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY + """, + new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue } + ); + } +} diff --git a/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs b/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs new file mode 100644 index 00000000..3e9b235d --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs @@ -0,0 +1,139 @@ +using System.Data.Common; +using Dapper; +using Database.Connection; +using Domain.Entities; +using Domain.Exceptions; + +namespace Features.Auth.Repository; + +/// +/// Dapper-based implementation of . +/// +public class UserProfileRepository(ISqlConnectionFactory connectionFactory) + : DapperRepository(connectionFactory), + IUserProfileRepository +{ + /// + public async Task CreateProfileAsync( + Guid userAccountId, + string biography, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + + return await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserProfile (UserAccountID, Biography) + OUTPUT INSERTED.UserProfileID + VALUES (@UserAccountId, @Biography) + """, + new { UserAccountId = userAccountId, Biography = biography }, + cancellationToken: cancellationToken + ) + ); + } + + /// + public async Task GetProfileIdAsync( + Guid userAccountId, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + + Guid? userProfileId = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT UserProfileID + FROM dbo.UserProfile + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId }, + cancellationToken: cancellationToken + ) + ); + + return userProfileId + ?? throw new NotFoundException( + $"No user profile found for user account ID {userAccountId}" + ); + } + + /// + public async Task UpdateBiographyAsync( + Guid userAccountId, + string biography, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + + int updatedRows = await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserProfile + SET Biography = @Biography + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId, Biography = biography }, + cancellationToken: cancellationToken + ) + ); + + if (updatedRows == 0) + throw new NotFoundException( + $"No user profile found for user account ID {userAccountId}" + ); + } + + /// + public async Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken) + { + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync( + cancellationToken + ); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserAvatar + SET ValidTo = SYSUTCDATETIME() + WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL + """, + new { avatar.UserProfileId }, + transaction: transaction, + cancellationToken: cancellationToken + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID) + VALUES (@UserAvatarId, @UserProfileId, @PhotoId) + """, + new + { + avatar.UserAvatarId, + avatar.UserProfileId, + avatar.PhotoId, + }, + transaction: transaction, + cancellationToken: cancellationToken + ) + ); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await RollbackQuietlyAsync(transaction); + throw; + } + } +} diff --git a/web/backend/Features/Features.Auth/Services/ITokenService.cs b/web/backend/Features/Features.Users/Services/ITokenService.cs similarity index 100% rename from web/backend/Features/Features.Auth/Services/ITokenService.cs rename to web/backend/Features/Features.Users/Services/ITokenService.cs diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Users/Services/TokenService.cs similarity index 100% rename from web/backend/Features/Features.Auth/Services/TokenService.cs rename to web/backend/Features/Features.Users/Services/TokenService.cs diff --git a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs index 5c573245..80e1133e 100644 --- a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs +++ b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs @@ -3,7 +3,7 @@ using MediatR; namespace Shared.Application.Emails; /// -/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a /// registration confirmation email, without either slice taking a project reference on the other. /// public record SendRegistrationEmailCommand(string FirstName, string Email, string ConfirmationToken) diff --git a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs index 0831d8c2..af2fa75b 100644 --- a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs +++ b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs @@ -3,7 +3,7 @@ using MediatR; namespace Shared.Application.Emails; /// -/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a /// fresh confirmation-link email, without either slice taking a project reference on the other. /// public record SendResendConfirmationEmailCommand( diff --git a/web/docker-compose.db.yaml b/web/docker-compose.db.yaml index 7653b7bf..e678d997 100644 --- a/web/docker-compose.db.yaml +++ b/web/docker-compose.db.yaml @@ -55,6 +55,8 @@ services: depends_on: database.migrations: condition: service_completed_successfully + seaweedfs: + condition: service_started build: context: ./backend dockerfile: Database/Database.Seed/Dockerfile @@ -67,14 +69,40 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" restart: "no" networks: - devnet + + seaweedfs: + env_file: ".env.dev" + image: chrislusf/seaweedfs + container_name: dev-env-seaweedfs + ports: + - "8333:8333" # S3 API + - "8888:8888" # Filer UI + - "9333:9333" # Master UI + - "23646:23646" # Admin UI + environment: + AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + S3_BUCKET: "${SEAWEEDFS_BUCKET}" + volumes: + - seaweedfsdata-dev:/data + restart: unless-stopped + networks: + - devnet + volumes: sqlserverdata-dev: driver: local nuget-cache-dev: driver: local + seaweedfsdata-dev: + driver: local networks: devnet: diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml index aa8a56a9..c3fdf845 100644 --- a/web/docker-compose.dev.yaml +++ b/web/docker-compose.dev.yaml @@ -57,6 +57,8 @@ services: depends_on: database.migrations: condition: service_completed_successfully + seaweedfs: + condition: service_started build: context: ./backend dockerfile: Database/Database.Seed/Dockerfile @@ -69,6 +71,10 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" restart: "no" networks: - devnet diff --git a/web/docker-compose.min.yaml b/web/docker-compose.min.yaml index 3e73c2a5..d2c9ae7f 100644 --- a/web/docker-compose.min.yaml +++ b/web/docker-compose.min.yaml @@ -69,6 +69,8 @@ services: depends_on: database.migrations: condition: service_completed_successfully + seaweedfs: + condition: service_started build: context: ./backend dockerfile: Database/Database.Seed/Dockerfile @@ -81,14 +83,40 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" restart: "no" networks: - devnet + + seaweedfs: + env_file: ".env.local" + image: chrislusf/seaweedfs + container_name: dev-env-seaweedfs + ports: + - "8333:8333" # S3 API + - "8888:8888" # Filer UI + - "9333:9333" # Master UI + - "23646:23646" # Admin UI + environment: + AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + S3_BUCKET: "${SEAWEEDFS_BUCKET}" + volumes: + - seaweedfsdata-dev:/data + restart: unless-stopped + networks: + - devnet + volumes: sqlserverdata-dev: driver: local nuget-cache-dev: driver: local + seaweedfsdata-dev: + driver: local networks: devnet: diff --git a/web/docker-compose.test.yaml b/web/docker-compose.test.yaml index 16dbe330..516bdcf5 100644 --- a/web/docker-compose.test.yaml +++ b/web/docker-compose.test.yaml @@ -55,6 +55,8 @@ services: depends_on: database.migrations: condition: service_completed_successfully + seaweedfs: + condition: service_started build: context: ./backend dockerfile: Database/Database.Seed/Dockerfile @@ -67,10 +69,28 @@ services: DB_NAME: "${DB_NAME}" DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" restart: "no" networks: - testnet + seaweedfs: + env_file: ".env.test" + image: chrislusf/seaweedfs + container_name: test-env-seaweedfs + environment: + AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + S3_BUCKET: "${SEAWEEDFS_BUCKET}" + volumes: + - seaweedfsdata-test:/data + restart: unless-stopped + networks: + - testnet + api.specs: env_file: ".env.test" image: api.specs @@ -122,6 +142,8 @@ services: volumes: sqlserverdata-test: driver: local + seaweedfsdata-test: + driver: local networks: testnet: diff --git a/web/generate-env.sh b/web/generate-env.sh index b0c8a7e5..15d1ced2 100755 --- a/web/generate-env.sh +++ b/web/generate-env.sh @@ -52,6 +52,12 @@ gen_username() { } DB_PASSWORD_VAL=$(gen_password) + +DB_SERVER_VAL=$(grep -m1 '^DB_SERVER=' "$TEMPLATE_FILE" | cut -d= -f2-) +DB_NAME_VAL=$(grep -m1 '^DB_NAME=' "$TEMPLATE_FILE" | cut -d= -f2-) +DB_USER_VAL=$(grep -m1 '^DB_USER=' "$TEMPLATE_FILE" | cut -d= -f2-) +DB_CONNECTION_STRING_VAL="Server=${DB_SERVER_VAL};Database=${DB_NAME_VAL};User Id=${DB_USER_VAL};Password=${DB_PASSWORD_VAL};TrustServerCertificate=True;" + ACCESS_TOKEN_SECRET_VAL=$(gen_jwt_secret) REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret) CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret) @@ -65,6 +71,7 @@ cp "$TEMPLATE_FILE" "$OUTPUT_FILE" TMP_FILE="$(mktemp)" sed \ -e "s|^DB_PASSWORD=.*|DB_PASSWORD=${DB_PASSWORD_VAL}|" \ + -e "s|^# DB_CONNECTION_STRING=.*|# DB_CONNECTION_STRING=${DB_CONNECTION_STRING_VAL}|" \ -e "s|^ACCESS_TOKEN_SECRET=.*|ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET_VAL}|" \ -e "s|^REFRESH_TOKEN_SECRET=.*|REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET_VAL}|" \ -e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \