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 }
+ );
+ }
}