diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 70efde10..2cc523a4 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -29,6 +29,7 @@ + diff --git a/web/backend/API/API.Core/ServiceCollectionExtensions.cs b/web/backend/API/API.Core/ServiceCollectionExtensions.cs index 07e222c8..10093479 100644 --- a/web/backend/API/API.Core/ServiceCollectionExtensions.cs +++ b/web/backend/API/API.Core/ServiceCollectionExtensions.cs @@ -7,6 +7,8 @@ using Features.Emails.DependencyInjection; using Features.Emails.Services; using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.DependencyInjection; +using Features.Locations.Controllers; +using Features.Locations.DependencyInjection; using Features.Users.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; @@ -26,6 +28,7 @@ internal static class ServiceCollectionExtensions .AddControllers(options => options.Filters.Add()) .AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(AuthController).Assembly) + .AddApplicationPart(typeof(CityController).Assembly) .Services; public IServiceCollection AddValidationAndMediatR() @@ -44,6 +47,7 @@ internal static class ServiceCollectionExtensions cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); + cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); @@ -96,7 +100,8 @@ internal static class ServiceCollectionExtensions .AddFeaturesBreweries() .AddFeaturesUsers() .AddFeaturesEmails() - .AddFeaturesPhotoUpload(); + .AddFeaturesPhotoUpload() + .AddFeaturesLocations(); public IServiceCollection AddCoreInfrastructure() => services diff --git a/web/backend/API/API.Specs/Steps/BrewerySteps.cs b/web/backend/API/API.Specs/Steps/BrewerySteps.cs index 92dc5085..4f25f19e 100644 --- a/web/backend/API/API.Specs/Steps/BrewerySteps.cs +++ b/web/backend/API/API.Specs/Steps/BrewerySteps.cs @@ -1,7 +1,6 @@ using System.Text; using System.Text.Json; using Database.Connection; -using Features.Locations.Dtos; using Features.Locations.Repository; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -23,11 +22,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario) private const string FixtureStateProvinceIsoCode = "FX-TS"; private const string FixtureCityName = "Fixture City"; - // GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same - // city (see its remarks); scenarios run in parallel across feature classes, so provisioning - // the shared fixture city is serialized the same way AuthSteps serializes its fixture account. - private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1); - private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url) { HttpRequestMessage requestMessage = new(method, url); @@ -53,24 +47,21 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario) .Services.GetRequiredService(); LocationRepository repository = new(connectionFactory); - CityLocation fixtureCity = new( - FixtureCityName, - FixtureStateProvinceName, - FixtureStateProvinceIsoCode, - FixtureCountryName, - FixtureCountryIsoCode - ); + Guid countryId = + await repository.GetCountryIdAsync(FixtureCountryIsoCode) + ?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode); - await CityProvisioningLock.WaitAsync(); - Guid cityId; - try - { - cityId = await repository.GetOrCreateCityIdAsync(fixtureCity); - } - finally - { - CityProvisioningLock.Release(); - } + Guid stateProvinceId = + await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode) + ?? await repository.CreateStateProvinceAsync( + FixtureStateProvinceName, + FixtureStateProvinceIsoCode, + countryId + ); + + Guid cityId = + await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode) + ?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId); Scenario[CityIdKey] = cityId; } diff --git a/web/backend/Database/Database.Migrations/scripts/02-Geolocation/02-StateProvince.sql b/web/backend/Database/Database.Migrations/scripts/02-Geolocation/02-StateProvince.sql index 2f34f720..541f05e3 100644 --- a/web/backend/Database/Database.Migrations/scripts/02-Geolocation/02-StateProvince.sql +++ b/web/backend/Database/Database.Migrations/scripts/02-Geolocation/02-StateProvince.sql @@ -5,10 +5,10 @@ CREATE TABLE Geolocation.StateProvince StateProvinceName NVARCHAR(100) NOT NULL, - ISO3166_2 CHAR(6) NOT NULL, - -- eg 'US-CA' for California, 'CA-ON' for Ontario - CountryID UNIQUEIDENTIFIER NOT NULL, + + -- eg 'US-CA' for California, 'CA-ON' for Ontario + ISO3166_2 CHAR(6) NOT NULL, RowVersion ROWVERSION, @@ -18,6 +18,9 @@ CREATE TABLE Geolocation.StateProvince CONSTRAINT AK_StateProvince_ISO3166_2 UNIQUE (ISO3166_2), + CONSTRAINT AK_StateProvince_Country + UNIQUE (StateProvinceName, CountryID), + CONSTRAINT FK_StateProvince_Country FOREIGN KEY (CountryID) REFERENCES Geolocation.Country (CountryID) diff --git a/web/backend/Database/Database.Migrations/scripts/02-Geolocation/03-City.sql b/web/backend/Database/Database.Migrations/scripts/02-Geolocation/03-City.sql index 64f5fe24..efec052f 100644 --- a/web/backend/Database/Database.Migrations/scripts/02-Geolocation/03-City.sql +++ b/web/backend/Database/Database.Migrations/scripts/02-Geolocation/03-City.sql @@ -14,7 +14,9 @@ CREATE TABLE Geolocation.City CONSTRAINT FK_City_StateProvince FOREIGN KEY (StateProvinceID) - REFERENCES Geolocation.StateProvince (StateProvinceID) + REFERENCES Geolocation.StateProvince (StateProvinceID), + + CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID) ); CREATE diff --git a/web/backend/Database/Database.Seed/BiergartenDataSeeder.cs b/web/backend/Database/Database.Seed/BiergartenDataSeeder.cs new file mode 100644 index 00000000..87028732 --- /dev/null +++ b/web/backend/Database/Database.Seed/BiergartenDataSeeder.cs @@ -0,0 +1,371 @@ +using Database.Seed.DatabaseHelpers; +using Database.Seed.Sqlite; +using Database.Seed.SourceDataModels; +using Domain.Entities; +using Features.Auth.Commands.Authentication.RegisterUser; +using Features.Auth.Commands.Profile.UpdateBiography; +using Features.Auth.Commands.Profile.UploadAvatar; +using Features.Auth.Dtos; +using Features.Breweries.Commands.CreateBrewery; +using Features.Locations.Commands.CreateCity; +using Features.Locations.Commands.CreateCountry; +using Features.Locations.Commands.CreateStateProvince; +using Features.Locations.Queries.GetCity; +using Features.Locations.Queries.GetCountry; +using Features.Locations.Queries.GetStateProvince; +using idunno.Password; +using MediatR; +using Microsoft.AspNetCore.Http; +using Spectre.Console; +using City = Database.Seed.SourceDataModels.City; + +namespace Database.Seed; + +public class BiergartenDataSeeder +{ + private readonly IMediator _mediator; + private readonly SeedRepository _reader; + + public BiergartenDataSeeder(IMediator mediator, SeedRepository reader) + { + _mediator = mediator; + _reader = reader; + } + + public async Task Run(CancellationToken cancellationToken = default) + { + AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); + AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); + AnsiConsole.WriteLine(); + + SeedData seedData = null!; + IReadOnlyList userIds = []; + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading seed data...", + async ctx => + { + seedData = await _reader.ReadSeedDataAsync(cancellationToken); + ctx.Status($"Done."); + } + ); + + AnsiConsole.MarkupLine( + $"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries." + ); + AnsiConsole.Write(BuildBreweryDisplayTable(seedData.Breweries)); + + AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users."); + AnsiConsole.Write(BuildUserDisplayTable(seedData.Users)); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Cities.Count}[/] cities."); + + AnsiConsole.WriteLine(); + AnsiConsole.WriteLine("Seed data loaded successfully."); + + AnsiConsole.Write( + new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified() + ); + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading user data into target database...", + async ctx => + { + userIds = await LoadUsersIntoDatabaseAsync(seedData.Users, cancellationToken); + 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( + userIds, + seedData.Users, + ctx, + cancellationToken + ); + ctx.Status("Avatar data loaded into target database."); + } + ); + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading city data into target database...", + async ctx => + { + await LoadCitiesIntoDatabaseAsync(seedData.Cities, ctx, cancellationToken); + ctx.Status("City data loaded into target database."); + } + ); + + await AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Loading brewery data into target database...", + async ctx => + { + await LoadBreweriesIntoDatabaseAsync( + seedData.Breweries, + userIds, + ctx, + cancellationToken + ); + ctx.Status("Brewery data loaded into target database."); + } + ); + } + + private async Task> LoadUsersIntoDatabaseAsync( + IReadOnlyList users, + CancellationToken cancellationToken + ) + { + List userAccountIds = []; + + foreach (UserRecord userRecord in users) + { + cancellationToken.ThrowIfCancellationRequested(); + + // allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible + // digit characters (0-9) + string password = PasswordGenerator.Generate( + length: 64, + numberOfDigits: 12, + numberOfSymbols: 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 + ), + cancellationToken + ); + + userAccountIds.Add(registration.UserAccountId); + } + + return userAccountIds; + } + + private async Task LoadAvatarsIntoDatabaseAsync( + IReadOnlyList userIds, + IReadOnlyList users, + StatusContext ctx, + CancellationToken cancellationToken + ) + { + for (int i = 0; i < userIds.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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), + cancellationToken + ); + + 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), cancellationToken); + } + } + + private async Task LoadCitiesIntoDatabaseAsync( + IReadOnlyList cities, + StatusContext ctx, + CancellationToken cancellationToken + ) + { + Dictionary countryIdsByIso31661 = []; + Dictionary stateProvinceIdsByIso31662 = []; + + foreach (City city in cities) + { + cancellationToken.ThrowIfCancellationRequested(); + + ctx.Status( + $"Loading city {city.CityName}, {city.StateProvinceName}, {city.CountryName}..." + ); + + if (!countryIdsByIso31661.TryGetValue(city.ISO_3166_1, out Guid countryId)) + { + countryId = + await _mediator.Send(new GetCountryQuery(city.ISO_3166_1), cancellationToken) + ?? await _mediator.Send( + new CreateCountryCommand(city.CountryName, city.ISO_3166_1), + cancellationToken + ); + countryIdsByIso31661[city.ISO_3166_1] = countryId; + } + + if (!stateProvinceIdsByIso31662.TryGetValue(city.ISO_3166_2, out Guid stateProvinceId)) + { + stateProvinceId = + await _mediator.Send( + new GetStateProvinceQuery(city.ISO_3166_2), + cancellationToken + ) + ?? await _mediator.Send( + new CreateStateProvinceCommand( + city.StateProvinceName, + city.ISO_3166_2, + countryId + ), + cancellationToken + ); + stateProvinceIdsByIso31662[city.ISO_3166_2] = stateProvinceId; + } + + await _mediator.Send( + new CreateCityCommand(city.CityName, stateProvinceId), + cancellationToken + ); + } + } + + private async Task LoadBreweriesIntoDatabaseAsync( + IReadOnlyList breweries, + IReadOnlyList posterUserIds, + StatusContext ctx, + CancellationToken cancellationToken + ) + { + if (posterUserIds.Count == 0) + throw new InvalidOperationException( + "Cannot load breweries without any registered users." + ); + + for (int i = 0; i < breweries.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + BreweryResult breweryRecord = breweries[i]; + BreweryAddress address = + breweryRecord.Address + ?? throw new InvalidOperationException("Brewery address was not loaded."); + City city = + address.City ?? throw new InvalidOperationException("Brewery city was not loaded."); + + ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database..."); + + Guid cityId = + await _mediator.Send( + new GetCityQuery(city.CityName, city.ISO_3166_2), + cancellationToken + ) + ?? throw new InvalidOperationException( + $"City '{city.CityName}' " + + $"({city.ISO_3166_2}) was not found. " + + "Cities must be loaded before breweries." + ); + + await _mediator.Send( + new CreateBreweryCommand( + posterUserIds[i % posterUserIds.Count], + breweryRecord.NameEn, + breweryRecord.DescriptionEn, + new CreateBreweryLocation( + cityId, + address.AddressLine1, + null, + address.PostalCode, + new CoordinateData(address.Latitude, address.Longitude) + ) + ), + cancellationToken + ); + } + } + + private static Table BuildBreweryDisplayTable(IReadOnlyList breweries) + { + Table table = new Table() + .AddColumn("Brewery Name (EN)") + .AddColumn("Brewery Name (Local)") + .AddColumn("City") + .AddColumn("State/Province") + .AddColumn("Country") + .AddColumn("Longitude") + .AddColumn("Latitude"); + + foreach (BreweryResult brewery in breweries) + { + BreweryAddress address = + brewery.Address + ?? throw new InvalidOperationException("Brewery address was not loaded."); + City city = + address.City ?? throw new InvalidOperationException("Brewery city was not loaded."); + + table.AddRow( + brewery.NameEn, + brewery.NameLocal, + city.CityName, + city.StateProvinceName, + city.CountryName, + address.Longitude.ToString("F6"), + address.Latitude.ToString("F6") + ); + } + + return table; + } + + private static Table BuildUserDisplayTable(IReadOnlyList users) + { + Table table = new Table() + .AddColumn("Username") + .AddColumn("First Name") + .AddColumn("Last Name") + .AddColumn("Email") + .AddColumn("Date of Birth"); + + foreach (UserRecord user in users) + { + table.AddRow( + user.User.Username, + user.User.FirstName, + user.User.LastName, + user.Email, + user.DateOfBirth + ); + } + + return table; + } +} diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index f4209e0f..ed9aae72 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -27,6 +27,9 @@ - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs deleted file mode 100644 index 7f14d50b..00000000 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ /dev/null @@ -1,24 +0,0 @@ -#region - -using Database.Connection; -using Microsoft.Extensions.Configuration; - -#endregion - -namespace Database.Seed.DatabaseHelpers; - -public static class ConnectionStrings -{ - public static string GetSqlServerConnectionString(IConfiguration configuration) => - SqlConnectionStringHelper.TryGetStandardDbConnectionString( - configuration, - out string connectionString - ) - ? connectionString - : throw new InvalidOperationException( - "Database connection string not configured. Set DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars." - ); - - public static string SqliteConnectionString => - "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"; -} diff --git a/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs b/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs deleted file mode 100644 index be6c7407..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record BreweryAddress -{ - public required City City { get; init; } - - public required double Longitude { get; init; } - - public required double Latitude { get; init; } - - public required string AddressLine1 { get; init; } - - public required string PostalCode { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/BreweryRecord.cs b/web/backend/Database/Database.Seed/PipelineData/BreweryRecord.cs deleted file mode 100644 index 93c67e9a..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/BreweryRecord.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record BreweryRecord -{ - public required BreweryAddress Address { get; init; } - - public required BreweryResult Brewery { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/BreweryResult.cs b/web/backend/Database/Database.Seed/PipelineData/BreweryResult.cs deleted file mode 100644 index 13d42f5f..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/BreweryResult.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record BreweryResult -{ - public required string NameEn { get; init; } - - public required string DescriptionEn { get; init; } - - public required string NameLocal { get; init; } - - public required string DescriptionLocal { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/City.cs b/web/backend/Database/Database.Seed/PipelineData/City.cs deleted file mode 100644 index 88de9e2e..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/City.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record City -{ - public required string CityName { get; init; } - - public required string StateProvince { get; init; } - - public required string Iso31662 { get; init; } - - public required string Country { get; init; } - - public required string Iso31661 { get; init; } - - public IReadOnlyList LocalLanguages { get; init; } = []; - - public required PostalCodeSpec PostalCode { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/PostalCodeSpec.cs b/web/backend/Database/Database.Seed/PipelineData/PostalCodeSpec.cs deleted file mode 100644 index d367da6b..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/PostalCodeSpec.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record PostalCodeSpec -{ - public required string CountryFormatRegex { get; init; } - - public IReadOnlyList CityRegexes { get; init; } = []; - - public IReadOnlyList Examples { get; init; } = []; -} diff --git a/web/backend/Database/Database.Seed/PipelineData/UserAddress.cs b/web/backend/Database/Database.Seed/PipelineData/UserAddress.cs deleted file mode 100644 index d4f5a7d1..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/UserAddress.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record UserAddress -{ - public required City City { get; init; } - - public required double Longitude { get; init; } - - public required double Latitude { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/UserRecord.cs b/web/backend/Database/Database.Seed/PipelineData/UserRecord.cs deleted file mode 100644 index fa8f0dc2..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/UserRecord.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record UserRecord -{ - public required UserAddress Address { get; init; } - - public required UserResult User { get; init; } - - public required string Email { get; init; } - - public required string DateOfBirth { get; init; } -} diff --git a/web/backend/Database/Database.Seed/PipelineData/UserResult.cs b/web/backend/Database/Database.Seed/PipelineData/UserResult.cs deleted file mode 100644 index 0e53881d..00000000 --- a/web/backend/Database/Database.Seed/PipelineData/UserResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Database.Seed.PipelineData; - -public sealed record UserResult -{ - public required string FirstName { get; init; } - - public required string LastName { get; init; } - - public required string Gender { get; init; } - - public required string Username { get; init; } - - public required string Bio { get; init; } - - public required float ActivityWeight { get; init; } -} diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index a2643f78..2bdffe8d 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -1,34 +1,26 @@ using Database.Connection.DependencyInjection; using Database.Seed.DatabaseHelpers; -using Database.Seed.PipelineData; using Database.Seed.Sqlite; -using Domain.Entities; -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.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.DependencyInjection; -using Features.Locations.Commands.GetOrCreateCity; using Features.Locations.DependencyInjection; -using Features.Locations.Dtos; -using idunno.Password; +using Features.Locations.Queries.GetCountry; +using Features.Users.DependencyInjection; using Infrastructure.FileUpload; using MediatR; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; -return await RunAsync(); +namespace Database.Seed; -static async Task RunAsync() +internal class Program { - try + private static IMediator CreateMediator() { IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); @@ -43,260 +35,41 @@ static async Task RunAsync() .AddScoped() .AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining() - .RegisterServicesFromAssemblyContaining() + .RegisterServicesFromAssemblyContaining() .RegisterServicesFromAssemblyContaining() .RegisterServicesFromAssemblyContaining() ); - await using ServiceProvider provider = services.BuildServiceProvider(); - IMediator mediator = provider.GetRequiredService(); - - AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); - AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); - AnsiConsole.WriteLine(); - - PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); - - SeedData seedData = null!; - IReadOnlyList userIds = []; - - await AnsiConsole - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync( - "Loading seed data...", - async ctx => - { - seedData = await reader.ReadSeedDataAsync(); - ctx.Status( - $"Loaded {seedData.Breweries.Count} breweries and {seedData.Users.Count} users." - ); - } - ); - - AnsiConsole.MarkupLine( - $"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries." - ); - AnsiConsole.Write(BuildBreweryTable(seedData.Breweries)); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users."); - AnsiConsole.Write(BuildUserTable(seedData.Users)); - AnsiConsole.WriteLine(); - - AnsiConsole.WriteLine("Seed data loaded successfully."); - - AnsiConsole.Write( - new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified() - ); - - await AnsiConsole - .Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync( - "Loading user data into target database...", - async ctx => - { - 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) - .SpinnerStyle(Style.Parse("green")) - .StartAsync( - "Loading brewery data into target database...", - async ctx => - { - await LoadBreweriesIntoDatabaseAsync( - mediator, - seedData.Breweries, - userIds, - ctx - ); - ctx.Status("Brewery data loaded into target database."); - } - ); - - return 0; - } - catch (Exception ex) - { - AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); - AnsiConsole.WriteException(ex); - return 1; - } -} - -static async Task> LoadUsersIntoDatabaseAsync( - IMediator mediator, - IReadOnlyList users -) -{ - List userAccountIds = []; - - foreach (UserRecord userRecord in users) - { - // allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible - // digit characters (0-9), which idunno.Password rejects outright. - 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 - ) - ); - - userAccountIds.Add(registration.UserAccountId); + using ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); } - return userAccountIds; -} - -static async Task LoadAvatarsIntoDatabaseAsync( - IMediator mediator, - IReadOnlyList userIds, - IReadOnlyList users, - StatusContext ctx -) -{ - for (int i = 0; i < userIds.Count; i++) + public static async Task Main() { - 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") + using CancellationTokenSource cts = new(); + Console.CancelKeyPress += (_, e) => { - Headers = new HeaderDictionary(), - ContentType = "image/png", + e.Cancel = true; + // ReSharper disable once AccessToDisposedClosure + cts.Cancel(); }; - await mediator.Send(new UploadAvatarCommand(userId, file)); + try + { + IMediator mediator = CreateMediator(); + SeedRepository reader = new SeedRepository( + connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" + ); + await new BiergartenDataSeeder(mediator, reader).Run(cts.Token); + } + catch (OperationCanceledException) + { + AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); + AnsiConsole.WriteException(ex); + } } } - -static async Task LoadBreweriesIntoDatabaseAsync( - IMediator mediator, - IReadOnlyList breweries, - IReadOnlyList posterUserIds, - StatusContext ctx -) -{ - if (posterUserIds.Count == 0) - throw new InvalidOperationException("Cannot load breweries without any registered users."); - - for (int i = 0; i < breweries.Count; i++) - { - BreweryRecord breweryRecord = breweries[i]; - - ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database..."); - - 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 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 - ) - ) - ) - ); - } -} - -static Table BuildBreweryTable(IReadOnlyList breweries) -{ - Table table = new Table() - .AddColumn("Brewery Name (EN)") - .AddColumn("Brewery Name (Local)") - .AddColumn("City") - .AddColumn("State/Province") - .AddColumn("Country") - .AddColumn("Longitude") - .AddColumn("Latitude"); - - foreach (BreweryRecord brewery in breweries) - { - table.AddRow( - brewery.Brewery.NameEn, - brewery.Brewery.NameLocal, - brewery.Address.City.CityName, - brewery.Address.City.StateProvince, - brewery.Address.City.Country, - brewery.Address.Longitude.ToString("F6"), - brewery.Address.Latitude.ToString("F6") - ); - } - - return table; -} - -static Table BuildUserTable(IReadOnlyList users) -{ - Table table = new Table() - .AddColumn("Username") - .AddColumn("First Name") - .AddColumn("Last Name") - .AddColumn("Email") - .AddColumn("Date of Birth"); - - foreach (UserRecord user in users) - { - table.AddRow( - user.User.Username, - user.User.FirstName, - user.User.LastName, - user.Email, - user.DateOfBirth - ); - } - - return table; -} diff --git a/web/backend/Database/Database.Seed/SourceDataModels/SourceDataModels.cs b/web/backend/Database/Database.Seed/SourceDataModels/SourceDataModels.cs new file mode 100644 index 00000000..a4f0ed02 --- /dev/null +++ b/web/backend/Database/Database.Seed/SourceDataModels/SourceDataModels.cs @@ -0,0 +1,104 @@ +namespace Database.Seed.SourceDataModels; + +public sealed record PostalCodeSpec( + string CountryFormatRegex, + IReadOnlyList CityRegexes +); + +public sealed class City +{ + // Identity + public int Id { get; init; } + + // Attributes + public string CityName { get; init; } = string.Empty; + + // Resharper disable InconsistentNaming + public string ISO_3166_1 { get; init; } = string.Empty; + public string ISO_3166_2 { get; init; } = string.Empty; + public string CountryName { get; init; } = string.Empty; + public string StateProvinceName { get; init; } = string.Empty; + + public double Longitude { get; init; } + public double Latitude { get; init; } + + public IReadOnlyList LocalLanguages { get; init; } = []; + public PostalCodeSpec? PostalCode { get; init; } +} + +public sealed class BreweryAddress +{ + // Identity + public int Id { get; init; } + + // FK References + public int CityId { get; init; } + public int BreweryId { get; init; } // FK lives on this side in the DB (brewery_addresses.brewery_id) + + // Attributes + public double Longitude { get; init; } + public double Latitude { get; init; } + public string AddressLine1 { get; init; } = string.Empty; + public string PostalCode { get; init; } = string.Empty; + + // Navigation properties + public City? City { get; init; } + public BreweryResult? Brewery { get; init; } +} + +public sealed class BreweryResult +{ + // Identity + public int Id { get; init; } + + // Attributes + public string NameEn { get; init; } = string.Empty; + public string DescriptionEn { get; init; } = string.Empty; + public string NameLocal { get; init; } = string.Empty; + public string DescriptionLocal { get; init; } = string.Empty; + + // Navigation properties + public BreweryAddress? Address { get; init; } +} + +public sealed class UserAddress +{ + // Identity + public int Id { get; init; } + + // FK References + public int CityId { get; init; } + public int UserId { get; init; } + + // Attributes + public double Longitude { get; init; } + public double Latitude { get; init; } + + // Navigation properties + public City? City { get; init; } +} + +public sealed class UserResult +{ + // Identity + public int Id { get; init; } + + // Attributes + public string FirstName { get; init; } = string.Empty; + public string LastName { get; init; } = string.Empty; + public string Gender { get; init; } = string.Empty; + public string Username { get; init; } = string.Empty; + public string Bio { get; init; } = string.Empty; + public float ActivityWeight { get; init; } +} + +public sealed class UserRecord +{ + // Attributes + public string Email { get; init; } = string.Empty; + public string DateOfBirth { get; init; } = string.Empty; + + // Navigation properties + public UserAddress Address { get; init; } = new(); + public UserResult User { get; init; } = new(); +} diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index ea94c6c4..12e715fb 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -1,215 +1,259 @@ using System.Text.Json; -using Database.Seed.PipelineData; + +using Dapper; + +using Database.Seed.SourceDataModels; + using Microsoft.Data.Sqlite; namespace Database.Seed.Sqlite; public sealed record SeedData( - IReadOnlyList Breweries, + IReadOnlyList Cities, + IReadOnlyList Breweries, IReadOnlyList Users ); -public sealed class PipelineSeedDataReader +public sealed class SeedRepository : IDisposable { - private readonly string _connectionString; - - public PipelineSeedDataReader(string connectionString) + private readonly SqliteConnection connection; + public SeedRepository(string connectionString) { - _connectionString = connectionString; + connection = new(connectionString); } + public void Dispose() + { + connection.Dispose(); + } + /// /// Reads breweries and users in a single connection, loading the cities /// lookup only once and sharing it between both reads. /// - public async Task ReadSeedDataAsync(CancellationToken cancellationToken = default) + public async Task ReadSeedDataAsync( + CancellationToken cancellationToken = default + ) { - await using SqliteConnection connection = new(_connectionString); await connection.OpenAsync(cancellationToken); - IReadOnlyDictionary cities = await ReadCitiesAsync( + IReadOnlyDictionary cities = await ReadCitiesAsync( connection, cancellationToken ); - IReadOnlyList breweries = await ReadBreweryRecordsAsync( + IReadOnlyList breweries = await ReadBreweryRecordsAsync( connection, cities, cancellationToken ); + IReadOnlyList users = await ReadUserRecordsAsync( connection, cities, cancellationToken ); - return new SeedData(breweries, users); + return new SeedData([.. cities.Values], breweries, users); } - private static async Task> ReadBreweryRecordsAsync( + private sealed record BreweryRow( + int Id, + string NameEn, + string DescriptionEn, + string NameLocal, + string DescriptionLocal, + int AddressId, + int CityId, + double Longitude, + double Latitude, + string AddressLine1, + string PostalCode + ); + + private static async Task> ReadBreweryRecordsAsync( SqliteConnection connection, - IReadOnlyDictionary cities, + IReadOnlyDictionary cities, CancellationToken cancellationToken ) { const string sql = """ - SELECT b.name_en, b.description_en, b.name_local, b.description_local, - ba.city_id, ba.longitude, ba.latitude, ba.address_line1, ba.postal_code - FROM breweries b - JOIN brewery_addresses ba ON ba.brewery_id = b.id; + SELECT + b.id AS Id, + b.name_en AS NameEn, + b.description_en AS DescriptionEn, + b.name_local AS NameLocal, + b.description_local AS DescriptionLocal, + ba.id AS AddressId, + ba.city_id AS CityId, + ba.longitude AS Longitude, + ba.latitude AS Latitude, + ba.address_line1 AS AddressLine1, + ba.postal_code AS PostalCode + FROM + breweries b + INNER JOIN + brewery_addresses ba ON ba.brewery_id = b.id; """; - await using SqliteCommand command = new(sql, connection); - await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + CommandDefinition command = new(sql, cancellationToken: cancellationToken); + IEnumerable rows = await connection.QueryAsync(command); - const int nameEnIndex = 0; - const int descriptionEnIndex = 1; - const int nameLocalIndex = 2; - const int descriptionLocalIndex = 3; - const int cityIdIndex = 4; - const int longitudeIndex = 5; - const int latitudeIndex = 6; - const int addressLine1Index = 7; - const int postalCodeIndex = 8; - - List records = []; - while (await reader.ReadAsync(cancellationToken)) - records.Add( - new BreweryRecord + return [.. rows.Select(row => new BreweryResult + { + Id = row.Id, + NameEn = row.NameEn, + DescriptionEn = row.DescriptionEn, + NameLocal = row.NameLocal, + DescriptionLocal = row.DescriptionLocal, + Address = new BreweryAddress { - Brewery = new BreweryResult - { - NameEn = reader.GetString(nameEnIndex), - DescriptionEn = reader.GetString(descriptionEnIndex), - NameLocal = reader.GetString(nameLocalIndex), - DescriptionLocal = reader.GetString(descriptionLocalIndex), - }, - Address = new BreweryAddress - { - City = cities[reader.GetInt64(cityIdIndex)], - Longitude = reader.GetDouble(longitudeIndex), - Latitude = reader.GetDouble(latitudeIndex), - AddressLine1 = reader.GetString(addressLine1Index), - PostalCode = reader.GetString(postalCodeIndex), - }, - } - ); - - return records; + Id = row.AddressId, + CityId = row.CityId, + BreweryId = row.Id, + Longitude = row.Longitude, + Latitude = row.Latitude, + AddressLine1 = row.AddressLine1, + PostalCode = row.PostalCode, + City = cities[row.CityId], + }, + })]; } + private sealed record UserRow( + int Id, + string FirstName, + string LastName, + string Gender, + string Username, + string Bio, + double ActivityWeight, + string Email, + string DateOfBirth, + int AddressId, + int CityId, + double Longitude, + double Latitude + ); + private static async Task> ReadUserRecordsAsync( SqliteConnection connection, - IReadOnlyDictionary cities, + IReadOnlyDictionary cities, CancellationToken cancellationToken ) { const string sql = """ - SELECT u.first_name, u.last_name, u.gender, u.username, u.bio, - u.activity_weight, u.email, u.date_of_birth, - ua.city_id, ua.longitude, ua.latitude - FROM users u - JOIN user_addresses ua ON ua.user_id = u.id; + SELECT + u.id AS Id, + u.first_name AS FirstName, + u.last_name AS LastName, + u.gender AS Gender, + u.username AS Username, + u.bio AS Bio, + u.activity_weight AS ActivityWeight, + u.email AS Email, + u.date_of_birth AS DateOfBirth, + ua.id AS AddressId, + ua.city_id AS CityId, + ua.longitude AS Longitude, + ua.latitude AS Latitude + FROM + users u + INNER JOIN + user_addresses ua ON ua.user_id = u.id; """; - await using SqliteCommand command = new(sql, connection); - await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + CommandDefinition command = new(sql, cancellationToken: cancellationToken); + IEnumerable rows = await connection.QueryAsync(command); - const int firstNameIndex = 0; - const int lastNameIndex = 1; - const int genderIndex = 2; - const int usernameIndex = 3; - const int bioIndex = 4; - const int activityWeightIndex = 5; - const int emailIndex = 6; - const int dateOfBirthIndex = 7; - const int cityIdIndex = 8; - const int longitudeIndex = 9; - const int latitudeIndex = 10; - - List records = []; - while (await reader.ReadAsync(cancellationToken)) - records.Add( - new UserRecord + return [.. rows.Select(row => new UserRecord + { + Email = row.Email, + DateOfBirth = row.DateOfBirth, + Address = new UserAddress { - User = new UserResult - { - FirstName = reader.GetString(firstNameIndex), - LastName = reader.GetString(lastNameIndex), - Gender = reader.GetString(genderIndex), - Username = reader.GetString(usernameIndex), - Bio = reader.GetString(bioIndex), - ActivityWeight = (float)reader.GetDouble(activityWeightIndex), - }, - Email = reader.GetString(emailIndex), - DateOfBirth = reader.GetString(dateOfBirthIndex), - Address = new UserAddress - { - City = cities[reader.GetInt64(cityIdIndex)], - Longitude = reader.GetDouble(longitudeIndex), - Latitude = reader.GetDouble(latitudeIndex), - }, - } - ); - - return records; + Id = row.AddressId, + CityId = row.CityId, + UserId = row.Id, + Longitude = row.Longitude, + Latitude = row.Latitude, + City = cities[row.CityId], + }, + User = new UserResult + { + Id = row.Id, + FirstName = row.FirstName, + LastName = row.LastName, + Gender = row.Gender, + Username = row.Username, + Bio = row.Bio, + ActivityWeight = (float)row.ActivityWeight, + }, + })]; } - private static async Task> ReadCitiesAsync( + private sealed record CityRow( + int Id, + string CityName, + string StateProvince, + string Iso31662, + string Country, + string Iso31661, + double Longitude, + double Latitude, + string LocalLanguagesJson, + string PostalCodeCountryFormatRegex, + string PostalCodeCityRegexesJson + ); + + private static async Task> ReadCitiesAsync( SqliteConnection connection, CancellationToken cancellationToken ) { const string sql = """ SELECT - id, - city, - state_province, - iso3166_2, - country, - iso3166_1, - local_languages_json, - postal_code_country_format_regex, - postal_code_city_regex_json + id AS Id, + city AS CityName, + state_province AS StateProvince, + iso3166_2 AS Iso31662, + country AS Country, + iso3166_1 AS Iso31661, + longitude AS Longitude, + latitude AS Latitude, + local_languages_json AS LocalLanguagesJson, + postal_code_country_format_regex AS PostalCodeCountryFormatRegex, + postal_code_city_regex_json AS PostalCodeCityRegexesJson FROM cities; """; - await using SqliteCommand command = new(sql, connection); - await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + CommandDefinition command = new(sql, cancellationToken: cancellationToken); + IEnumerable rows = await connection.QueryAsync(command); - Dictionary cities = []; - - const int idIndex = 0; - const int cityNameIndex = 1; - const int stateProvinceIndex = 2; - const int iso31662Index = 3; - const int countryIndex = 4; - const int iso31661Index = 5; - const int localLanguagesIndex = 6; - const int countryFormatRegexIndex = 7; - const int cityRegexesIndex = 8; - - while (await reader.ReadAsync(cancellationToken)) - cities[reader.GetInt64(idIndex)] = new City + return rows.ToDictionary( + row => row.Id, + row => new City { - CityName = reader.GetString(cityNameIndex), - StateProvince = reader.GetString(stateProvinceIndex), - Iso31662 = reader.GetString(iso31662Index), - Country = reader.GetString(countryIndex), - Iso31661 = reader.GetString(iso31661Index), - LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)), - PostalCode = new PostalCodeSpec - { - CountryFormatRegex = reader.GetString(countryFormatRegexIndex), - CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)), - }, - }; - - return cities; + Id = row.Id, + CityName = row.CityName, + StateProvinceName = row.StateProvince, + ISO_3166_2 = row.Iso31662, + CountryName = row.Country, + ISO_3166_1 = row.Iso31661, + Longitude = row.Longitude, + Latitude = row.Latitude, + LocalLanguages = DeserializeStringArray(row.LocalLanguagesJson), + PostalCode = new PostalCodeSpec( + CountryFormatRegex: row.PostalCodeCountryFormatRegex, + CityRegexes: DeserializeStringArray(row.PostalCodeCityRegexesJson) + ), + } + ); } private static IReadOnlyList DeserializeStringArray(string json) { return JsonSerializer.Deserialize>(json) ?? []; } + } diff --git a/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityCommand.cs b/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityCommand.cs new file mode 100644 index 00000000..24a5b331 --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Features.Locations.Commands.CreateCity; + +public record CreateCityCommand(string CityName, Guid StateProvinceId) : IRequest; diff --git a/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityHandler.cs b/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityHandler.cs new file mode 100644 index 00000000..c0b94c1b --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateCity/CreateCityHandler.cs @@ -0,0 +1,11 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Commands.CreateCity; + +public class CreateCityHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(CreateCityCommand request, CancellationToken cancellationToken) => + repository.CreateCityAsync(request.CityName, request.StateProvinceId); +} diff --git a/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryCommand.cs b/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryCommand.cs new file mode 100644 index 00000000..e6f15e29 --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Features.Locations.Commands.CreateCountry; + +public record CreateCountryCommand(string CountryName, string IsoCode) : IRequest; diff --git a/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryHandler.cs b/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryHandler.cs new file mode 100644 index 00000000..894cc83c --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateCountry/CreateCountryHandler.cs @@ -0,0 +1,11 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Commands.CreateCountry; + +public class CreateCountryHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(CreateCountryCommand request, CancellationToken cancellationToken) => + repository.CreateCountryAsync(request.CountryName, request.IsoCode); +} diff --git a/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceCommand.cs b/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceCommand.cs new file mode 100644 index 00000000..c0b9f1e2 --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceCommand.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace Features.Locations.Commands.CreateStateProvince; + +public record CreateStateProvinceCommand(string StateProvinceName, string IsoCode, Guid CountryId) + : IRequest; diff --git a/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceHandler.cs b/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceHandler.cs new file mode 100644 index 00000000..b758d42f --- /dev/null +++ b/web/backend/Features/Features.Locations/Commands/CreateStateProvince/CreateStateProvinceHandler.cs @@ -0,0 +1,18 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Commands.CreateStateProvince; + +public class CreateStateProvinceHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle( + CreateStateProvinceCommand request, + CancellationToken cancellationToken + ) => + repository.CreateStateProvinceAsync( + request.StateProvinceName, + request.IsoCode, + request.CountryId + ); +} diff --git a/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs b/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs deleted file mode 100644 index 95ddb918..00000000 --- a/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index a5f2a95a..00000000 --- a/web/backend/Features/Features.Locations/Commands/GetOrCreateCity/GetOrCreateCityHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -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.Locations/Controllers/CityController.cs b/web/backend/Features/Features.Locations/Controllers/CityController.cs new file mode 100644 index 00000000..55750a2b --- /dev/null +++ b/web/backend/Features/Features.Locations/Controllers/CityController.cs @@ -0,0 +1,45 @@ +using Features.Locations.Dtos; +using Features.Locations.Queries.GetAllCities; +using Features.Locations.Queries.GetCityById; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Shared.Contracts; + +namespace Features.Locations.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] +public class CityController(IMediator mediator) : ControllerBase +{ + [AllowAnonymous] + [HttpGet("{id:guid}")] + public async Task>> GetById(Guid id) + { + CityDto? city = await mediator.Send(new GetCityByIdQuery(id)); + if (city is null) + return NotFound(new ResponseBody { Message = $"City with ID {id} not found." }); + + return Ok( + new ResponseBody { Message = "City retrieved successfully.", Payload = city } + ); + } + + [AllowAnonymous] + [HttpGet] + public async Task>>> GetAll( + [FromQuery] int? limit, + [FromQuery] int? offset + ) + { + IEnumerable cities = await mediator.Send(new GetAllCitiesQuery(limit, offset)); + return Ok( + new ResponseBody> + { + Message = "Cities retrieved successfully.", + Payload = cities, + } + ); + } +} diff --git a/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs b/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs index 5d412a62..259b4d07 100644 --- a/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Locations/DependencyInjection/FeaturesLocationsServiceCollectionExtensions.cs @@ -3,15 +3,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Locations.DependencyInjection; -/// -/// Registers the services used by the Features.Locations slice. -/// public static class FeaturesLocationsServiceCollectionExtensions { - /// - /// Registers and its Dapper-based implementation. - /// - /// The same instance, for chaining. public static IServiceCollection AddFeaturesLocations(this IServiceCollection services) { services.AddScoped(); diff --git a/web/backend/Features/Features.Locations/Dtos/CityDto.cs b/web/backend/Features/Features.Locations/Dtos/CityDto.cs new file mode 100644 index 00000000..e119a0dd --- /dev/null +++ b/web/backend/Features/Features.Locations/Dtos/CityDto.cs @@ -0,0 +1,11 @@ +namespace Features.Locations.Dtos; + +public record CityDto( + Guid CityId, + string CityName, + string StateProvinceName, + string StateProvinceCode, + string CountryName, + string CountryCode, + int BreweryCount +); diff --git a/web/backend/Features/Features.Locations/Dtos/CityLocation.cs b/web/backend/Features/Features.Locations/Dtos/CityLocation.cs deleted file mode 100644 index 3139e951..00000000 --- a/web/backend/Features/Features.Locations/Dtos/CityLocation.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Features.Locations.Dtos; - -/// -/// Describes a city's location for resolution by ILocationRepository, including the -/// state/province and country it belongs to. -/// -public sealed record CityLocation( - string CityName, - string StateProvinceName, - string StateProvinceIsoCode, - string CountryName, - string CountryIsoCode -); diff --git a/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesHandler.cs b/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesHandler.cs new file mode 100644 index 00000000..7739bd7c --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesHandler.cs @@ -0,0 +1,14 @@ +using Features.Locations.Dtos; +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Queries.GetAllCities; + +public class GetAllCitiesHandler(ILocationRepository repository) + : IRequestHandler> +{ + public Task> Handle( + GetAllCitiesQuery request, + CancellationToken cancellationToken + ) => repository.GetAllCitiesAsync(request.Limit, request.Offset); +} diff --git a/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesQuery.cs b/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesQuery.cs new file mode 100644 index 00000000..02654104 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetAllCities/GetAllCitiesQuery.cs @@ -0,0 +1,6 @@ +using Features.Locations.Dtos; +using MediatR; + +namespace Features.Locations.Queries.GetAllCities; + +public record GetAllCitiesQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.Locations/Queries/GetCity/GetCityHandler.cs b/web/backend/Features/Features.Locations/Queries/GetCity/GetCityHandler.cs new file mode 100644 index 00000000..f23b87fb --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCity/GetCityHandler.cs @@ -0,0 +1,10 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Queries.GetCity; + +public class GetCityHandler(ILocationRepository repository) : IRequestHandler +{ + public Task Handle(GetCityQuery request, CancellationToken cancellationToken) => + repository.GetCityIdAsync(request.CityName, request.StateProvinceIsoCode); +} diff --git a/web/backend/Features/Features.Locations/Queries/GetCity/GetCityQuery.cs b/web/backend/Features/Features.Locations/Queries/GetCity/GetCityQuery.cs new file mode 100644 index 00000000..df1ad92f --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCity/GetCityQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Features.Locations.Queries.GetCity; + +public record GetCityQuery(string CityName, string StateProvinceIsoCode) : IRequest; diff --git a/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdHandler.cs b/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdHandler.cs new file mode 100644 index 00000000..c8013312 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdHandler.cs @@ -0,0 +1,12 @@ +using Features.Locations.Dtos; +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Queries.GetCityById; + +public class GetCityByIdHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(GetCityByIdQuery request, CancellationToken cancellationToken) => + repository.GetCityByIdAsync(request.CityId); +} diff --git a/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdQuery.cs b/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdQuery.cs new file mode 100644 index 00000000..f5bbb5b5 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCityById/GetCityByIdQuery.cs @@ -0,0 +1,6 @@ +using Features.Locations.Dtos; +using MediatR; + +namespace Features.Locations.Queries.GetCityById; + +public record GetCityByIdQuery(Guid CityId) : IRequest; diff --git a/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryHandler.cs b/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryHandler.cs new file mode 100644 index 00000000..ea26df9b --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryHandler.cs @@ -0,0 +1,11 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Queries.GetCountry; + +public class GetCountryHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(GetCountryQuery request, CancellationToken cancellationToken) => + repository.GetCountryIdAsync(request.IsoCode); +} diff --git a/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryQuery.cs b/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryQuery.cs new file mode 100644 index 00000000..0ec5b204 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetCountry/GetCountryQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Features.Locations.Queries.GetCountry; + +public record GetCountryQuery(string IsoCode) : IRequest; diff --git a/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceHandler.cs b/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceHandler.cs new file mode 100644 index 00000000..e44f2f02 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceHandler.cs @@ -0,0 +1,11 @@ +using Features.Locations.Repository; +using MediatR; + +namespace Features.Locations.Queries.GetStateProvince; + +public class GetStateProvinceHandler(ILocationRepository repository) + : IRequestHandler +{ + public Task Handle(GetStateProvinceQuery request, CancellationToken cancellationToken) => + repository.GetStateProvinceIdAsync(request.IsoCode); +} diff --git a/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceQuery.cs b/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceQuery.cs new file mode 100644 index 00000000..c0872194 --- /dev/null +++ b/web/backend/Features/Features.Locations/Queries/GetStateProvince/GetStateProvinceQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Features.Locations.Queries.GetStateProvince; + +public record GetStateProvinceQuery(string IsoCode) : IRequest; diff --git a/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs b/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs index ee02ea77..a82eee88 100644 --- a/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs +++ b/web/backend/Features/Features.Locations/Repository/ILocationRepository.cs @@ -2,15 +2,21 @@ using Features.Locations.Dtos; namespace Features.Locations.Repository; -/// -/// Resolves the Country/StateProvince/City location hierarchy shared by features that attach a -/// location to a record (e.g. brewery posts, and eventually user accounts). -/// public interface ILocationRepository { - /// - /// Resolves to a City ID, creating the Country, StateProvince, - /// and/or City rows if any part of the chain doesn't exist yet. - /// - Task GetOrCreateCityIdAsync(CityLocation location); + Task GetCityByIdAsync(Guid cityId); + + Task> GetAllCitiesAsync(int? limit, int? offset); + + Task GetCountryIdAsync(string isoCode); + + Task CreateCountryAsync(string countryName, string isoCode); + + Task GetStateProvinceIdAsync(string isoCode); + + Task CreateStateProvinceAsync(string stateProvinceName, string isoCode, Guid countryId); + + Task GetCityIdAsync(string cityName, string stateProvinceIsoCode); + + Task CreateCityAsync(string cityName, Guid stateProvinceId); } diff --git a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs index d2c8c7d9..da5d1fa6 100644 --- a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs +++ b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs @@ -1,153 +1,61 @@ using System.Data.Common; using Dapper; using Database.Connection; -using Domain.Exceptions; using Features.Locations.Dtos; namespace Features.Locations.Repository; -/// -/// Dapper-based implementation of . -/// public class LocationRepository(ISqlConnectionFactory connectionFactory) : DapperRepository(connectionFactory), ILocationRepository { - /// - /// - /// Not fully race-safe under concurrent callers for the same new city (neither was the stored - /// procedure this replaces, since City has no unique constraint on name+state): a - /// concurrent duplicate insert can succeed rather than being rejected. Country/StateProvince - /// creation guards against this the same way the original stored procedures did, via an - /// existence pre-check immediately before the insert. - /// - public async Task GetOrCreateCityIdAsync(CityLocation location) + private const string CityDtoSelect = """ + SELECT + c.CityID AS CityId, + c.CityName, + sp.StateProvinceName, + sp.ISO3166_2 AS StateProvinceCode, + co.CountryName, + co.ISO3166_1 AS CountryCode, + COUNT(bpl.BreweryPostLocationID) AS BreweryCount + FROM Geolocation.City c + INNER JOIN Geolocation.StateProvince sp ON sp.StateProvinceID = c.StateProvinceID + INNER JOIN Geolocation.Country co ON co.CountryID = sp.CountryID + LEFT JOIN Brewery.BreweryPostLocation bpl ON bpl.CityID = c.CityID + """; + + private const string CityDtoGroupBy = """ + GROUP BY c.CityID, c.CityName, sp.StateProvinceName, sp.ISO3166_2, co.CountryName, co.ISO3166_1 + """; + + public async Task GetCityByIdAsync(Guid cityId) { - await EnsureCountryExistsAsync(location.CountryName, location.CountryIsoCode); - await EnsureStateProvinceExistsAsync( - location.StateProvinceName, - location.StateProvinceIsoCode, - location.CountryIsoCode + await using DbConnection connection = await CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + $""" + {CityDtoSelect} + WHERE c.CityID = @CityId + {CityDtoGroupBy} + """, + new { CityId = cityId } ); - - Guid? cityId = await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode); - if (cityId is not null) - return cityId.Value; - - Guid stateProvinceId = - await GetStateProvinceIdAsync(location.StateProvinceIsoCode) - ?? throw new NotFoundException( - $"State/province '{location.StateProvinceIsoCode}' not found." - ); - - await using DbConnection connection = await CreateConnection(); - - bool cityExists = - await connection.ExecuteScalarAsync( - """ - SELECT 1 - FROM Geolocation.City - WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId - """, - new { location.CityName, StateProvinceId = stateProvinceId } - ) - is not null; - - if (!cityExists) - await connection.ExecuteAsync( - """ - INSERT INTO Geolocation.City (StateProvinceID, CityName) - VALUES (@StateProvinceId, @CityName) - """, - new { StateProvinceId = stateProvinceId, location.CityName } - ); - - return await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode) - ?? throw new InvalidOperationException( - $"City '{location.CityName}' was not found after creation." - ); } - private async Task EnsureCountryExistsAsync(string countryName, string isoCode) + public async Task> GetAllCitiesAsync(int? limit, int? offset) { - if (await GetCountryIdAsync(isoCode) is not null) - return; - await using DbConnection connection = await CreateConnection(); - - bool exists = - await connection.ExecuteScalarAsync( - """ - SELECT 1 - FROM Geolocation.Country - WHERE ISO3166_1 = @ISO3166_1 - """, - new { ISO3166_1 = isoCode } - ) - is not null; - - if (!exists) - // A concurrent caller may create this country between the check above and here; that - // race is tolerated (the resulting duplicate row, if any, is out of scope for this method). - await connection.ExecuteAsync( - """ - INSERT INTO Geolocation.Country (CountryName, ISO3166_1) - VALUES (@CountryName, @ISO3166_1) - """, - new { CountryName = countryName, ISO3166_1 = isoCode } - ); + return await connection.QueryAsync( + $""" + {CityDtoSelect} + {CityDtoGroupBy} + ORDER BY c.CityName + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY + """, + new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue } + ); } - /// - /// Ensures a StateProvince row exists for . - /// - /// - /// Thrown when no Country exists for . Not expected in normal - /// operation, since callers ensure the Country exists first — retained as a defensive translation - /// in case of a race between concurrent callers. - /// - private async Task EnsureStateProvinceExistsAsync( - string stateProvinceName, - string isoCode, - string countryIsoCode - ) - { - if (await GetStateProvinceIdAsync(isoCode) is not null) - return; - - Guid countryId = - await GetCountryIdAsync(countryIsoCode) - ?? throw new NotFoundException($"Country '{countryIsoCode}' not found."); - - await using DbConnection connection = await CreateConnection(); - - bool exists = - await connection.ExecuteScalarAsync( - """ - SELECT 1 - FROM Geolocation.StateProvince - WHERE ISO3166_2 = @ISO3166_2 - """, - new { ISO3166_2 = isoCode } - ) - is not null; - - if (!exists) - await connection.ExecuteAsync( - """ - INSERT INTO Geolocation.StateProvince (StateProvinceName, ISO3166_2, CountryID) - VALUES (@StateProvinceName, @ISO3166_2, @CountryId) - """, - new - { - StateProvinceName = stateProvinceName, - ISO3166_2 = isoCode, - CountryId = countryId, - } - ); - } - - private async Task GetCountryIdAsync(string isoCode) + public async Task GetCountryIdAsync(string isoCode) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( @@ -160,7 +68,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) ); } - private async Task GetStateProvinceIdAsync(string isoCode) + public async Task CreateCountryAsync(string countryName, string isoCode) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + INSERT INTO Geolocation.Country (CountryName, ISO3166_1) + OUTPUT INSERTED.CountryID + VALUES (@CountryName, @ISO3166_1) + """, + new { CountryName = countryName, ISO3166_1 = isoCode } + ); + } + + public async Task GetStateProvinceIdAsync(string isoCode) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( @@ -173,7 +94,29 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) ); } - private async Task GetCityIdAsync(string cityName, string stateProvinceIsoCode) + public async Task CreateStateProvinceAsync( + string stateProvinceName, + string isoCode, + Guid countryId + ) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + INSERT INTO Geolocation.StateProvince (StateProvinceName, ISO3166_2, CountryID) + OUTPUT INSERTED.StateProvinceID + VALUES (@StateProvinceName, @ISO3166_2, @CountryId) + """, + new + { + StateProvinceName = stateProvinceName, + ISO3166_2 = isoCode, + CountryId = countryId, + } + ); + } + + public async Task GetCityIdAsync(string cityName, string stateProvinceIsoCode) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( @@ -186,4 +129,17 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) new { CityName = cityName, StateProvinceCode = stateProvinceIsoCode } ); } + + public async Task CreateCityAsync(string cityName, Guid stateProvinceId) + { + await using DbConnection connection = await CreateConnection(); + return await connection.ExecuteScalarAsync( + """ + INSERT INTO Geolocation.City (StateProvinceID, CityName) + OUTPUT INSERTED.CityID + VALUES (@StateProvinceId, @CityName) + """, + new { StateProvinceId = stateProvinceId, CityName = cityName } + ); + } }