Compare commits

77 Commits

Author SHA1 Message Date
6297cb141c Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)
* Integrate Spectre.Console into seed application, use async data loading

* Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.

* Add new documentation for Dapper migration/database schema

* Update FluentAssertions to most recent open source ver

* Update XML doc comment in backend projects

* Modify brewery location dto to include denormalized data

* - Updated column names from 'Timer' to 'RowVersion' in schema.sql for
- Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency.
- Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'.
- Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'.
- Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly.
- Ensured all repository methods and interfaces are aligned with the new concurrency handling approach.

* updates

* Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts

* Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture

* Remove repository tests, usages of registration dto used in repo

* archive old stored procs/functions

* update stale docs

* Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
2026-08-21 13:09:13 -04:00
Aaron Po
a4da01586d Documentation updates (#236)
Remove vague or flowery language, use Simplified Technical English whenever possible
2026-08-04 09:38:24 -04:00
Aaron Po
3087d599ae remove comment 2026-08-04 06:30:44 -04:00
Aaron Po
48ee6b0fef Format 2026-08-04 06:30:44 -04:00
Aaron Po
7a0620337d update backend/shared docs 2026-08-04 06:30:44 -04:00
Aaron Po
64302286fb update infra docs 2026-08-04 06:30:44 -04:00
Aaron Po
88abb8e995 update features docs 2026-08-04 06:30:44 -04:00
Aaron Po
2ed655e03d update domain docs 2026-08-04 06:30:44 -04:00
Aaron Po
be0fe9d405 update api docs 2026-08-04 06:30:44 -04:00
Aaron Po
492d5d280e Add navigation properties to entity types 2026-08-04 06:30:44 -04:00
Aaron Po
f3f704da8d format sql 2026-08-04 06:30:44 -04:00
Aaron Po
1d1edd37d0 Refactor user registration process to use UserRegistrationDto;
implement user registration in seed app
2026-08-04 06:30:44 -04:00
Aaron Po
417fa35ced Update database migration and seeding logic; adjust Docker port mapping 2026-08-04 06:30:44 -04:00
Aaron Po
66d84dee9e add wiring 2026-08-04 06:30:44 -04:00
Aaron Po
fcca12b49d Update viewer 2026-08-04 06:30:44 -04:00
Aaron Po
84e9e79d81 Create biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite 2026-08-04 06:30:44 -04:00
Aaron Po
5b2eceb6b8 Add sqlite load into Database.Seed 2026-08-04 06:30:44 -04:00
Aaron Po
3154c2feed Formatted src/web_client 2026-07-19 06:50:56 -04:00
Aaron Po
3df657dc54 Formatted src/services/xeger 2026-07-19 06:50:56 -04:00
Aaron Po
a238db1d37 Formatted src/services/sqlite/helpers 2026-07-19 06:50:56 -04:00
Aaron Po
5d48d6757a Formatted src/services/sqlite 2026-07-19 06:50:56 -04:00
Aaron Po
34a036d7b3 Formatted src/services/postal_code 2026-07-19 06:50:56 -04:00
Aaron Po
59053fcddd Formatted src/services/logging 2026-07-19 06:50:56 -04:00
Aaron Po
d4c7260127 Formatted src/services/enrichment/wikipedia 2026-07-19 06:50:56 -04:00
Aaron Po
be90522eae Formatted src/services/curated_data 2026-07-19 06:50:56 -04:00
Aaron Po
d055a634c2 Formatted src/services 2026-07-19 06:50:56 -04:00
Aaron Po
933b728135 Formatted src/data_generation/prompt_formatting 2026-07-19 06:50:56 -04:00
Aaron Po
ac98a2a22e Formatted src/data_generation/mock 2026-07-19 06:50:56 -04:00
Aaron Po
d723f53cba Formatted src/data_generation/llama 2026-07-19 06:50:56 -04:00
Aaron Po
1885d9859f Formatted src/biergarten_pipeline_orchestrator 2026-07-19 06:50:56 -04:00
Aaron Po
03467cbb65 Formatted src/application_options 2026-07-19 06:50:56 -04:00
Aaron Po
1f25d25dce Formatted src 2026-07-19 06:50:56 -04:00
Aaron Po
67bc5bfc2a Formatted includes/web_client 2026-07-19 06:50:56 -04:00
Aaron Po
eb32a55831 Formatted includes/services/xeger 2026-07-19 06:50:56 -04:00
Aaron Po
fad1611685 Formatted includes/services/prompting 2026-07-19 06:50:56 -04:00
Aaron Po
74f848d015 Formatted includes/services/postal_code 2026-07-19 06:50:56 -04:00
Aaron Po
86db1c2827 Formatted includes/services/logging 2026-07-19 06:50:56 -04:00
Aaron Po
9bcf4c6879 Formatted includes/services/enrichment 2026-07-19 06:50:56 -04:00
Aaron Po
d9b10df436 Formatted includes/services/datetime 2026-07-19 06:50:56 -04:00
Aaron Po
4f0f56d7d7 Formatted includes/services/database 2026-07-19 06:50:56 -04:00
Aaron Po
6c07edf845 Formatted includes/services/curated_data 2026-07-19 06:50:56 -04:00
Aaron Po
969125e3a5 Formatted includes/json_handling 2026-07-19 06:50:56 -04:00
Aaron Po
ffabd85b6d Formatted includes/data_model 2026-07-19 06:50:56 -04:00
Aaron Po
4fae5ead08 Formatted includes/data_generation/prompt_formatting 2026-07-19 06:50:56 -04:00
Aaron Po
efd0d4f2aa Formatted includes/data_generation 2026-07-19 06:50:56 -04:00
Aaron Po
522381357a Formatted includes/concurrency 2026-07-19 06:50:56 -04:00
Aaron Po
5f3706bb93 Formatted includes 2026-07-19 06:50:56 -04:00
Aaron Po
a52b14c49a update clang format 2026-07-19 06:50:56 -04:00
Aaron Po
4ebd28c06b format docs 2026-07-19 06:50:56 -04:00
Aaron Po
9593a07c13 Format curated data and prompts 2026-07-19 06:50:56 -04:00
Aaron Po
f020671703 Pipeline: City rename, add address/postal-code generation, add xeger generation (#232) 2026-07-19 03:59:06 -04:00
Aaron Po
fbcf438381 Fix mock-only builds (#231) 2026-07-03 02:47:54 -04:00
2b8a900d12 Pipeline: Add LLM and mocked user generation to the pipeline (#230) 2026-07-01 07:47:24 -04:00
Aaron Po
880f73e004 update pipeline docs 2026-06-21 21:58:03 -04:00
Aaron Po
3711591db1 commit csharpier config update 2026-06-20 15:54:53 -04:00
Aaron Po
194509f9c9 update mailkit 2026-06-20 15:54:53 -04:00
Aaron Po
67d6350425 Format ./web/backend/Shared/Shared.Contracts 2026-06-20 15:54:53 -04:00
Aaron Po
cee2917556 Format ./web/backend/Shared/Shared.Application 2026-06-20 15:54:53 -04:00
Aaron Po
6f8e5dc722 Format ./web/backend/API/API.Specs 2026-06-20 15:54:53 -04:00
Aaron Po
db3ed3581d Format ./web/backend/API/API.Core 2026-06-20 15:54:53 -04:00
Aaron Po
b54f70ccbe Format ./web/backend/Domain/Domain.Exceptions 2026-06-20 15:54:53 -04:00
Aaron Po
d4734ae9e7 Format ./web/backend/Domain/Domain.Entities 2026-06-20 15:54:53 -04:00
Aaron Po
27f28d5207 Format ./web/backend/Database/Database.Seed 2026-06-20 15:54:53 -04:00
Aaron Po
bf0d29fa54 Format ./web/backend/Database/Database.Migrations 2026-06-20 15:54:53 -04:00
Aaron Po
4772f59a68 Format ./web/backend/Infrastructure/Infrastructure.Sql 2026-06-20 15:54:53 -04:00
Aaron Po
2e5d4923c0 Format ./web/backend/Infrastructure/Infrastructure.PasswordHashing 2026-06-20 15:54:53 -04:00
Aaron Po
62766b7638 Format ./web/backend/Infrastructure/Infrastructure.Jwt 2026-06-20 15:54:53 -04:00
Aaron Po
744d4c7f8c Format ./web/backend/Infrastructure/Infrastructure.Email.Templates 2026-06-20 15:54:53 -04:00
Aaron Po
2f28ac9350 Format ./web/backend/Infrastructure/Infrastructure.Email 2026-06-20 15:54:53 -04:00
Aaron Po
c8bddae817 Format ./web/backend/Features/Features.UserManagement.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
b298bd02d3 Format ./web/backend/Features/Features.UserManagement 2026-06-20 15:54:53 -04:00
Aaron Po
e2e5eb89e3 Format ./web/backend/Features/Features.Emails.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
3dd44fca5a Format ./web/backend/Features/Features.Emails 2026-06-20 15:54:53 -04:00
Aaron Po
0c55d6c83f Format ./web/backend/Features/Features.Breweries.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
58cbc5b5ba Format ./web/backend/Features/Features.Breweries 2026-06-20 15:54:53 -04:00
Aaron Po
5bef24696b Format ./web/backend/Features/Features.Auth.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
79ae18b3e2 Format ./web/backend/Features/Features.Auth 2026-06-20 15:54:53 -04:00
347 changed files with 48114 additions and 8839 deletions

View File

@@ -1,25 +1,30 @@
# The Biergarten App # The Biergarten App
The Biergarten App is a full-stack directory and discovery platform for The Biergarten App is a full-stack directory and discovery platform for
breweries. It features a robust user authentication system, a searchable breweries. It provides:
database of brewery locations, and a custom offline data-generation pipeline
that uses LLMs (Llama.cpp) and Wikipedia to synthesize realistic seed data.
It features: - JWT-based user authentication, with email confirmation and refresh-token
reissue
- A searchable database of brewery locations
- An offline data-generation pipeline that uses large language models (LLMs)
via Llama.cpp and Wikipedia to synthesize realistic seed data
The repository is organized into:
- A .NET backend (Web API + database migrations/seed) under `web/backend/` - A .NET backend (Web API + database migrations/seed) under `web/backend/`
- A server-rendered React website (React Router + Vite) under `web/frontend/` - A server-rendered React website (React Router + Vite) under `web/frontend/`
- A C++20 “pipeline” CLI for generating seed data under `tooling/pipeline/` - A C++20 data-generation CLI under `tooling/pipeline/`
Specialized documentation (setup, architecture, docker, testing, diagrams, and Specialized documentation (setup, architecture, docker, testing, diagrams, and
pipeline notes) lives under `docs/`. pipeline notes) lives under `docs/`.
## Documentation (Start Here) ## Documentation
Website + backend (active stack): Website + backend (active stack):
- [Getting Started](docs/website/getting-started.md) - [Getting Started](docs/website/getting-started.md)
- [Architecture](docs/architecture.md) - [Architecture](docs/architecture.md)
- [Database](docs/website/database.md)
- [Docker Guide](docs/website/docker.md) - [Docker Guide](docs/website/docker.md)
- [Testing](docs/website/testing.md) - [Testing](docs/website/testing.md)
- [Environment Variables](docs/website/environment-variables.md) - [Environment Variables](docs/website/environment-variables.md)
@@ -37,7 +42,7 @@ Data generation pipeline (C++):
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg) - [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
- [Database Schema](docs/website/diagrams-out/database-schema.svg) - [Database Schema](docs/website/diagrams-out/database-schema.svg)
## Current Status ## Current status
Active areas in the repository: Active areas in the repository:
@@ -52,18 +57,27 @@ Archived/reference areas:
- `archive/next-js-web-app/` contains an older Next.js frontend retained for - `archive/next-js-web-app/` contains an older Next.js frontend retained for
reference reference
## Tech Stack ## Tech stack
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp - **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5 - **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright - **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq - **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
- **Infrastructure**: Docker, Docker Compose - **Infrastructure**: Docker, Docker Compose
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation - **Security**: Argon2id password hashing, JSON Web Tokens (JWT) for
tokens access/refresh/confirmation
- **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp - **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp
## Quick Start ## Requirements
- .NET SDK 10 or later
- Node.js 18 or later
- Docker Desktop, or an equivalent Docker Engine setup
See [Getting Started](docs/website/getting-started.md) for the full
prerequisite list, including the pipeline's C++ toolchain.
## Quick start
For full setup details, use [Getting Started](docs/website/getting-started.md). For full setup details, use [Getting Started](docs/website/getting-started.md).
This section is the shortest path to a working dev environment. This section is the shortest path to a working dev environment.
@@ -101,7 +115,7 @@ npm run test:storybook
npm run test:storybook:playwright npm run test:storybook:playwright
``` ```
## Repository Structure ## Repository structure
```text ```text
web/ web/
@@ -123,9 +137,10 @@ archive/
## Testing ## Testing
Run the backend test stack with Docker: Create a test environment file, then run the backend test stack with Docker:
```bash ```bash
cp web/.env.example web/.env.test
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
``` ```
@@ -147,5 +162,16 @@ See [Environment Variables](docs/website/environment-variables.md) for details.
1. Fork the repository 1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`) 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`) 3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`) 4. Run the checks in [Testing](docs/website/testing.md) (backend tests,
5. Open a Pull Request `npm run lint`, `npm run typecheck`)
5. Push to the branch (`git push origin feature/amazing-feature`)
6. Open a Pull Request
## Support
Report bugs and request features via
[GitHub Issues](https://github.com/aaronpo97/the-biergarten-app/issues).
## License
GPL-3.0. See [LICENSE.md](LICENSE.md).

View File

@@ -0,0 +1,15 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
END;

View File

@@ -0,0 +1,13 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END;

View File

@@ -0,0 +1,31 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE
(
UserAccountID UNIQUEIDENTIFIER
);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -0,0 +1,20 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -0,0 +1,15 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;

View File

@@ -0,0 +1,20 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;

View File

@@ -0,0 +1,20 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END

View File

@@ -0,0 +1,20 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR(64)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;

View File

@@ -0,0 +1,28 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;
END
END;

View File

@@ -0,0 +1,18 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -0,0 +1,28 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
COMMIT TRANSACTION;
END;

View File

@@ -0,0 +1,50 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username,
@FirstName = @FirstName,
@LastName = @LastName,
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF
@UserAccountId_ IS NULL
BEGIN
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF
@@ROWCOUNT = 0
BEGIN
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END

View File

@@ -0,0 +1,31 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
END;

View File

@@ -0,0 +1,27 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION;
END

View File

@@ -0,0 +1,38 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -0,0 +1,24 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -0,0 +1,32 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -0,0 +1,59 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -0,0 +1,17 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,28 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -0,0 +1,9 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,77 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -2,7 +2,7 @@
This document describes the active architecture of The Biergarten App. This document describes the active architecture of The Biergarten App.
## High-Level Overview ## High-level overview
The Biergarten App is a monorepo with a clear split between the backend and the The Biergarten App is a monorepo with a clear split between the backend and the
active website: active website:
@@ -13,8 +13,8 @@ active website:
- **Architecture Style**: Vertical-slice backend plus server-rendered React - **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend frontend
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
for reference only. reference only.
## Diagrams ## Diagrams
@@ -29,15 +29,17 @@ For visual representations, see:
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database - [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
relationships relationships
## Backend Architecture ## Backend architecture
### Vertical Slice Architecture Pattern ### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`, technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`) is a single project that owns `Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
its own controller, MediatR commands/queries/handlers, validators, and project that owns its own MediatR commands/queries/handlers, validators, and
repository, end to end: repository end to end. `Features.Emails` and `Features.Locations` have no
`Controllers/` folder — both are invoked internally by other slices via
MediatR/DI, never over HTTP:
``` ```
┌───────────────────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────────────────┐
@@ -46,15 +48,15 @@ repository, end to end:
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
└───────────────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart ↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬───────────────────┐ ┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │ │ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
│ │Breweries │ UserManagement │ (no controller, │ │ │Breweries │ UserManagement │ (no controller, │ Locations │
│ Controller │ Controller │ Controller │ internal only) │ │ Controller │ Controller │ Controller │ internal only) │ (no controller, │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ │ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
│ Queries + │ Queries + │ Queries + │ Handlers │ │ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
│ Handlers │ Handlers │ Handlers │ │ │ Handlers │ Handlers │ Handlers │ │ │
│ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │ Repository │ Repository │ EmailDispatcher │ │
└───────────────┴───────────────┴───────────────────┴───────────────────┘ └───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice ↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐ ┌─────────────────────────┬─────────────────────────┬────────────────────┐
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │
@@ -69,23 +71,22 @@ repository, end to end:
↓ ↓
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Database (SQL Server) │ │ Database (SQL Server) │
│ - Stored procedures │
│ - Tables & constraints │ │ - Tables & constraints │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
Slices never reference each other's project. The one cross-slice Slices never reference each other's project. The one cross-slice interaction
interaction (`Features.Auth` triggering a confirmation email handled by (`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`) goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
whose contract lives in `Shared.Application`, so neither slice takes a lives in `Shared.Application`, so neither slice takes a project reference on the
project reference on the other. other.
### Layer Responsibilities ### Layer responsibilities
#### API Layer (`API.Core`) #### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of **Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
its own own
**Components**: **Components**:
@@ -108,32 +109,36 @@ its own
- No controllers, no business logic, no feature-specific contracts - No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices - Exists purely to host and wire up the feature slices
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`) #### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
**Purpose**: Each slice is the complete vertical for one business capability **Purpose**: Each slice is the complete vertical for one business capability
**Components** (per slice): **Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types - `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
as the request contract request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per - `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
operation, each containing the Command/Query record, its each containing the Command/Query record, its `IRequestHandler`, and (for
`IRequestHandler`, and (for commands) a FluentValidation validator commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository - `Repository/`: the slice's own Dapper repository implementation
implementation - `Dtos/`: response shapes returned by query handlers (never the raw domain
- `Dtos/`: response shapes returned by query handlers (never the raw entity)
domain entity) - `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering slice's repository/services
the slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via `Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
MediatR commands sent from other slices, never over HTTP. commands sent from other slices, never over HTTP. `Features.Locations` also has
no `Controllers/` folder and no MediatR handlers — it exposes
`ILocationRepository` directly as a plain service, currently consumed only by
`Database.Seed` to look up cities while seeding brewery locations. Other slices
(for example, `Features.Breweries`) do not yet depend on it; they run their own
`CityID` existence checks inline.
**Dependencies**: **Dependencies**:
- `Domain.Entities`, `Domain.Exceptions` - `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever - `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
infrastructure project the slice needs (`Infrastructure.Jwt`/ project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ `Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails) `Infrastructure.Email.Templates` for Emails)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
@@ -142,31 +147,29 @@ MediatR commands sent from other slices, never over HTTP.
**Rules**: **Rules**:
- All business logic for that feature lives in its command/query handlers - All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through - No direct controller-to-repository calls; everything flows through MediatR
MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly - Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared Projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every **Purpose**: The minimum cross-slice surface area required because every slice
slice needs it, or because duplicating it four times would be worse than needs it, or because duplicating it four times would be worse than sharing it
sharing it
**Components**: **Components**:
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response - `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
envelope every controller returns envelope every controller returns
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the - `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
MediatR pipeline behavior that runs FluentValidation before a handler pipeline behavior that runs FluentValidation before a handler executes) and
executes) and the cross-slice email commands the cross-slice email commands (`SendRegistrationEmailCommand`,
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) `SendResendConfirmationEmailCommand`)
**Rules**: **Rules**:
- Kept deliberately small: this is the exception to "no slice depends on - Kept deliberately small: this is the exception to "no slice depends on another
another slice," not a general-purpose dumping ground slice," not a general-purpose dumping ground
#### Infrastructure Layer #### Infrastructure layer
**Purpose**: Technical capabilities and external integrations, shared by **Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them whichever slices need them
@@ -185,14 +188,14 @@ whichever slices need them
**Dependencies**: **Dependencies**:
- Domain entities - Domain entities
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.) - External libraries (ADO.NET, JWT, Argon2, MailKit, and so on)
**Rules**: **Rules**:
- Implements technical concerns only, no business logic - Implements technical concerns only, no business logic
- Reusable across slices - Reusable across slices
#### Domain Layer (`Domain.Entities`) #### Domain layer (`Domain.Entities`)
**Purpose**: Core business entities and models **Purpose**: Core business entities and models
@@ -201,6 +204,14 @@ whichever slices need them
- `UserAccount` - User profile data - `UserAccount` - User profile data
- `UserCredential` - Authentication credentials - `UserCredential` - Authentication credentials
- `UserVerification` - Account verification state - `UserVerification` - Account verification state
- `BreweryPost` - A user-submitted brewery listing
- `BreweryPostLocation` - A brewery's address and coordinates
- `City` - A city referenced by a brewery location
- `StateProvince` - A state/province referenced by a city
- `Country` - A country referenced by a state/province
- `BeerStyle` - A beer style/category (schema table exists; no repository yet)
- `BeerPost` - A beer listing tied to a brewery and style (schema table
exists; no repository yet)
**Dependencies**: **Dependencies**:
@@ -213,21 +224,20 @@ whichever slices need them
- No infrastructure references - No infrastructure references
- Represents business concepts - Represents business concepts
### Design Patterns ### Design patterns
#### Vertical Slice + MediatR #### Vertical slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so **Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one everything needed to understand or change one capability lives in one project
project
**Implementation**: **Implementation**:
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in - Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (e.g. `GetBreweryByIdQuery`) `Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body; - Controllers bind directly to the Command/Query as the request body; there is
there is no separate request DTO + mapping step for writes no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the (`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes MediatR pipeline before any handler executes
@@ -246,23 +256,22 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
} }
``` ```
#### Repository Pattern #### Repository pattern
**Purpose**: Abstract database access behind interfaces **Purpose**: Abstract database access behind interfaces
**Implementation**: each slice owns its own repository, scoped to that **Implementation**: each slice owns its own repository, scoped to that feature
feature only: only:
- `Features.Auth/Repository/IAuthRepository.cs` - `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs` - `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic - `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
connection factory every slice's repository builds on factory every slice's repository builds on
**Benefits**: **Benefits**:
- Testable (easy to mock) - Testable (easy to mock)
- SQL-first approach (stored procedures)
- Each slice's data access logic is self-contained - Each slice's data access logic is self-contained
**Example**: **Example**:
@@ -275,13 +284,13 @@ public interface IAuthRepository
} }
``` ```
#### Dependency Injection #### Dependency injection
**Purpose**: Loose coupling and testability **Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across **Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()` `Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
extension method that registers its repository and slice-internal services method that registers its repository and slice-internal services
**Lifetimes**: **Lifetimes**:
@@ -289,26 +298,36 @@ extension method that registers its repository and slice-internal services
- Singleton: `ISqlConnectionFactory` - Singleton: `ISqlConnectionFactory`
- Transient: Utilities, helpers - Transient: Utilities, helpers
#### SQL-First Approach #### Direct SQL via Dapper/ADO.NET
**Purpose**: Push complex logic into the database **Purpose**: Keep data access explicit and colocated with the slice that owns
it, without an ORM's change-tracking or mapping magic
**Strategy**: **Strategy**:
- All queries via stored procedures - No ORM (Entity Framework not used); each repository issues inline SQL
- No ORM (Entity Framework not used) through Dapper, with no manual `IDbCommand` parameter binding. The one
- Database handles complex logic wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
- Application focuses on orchestration can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind
- Referential checks that a stored procedure used to perform (e.g. "does this
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
repository method, run inside the same transaction as the write
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
`ConflictException`, ...) directly when a check fails, rather than relying on
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
exception types to HTTP status codes
- Application focuses on orchestration; the database enforces integrity via
keys, `CHECK` constraints, and cascades
**Stored Procedure Examples**: See [Database](website/database.md) for the schema and the app's current SQL
error-handling approach in more detail.
- `USP_RegisterUser` - User registration ## Frontend architecture
- `USP_GetUserAccountByUsername` - User lookup
- `USP_RotateUserCredential` - Password update
## Frontend Architecture ### Active website (`web/frontend`)
### Active Website (`web/frontend`)
The current website is a React Router 7 application with server-side rendering The current website is a React Router 7 application with server-side rendering
enabled. enabled.
@@ -327,7 +346,7 @@ web/frontend/
└── package.json Frontend scripts and dependencies └── package.json Frontend scripts and dependencies
``` ```
### Frontend Responsibilities ### Frontend responsibilities
- Render the auth demo and theme guide routes - Render the auth demo and theme guide routes
- Manage cookie-backed website session state - Manage cookie-backed website session state
@@ -335,7 +354,7 @@ web/frontend/
- Provide shared UI building blocks for forms, navigation, themes, and toasts - Provide shared UI building blocks for forms, navigation, themes, and toasts
- Supply Storybook documentation and browser-based component verification - Supply Storybook documentation and browser-based component verification
### Theme System ### Theme system
The active website uses semantic DaisyUI theme tokens backed by four Biergarten The active website uses semantic DaisyUI theme tokens backed by four Biergarten
themes: themes:
@@ -348,34 +367,45 @@ themes:
All component styling should prefer semantic tokens such as `primary`, All component styling should prefer semantic tokens such as `primary`,
`success`, `surface`, and `highlight` instead of hard-coded color values. `success`, `surface`, and `highlight` instead of hard-coded color values.
### Legacy Frontend ### Legacy frontend
The previous Next.js frontend has been archived at `archive/next-js-web-app/` The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point for reference only. Active product and engineering documentation should point to
to `web/frontend`. `web/frontend`.
## Security Architecture ## Security architecture
### Authentication Flow ### Authentication flow
1. **Registration**: 1. **Registration**:
- User submits credentials - User submits credentials
- Password hashed with Argon2id - Password hashed with Argon2id
- User account created - User account created (unverified) and a confirmation email is dispatched
- JWT token issued via `Features.Emails`
2. **Login**: 2. **Email confirmation**:
- User follows the confirmation link/token from the email
- `Features.Auth`'s `ConfirmUser` command marks the account verified
3. **Login**:
- User submits credentials - User submits credentials
- Password verified against hash - Password verified against hash
- JWT token issued - Access and refresh JWTs are issued; the refresh token is stored server-side
- Token stored client-side against the user's credential row
3. **API Requests**: 4. **Token refresh**:
- Client sends JWT in Authorization header - Client exchanges a valid, unexpired refresh token for a new access/refresh
- Middleware validates token pair via `Features.Auth`'s `RefreshToken` command
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
refresh token is not invalidated and remains usable until its own
expiration
5. **API Requests**:
- Client sends the access JWT in the `Authorization` header
- `JwtAuthenticationHandler` validates the token
- Request proceeds if valid - Request proceeds if valid
### Password Security ### Password security
**Algorithm**: Argon2id **Algorithm**: Argon2id
@@ -385,7 +415,7 @@ to `web/frontend`.
- Salt: 128-bit (16 bytes) - Salt: 128-bit (16 bytes)
- Hash: 256-bit (32 bytes) - Hash: 256-bit (32 bytes)
### JWT Tokens ### JWT tokens
**Algorithm**: HS256 (HMAC-SHA256) **Algorithm**: HS256 (HMAC-SHA256)
@@ -409,60 +439,72 @@ to `web/frontend`.
} }
``` ```
## Database Architecture ## Database architecture
### SQL-First Philosophy For the table-by-table schema and the custom SQL error code scheme, see
[Database](website/database.md).
### SQL-first philosophy
**Principles**: **Principles**:
1. Database is source of truth 1. Database is source of truth
2. Complex queries in stored procedures 2. Queries live in the owning repository, not scattered across the app
3. Database handles referential integrity 3. Database handles referential integrity
4. Application orchestrates, database executes 4. Application orchestrates, database executes
**Benefits**: **Benefits**:
- Performance optimization via execution plans - Performance optimization via execution plans
- Centralized query logic - Query logic scoped to the repository that owns it, not spread across layers
- Version-controlled schema (migrations) - Version-controlled schema (migrations)
- Easier query profiling and tuning - Easier query profiling and tuning
### Migration Strategy ### Migration strategy
**Tool**: DbUp **Tool**: DbUp
**Process**: **Process**:
1. Write SQL migration script 1. Write a SQL script under `Database.Migrations/scripts/`
2. Embed in `Database.Migrations` project 2. Embed it in the `Database.Migrations` project
3. Run migrations on startup 3. `Database.Migrations` runs each script against the target database on
4. Idempotent and versioned startup, tracked so a script never re-runs once applied
**Migration Files**: **Migration Files**:
``` ```
scripts/ scripts/
├── 001-CreateUserTables.sql └── 01-schema/
├── 002-CreateLocationTables.sql └── schema.sql # full table/index/constraint definitions
├── 003-CreateBreweryTables.sql
└── ...
``` ```
### Data Seeding Currently the whole schema is a single versioned script. As the schema evolves,
new numbered scripts get added alongside it rather than editing `schema.sql` in
place, so DbUp's already-applied tracking stays valid.
**Purpose**: Populate development/test databases ### Data seeding
**Implementation**: `Database.Seed` project **Purpose**: Populate development/test databases with realistic data
**Implementation**: `Database.Seed` project, using data produced by the C++
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
**Seed Data**: **Seed Data**:
- Countries, states/provinces, cities - Countries, state/provinces, and cities (via `Features.Locations`'
- Test user accounts `ILocationRepository`, which creates `Country`/`StateProvince` rows on
- Sample breweries (future) demand while resolving a city)
- User accounts (via `Features.Auth`'s repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
## Deployment Architecture Tables that exist in the schema but have no repository yet (`Photo`,
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
### Docker Containerization ## Deployment architecture
### Docker containerization
**Container Structure**: **Container Structure**:
@@ -470,6 +512,7 @@ scripts/
- `database.migrations` - Schema migration runner - `database.migrations` - Schema migration runner
- `database.seed` - Data seeder - `database.seed` - Data seeder
- `api.core` - ASP.NET Core Web API - `api.core` - ASP.NET Core Web API
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
**Environments**: **Environments**:
@@ -479,10 +522,10 @@ scripts/
For details, see [Docker Guide](website/docker.md). For details, see [Docker Guide](website/docker.md).
### Health Checks ### Health checks
**SQL Server**: Validates database connectivity **API**: Checks service health - **SQL Server**: Validates database connectivity
and dependencies - **API**: Checks service health and dependencies
**Configuration**: **Configuration**:
@@ -494,9 +537,9 @@ healthcheck:
start_period: 30s start_period: 30s
``` ```
## Testing Architecture ## Testing architecture
### Test Pyramid ### Test pyramid
``` ```
┌──────────────┐ ┌──────────────┐
@@ -505,8 +548,8 @@ healthcheck:
├──────────────┤ ├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
│ handlers + │ (commands/queries/handlers + that slice's own │ handler │ (commands/queries/handlers, with repository/service
│ repository) │ repository, mocked with Moq/DbMocker) │ tests) │ dependencies mocked via Moq)
└──────────────┘ └──────────────┘
``` ```

View File

@@ -1,26 +1,28 @@
# Ethics, Bias, and Known Issues # Ethics, bias, and known issues
This document covers the ethical context of the Biergarten Pipeline's output, This document covers the ethical context of the Biergarten Pipeline's output,
the model's biases, and known issues including hallucinated brewing science and the model's biases, and known issues including hallucinated brewing science and
low-resource language failures. low-resource language failures.
> Note that all testing was used using `google_gemma-4-E4B-it-Q6_K.gguf`. > All testing described below used `google_gemma-4-E4B-it-Q6_K.gguf`.
## Table of Contents ## Table of contents
- [What This Dataset Is](#what-this-dataset-is) - [What this dataset is](#what-this-dataset-is)
- [What This Dataset Is Not](#what-this-dataset-is-not) - [What this dataset is not](#what-this-dataset-is-not)
- [Model Bias and Language Quality](#model-bias-and-language-quality) - [Model bias and language quality](#model-bias-and-language-quality)
- [Western and Eurocentric Lens](#western-and-eurocentric-lens) - [Western and Eurocentric lens](#western-and-eurocentric-lens)
- [Wikipedia Enrichment](#wikipedia-enrichment) - [Wikipedia enrichment](#wikipedia-enrichment)
- [The "Avoid AI Phrases" Prompt Instruction](#the-avoid-ai-phrases-prompt-instruction) - [Names-by-country dataset](#names-by-country-dataset)
- [Known Issues](#known-issues) - [The "avoid AI phrases" prompt instruction](#the-avoid-ai-phrases-prompt-instruction)
- [Hallucinated Brewing Techniques](#hallucinated-brewing-techniques) - [Known issues](#known-issues)
- [Low-Resource Language Hallucination](#low-resource-language-hallucination) - [Hallucinated brewing techniques](#hallucinated-brewing-techniques)
- [Low-resource language hallucination](#low-resource-language-hallucination)
- [Synthetic postal codes](#synthetic-postal-codes)
--- ---
## What This Dataset Is ## What this dataset is
This is AI-generated fixture data for a proof-of-concept version of The This is AI-generated fixture data for a proof-of-concept version of The
Biergarten App. Anyone who interacts with an application seeded from this Biergarten App. Anyone who interacts with an application seeded from this
@@ -28,29 +30,29 @@ pipeline must be told upfront that the content is AI-generated.
--- ---
## What This Dataset Is Not ## What this dataset is not
The pipeline is not intended to produce accurate brewing science, faithful The pipeline is not intended to produce accurate brewing science, faithful
cultural representation, or reliable local-language text. Hallucinations such as cultural representation, or reliable local-language text. Hallucinations such as
invented fermentation techniques, or incoherent local-language prose, are invented fermentation techniques, or incoherent local-language prose, are
expected, observed, and partially documented in [Known Issues](#known-issues) expected, observed, and partially documented in [Known issues](#known-issues)
below. below.
Human control sits at the context layer (i.e. prompt design, Wikipedia Human control sits at the context layer (that is, prompt design, Wikipedia
enrichment). Statistical output shapes in future pipeline stages (check-in enrichment). Statistical output shapes in future pipeline stages (check-in
distributions, rating skews, activity profiles) will be handled the same way. distributions, rating skews, activity profiles) will be handled the same way.
**Treat this data as an exercise in prompt engineering and model behaviour, not **Treat this data as an exercise in prompt engineering and model behaviour, not
as a source of truth for brewing techniques or cultural representation.** as a source of truth for brewing techniques or cultural representation.**
**Natural language processing, although a powerful tool for data analysis and **Natural language processing is a powerful tool for data analysis and
generation is to be taken with scrutiny. Human language is not simply just data generation, but its output should be treated with scrutiny. Human language is
points to be analyzed, but it also carries deep cultural and human meaning that not simply data points to be analyzed — it carries cultural and human meaning
artificial intelligence is incapable of.** that artificial intelligence cannot capture.**
--- ---
## Model Bias and Language Quality ## Model bias and language quality
The underlying model's training biases surface within this pipeline. Output The underlying model's training biases surface within this pipeline. Output
quality tracks with how well a language is represented in the training corpus: quality tracks with how well a language is represented in the training corpus:
@@ -65,9 +67,9 @@ large language models trained predominantly on English-language
material.[^llm-bias] material.[^llm-bias]
Mitigations are documented in Mitigations are documented in
[Known Issues: Low-Resource Language Hallucination](#low-resource-language-hallucination). [Known issues: Low-resource language hallucination](#low-resource-language-hallucination).
### Western and Eurocentric Lens ### Western and Eurocentric lens
The model's training data skews heavily Western and North American. When The model's training data skews heavily Western and North American. When
generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it
@@ -79,19 +81,44 @@ some generation in city-specific material, but it does not eliminate the skew.
--- ---
## Wikipedia Enrichment ## Wikipedia enrichment
City and beer context is fetched from the Wikipedia API. Wikipedia text is City and beer context is fetched from the Wikipedia API. Wikipedia text is
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0 co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
International License (CC BY-SA 4.0)** and the **GNU Free Documentation License International License (CC BY-SA 4.0)**[^cc-sa] and the **GNU Free Documentation
(GFDL)**.[^wp-license] License (GFDL)**.[^wp-license]
Wikipedia's own accuracy limitations and editorial biases can propagate into Wikipedia's own accuracy limitations and editorial biases can propagate into
generated descriptions. generated descriptions.
--- ---
## The "Avoid AI Phrases" Prompt Instruction ## Names-by-country dataset
`tooling/pipeline/forenames-by-country.json` and `surnames-by-country.json`
(used to sample a `Name` per ISO 3166-1 country code for user generation) are
vendored verbatim, unmodified, from
[sigpwned/popular-names-by-country-dataset](https://github.com/sigpwned/popular-names-by-country-dataset)
(the `common-forenames-by-country.json` / `common-surnames-by-country.json`
release assets), released under **CC0** (public domain). That dataset's own
forename/surname lists are pulled from Wikipedia's "Lists of most common
surnames" and "List of most popular given names" as of the week of 2023-07-08 —
see that project's README for full provenance. Names are not LLM-generated; this
is curated fixture data per ROADMAP.md §2. Per-forename gender from the source
data is preserved through to the sampled `Name` (rather than discarded during
loading) so it's available for gender-aware persona/bio generation later.
The full multinational dataset is kept as-is (106 countries for forenames, 75
for surnames) rather than trimmed to `locations.json`'s current country list, so
it doesn't need re-sourcing if more countries are added later. `SampleName()` (a
free helper in `generate_users.cc`) returns no result for a country present in
neither file; of the countries in `locations.json`, that's currently `KE`, `SE`,
`SG`, `TH`, `VN`, and `ZA` — `GenerateUsers` skips cities in those countries the
same way brewery generation skips cities whose enrichment lookup fails.
---
## The "avoid AI phrases" prompt instruction
The system prompt instructs the model to avoid common AI-generated phrasing The system prompt instructs the model to avoid common AI-generated phrasing
patterns. This is a prompt engineering experiment: patterns. This is a prompt engineering experiment:
@@ -103,14 +130,14 @@ consumers are informed of the AI-generated origin before engagement.
--- ---
## Known Issues ## Known issues
### Hallucinated Brewing Techniques ### Hallucinated brewing techniques
When forced by the system prompt to generate a "highly specialized technical When forced by the system prompt to generate a "highly specialized technical
brewing detail," the model frequently hallucinates fermentation science and brewing detail," the model frequently hallucinates fermentation science and
brewing chemistry. While the resulting sounds confident, it will be nonsensical brewing chemistry. While the resulting text sounds confident, it is nonsensical
to reader with brewing and/or scientific expertise. to a reader with brewing or scientific expertise.
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
calling for specialist brewing detail. This is consistent with the **CHOKE** calling for specialist brewing detail. This is consistent with the **CHOKE**
@@ -128,9 +155,9 @@ and factually wrong output.
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。" [2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
``` ```
A review of the following text for brewing techniques reveals several A review of the following text for brewing techniques reveals three
inaccuracies, and no comments could be made on the local-language version due to inaccuracies. No comments are made on the local-language version, since its
my own lack of proficiency in Japanese: reviewer does not read Japanese:
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour #### 1. "Buttery flavours" framed as a desirable malt-derived flavour
@@ -164,7 +191,7 @@ fermentable sugars.[^yn-sakeonline]
**Misleading** **Misleading**
Anaerobic conditions during packaging and aging are not differentiating Anaerobic conditions during packaging and aging are not a differentiating
technique. Anaerobic conditions are the standard baseline for all commercial technique. Anaerobic conditions are the standard baseline for all commercial
beer production. Breweries exclude oxygen as a top priority for packaging and beer production. Breweries exclude oxygen as a top priority for packaging and
shelf stability; published research in _Microbiology Spectrum_ confirms that shelf stability; published research in _Microbiology Spectrum_ confirms that
@@ -173,7 +200,7 @@ Professional packaging lines use CO_2 purges and closed transfers specifically
to maintain this state.[^packaging] Framing anaerobic aging as a distinctive to maintain this state.[^packaging] Framing anaerobic aging as a distinctive
practice is misleading and suggests hallucinated output. practice is misleading and suggests hallucinated output.
### Low-Resource Language Hallucination ### Low-resource language hallucination
The generation pipeline passes local language codes to the model to retrieve a The generation pipeline passes local language codes to the model to retrieve a
translated `description_local`. Output quality is reliable for high-resource translated `description_local`. Output quality is reliable for high-resource
@@ -235,19 +262,18 @@ idiomatic phrasing.
] ]
``` ```
This dataset, when fed into the pipeline will often times reason that a local When fed into the pipeline, this dataset often causes the model to reason that a
variant of French is needed, but will often times just default to a standardized local variant of French is needed, but it often defaults to a standardized
dialect of French, devoid of any cultural or linguistic nuance. dialect of French, without cultural or linguistic nuance.
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
(Sicily, Italy), the model can generate text that looks syntactically plausible (Sicily, Italy), the model can generate text that looks syntactically plausible
but is semantically incoherent. This comes from limited training-data coverage but is semantically incoherent. This comes from limited training-data coverage
rather than prompt engineering. rather than prompt engineering.
Output sample: Output sample: [./french-cities.example](french-cities.example)
[./out-sample/french-cities.example](out-sample/french-cities.example)
#### Proposed Mitigations #### Proposed mitigations
- **Prevention via allowlist:** introduce a high-resource language allowlist. If - **Prevention via allowlist:** introduce a high-resource language allowlist. If
a location's code is unlisted, skip `description_local` generation and fall a location's code is unlisted, skip `description_local` generation and fall
@@ -258,6 +284,21 @@ Output sample:
SQLite schema so downstream applications can filter or flag potentially SQLite schema so downstream applications can filter or flag potentially
hallucinated text by language tier. hallucinated text by language tier.
### Synthetic postal codes
When the pipeline is run without `--mocked`, `XegerPostalCodeService` generates
a fresh postal code per brewery/user address by reverse-matching one of the
city's curated postal-code regexes from `locations.json` (falling back to the
country format regex), rather than returning one of `MockPostalCodeService`'s
curated real-world examples.
The generated code is **format-conformant by construction** — it always
satisfies the regex it was generated from — but it is not looked up from any
real postal registry. It does not correspond to a real address, business, or
resident, and should not be treated as accurate geocoding or delivery data. This
is consistent with the rest of the dataset: fixture data for a proof-of-concept,
not a source of truth.
--- ---
## Footnotes ## Footnotes
@@ -271,10 +312,10 @@ Output sample:
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov. Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
[^llm-bias]: [^llm-bias]:
e.g., Blasi et al. (2022), "Systematic Inequalities in Language Technology For example, Blasi et al. (2022), "Systematic Inequalities in Language
Performance across the World's Languages," _ACL Anthology_. The pattern is Technology Performance across the World's Languages," _ACL Anthology_. The
consistent with models trained predominantly on English-language web pattern is consistent with models trained predominantly on English-language
corpora. web corpora.
[^wp-license]: [^wp-license]:
Source: Source:

View File

@@ -2,7 +2,8 @@
A C++20 command-line pipeline that samples city records from local JSON, A C++20 command-line pipeline that samples city records from local JSON,
enriches each with Wikipedia context, and generates bilingual brewery names and enriches each with Wikipedia context, and generates bilingual brewery names and
descriptions via a local GGUF model or a deterministic mock. descriptions plus locale-grounded user profiles via a local GGUF model or a
deterministic mock.
> **This pipeline produces AI-generated data.** It is not a source of truth for > **This pipeline produces AI-generated data.** It is not a source of truth for
> brewing techniques, cultural representation, or local-language accuracy. See > brewing techniques, cultural representation, or local-language accuracy. See
@@ -13,8 +14,8 @@ descriptions via a local GGUF model or a deterministic mock.
## Table of Contents ## Table of Contents
- [How It Fits The Main App](#how-it-fits-the-main-app) - [How it fits the main app](#how-it-fits-the-main-app)
- [Quick Start](#quick-start) - [Quick start](#quick-start)
- [Build](#build) - [Build](#build)
- [Model](#model) - [Model](#model)
- [Run](#run) - [Run](#run)
@@ -23,32 +24,33 @@ descriptions via a local GGUF model or a deterministic mock.
- [Pipeline Stages](#pipeline-stages) - [Pipeline Stages](#pipeline-stages)
- [Key Components](#key-components) - [Key Components](#key-components)
- [Runtime Behaviour](#runtime-behaviour) - [Runtime Behaviour](#runtime-behaviour)
- [Generated Output](#generated-output) - [Generated output](#generated-output)
- [Tech Stack](#tech-stack) - [Tech stack](#tech-stack)
- [Tested Hardware](#tested-hardware) - [Tested hardware](#tested-hardware)
- [Fixture Strategy](#fixture-strategy) - [Fixture strategy](#fixture-strategy)
- [Repo Layout](#repo-layout) - [Repo layout](#repo-layout)
- [Code Tour](#code-tour) - [Code tour](#code-tour)
- [Next Steps](#next-steps) - [Next steps](#next-steps)
--- ---
## How It Fits The Main App ## How it fits the main app
The pipeline is a data ingestion layer. It sits outside the web app runtime and The pipeline is a data ingestion layer. It sits outside the web app runtime and
produces seed records the app imports at startup or during a dedicated seed produces seed records the app imports at startup or during a dedicated seed
step. step.
| Planned app area | Pipeline contribution | | Planned app area | Pipeline contribution |
| -------------------------------- | ------------------------------------------------------------------ | | -------------------------------- | ------------------------------------------------------------------------------------------------- |
| Brewery discovery and management | Sampled city records, localized names, long-form descriptions | | Brewery discovery and management | Sampled city records, localized names, long-form descriptions |
| Beer reviews and ratings | Stable brewery fixtures with enough context to anchor review pages | | Beer reviews and ratings | Stable brewery fixtures with enough context to anchor review pages |
| Social follow relationships | Repeatable brewery entities for feeds, follows, and saved lists | | Social follow relationships | Repeatable brewery entities for feeds, follows, and saved lists |
| Geospatial brewery experiences | Latitude, longitude, and country-level metadata | | Geospatial brewery experiences | Latitude, longitude, and country-level metadata |
| User accounts and profiles | Locale-grounded names, bios, and an auth-ready email/date-of-birth pair for seeding real accounts |
--- ---
## Quick Start ## Quick start
### Build ### Build
@@ -63,10 +65,9 @@ cmake --build build
CMake automatically detects whether a compatible llama.cpp installation is CMake automatically detects whether a compatible llama.cpp installation is
present on the system (`libllama`, `libggml`, `libggml-base`, and `llama.h` present on the system (`libllama`, `libggml`, `libggml-base`, and `llama.h`
visible on the default search paths). If found, it links against those visible on the default search paths). If found, it links against those libraries
libraries and skips the FetchContent build. If not found, it fetches and builds and skips the FetchContent build. If not found, it fetches and builds llama.cpp
llama.cpp from source at tag `b9012`. No additional flags are required in from source at tag `b9012`. No additional flags are required in either case.
either case.
Metal is enabled automatically on Apple Silicon. CUDA or HIP/ROCm is detected Metal is enabled automatically on Apple Silicon. CUDA or HIP/ROCm is detected
automatically on Linux when the relevant toolkit is present. automatically on Linux when the relevant toolkit is present.
@@ -94,23 +95,26 @@ Each run writes a fresh dated SQLite file such as
./biergarten-pipeline \ ./biergarten-pipeline \
--model ../models/google_gemma-4-E4B-it-Q6_K.gguf \ --model ../models/google_gemma-4-E4B-it-Q6_K.gguf \
--prompt-dir prompts \ --prompt-dir prompts \
--location-count 25 \
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1 --temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
``` ```
#### CLI Flags #### CLI flags
| Flag | Purpose | | Flag | Purpose |
| --------------- | ---------------------------------------------------------------------------------------------------- | | ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `--mocked` | Deterministic mock generator, no model required. | | `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. | | `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
| `--prompt-dir` | Directory containing prompt files (e.g. `BREWERY_GENERATION.md`). Required unless `--mocked` is set. | | `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | | `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. | | `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
| `--temperature` | Sampling temperature. Default: `1.0`. | | `--temperature` | Sampling temperature. Default: `1.0`. |
| `--top-p` | Nucleus sampling. Default: `0.95`. | | `--top-p` | Nucleus sampling. Default: `0.95`. |
| `--top-k` | Top-k sampling. Default: `64`. | | `--top-k` | Top-k sampling. Default: `64`. |
| `--n-ctx` | Context window size. Default: `8192`. | | `--n-ctx` | Context window size. Default: `8192`. |
| `--seed` | Random seed. Default: `-1` (random at runtime). | | `--seed` | Random seed. Default: `-1` (random at runtime). |
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
| `--help, -h` | Print usage and exit. | | `--help, -h` | Print usage and exit. |
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an `--mocked` and `--model` are mutually exclusive. Omitting both exits with an
@@ -130,17 +134,21 @@ NVIDIA GPU.
### How it works ### How it works
The container uses a two-stage build. The first stage pulls prebuilt The container uses a two-stage build. The builder stage installs CMake/Ninja,
`libllama`, `libggml`, and backend plugin libraries (including `libggml-cuda.so` clones the matching llama.cpp release tag for its headers only (installed into
and the CPU variant plugins) from `ghcr.io/ggml-org/llama.cpp:full-cuda`. The `/usr/local/include`), and copies prebuilt shared libraries (`libllama`,
second stage copies those libraries into `/usr/local/lib` and runs `ldconfig` so `libggml`, and CUDA/CPU backend plugins) from
the dynamic linker and `dlopen` calls from `ggml_backend_load_all()` can resolve `ghcr.io/ggml-org/llama.cpp:full-cuda` into `/usr/local/lib`. With both headers
the CUDA backend plugin at runtime. llama.cpp headers are cloned at the matching and libraries present, CMake's system-library detection (see [Build](#build)
tag and installed into `/usr/local/include`. CMake auto-detects both and skips above) finds them and skips the FetchContent source build, keeping image build
the FetchContent source build entirely, keeping image build times short. times short.
`GGML_BACKEND_PATH` is set to `/usr/local/lib` so llama.cpp knows where to scan The runtime stage copies the compiled binary, the same prebuilt shared
for backend plugins. libraries, and config/prompt assets into a slim CUDA runtime image. It sets
`LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH` so the dynamic linker resolves
`libllama`/`libggml` at startup, and also co-locates `libggml-cuda.so` and the
CPU backend plugins next to the binary for `ggml_backend_load_all()`'s `dlopen`
scan.
### Build the image ### Build the image
@@ -165,112 +173,139 @@ docker build \
Look for `[biergarten] Found system llama.cpp — skipping FetchContent` in the Look for `[biergarten] Found system llama.cpp — skipping FetchContent` in the
output to confirm the fast path was taken. output to confirm the fast path was taken.
### Run in mocked mode ### Run the container
No model or GPU required. Useful for validating the pipeline logic and SQLite The container always runs the model-backed path; there is no `--mocked`
export path. container mode (use a native build for that — see [Quick start](#quick-start)).
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
```bash is not already present at the configured path.
docker run --rm \
-e BIERGARTEN_MODE=mocked \
-v "$PWD/output:/workspace/output" \
-v "$PWD/logs:/workspace/logs" \
biergarten-pipeline:latest
```
### Run in live mode
Mount your GGUF model before starting. The container validates the model path
before launching the binary.
```bash ```bash
docker run --rm \ docker run --rm \
--runtime=nvidia \ --runtime=nvidia \
-e BIERGARTEN_MODE=live \
-e GGML_BACKEND_PATH="/usr/local/lib/libggml-cuda.so" \
-v "$PWD/models:/workspace/models" \ -v "$PWD/models:/workspace/models" \
-v "$PWD/output:/workspace/output" \ -v "$PWD/output:/workspace/output" \
-v "$PWD/logs:/workspace/logs" \ -v "$PWD/logs:/workspace/logs" \
biergarten-pipeline:latest biergarten-pipeline:latest
``` ```
The model must be present at `./models/google_gemma-4-E4B-it-Q6_K.gguf` on the By default this downloads `google_gemma-4-E4B-it-Q6_K.gguf` to `./models/` on
host. See [Model](#model) above for the download command. first run if it isn't already there. To use a pre-downloaded model, place it at
that path first — see [Model](#model) above.
#### Environment variables
| Variable | Purpose |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `BIERGARTEN_MODEL_PATH` | GGUF model path. Default: `/workspace/models/google_gemma-4-E4B-it-Q6_K.gguf`. |
| `BIERGARTEN_OUTPUT_DIR` | SQLite output directory. Default: `/workspace/output`. |
| `BIERGARTEN_LOG_PATH` | Log file path. Default: `/workspace/logs/pipeline.log`. |
| `BIERGARTEN_GL_LAYERS` | GPU layers to offload (`--n-gpu-layers`). Default: `40`. |
| `BIERGARTEN_TEMPERATURE`, `BIERGARTEN_TOP_P`, `BIERGARTEN_TOP_K`, `BIERGARTEN_N_CTX`, `BIERGARTEN_SEED` | Optional sampling overrides, unset by default (binary defaults apply). |
| `BIERGARTEN_EXTRA_ARGS` | Additional raw CLI args appended verbatim. |
`--prompt-dir` is hardcoded to `/app/prompts` inside the container and is not
configurable via environment variable.
### RunPod deployment ### RunPod deployment
Use a GPU pod template. Mount persistent storage for `/workspace/models`, Use a GPU pod template. Mount persistent storage for `/workspace/models`,
`/workspace/output`, and `/workspace/logs`. Set `BIERGARTEN_MODE=live` in the `/workspace/output`, and `/workspace/logs`. See
template environment. See `tooling/pipeline/runpod/pod-template.yaml` for a `tooling/pipeline/runpod/pod-template.yaml` for a starter template — set the
starter template. environment variables listed above to match your run.
--- ---
## Architecture ## Architecture
### Pipeline Stages ### Pipeline stages
| Stage | Implementation | | Stage | Implementation |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Load | `JsonLoader::LoadLocations()` reads `locations.json` into typed `Location` records. | | Load | `ICuratedDataService` (`CuratedJsonDataService`) reads `locations.json`, `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json` (paths supplied via a `CuratedDataFilePaths` DTO at construction) into typed records, caching each after its first load. `--mocked` runs use `MockCuratedDataService`'s fixed in-memory dataset instead. |
| Sample | `BiergartenDataGenerator::QueryCitiesWithCountries()` samples up to 50 locations per run. | | Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). |
| Enrich | `WikipediaService` fetches city and beer context. Keeps going when a lookup fails. | | Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. |
| Generate | `MockGenerator` or `LlamaGenerator` produces brewery names and descriptions in English and the local language. | | Generate Users | `GenerateUsers()` samples a persona and a forename/surname pair per enriched city (skipping countries with no name data), then `MockGenerator` or `LlamaGenerator` produces a username, bio, and activity weight around the sampled name. |
| Store | `SqliteExportService` writes each successful brewery into a fresh dated `.sqlite` database with normalized location and brewery tables. | | Generate Breweries | `MockGenerator` or `LlamaGenerator` produces brewery names and descriptions in English and the local language. |
| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `locations`, `users`, and `breweries` tables. |
| Log | `spdlog` writes results and warnings to the console. | | Log | `spdlog` writes results and warnings to the console. |
If enrichment or generation fails for a city, that city is skipped and the If name sampling, enrichment, or generation fails for a city, that city is
pipeline continues. skipped and the pipeline continues. `GenerateUsers()` runs before
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
### Key Components ### Key components
- `src/main.cc` — argument parsing and Boost.DI composition root. - `src/main.cc` — argument parsing and Boost.DI composition root.
- `JsonLoader` — validates curated location input. - `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
- `WikipediaService` — queries Wikipedia extracts, caches results, returns empty `CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
context on failure. its constructor, then parses and validates curated location, persona, and
- `LlamaGenerator` — formats prompts for Gemma 4, validates JSON output, retries forename/surname JSON, memoizing each result after its first load on a given
malformed responses up to three times. If output looks truncated, the retry instance. `MockCuratedDataService` is the in-memory substitute (4 fixed
raises the token budget before trying again. locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in
- `MockGenerator` — stable hash-based output so the same city input always `--mocked` runs.
produces the same brewery. - `WikipediaEnrichmentService` — queries Wikipedia extracts, caches results,
returns empty context on failure. `MockEnrichmentService` is the no-op
substitute used in `--mocked` runs.
- `LlamaGenerator` — formats prompts for Gemma 4, validates JSON output for both
`GenerateBrewery` and `GenerateUser`, retries malformed responses up to three
times with corrective feedback in the retry prompt. The token budget is fixed
across attempts; it is not raised automatically on truncation.
- `MockGenerator` — stable hash-based output so the same city/persona/name input
always produces the same brewery or user.
- `SqliteExportService` — creates a dated SQLite file per run and persists each - `SqliteExportService` — creates a dated SQLite file per run and persists each
successful brewery into normalized tables. successful user and brewery into normalized tables.
- Brewery payloads include English and local-language name and description - Brewery payloads include English and local-language name and description
fields. fields. User payloads carry a sampled first/last name and gender, an
LLM-generated username/bio/activity weight, and a programmatically generated
(not LLM-authored) unique email and date of birth.
### Runtime Behaviour ### Runtime behaviour
`WikipediaService` queries city, country, and beer-related Wikipedia extracts `WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
using its configured lookup, then caches the first successful response per query "brewing" extract and a "beer in `{country}`" extract. It does not currently
string. The fetched extract text is included in the prompt as context for query a city- or region-specific page. Each query string is cached after its
generation. first successful (or empty) lookup.
`GetLocationContext()` returns an empty string when the web client is `GetLocationContext()` returns an empty string when the web client is
unavailable or when lookup/parsing fails. unavailable or when lookup/parsing fails.
`LlamaGenerator` validates model output as structured JSON. The retry path `LlamaGenerator` validates model output as structured JSON. On validation
exists as a safety hatch for cases where the reasoning block consumes available failure it retries up to three times, replaying the previous error message in
token budget and compresses the JSON output space. All runs to date have the next prompt so the model can self-correct. All runs to date have produced
produced valid output on the first pass; the path is kept for resilience. valid output on the first pass; the retry path is kept for resilience.
`MockGenerator` uses stable hashes for repeatable output in demos and Storybook `MockGenerator` uses stable hashes for repeatable output in demos and Storybook
runs. runs.
### Process Flow - Activity Diagram `CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`,
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the
first time each is called, since `BiergartenPipelineOrchestrator` owns a single
`ICuratedDataService` instance for the whole run — later calls return the cached
result instead of re-parsing.
`GenerateUsers()` samples a forename/surname pair per city via `SampleName()`,
keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but
absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`)
are skipped, the same way a failed enrichment or generation call skips a city —
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
### Process flow - activity diagram
![An activity diagram](./diagrams/current/output/activity.svg) ![An activity diagram](./diagrams/current/output/activity.svg)
### Architectural Overview - Class Diagram ### Architectural overview - class diagram
![A class diagram](./diagrams/current/output/class.svg) ![A class diagram](./diagrams/current/output/class.svg)
--- ---
## Generated Output ## Generated output
Each successful run stores a `GeneratedBrewery` pair with the source location Each successful run stores a `BreweryRecord` pair with the source location and a
and a `BreweryResult` payload. The same generated records are also written to a `BreweryResult` payload, and a `UserRecord` pair with the source location and a
fresh SQLite export file named with the current UTC timestamp. `UserResult` payload. The same generated records are also written to a fresh
SQLite export file named with the current UTC timestamp.
| Field | Meaning | | Field | Meaning |
| ------------------- | ------------------------------------------ | | ------------------- | ------------------------------------------ |
@@ -279,10 +314,21 @@ fresh SQLite export file named with the current UTC timestamp.
| `name_local` | Brewery name in the local language. | | `name_local` | Brewery name in the local language. |
| `description_local` | Brewery description in the local language. | | `description_local` | Brewery description in the local language. |
| Field | Meaning |
| ----------------- | -------------------------------------------------------------------------------------- |
| `first_name` | Sampled forename, copied from the curated name data (not LLM-invented). |
| `last_name` | Sampled surname, copied from the curated name data (not LLM-invented). |
| `gender` | Gender associated with the sampled forename in the source dataset. |
| `username` | LLM-generated handle. |
| `bio` | LLM-generated short biography. |
| `activity_weight` | Relative check-in/activity weight, reserved for a future J-curve activity profile. |
| `email` | Unique `@thebiergarten.app` address, generated programmatically from the sampled name. |
| `date_of_birth` | Randomized date of birth (age 19-48), generated programmatically. |
The log dump also includes city, country, state or province, ISO subdivision The log dump also includes city, country, state or province, ISO subdivision
code, latitude, and longitude for each entry. code, latitude, and longitude for each entry.
### Consumer Data Shape ### Consumer data shape
| Field | Why it matters | | Field | Why it matters |
| ----------------------------------- | ------------------------------------------------ | | ----------------------------------- | ------------------------------------------------ |
@@ -292,11 +338,10 @@ code, latitude, and longitude for each entry.
| `local_languages` | Locale-aware copy selection | | `local_languages` | Locale-aware copy selection |
| `name_en`, `description_en` | Default English display content | | `name_en`, `description_en` | Default English display content |
| `name_local`, `description_local` | Local-language display content | | `name_local`, `description_local` | Local-language display content |
| `region_context` | Richer copy for cards and detail pages |
--- ---
## Tech Stack ## Tech stack
- C++20 - C++20
- CMake 3.31+ - CMake 3.31+
@@ -312,15 +357,13 @@ The build fetches Boost.DI, spdlog, and SQLite via CMake. llama.cpp is fetched
only when a system installation is not detected. Metal is enabled on Apple only when a system installation is not detected. Metal is enabled on Apple
Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present. Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
> **Code Style:** Modern C++20 throughout — RAII for ownership, > **Code Style:** Code style generally follows the Google Style Guide and
> `std::unique_ptr` for injected dependencies, `std::optional` for parse > features modern C++20 throughout. Formatting follows the Google C++ Style
> outcomes, `std::span` for read-only views over generated city data, structured > Guide, with the exception of indentation width (3 spaces vs. Google's 2).
> bindings in pipeline loops. Formatting follows the Google C++ Style Guide via
> `.clang-format` with a narrow column limit and two-space indentation.
--- ---
## Tested Hardware ## Tested hardware
### ARM macOS — M1 Pro ### ARM macOS — M1 Pro
@@ -355,52 +398,67 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
--- ---
## Fixture Strategy ## Fixture strategy
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs. - `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
`MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture
files need to be present on disk.
- `--model` when geographically grounded content matters for demos. - `--model` when geographically grounded content matters for demos.
- Keep `locations.json` structured enough to support discovery and future - Keep `locations.json` structured enough to support discovery and future
filtering. filtering.
- Treat SQLite output as seed material for the app's brewery domain, not - `personas.json`, `forenames-by-country.json`, and `surnames-by-country.json`
production data. are curated/vendored fixture data, not LLM-generated — see
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for provenance.
- Treat SQLite output as seed material for the app's brewery and user domains,
not production data.
--- ---
## Repo Layout ## Repo layout
| Path | Purpose | | Path | Purpose |
| ---------------------------- | -------------------------------------------------- | | -------------------------------------------- | ----------------------------------------------------------- |
| `includes/` | Public headers and shared models. | | `tooling/pipeline/includes/` | Public headers and shared models. |
| `src/` | Implementation files. | | `tooling/pipeline/src/` | Implementation files. |
| `locations.json` | Curated city input copied into the build tree. | | `tooling/pipeline/locations.json` | Curated city input copied into the build tree. |
| `prompts/` | System prompts used by the model-backed path. | | `tooling/pipeline/personas.json` | Curated user persona archetypes copied into the build tree. |
| `diagrams/` | Architecture and pipeline diagrams. | | `tooling/pipeline/forenames-by-country.json` | Vendored (CC0) forename data by ISO 3166-1 country code. |
| `tooling/pipeline/surnames-by-country.json` | Vendored (CC0) surname data by ISO 3166-1 country code. |
| `tooling/pipeline/prompts/` | System prompts used by the model-backed path. |
| `tooling/pipeline/runpod/` | Dockerfile, launcher, and RunPod pod template. | | `tooling/pipeline/runpod/` | Dockerfile, launcher, and RunPod pod template. |
| `ETHICS-AND-KNOWN-ISSUES.md` | Ethics, bias, hallucination analysis, mitigations. | | `docs/pipeline/diagrams/` | Architecture and pipeline diagrams. |
| `docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md` | Ethics, bias, hallucination analysis, mitigations. |
--- ---
## Code Tour ## Code tour
Paths below are relative to `tooling/pipeline/`.
- `src/main.cc` — argument parsing and DI composition root. - `src/main.cc` — argument parsing and DI composition root.
- `src/biergarten_data_generator/` — orchestration, sampling, logging, and - `src/biergarten_pipeline_orchestrator/` — orchestration, sampling, logging,
export. and export.
- `src/services/wikipedia/` — enrichment service and cache. - `src/services/curated_data/` — `CuratedJsonDataService`, the file-backed
`ICuratedDataService`, and `MockCuratedDataService`, the in-memory
`ICuratedDataService` used in `--mocked` runs.
- `src/services/enrichment/wikipedia/` — enrichment service and cache.
- `src/services/sqlite/` — SQLite export implementation. - `src/services/sqlite/` — SQLite export implementation.
- `src/data_generation/llama/` — local inference, prompt loading, output - `src/data_generation/llama/` — local inference, prompt loading, output
validation. validation.
- `src/data_generation/mock/` — deterministic fallback. - `src/data_generation/mock/` — deterministic fallback.
- `tooling/pipeline/runpod/` — container build and runtime launcher. - `runpod/` — container build and runtime launcher.
--- ---
## Next Steps ## Next steps
The pipeline currently produces city-aware brewery records and dated SQLite The pipeline currently produces city-aware brewery and user records and dated
exports. The next passes add additional fixture types so the app can exercise SQLite exports. The next passes add additional fixture types so the app can
the full brewery domain without live data. exercise the full brewery and social domains without live data. For the detailed
engineering breakdown of what's needed to reach the architecture in
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
### Testing — Very High Priority ### Testing — very high priority
- Unit test JSON validation and retry logic against malformed, truncated, and - Unit test JSON validation and retry logic against malformed, truncated, and
empty model outputs. empty model outputs.
@@ -414,25 +472,19 @@ the full brewery domain without live data.
- Confirm the retry path is reachable when the reasoning block consumes - Confirm the retry path is reachable when the reasoning block consumes
available token budget. available token budget.
### Beer Generation ### Beer generation
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
pairing hints. Link beers back to breweries and cities. Keep style coverage wide pairing hints. Link beers back to breweries and cities. Keep style coverage wide
enough to exercise search, sort, and category filters. enough to exercise search, sort, and category filters.
### User Generation ### Check-in system
Generate user profiles with stable names, bios, locale hints, and preference
signals. Include stable IDs for downstream fixture joins. Keep output
deterministic for screenshots while allowing larger randomized batches.
### Check-In System
Produce timestamped check-in events between users and breweries. Use a J-curve Produce timestamped check-in events between users and breweries. Use a J-curve
activity profile — a small set of users accounts for most check-ins, the rest activity profile — a small set of users accounts for most check-ins, the rest
appear occasionally. Add bursty behaviour around weekends and travel periods. appear occasionally. Add bursty behaviour around weekends and travel periods.
### Beer Ratings ### Beer ratings
Generate rating events with a strong positive skew and a long tail of lower Generate rating events with a strong positive skew and a long tail of lower
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app scores. Avoid uniform distributions. Attach timestamps and user IDs so the app

244
docs/pipeline/ROADMAP.md Normal file
View File

@@ -0,0 +1,244 @@
# Pipeline Roadmap — Reaching the Planned Architecture
This is the engineering breakdown for closing the gap between the **current**
pipeline and the **planned** architecture documented in
[`diagrams/planned/class.puml`](./diagrams/planned/class.puml) and
[`diagrams/planned/activity.puml`](./diagrams/planned/activity.puml). Nothing in
`diagrams/planned/` is implemented yet — this file tracks what it would take to
get there. For the current implementation, see [README.md](./README.md).
Items are grouped by layer and roughly ordered by dependency: later groups build
on types and services introduced earlier.
The only concurrency in the current pipeline is the log dispatcher thread
(`main.cc` spawns it to drain a `BoundedChannel<LogEntry>` into spdlog for the
whole run). Sampling, enrichment, generation, and export all happen
synchronously on the main thread inside `BiergartenPipelineOrchestrator::Run()`
— §6 below is what introduces the additional worker threads the planned
architecture needs.
---
## 1. Domain Models
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
- [ ] Add `Completeness` enum (`Full`, `Partial`, `Absent`) and a
`LocationContext` struct (`text`, `completeness`, `char_count`). Replace
`EnrichedCity::region_context` (currently a plain `std::string`) with
`context : LocationContext`.
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
`max_ibu`).
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` result payloads.
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
`context_provided`, `generated_with`).
- [x] Add `first_name`, `last_name`, `gender`, and `activity_weight` to
`UserResult` (currently just `username`, `bio`). `first_name`/
`last_name`/`gender` are copied from the sampled `Name` (see below), not
LLM-invented.
- [x] Add `Name` (`first_name`, `last_name`, `gender`) — the sampled result
handed to `DataGenerator::GenerateUser`. Add `ForenameEntry` (`name`,
`gender`). Landed as a flatter shape than originally planned here: no
`NamesByCountry` wrapper class. `curated_data_service.h` instead declares
`ForenameList = std::vector<ForenameEntry>` and
`SurnameList = std::vector<std::string>`, and `ICuratedDataService`
exposes two maps keyed by ISO 3166-1 code —
`ForenamesByCountryMap = unordered_map<string, ForenameList>` and
`SurnamesByCountryMap = unordered_map<string, SurnameList>` — directly
(see §2). Sampling is a free function,
`SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng)`,
in `generate_users.cc`'s anonymous namespace, not a method on a class.
Pairing a forename with a surname happens at sample time so gender
(present per-forename in the source data) is never discarded the way a
pre-flattened `{first_name, last_name}` list would lose it; see §2.
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
`metadata` (currently just `{location, brewery}`).
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
`GeneratedFollow` aggregate structs.
- [x] Add `UserRecord` (`location`, `user : UserResult`, `email`,
`date_of_birth`) as the current stand-in for the planned `GeneratedUser` —
`email` and `date_of_birth` are programmatically generated by the
orchestrator, never LLM-authored, so a downstream auth-account seeding
consumer can register real accounts from the pipeline's SQLite export. No
`password`, `user_id`, or `metadata` field yet, matching `BreweryRecord`'s
equally pre-`metadata` shape. Already wired into
`IExportService`/`SqliteExportService`: a `users` table exists and
`GenerateUsers()` exports each successful record via
`ProcessRecord(const UserRecord&)` (see §5) in addition to logging and
holding `generated_users_` in memory. Still missing vs. the planned
`GeneratedUser`: `user_id`, `password`, and `metadata`.
- [x] Add `UserPersona` (`name`, `description`, `style_affinities`).
## 2. Data Preloading
`tooling/pipeline/includes/services/curated_data/`, fixture files.
- [x] Extract an interface; have `CuratedJsonDataService` implement it. Landed
as `ICuratedDataService` (`services/curated_data/curated_data_service.h`),
not `DataPreloader` — `LoadLocations()`, `LoadPersonas()`,
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` are all virtual
methods returning `const&` and take no arguments. `CuratedJsonDataService`
(`services/curated_data/curated_json_data_service.h`, originally landed as
`JsonLoader` under `json_handling/` before being renamed and moved) takes
a `CuratedDataFilePaths` DTO (`locations_path`, `personas_path`,
`forenames_path`, `surnames_path`) in its constructor rather than a path
per `Load*()` call, and memoizes each result in a private `cache` struct
(`locations`, `personas`, `forenames_by_country`, `surnames_by_country`),
so a second call on the same instance returns the cached result instead of
re-parsing — safe because `CuratedJsonDataService` outlives every call
site (owned by `BiergartenPipelineOrchestrator` via
`unique_ptr<ICuratedDataService>` for the whole run). `main.cc`'s DI
injector also gained a `MockCuratedDataService` (fixed in-memory data: 4
locations across `US`/`DE`/`FR`/`BE`, 3 personas, and matching
forename/surname sets for those four countries), bound in place of
`CuratedJsonDataService` under `--mocked`, mirroring the existing
`MockEnrichmentService`/`MockGenerator` pattern.
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` already exists in the repo and
is already copied into the Docker image (`runpod/Dockerfile`), but no
loader reads it yet, and the native CMake build doesn't copy it into
`build/` at all — `CMakeLists.txt`'s "Runtime Assets" step only copies
`locations.json` and `prompts/`.
- [x] Add `LoadPersonas()` and author `personas.json` (doesn't exist yet).
- [x] Add name-by-country loading, parsing
`tooling/pipeline/forenames-by-country.json` and
`surnames-by-country.json`. Landed as two separate methods —
`LoadForenamesByCountry()` and `LoadSurnamesByCountry()` — each returning
a flat `ForenamesByCountryMap` / `SurnamesByCountryMap` (aliases for
`unordered_map<string, ForenameList>` /
`unordered_map<string, SurnameList>`) keyed by ISO 3166-1 code, rather
than one combined `LoadNamesByCountry() : NamesByCountry` call. Both files
are vendored verbatim (unmodified, full multinational coverage) from
`sigpwned/popular-names-by-country-dataset` (CC0) — see
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for
provenance. Deliberately not pre-paired or filtered down to just the
countries in `locations.json`: keeping the source shape (including
per-forename gender) intact means the loader can support more countries
later, or gender-aware persona/bio generation, without re-sourcing data.
## 3. Policy / Strategy Layer
Entirely new — no `includes/policy/` (or equivalent) directory exists today.
- [ ] `ContextStrategy` interface + `BreweryContextStrategy` /
`BeerContextStrategy`. Today
`WikipediaEnrichmentService::GetLocationContext` hardcodes a generic
`"brewing"` query and a `"beer in {country}"` query directly — no
per-phase strategy selection.
- [ ] `SamplingStrategy` interface + `UniformSamplingStrategy`, replacing the
inline `std::ranges::sample(...)` call in
`BiergartenPipelineOrchestrator::QueryCitiesWithCountries()`.
- [ ] `BeerSelectionStrategy` interface + `RandomBeerSelectionStrategy`, to pick
styles per brewery from the `BeerStyle` palette (depends on §1 and §2).
- [ ] `CheckinDistributionStrategy` interface + `JCurveCheckinStrategy` /
`RandomCheckinStrategy` — this is the "Check-In System" item already
called out in README.md's Next Steps, made concrete.
- [ ] `FollowGenerationStrategy` interface + `RandomFollowStrategy` /
`ActivityWeightedFollowStrategy`.
## 4. Data Generation
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
- [ ] Extend the `DataGenerator` interface with `GenerateBeer`,
`GenerateCheckin`, `GenerateRating` (today: only `GenerateBrewery` and
`GenerateUser`).
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
Changed the `DataGenerator::GenerateUser` signature to
`(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult`,
matching what the activity diagram actually passes.
`MockGenerator::GenerateUser` updated to match the new signature too.
- [ ] Implement `MockGenerator::GenerateBeer` / `GenerateCheckin` /
`GenerateRating`.
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
`LlamaGenerator::ValidateModelArchitecture()` so loading a GGUF that
doesn't match the configured chat template fails fast instead of silently
producing degraded output.
- [ ] Add prompt template files for beer/checkin/rating generation next to the
existing `prompts/BREWERY_GENERATION.md`.
## 5. Export Service
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
- [x] `IExportService::ProcessRecord(const UserRecord&)` and
`SqliteExportService`'s `users` table (see `kCreateUsersTableSql` /
`kInsertUserSql` in
`includes/services/database/sqlite_statement_helpers.h`) are implemented —
`GenerateUsers()` exports every successful user alongside its resolved
`location_id`.
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
`ProcessRating`, `ProcessFollow` (today
`ProcessRecord(const BreweryRecord&)` and
`ProcessRecord(const UserRecord&)` exist).
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
`SqliteExportService::InitializeSchema()` (`locations`, `breweries`, and
`users` already exist) — see `kCreateLocationsTableSql` /
`kCreateBreweriesTableSql` / `kCreateUsersTableSql` in
`includes/services/database/sqlite_statement_helpers.h` for the existing
pattern to follow.
- [ ] Add a `brewery_cache_` alongside the existing `location_cache_`, and move
from one open transaction for the whole run to per-phase batched commits
(`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the
planned activity diagram.
## 6. Concurrency & Orchestration
`tooling/pipeline/includes/concurrency/`,
`src/biergarten_pipeline_orchestrator/`.
- [ ] `BoundedChannel<T>` already exists and is production-tested — but it's
only wired to the single log channel today. Stand up the per-phase
producer/consumer channels (`loc_ch`, `exp_ch`, etc.) the planned activity
diagram describes, each with a dedicated LLM worker thread and SQLite
worker thread.
- [ ] Rewrite `BiergartenPipelineOrchestrator::Run()` from one synchronous pass
over `generated_breweries_` into phased methods — `RunUserPhase` →
`RunBreweryAndBeerPhase` (with a `RunBeerPhase` sub-step once
`brewery_pool_` is populated) → `RunCheckinPhase` / `RunFollowPhase`
(these two can run in parallel) → `RunRatingPhase` — backed by
`user_pool_`, `brewery_pool_`, `beer_pool_`, `checkin_pool_`,
`follow_pool_`.
- [ ] Honor the structural-concurrency requirement already called out in a
comment on `BiergartenPipelineOrchestrator::Run()` in
`biergarten_pipeline_orchestrator.h`: once real worker threads exist, they
must be structurally joined (e.g. via `std::jthread`) before `Run()`
returns, so no worker logs to a closed channel during teardown.
## 7. Enrichment
- [ ] Decide whether to restore the city/region-specific Wikipedia query that's
currently commented out in
`src/services/enrichment/wikipedia/get_summary.cc` (`GetLocationContext`).
The `ContextStrategy` work in §3 is a natural place to reintroduce it via
`BreweryContextStrategy::QueriesFor()`.
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
`PreWarmLocationCache` in the planned activity diagram) instead of
fetching lazily per record, so the streaming phases never block on a cold
cache mid-run.
## 8. Fixtures / Build
- [x] Author `personas.json`, `forenames-by-country.json`, and
`surnames-by-country.json` (see §2).
- [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets"
`configure_file` step in `CMakeLists.txt` so native builds and the Docker
image agree on what's available at runtime.
---
## Suggested build order
The planned activity diagram's own phase notes ("brewery*pool* is now fully
populated. Phase 1b may begin.", etc.) imply a dependency order. Roughly:
1. Domain models (§1) and data preloading (§2) — nothing else compiles without
these.
2. Export schema (§5) — so every later generation phase has somewhere to land
its output.
3. Policy/strategy layer (§3) and generator interface (§4).
4. Concurrency/orchestration rewrite (§6), which is the only piece that actually
wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
5. Enrichment cache pre-warming and the city/region query decision (§7) — useful
at any point, but most valuable once phases run concurrently.

View File

@@ -18,23 +18,40 @@ skinparam ActivityBarColor #628A5B
skinparam SwimlaneBorderColor #547461 skinparam SwimlaneBorderColor #547461
skinparam SwimlaneBorderThickness 0.3 skinparam SwimlaneBorderThickness 0.3
title The Biergarten Data Pipeline (Streaming Architecture) title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|#F2F6F0|main.cc| |#F2F6F0|main.cc|
start start
:Create BoundedChannel<LogEntry> log_channel;
:Spawn log dispatcher thread\n(LogDispatcher::Run());
note right
The only concurrency in the current pipeline:
a dedicated thread drains log_channel and
forwards entries to spdlog for the entire run.
Joined during shutdown after log_channel closes.
end note
:ParseArguments(argc, argv); :ParseArguments(argc, argv);
if (Are arguments valid?) then (no) if (Are arguments valid?) then (no)
:spdlog::error usage info; :Log error / usage info;
stop stop
else (yes) else (yes)
endif endif
:Init OpenSSL global state & LlamaBackendState; :Init OpenSSL global state & LlamaBackendState;
:di::make_injector(...); :di::make_injector(...);
:injector.create<std::unique_ptr<BiergartenDataGenerator>>(); note right
:BiergartenDataGenerator::Run(); Binds ILogger, IEnrichmentService, DataGenerator,
IExportService, IPromptFormatter, WebClient based
on --mocked vs. model-backed mode.
end note
:injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>();
:BiergartenPipelineOrchestrator::Run();
note right
Run() itself is synchronous — sampling, enrichment,
generation, and export all happen on this thread.
end note
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
:Initialize SQLite export; :Initialize SQLite export;
|#E0EAE0|SqliteExportService| |#E0EAE0|SqliteExportService|
@@ -48,23 +65,106 @@ note right
Begins Transaction Begins Transaction
end note end note
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
:QueryCitiesWithCountries(); :QueryCitiesWithCountries();
|#E2EBDC|JsonLoader| |#E2EBDC|JsonLoader|
:JsonLoader::LoadLocations("locations.json"); :JsonLoader::LoadLocations("locations.json");
:std::ranges::sample(all_locations, 50); :std::ranges::sample(all_locations, --location-count);
note right
--location-count defaults to 10.
end note
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
while (For each sampled Location?) is (Remaining cities) while (For each sampled Location?) is (Remaining cities)
|#DCE8D8|WikipediaService| |#DCE8D8|IEnrichmentService|
:GetLocationContext(loc); :GetLocationContext(loc);
:FetchExtracts(City, Country, Beer); note right
|#EAF0E8|BiergartenDataGenerator| WikipediaEnrichmentService fetches a generic
"brewing" extract and a "beer in {country}" extract
(not a city/region-specific page). MockEnrichmentService
(--mocked) returns an empty string instead.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Lookup failed?) then (yes)
:Log warning, skip city;
else (no)
:Store EnrichedCity{Location, region_context}; :Store EnrichedCity{Location, region_context};
endif
endwhile (Done) endwhile (Done)
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
:GenerateUsers(enriched_cities);
|#E2EBDC|JsonLoader|
:LoadPersonas("personas.json");
:LoadForenamesByCountry("forenames-by-country.json");
:LoadSurnamesByCountry("surnames-by-country.json");
note right
Each call is cached after its first parse.
MockCuratedDataService (--mocked) returns a
fixed in-memory dataset instead and skips
these three JSON files entirely.
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
while (For each EnrichedCity?) is (Remaining cities)
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1);
if (Names available for country?) then (no)
:Log warning, skip city;
else (yes)
:Sample UserPersona (uniform random);
|#E5EDE1|DataGenerator|
if (Generator Mode) then (MockGenerator)
:DeterministicHash & Format;
else (LlamaGenerator)
:prompt_directory_->Load("USER_GENERATION");
note right
Resolves to USER_GENERATION.md inside --prompt-dir.
end note
repeat
:Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar);
:ValidateUserJson(raw, user);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 3?) is (yes)
endif
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes)
:BuildEmail(name, used_email_local_parts);
:GenerateDateOfBirth(rng);
note right
email and date_of_birth are generated
programmatically, never LLM-authored.
end note
|#E0EAE0|SqliteExportService|
:ProcessRecord(UserRecord);
if (Location in cache?) then (yes)
:Reuse location_id;
else (no)
:Insert Location & Cache ID;
endif
:Insert User (FK: location_id);
if (Exception caught during insert?) then (yes)
|#EAF0E8|BiergartenPipelineOrchestrator|
:Log warning "SQLite export failed";
else (no)
endif
else (no)
:Log warning "Generation failed, skipping...";
endif
endif
endwhile (Done)
|#EAF0E8|BiergartenPipelineOrchestrator|
:GenerateBreweries(enriched_cities); :GenerateBreweries(enriched_cities);
|#E5EDE1|DataGenerator| |#E5EDE1|DataGenerator|
@@ -73,7 +173,10 @@ while (For each EnrichedCity?) is (Remaining cities)
:DeterministicHash & Format; :DeterministicHash & Format;
else (LlamaGenerator) else (LlamaGenerator)
:PrepareRegionContext; :PrepareRegionContext;
:LoadBrewerySystemPrompt("prompts/system.md"); :prompt_directory_->Load("BREWERY_GENERATION");
note right
Resolves to BREWERY_GENERATION.md inside --prompt-dir.
end note
repeat repeat
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar); :Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
:ValidateBreweryJson(raw, brewery); :ValidateBreweryJson(raw, brewery);
@@ -81,11 +184,16 @@ while (For each EnrichedCity?) is (Remaining cities)
break break
else (no) else (no)
:Attempt++; :Attempt++;
:Append validation error to retry prompt;
endif endif
repeat while (Attempt < 3?) is (yes) repeat while (Attempt < 3?) is (yes)
note right
max_tokens is fixed across retries —
it is not raised on truncation.
end note
endif endif
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes) if (Generation successful?) then (yes)
|#E0EAE0|SqliteExportService| |#E0EAE0|SqliteExportService|
:ProcessRecord(GeneratedBrewery); :ProcessRecord(GeneratedBrewery);
@@ -97,8 +205,8 @@ while (For each EnrichedCity?) is (Remaining cities)
:Insert Brewery (FK: location_id); :Insert Brewery (FK: location_id);
if (Exception caught during insert?) then (yes) if (Exception caught during insert?) then (yes)
|#EAF0E8|BiergartenDataGenerator| |#EAF0E8|BiergartenPipelineOrchestrator|
:spdlog::warn "Failed to stream record to SQLite export"; :Log warning "SQLite export failed";
note right note right
Data loss is prevented per-record. Data loss is prevented per-record.
The pipeline continues running. The pipeline continues running.
@@ -106,7 +214,7 @@ while (For each EnrichedCity?) is (Remaining cities)
else (no) else (no)
endif endif
else (no) else (no)
:spdlog::warn "Generation failed, skipping..."; :Log warning "Generation failed, skipping...";
endif endif
|#E5EDE1|DataGenerator| |#E5EDE1|DataGenerator|
endwhile (Done) endwhile (Done)
@@ -119,6 +227,8 @@ note right
end note end note
|#F2F6F0|main.cc| |#F2F6F0|main.cc|
:Close log_channel;
:Join log dispatcher thread;
:Return 0; :Return 0;
stop stop

View File

@@ -25,15 +25,19 @@ skinparam note {
title The Biergarten Data Pipeline - Class Diagram title The Biergarten Data Pipeline - Class Diagram
class BiergartenDataGenerator { class BiergartenPipelineOrchestrator {
- logger_ : std::shared_ptr<ILogger> - logger_ : std::shared_ptr<ILogger>
- context_service_ : std::unique_ptr<IEnrichmentService> - context_service_ : std::unique_ptr<IEnrichmentService>
- generator_ : std::unique_ptr<DataGenerator> - generator_ : std::unique_ptr<DataGenerator>
- exporter_ : std::unique_ptr<IExportService> - exporter_ : std::unique_ptr<IExportService>
- generated_breweries_ : std::vector<GeneratedBrewery> - curated_data_service_ : std::unique_ptr<ICuratedDataService>
- application_options_ : ApplicationOptions
- generated_breweries_ : std::vector<BreweryRecord>
- generated_users_ : std::vector<UserRecord>
+ Run() : bool + Run() : bool
- QueryCitiesWithCountries() : std::vector<Location> - QueryCitiesWithCountries() : std::vector<Location>
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void - GenerateBreweries(cities : std::span<const EnrichedCity>) : void
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
- LogResults() : void - LogResults() : void
} }
@@ -54,21 +58,29 @@ class PipelinePhase <<enumeration>> {
Teardown Teardown
} }
struct LogEntry { struct LogDTO {
+ timestamp : std::chrono::system_clock::time_point + level : LogLevel
+ phase : PipelinePhase
+ message : std::string
}
struct LogEntry {
+ timestamp : std::chrono::system_clock::time_point
+ origin : std::source_location
+ thread_id : std::thread::id
+ level : LogLevel + level : LogLevel
+ phase : PipelinePhase + phase : PipelinePhase
+ message : std::string + message : std::string
+ worker : std::optional<std::string>
} }
interface ILogger <<interface>> { interface ILogger <<interface>> {
+ Log(entry : const LogEntry&) : void + Log(payload : LogDTO) : void
- {abstract} DoLog(entry : LogEntry) : void
} }
class LogProducer { class LogProducer {
- channel_ : BoundedChannel<LogEntry>& - channel_ : BoundedChannel<LogEntry>&
+ Log(entry : const LogEntry&) : void - DoLog(entry : LogEntry) : void
} }
class LogDispatcher { class LogDispatcher {
@@ -81,7 +93,11 @@ interface IEnrichmentService <<interface>> {
+ GetLocationContext(loc : const Location&) : std::string + GetLocationContext(loc : const Location&) : std::string
} }
class WikipediaService { class MockEnrichmentService {
+ GetLocationContext(loc : const Location&) : std::string
}
class WikipediaEnrichmentService {
- client_ : std::unique_ptr<WebClient> - client_ : std::unique_ptr<WebClient>
- extract_cache_ : std::unordered_map<std::string, std::string> - extract_cache_ : std::unordered_map<std::string, std::string>
+ GetLocationContext(loc : const Location&) : std::string + GetLocationContext(loc : const Location&) : std::string
@@ -100,26 +116,27 @@ class HttpWebClient {
interface DataGenerator <<interface>> { interface DataGenerator <<interface>> {
+ GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult + GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult
+ GenerateUser(locale : const std::string&) : UserResult + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
} }
class MockGenerator { class MockGenerator {
+ GenerateBrewery(...) : BreweryResult + GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult
+ GenerateUser(...) : UserResult + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
- DeterministicHash(location : const Location&) : size_t - DeterministicHash(location : const Location&) : size_t
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
} }
class LlamaGenerator { class LlamaGenerator {
- model_ : ModelHandle - model_ : ModelHandle
- context_ : ContextHandle - context_ : ContextHandle
- prompt_formatter_ : std::unique_ptr<IPromptFormatter> - prompt_formatter_ : std::unique_ptr<IPromptFormatter>
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
- rng_ : std::mt19937 - rng_ : std::mt19937
+ GenerateBrewery(...) : BreweryResult + GenerateBrewery(...) : BreweryResult
+ GenerateUser(...) : UserResult + GenerateUser(...) : UserResult
- Load(model_path : const std::string&) : void - Load(model_path : const std::string&) : void
- Infer(...) : std::string - Infer(...) : std::string
- InferFormatted(...) : std::string - InferFormatted(...) : std::string
- LoadBrewerySystemPrompt(...) : std::string
} }
interface IPromptFormatter <<interface>> { interface IPromptFormatter <<interface>> {
@@ -130,13 +147,59 @@ class Gemma4JinjaPromptFormatter {
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string + Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
} }
class JsonLoader { interface IPromptDirectory <<interface>> {
+ {static} LoadLocations(filepath : const std::filesystem::path&) : std::vector<Location> + Load(key : std::string_view) : std::string
} }
class PromptDirectory {
- prompt_dir_ : std::filesystem::path
- cache_ : std::unordered_map<std::string, std::string>
+ Load(key : std::string_view) : std::string
}
interface ICuratedDataService <<interface>> {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
class JsonLoader {
- cache_ : cache
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of JsonLoader
Each Load* method memoizes its result in
cache_ on first call; later calls on the
same instance return the cached value
without re-parsing.
end note
class MockCuratedDataService {
- locations_ : std::vector<Location>
- personas_ : std::vector<UserPersona>
- forenames_by_country_ : std::unordered_map<std::string, forename_list>
- surnames_by_country_ : std::unordered_map<std::string, surname_list>
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of MockCuratedDataService
Fixed 4-location / 3-persona dataset
(US, DE, FR, BE) used in --mocked runs;
filepath arguments are ignored.
end note
interface IExportService <<interface>> { interface IExportService <<interface>> {
+ Initialize() : void + Initialize() : void
+ ProcessRecord(brewery : const GeneratedBrewery&) : void + ProcessRecord(brewery : const BreweryRecord&) : uint64_t
+ ProcessRecord(user : const UserRecord&) : uint64_t
+ Finalize() : void + Finalize() : void
} }
@@ -144,15 +207,18 @@ class SqliteExportService {
- date_time_provider_ : std::unique_ptr<IDateTimeProvider> - date_time_provider_ : std::unique_ptr<IDateTimeProvider>
- run_timestamp_utc_ : std::string - run_timestamp_utc_ : std::string
- database_path_ : std::filesystem::path - database_path_ : std::filesystem::path
- db_handle_ : sqlite3* - db_handle_ : SqliteDatabaseHandle
- insert_location_stmt_ : sqlite3_stmt* - insert_location_stmt_ : SqliteStatementHandle
- insert_brewery_stmt_ : sqlite3_stmt* - insert_brewery_stmt_ : SqliteStatementHandle
- insert_user_stmt_ : SqliteStatementHandle
- transaction_open_ : bool - transaction_open_ : bool
- location_cache_ : std::unordered_map<std::string, sqlite3_int64> - location_cache_ : std::unordered_map<std::string, sqlite3_int64>
+ Initialize() : void + Initialize() : void
+ ProcessRecord(brewery : const GeneratedBrewery&) : void + ProcessRecord(brewery : const BreweryRecord&) : uint64_t
+ ProcessRecord(user : const UserRecord&) : uint64_t
+ Finalize() : void + Finalize() : void
- InitializeSchema() : void - InitializeSchema() : void
- ResolveLocationId(location : const Location&) : sqlite3_int64
} }
interface IDateTimeProvider <<interface>> { interface IDateTimeProvider <<interface>> {
@@ -164,19 +230,23 @@ class SystemDateTimeProvider {
} }
' Structural Relationships / Dependency Injection ' Structural Relationships / Dependency Injection
BiergartenDataGenerator *-- ILogger : owns BiergartenPipelineOrchestrator *-- ILogger : owns
BiergartenDataGenerator *-- IEnrichmentService : owns BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
BiergartenDataGenerator *-- DataGenerator : owns BiergartenPipelineOrchestrator *-- DataGenerator : owns
BiergartenDataGenerator *-- IExportService : owns BiergartenPipelineOrchestrator *-- IExportService : owns
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
LogEntry *-- LogLevel LogEntry *-- LogLevel
LogEntry *-- PipelinePhase LogEntry *-- PipelinePhase
LogDTO *-- LogLevel
LogDTO *-- PipelinePhase
ILogger <|.. LogProducer : implements ILogger <|.. LogProducer : implements
LogProducer ..> LogEntry : emits LogProducer ..> LogEntry : emits
LogDispatcher ..> LogEntry : consumes LogDispatcher ..> LogEntry : consumes
IEnrichmentService <|.. WikipediaService : implements IEnrichmentService <|.. WikipediaEnrichmentService : implements
WikipediaService *-- WebClient : owns IEnrichmentService <|.. MockEnrichmentService : implements
WikipediaEnrichmentService *-- WebClient : owns
WebClient <|.. HttpWebClient : implements WebClient <|.. HttpWebClient : implements
@@ -184,10 +254,13 @@ DataGenerator <|.. MockGenerator : implements
DataGenerator <|.. LlamaGenerator : implements DataGenerator <|.. LlamaGenerator : implements
LlamaGenerator *-- IPromptFormatter : uses LlamaGenerator *-- IPromptFormatter : uses
LlamaGenerator *-- IPromptDirectory : uses
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
IPromptDirectory <|.. PromptDirectory : implements
BiergartenDataGenerator ..> JsonLoader : uses ICuratedDataService <|.. JsonLoader : implements
ICuratedDataService <|.. MockCuratedDataService : implements
IExportService <|.. SqliteExportService : implements IExportService <|.. SqliteExportService : implements
SqliteExportService *-- IDateTimeProvider : owns SqliteExportService *-- IDateTimeProvider : owns

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -43,10 +43,18 @@ fork again
:EnrichmentService::PreWarmLocationCache(sampled_locations); :EnrichmentService::PreWarmLocationCache(sampled_locations);
end fork end fork
fork fork
:JsonLoader::LoadNamesByCountry("names-by-country.json"); :JsonLoader::LoadForenamesByCountry(\n "forenames-by-country.json");
fork again
:JsonLoader::LoadSurnamesByCountry(\n "surnames-by-country.json");
fork again fork again
:JsonLoader::LoadPersonas("personas.json"); :JsonLoader::LoadPersonas("personas.json");
end fork end fork
note right
Each call is memoized after its first parse
(implemented today). MockCuratedDataService
returns a fixed in-memory dataset instead
under --mocked, skipping these JSON files.
end note
' ═══════════════════════════════════════════ ' ═══════════════════════════════════════════
' PHASE 0 — USER GENERATION ' PHASE 0 — USER GENERATION
@@ -82,11 +90,15 @@ fork again
ibu_preference, checkin_weight. ibu_preference, checkin_weight.
end note end note
:NamesByCountry::SampleName(\n location.iso3166_1); :SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1, rng);
note right note right
Deterministic lookup -- no LLM involved. Deterministic lookup -- no LLM involved.
Name selected from pre-keyed table Free function (implemented today in
generate_users.cc), not a class method.
Name selected from pre-keyed maps
and passed into the generation prompt. and passed into the generation prompt.
Skips the city if either map has no
entry for iso3166_1.
end note end note
:GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator; :GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator;

View File

@@ -67,11 +67,50 @@ package "Domain: Models" {
} }
class UserResult { class UserResult {
+ first_name : std::string
+ last_name : std::string
+ gender : std::string
+ username : std::string + username : std::string
+ bio : std::string + bio : std::string
+ activity_weight : float + activity_weight : float
} }
class Name {
+ first_name : std::string
+ last_name : std::string
+ gender : std::string
}
class ForenameEntry {
+ name : std::string
+ gender : std::string
}
note right of ForenameEntry
forename_list = std::unordered_set<ForenameEntry>
surname_list = std::unordered_set<std::string>
(aliases declared in curated_data_service.h).
ICuratedDataService::LoadForenamesByCountry() /
LoadSurnamesByCountry() each return a flat
std::unordered_map<std::string, forename_list>
or <std::string, surname_list> keyed by ISO
3166-1 country code -- no NamesByCountry
wrapper class. SampleName(forenames_by_country,
surnames_by_country, iso3166_1, rng) is a free
function, not a method, so it can be called by
any per-city worker without owning the maps.
Sourced from popular-names-by-country-dataset
(CC0) -- see ETHICS-AND-KNOWN-ISSUES.md. Gender
is preserved per forename so it can be threaded
through to persona/bio generation later; surnames
carry no gender in the source data. Pairing
happens at sample time, not at fixture-authoring
time, so no information from the source dataset
is discarded up front.
end note
ForenameEntry ..> Name : SampleName() produces
class CheckinResult { class CheckinResult {
+ checked_in_at : std::string + checked_in_at : std::string
+ note : std::string + note : std::string
@@ -110,9 +149,20 @@ package "Domain: Models" {
+ user_id : uint64_t + user_id : uint64_t
+ location : Location + location : Location
+ user : UserResult + user : UserResult
+ email : std::string
+ date_of_birth : std::string
+ password : std::string
+ metadata : GenerationMetadata + metadata : GenerationMetadata
} }
note right of GeneratedUser
email, date_of_birth, and password are
programmatically generated by the orchestrator
(not LLM-authored) so a downstream auth-account
seeding consumer can register real accounts from
this export. See ROADMAP.md §1.
end note
class GeneratedCheckin { class GeneratedCheckin {
+ checkin_id : uint64_t + checkin_id : uint64_t
+ user_id : uint64_t + user_id : uint64_t
@@ -318,20 +368,43 @@ package "Infrastructure: Pipeline Channel" {
package "Infrastructure: Data Preloading" { package "Infrastructure: Data Preloading" {
interface DataPreloader <<interface>> { interface ICuratedDataService <<interface>> {
+ LoadLocations(filepath : const std::filesystem::path&) : std::vector<Location> + LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : std::vector<BeerStyle> + LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : std::vector<Persona> + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadNamesByCountry(filepath : const std::filesystem::path&) : NamesByCountry + LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
} }
class JsonLoader { class JsonLoader {
+ LoadLocations(filepath : const std::filesystem::path&) : std::vector<Location> + LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : std::vector<BeerStyle> + LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : std::vector<Persona> + LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadNamesByCountry(filepath : const std::filesystem::path&) : NamesByCountry + LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
} }
note right of JsonLoader
Each Load* call is memoized after its first
parse -- implemented today, ahead of the rest
of this package (LoadBeerStyles is still planned).
end note
class MockCuratedDataService {
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
+ LoadBeerStyles(filepath : const std::filesystem::path&) : const std::vector<BeerStyle>&
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
+ LoadForenamesByCountry(forenames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
+ LoadSurnamesByCountry(surnames_filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
}
note right of MockCuratedDataService
Fixed in-memory dataset used in --mocked mode,
implemented today for Locations/Personas/
Forenames/Surnames; would need a LoadBeerStyles
fixture too once that method is implemented.
end note
} }
package "Infrastructure: Enrichment" { package "Infrastructure: Enrichment" {
@@ -380,7 +453,7 @@ package "Infrastructure: Data Generation" {
interface DataGenerator <<interface>> { interface DataGenerator <<interface>> {
+ GenerateBrewery(location : const Location&,\n context : const LocationContext&) : BreweryResult + GenerateBrewery(location : const Location&,\n context : const LocationContext&) : BreweryResult
+ GenerateBeer(brewery_id : uint64_t,\n location : const Location&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult + GenerateBeer(brewery_id : uint64_t,\n location : const Location&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult
+ GenerateUser(location : const Location&) : UserResult + GenerateUser(city : const EnrichedCity&,\n persona : const UserPersona&,\n name : const Name&) : UserResult
+ GenerateCheckin(user : const GeneratedUser&,\n brewery : const GeneratedBrewery&,\n timestamp : const std::string&) : CheckinResult + GenerateCheckin(user : const GeneratedUser&,\n brewery : const GeneratedBrewery&,\n timestamp : const std::string&) : CheckinResult
+ GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult + GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult
} }
@@ -473,7 +546,7 @@ package "Infrastructure: Data Export" {
} }
class BiergartenPipelineOrchestrator { class BiergartenPipelineOrchestrator {
- preloader_ : std::unique_ptr<DataPreloader> - curated_data_service_ : std::unique_ptr<ICuratedDataService>
- enrichment_service_ : std::unique_ptr<EnrichmentService> - enrichment_service_ : std::unique_ptr<EnrichmentService>
- generator_ : std::unique_ptr<DataGenerator> - generator_ : std::unique_ptr<DataGenerator>
- logger_ : std::unique_ptr<Logger> - logger_ : std::unique_ptr<Logger>
@@ -501,7 +574,7 @@ class BiergartenPipelineOrchestrator {
} }
' --- Orchestration Aggregations (Services & Strategies) --- ' --- Orchestration Aggregations (Services & Strategies) ---
BiergartenPipelineOrchestrator *-- DataPreloader BiergartenPipelineOrchestrator *-- ICuratedDataService
BiergartenPipelineOrchestrator *-- EnrichmentService BiergartenPipelineOrchestrator *-- EnrichmentService
BiergartenPipelineOrchestrator *-- DataGenerator BiergartenPipelineOrchestrator *-- DataGenerator
BiergartenPipelineOrchestrator *-- ExportService BiergartenPipelineOrchestrator *-- ExportService
@@ -520,7 +593,8 @@ BiergartenPipelineOrchestrator *-- "0..*" GeneratedCheckin : checkin_pool_
BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_ BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_
' --- Interfaces & Implementations --- ' --- Interfaces & Implementations ---
DataPreloader <|.. JsonLoader ICuratedDataService <|.. JsonLoader
ICuratedDataService <|.. MockCuratedDataService
Logger <|.. PipelineLogger Logger <|.. PipelineLogger
ContextStrategy <|.. BreweryContextStrategy ContextStrategy <|.. BreweryContextStrategy
ContextStrategy <|.. BeerContextStrategy ContextStrategy <|.. BeerContextStrategy

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 142 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 240 KiB

79
docs/website/database.md Normal file
View File

@@ -0,0 +1,79 @@
# Database
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
convention. For the vertical-slice code that talks to this schema, see
[Architecture](../architecture.md).
## Schema
The full schema lives in one script:
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
owning feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
### Tables backed by the API today
| Table | Backed by | Notes |
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
### Tables defined in the schema but not yet wired to a feature slice
These exist so the schema can support planned functionality, but have no
repository or seed data yet:
| Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `Photo` | A photo uploaded by a user account |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
## Migrations
**Tool**: DbUp, run by the `Database.Migrations` project/container.
Scripts live under `scripts/`, applied in order and tracked so a script never
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
When the schema needs to change, add a new numbered script rather than editing
`schema.sql` in place — DbUp's tracking is per-script, so editing an
already-applied script means it won't re-run against existing databases.
## Data seeding
`Database.Seed` populates a target database from data produced by the C++
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
state/provinces, and cities (created on demand while resolving each brewery's
location), user accounts, and brewery posts with locations. It only seeds
tables that have a feature slice to write through (see the table above).
## Database error handling
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
rows against the `RowVersion` column.
- On failure, the repository throws a `Domain.Exceptions` type directly
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
constraint-violation failure) maps to 503.
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
error 2601 or 2627) when two requests try to verify the same account
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,18 +23,26 @@ package "Feature Slices" #F3E5F5 {
[Features.Breweries] as BrewerySlice [Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice [Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice [Features.Emails] as EmailsSlice
[Features.Locations] as LocationsSlice
note right of AuthSlice note right of AuthSlice
Each slice owns its own: Each slice owns its own:
- Controller (HTTP endpoints) - Controller (HTTP endpoints)
- Commands/Queries + Handlers - Commands/Queries + Handlers
- Validators - Validators
- Repository - Repository (Dapper)
end note end note
note right of EmailsSlice note right of EmailsSlice
No controller - invoked only No controller - invoked only
via MediatR commands sent via MediatR commands sent
from other slices from other slices
end note end note
note right of LocationsSlice
No controller and no MediatR
handlers - exposes ILocationRepository
directly as a plain DI service.
Not registered by API.Core at all;
only Database.Seed depends on it today
end note
} }
package "Shared" #FCE4EC { package "Shared" #FCE4EC {
@@ -61,24 +69,30 @@ package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain [Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions [Domain.Exceptions] as DomainExceptions
note right of Domain note right of Domain
- UserAccount - UserAccount, UserCredential, UserVerification
- UserCredential - BreweryPost, BreweryPostLocation
- UserVerification - City, StateProvince, Country
- BreweryPost - BeerPost, BeerStyle
(schema tables exist; no repository yet)
end note end note
} }
database "SQL Server" { database "SQL Server" {
[Stored Procedures] as SP
[Tables] as Tables [Tables] as Tables
} }
node "Database.Seed\n(separate console app)" as Seed
' Relationships ' Relationships
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller) API ..> EmailsSlice : MediatR scan only\n(no controller)
Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR)
Seed ..> BrewerySlice : AddFeaturesBreweries()
Seed ..> LocationsSlice : AddFeaturesLocations()
AuthSlice --> Sql AuthSlice --> Sql
AuthSlice --> JWT AuthSlice --> JWT
AuthSlice --> PwdHash AuthSlice --> PwdHash
@@ -93,6 +107,8 @@ UserSlice --> Sql
UserSlice --> SharedContracts UserSlice --> SharedContracts
UserSlice --> SharedApp UserSlice --> SharedApp
LocationsSlice --> Sql
EmailsSlice --> Email EmailsSlice --> Email
EmailsSlice --> EmailTemplates EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp EmailsSlice --> SharedApp
@@ -101,20 +117,25 @@ EmailsSlice --> SharedApp
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> SP
Sql --> Domain Sql --> Domain
SP --> Tables
AuthSlice --> Tables : Dapper SQL
BrewerySlice --> Tables : Dapper SQL
UserSlice --> Tables : Dapper SQL
LocationsSlice --> Tables : Dapper SQL
AuthSlice --> Domain AuthSlice --> Domain
BrewerySlice --> Domain BrewerySlice --> Domain
UserSlice --> Domain UserSlice --> Domain
LocationsSlice --> Domain
AuthSlice --> DomainExceptions AuthSlice --> DomainExceptions
' Notes ' Notes
note left of Sql note left of Sql
SQL-first approach SQL-first approach
All queries via Each slice's own repository issues
stored procedures inline SQL via Dapper - no ORM,
no stored procedures
end note end note
note bottom of AuthSlice note bottom of AuthSlice

View File

@@ -9,11 +9,11 @@ title User Authentication Flow - Expanded
actor User actor User
participant "API\nController" as API participant "API\nController" as API
box "Service Layer" #LightBlue participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
participant "RegisterService" as RegSvc box "Handler Layer" #LightBlue
participant "LoginService" as LoginSvc participant "RegisterUserHandler" as RegHandler
participant "LoginHandler" as LoginHandler
participant "TokenService" as TokenSvc participant "TokenService" as TokenSvc
participant "EmailService" as EmailSvc
end box end box
box "Infrastructure Layer" #LightGreen box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2 participant "Argon2\nInfrastructure" as Argon2
@@ -21,55 +21,59 @@ participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template participant "Template\nProvider" as Template
end box end box
box "Repository Layer" #LightYellow box "Repository / Cross-slice" #LightYellow
participant "AuthRepository" as AuthRepo participant "AuthRepository" as AuthRepo
participant "UserAccount\nRepository" as UserRepo participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box end box
database "SQL Server\nStored Procedures" as DB database "SQL Server" as DB
== Registration Flow == == Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password} User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API activate API
note right of API API -> Mediator: Send(RegisterUserCommand)
FluentValidation runs: activate Mediator
note right of Mediator
ValidationBehavior runs RegisterUserValidator
(FluentValidation) before the handler:
- Username: 3-64 chars, alphanumeric + [._-] - Username: 3-64 chars, alphanumeric + [._-]
- Email: valid format, max 128 chars - Email: valid format, max 128 chars
- Password: min 8 chars, uppercase,\n lowercase, number, special char - Password: min 8 chars, uppercase,\n lowercase, number, special char
- DateOfBirth: must be 19+ years old - DateOfBirth: must be 19+ years old
end note end note
API -> API: Validate request\n(FluentValidation)
alt Validation fails alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}} Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds else Validation succeeds
API -> RegSvc: RegisterAsync(userAccount, password) Mediator -> RegHandler: Handle(command)
activate RegSvc activate RegHandler
RegSvc -> AuthRepo: GetUserByUsernameAsync(username) RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB activate DB
DB --> AuthRepo: null (user doesn't exist) DB --> AuthRepo: null (user doesn't exist)
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
RegSvc -> AuthRepo: GetUserByEmailAsync(email) RegHandler -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB activate DB
DB --> AuthRepo: null (email doesn't exist) DB --> AuthRepo: null (email doesn't exist)
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt User/Email already exists alt Username or email already exists
RegSvc -> API: throw ConflictException RegHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict\n"Username or email already exists" API -> User: 409 Conflict\n"Username or email already exists"
else User doesn't exist else Username and email are free
RegSvc -> Argon2: Hash(password) RegHandler -> Argon2: Hash(password)
activate Argon2 activate Argon2
note right of Argon2 note right of Argon2
Argon2id parameters: Argon2id parameters:
@@ -81,33 +85,39 @@ else Validation succeeds
end note end note
Argon2 -> Argon2: Generate random salt\n(16 bytes) Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegSvc: "base64(salt):base64(hash)" Argon2 --> RegHandler: "base64(salt):base64(hash)"
deactivate Argon2 deactivate Argon2
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash) RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC USP_RegisterUser AuthRepo -> DB: BEGIN TRANSACTION
activate DB
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
DB --> AuthRepo: UserAccountId (GUID)
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
AuthRepo -> DB: COMMIT TRANSACTION
deactivate DB
note right of AuthRepo
All three statements run via Dapper's
ExecuteScalarAsync/ExecuteAsync against
a single DbTransaction, not a stored
procedure - no manual parameter binding
end note
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
activate DB activate DB
note right of DB note right of DB
Transaction begins: Round trip to load the row the
1. INSERT UserAccount INSERT just created
2. INSERT UserCredential
(with hashed password)
Transaction commits
end note end note
DB -> DB: BEGIN TRANSACTION DB --> AuthRepo: UserAccount row
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
DB -> DB: OUTPUT INSERTED.UserAccountID
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
DB -> DB: COMMIT TRANSACTION
DB --> AuthRepo: UserAccountId (GUID)
deactivate DB deactivate DB
AuthRepo --> RegSvc: UserAccount entity AuthRepo --> RegHandler: UserAccount entity
deactivate AuthRepo deactivate AuthRepo
RegSvc -> TokenSvc: GenerateAccessToken(userAccount) RegHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry) TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT activate JWT
@@ -118,33 +128,55 @@ else Validation succeeds
- Claims: - Claims:
* sub: userId * sub: userId
* unique_name: username * unique_name: username
* jti: unique token ID * iat / jti
end note end note
JWT -> JWT: Create JWT with claims JWT -> JWT: Create JWT with claims
JWT -> JWT: Sign with secret key JWT -> JWT: Sign with secret key
JWT --> TokenSvc: Access Token JWT --> TokenSvc: Access Token
deactivate JWT deactivate JWT
TokenSvc --> RegSvc: Access Token TokenSvc --> RegHandler: Access Token
deactivate TokenSvc deactivate TokenSvc
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount) RegHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry) TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT activate JWT
note right of JWT note right of JWT
Refresh Token: Refresh Token:
- Expires: 21 days - Expires: 21 days (504 hours)
- Same structure as access token - Same structure as access token,\n signed with a separate secret
end note end note
JWT --> TokenSvc: Refresh Token JWT --> TokenSvc: Refresh Token
deactivate JWT deactivate JWT
TokenSvc --> RegSvc: Refresh Token TokenSvc --> RegHandler: Refresh Token
deactivate TokenSvc deactivate TokenSvc
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken) RegHandler -> TokenSvc: GenerateConfirmationToken(user)
activate EmailSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
Confirmation Token:
- Expires: 30 minutes (0.5 hours)
- Signed with a third, separate secret
end note
JWT --> TokenSvc: Confirmation Token
deactivate JWT
TokenSvc --> RegHandler: Confirmation Token
deactivate TokenSvc
EmailSvc -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink) RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
activate Mediator
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
note right of Mediator
Routed to Features.Emails'
SendRegistrationEmailHandler,
the only cross-slice call in
the backend
end note
activate EmailDispatcher
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
activate Template activate Template
note right of Template note right of Template
Razor Component: Razor Component:
@@ -154,38 +186,37 @@ else Validation succeeds
- Footer - Footer
end note end note
Template -> Template: Render Razor component\nto HTML Template -> Template: Render Razor component\nto HTML
Template --> EmailSvc: HTML email content Template --> EmailDispatcher: HTML email content
deactivate Template deactivate Template
EmailSvc -> SMTP: SendAsync(email, subject, body) EmailDispatcher -> SMTP: SendAsync(email, subject, body)
activate SMTP activate SMTP
note right of SMTP note right of SMTP
SMTP Configuration: SMTP Configuration (MailKit):
- Host: from env (SMTP_HOST) - Host/port: from env (SMTP_HOST/SMTP_PORT)
- Port: from env (SMTP_PORT) - TLS: StartTLS when SMTP_USE_SSL=true
- TLS: StartTLS - Auth: username/password, if configured
- Auth: username/password
end note end note
SMTP -> SMTP: Create MIME message SMTP -> SMTP: Build MIME message
SMTP -> SMTP: Connect to SMTP server SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
SMTP -> SMTP: Authenticate SMTP --> EmailDispatcher: Success / throws
SMTP -> SMTP: Send email
SMTP -> SMTP: Disconnect
SMTP --> EmailSvc: Success / Failure
deactivate SMTP deactivate SMTP
deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully alt Email sent successfully
EmailSvc --> RegSvc: emailSent = true RegHandler -> RegHandler: emailSent = true
else Email failed else mediator.Send throws
EmailSvc --> RegSvc: emailSent = false\n(error suppressed) RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
end end
deactivate EmailSvc
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent) RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
deactivate RegSvc deactivate RegHandler
Mediator --> API: RegistrationPayload
deactivate Mediator
API -> API: Create response body API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
end end
end end
deactivate API deactivate API
@@ -195,88 +226,94 @@ deactivate API
User -> API: POST /api/auth/login\n{username, password} User -> API: POST /api/auth/login\n{username, password}
activate API activate API
API -> API: Validate request\n(FluentValidation) API -> Mediator: Send(LoginCommand)
activate Mediator
note right of Mediator
ValidationBehavior runs LoginValidator:
username and password must both be non-empty
end note
alt Validation fails alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}} Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds else Validation succeeds
API -> LoginSvc: LoginAsync(username, password) Mediator -> LoginHandler: Handle(command)
activate LoginSvc activate LoginHandler
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username) LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB activate DB
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username DB --> AuthRepo: UserAccount row or null
DB --> AuthRepo: UserAccount entity
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt User not found alt User not found
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password" LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
Mediator -> API: propagate
API -> User: 401 Unauthorized API -> User: 401 Unauthorized
else User found else User found
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId) LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo activate AuthRepo
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
activate DB activate DB
note right of DB DB --> AuthRepo: UserCredential row or null
SELECT FROM UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0
end note
DB --> AuthRepo: UserCredential entity
deactivate DB deactivate DB
deactivate AuthRepo deactivate AuthRepo
alt No active credential alt No active credential
LoginSvc -> API: throw UnauthorizedException LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized API -> User: 401 Unauthorized
else Active credential found else Active credential found
LoginSvc -> Argon2: Verify(password, storedHash) LoginHandler -> Argon2: Verify(password, storedHash)
activate Argon2 activate Argon2
note right of Argon2 note right of Argon2
1. Split stored hash: "salt:hash" 1. Split stored hash: "salt:hash"
2. Extract salt 2. Extract salt
3. Hash provided password\n with same salt 3. Hash provided password\n with same salt
4. Constant-time comparison 4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note end note
Argon2 -> Argon2: Parse salt from stored hash Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored) Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginSvc: true/false Argon2 --> LoginHandler: true/false
deactivate Argon2 deactivate Argon2
alt Password invalid alt Password invalid
LoginSvc -> API: throw UnauthorizedException LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized API -> User: 401 Unauthorized
else Password valid else Password valid
LoginSvc -> TokenSvc: GenerateAccessToken(user) LoginHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...) TokenSvc -> JWT: GenerateJwt(...)
activate JWT activate JWT
JWT --> TokenSvc: Access Token JWT --> TokenSvc: Access Token
deactivate JWT deactivate JWT
TokenSvc --> LoginSvc: Access Token TokenSvc --> LoginHandler: Access Token
deactivate TokenSvc deactivate TokenSvc
LoginSvc -> TokenSvc: GenerateRefreshToken(user) LoginHandler -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...) TokenSvc -> JWT: GenerateJwt(...)
activate JWT activate JWT
JWT --> TokenSvc: Refresh Token JWT --> TokenSvc: Refresh Token
deactivate JWT deactivate JWT
TokenSvc --> LoginSvc: Refresh Token TokenSvc --> LoginHandler: Refresh Token
deactivate TokenSvc deactivate TokenSvc
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken) LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
deactivate LoginSvc deactivate LoginHandler
Mediator --> API: LoginPayload
deactivate Mediator
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n} API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
end end
end end
end end
@@ -286,13 +323,15 @@ deactivate API
== Error Handling (Global Exception Filter) == == Error Handling (Global Exception Filter) ==
note over API note over API
GlobalExceptionFilter catches: API.Core's GlobalExceptionFilter catches:
- ValidationException → 400 Bad Request - FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
- ConflictException → 409 Conflict - ConflictException -> 409 Conflict
- NotFoundException → 404 Not Found - NotFoundException -> 404 Not Found
- UnauthorizedException → 401 Unauthorized - UnauthorizedException -> 401 Unauthorized
- ForbiddenException → 403 Forbidden - ForbiddenException -> 403 Forbidden
- All others → 500 Internal Server Error - Domain.Exceptions.ValidationException -> 400 Bad Request
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
- All others -> 500 Internal Server Error
end note end note
@enduml @enduml

View File

@@ -3,102 +3,205 @@
skinparam backgroundColor #FFFFFF skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial skinparam defaultFontName Arial
skinparam linetype ortho skinparam linetype ortho
skinparam packageStyle rectangle
title Key Database Schema - User & Authentication title Database Schema
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
entity "UserAccount" as User { package "User & Auth" {
* UserAccountId: INT <<PK>> entity "UserAccount" as User {
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
-- --
* Username: NVARCHAR(30) <<UNIQUE>> * Username: VARCHAR(64) <<UNIQUE>>
* Email: NVARCHAR(255) <<UNIQUE>> * FirstName: NVARCHAR(128)
* FirstName: NVARCHAR(50) * LastName: NVARCHAR(128)
* LastName: NVARCHAR(50) * Email: VARCHAR(128) <<UNIQUE>>
Bio: NVARCHAR(500) * CreatedAt: DATETIME
CreatedAt: DATETIME2 UpdatedAt: DATETIME
UpdatedAt: DATETIME2 * DateOfBirth: DATE
LastLoginAt: DATETIME2 }
entity "UserCredential" as Cred {
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
* Expiry: DATETIME
* Hash: NVARCHAR(256)
* IsRevoked: BIT
RevokedAt: DATETIME
}
entity "UserVerification" as Verify {
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* VerificationDateTime: DATETIME
}
entity "UserAvatar (*)" as Avatar {
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
}
entity "UserFollow (*)" as Follow {
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
* FollowingID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
}
entity "Photo (*)" as Photo {
* PhotoID: UNIQUEIDENTIFIER <<PK>>
--
Hyperlink: NVARCHAR(256)
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
* UploadedAt: DATETIME
}
} }
entity "UserCredential" as Cred { package "Location" {
* UserCredentialId: INT <<PK>> entity "Country" as Country {
* CountryID: UNIQUEIDENTIFIER <<PK>>
-- --
* UserAccountId: INT <<FK>> * CountryName: NVARCHAR(100)
* PasswordHash: VARBINARY(32) * ISO3166_1: CHAR(2) <<UNIQUE>>
* PasswordSalt: VARBINARY(16) }
CredentialRotatedAt: DATETIME2
CredentialExpiresAt: DATETIME2 entity "StateProvince" as StateProvince {
CredentialRevokedAt: DATETIME2 * StateProvinceID: UNIQUEIDENTIFIER <<PK>>
* IsActive: BIT --
CreatedAt: DATETIME2 * StateProvinceName: NVARCHAR(100)
* ISO3166_2: CHAR(6) <<UNIQUE>>
* CountryID: UNIQUEIDENTIFIER <<FK>>
}
entity "City" as City {
* CityID: UNIQUEIDENTIFIER <<PK>>
--
* CityName: NVARCHAR(100)
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
}
} }
entity "UserVerification" as Verify { package "Brewery" {
* UserVerificationId: INT <<PK>> entity "BreweryPost" as Brewery {
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
-- --
* UserAccountId: INT <<FK>> * BreweryName: NVARCHAR(256)
* IsVerified: BIT * PostedByID: UNIQUEIDENTIFIER <<FK>>
VerifiedAt: DATETIME2 * Description: NVARCHAR(512)
VerificationToken: NVARCHAR(255) * CreatedAt: DATETIME
TokenExpiresAt: DATETIME2 UpdatedAt: DATETIME
}
entity "BreweryPostLocation" as BreweryLoc {
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* AddressLine1: NVARCHAR(256)
AddressLine2: NVARCHAR(256)
* PostalCode: NVARCHAR(20)
* CityID: UNIQUEIDENTIFIER <<FK>>
Coordinates: GEOGRAPHY
}
entity "BreweryPostPhoto (*)" as BreweryPhoto {
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
} }
entity "UserAvatar" as Avatar { package "Beer (*)" {
* UserAvatarId: INT <<PK>> entity "BeerStyle (*)" as BeerStyle {
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
-- --
* UserAccountId: INT <<FK>> * StyleName: NVARCHAR(100) <<UNIQUE>>
PhotoId: INT <<FK>> Description: NVARCHAR(MAX)
* IsActive: BIT }
CreatedAt: DATETIME2
entity "BeerPost (*)" as Beer {
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
--
* Name: NVARCHAR(100)
* Description: NVARCHAR(MAX)
* ABV: DECIMAL(4,2)
* IBU: INT
* PostedByID: UNIQUEIDENTIFIER <<FK>>
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
entity "BeerPostPhoto (*)" as BeerPhoto {
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
--
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* LinkedAt: DATETIME
}
entity "BeerPostComment (*)" as BeerComment {
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
--
* Comment: NVARCHAR(250)
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
* Rating: INT
* CreatedAt: DATETIME
UpdatedAt: DATETIME
}
} }
entity "UserFollow" as Follow { ' Relationships: User & Auth
* UserFollowId: INT <<PK>>
--
* FollowerUserId: INT <<FK>>
* FollowedUserId: INT <<FK>>
CreatedAt: DATETIME2
}
entity "Photo" as Photo {
* PhotoId: INT <<PK>>
--
* Url: NVARCHAR(500)
* CloudinaryPublicId: NVARCHAR(255)
Width: INT
Height: INT
Format: NVARCHAR(10)
CreatedAt: DATETIME2
}
' Relationships
User ||--o{ Cred : "has" User ||--o{ Cred : "has"
User ||--o| Verify : "has" User ||--o| Verify : "has"
User ||--o{ Avatar : "has" User ||--o| Avatar : "has"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
Avatar }o--|| Photo : "refers to" Avatar }o--|| Photo : "refers to"
User ||--o{ Follow : "follows (UserAccountID)"
User ||--o{ Follow : "followed by (FollowingID)"
User ||--o{ Photo : "uploaded"
note right of Cred ' Relationships: Location
Password hashing: Country ||--o{ StateProvince : "has"
- Algorithm: Argon2id StateProvince ||--o{ City : "has"
- Memory: 64MB
- Iterations: 4
- Salt: 128-bit
- Hash: 256-bit
end note
note right of Verify ' Relationships: Brewery
Account verification User ||--o{ Brewery : "posted"
via email token Brewery ||--o| BreweryLoc : "has"
with expiry City ||--o{ BreweryLoc : "located in"
end note Brewery ||--o{ BreweryPhoto : "has"
Photo ||--o{ BreweryPhoto : "linked to"
' Relationships: Beer
User ||--o{ Beer : "posted"
BeerStyle ||--o{ Beer : "categorizes"
Brewery ||--o{ Beer : "brewed"
Beer ||--o{ BeerPhoto : "has"
Photo ||--o{ BeerPhoto : "linked to"
Beer ||--o{ BeerComment : "has"
User ||--o{ BeerComment : "commented"
note bottom of User note bottom of User
Core stored procedures: Backed by Features.Auth/Features.UserManagement's
- USP_RegisterUser Dapper repositories (AuthRepository,
- USP_GetUserAccountByUsername UserAccountRepository) - registration, credential
- USP_RotateUserCredential rotation, and verification each issue their own
- USP_UpdateUserAccount inline SQL, no stored procedures
end note
note bottom of Brewery
Backed by Features.Breweries' BreweryRepository
(Dapper); create/update/delete run inline SQL
inside a transaction, with optimistic concurrency
via RowVersion
end note end note
@enduml @enduml

View File

@@ -113,7 +113,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
} }
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests { node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
component "xUnit + Moq + DbMocker" as UnitComp component "xUnit + Moq" as UnitComp
note right note right
Builds Dockerfile.tests, which Builds Dockerfile.tests, which
restores/builds against Core.slnx restores/builds against Core.slnx
@@ -124,8 +124,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
- Features.UserManagement.Tests - Features.UserManagement.Tests
- Features.Emails.Tests - Features.Emails.Tests
Uses: Moq for handlers, Uses: Moq to mock each handler's
DbMocker for repositories repository/service dependencies
No real database needed No real database needed
end note end note
} }
@@ -164,7 +164,7 @@ DevAPI .up.> DevSeed : depends_on
TestMig --> TestDB : 1. Migrate TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test Specs --> TestDB : 3. Integration test
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection) UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
TestMig .up.> TestDB : depends_on TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on TestSeed .up.> TestMig : depends_on

View File

@@ -1,4 +1,4 @@
# Docker Guide # Docker guide
This document covers Docker deployment, configuration, and troubleshooting for This document covers Docker deployment, configuration, and troubleshooting for
The Biergarten App. The Biergarten App.
@@ -16,7 +16,7 @@ The project uses Docker Compose to orchestrate multiple services:
See the [deployment diagram](diagrams-out/deployment.svg) for visual See the [deployment diagram](diagrams-out/deployment.svg) for visual
representation. representation.
## Docker Compose Environments ## Docker Compose environments
### 1. Development (`docker-compose.dev.yaml`) ### 1. Development (`docker-compose.dev.yaml`)
@@ -66,7 +66,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
### 2. Testing (`docker-compose.test.yaml`) ### 2. Testing (`docker-compose.test.yaml`)
**Purpose**: Automated CI/CD testing in isolated environment **Purpose**: Automated CI/CD testing in an isolated environment
**Features**: **Features**:
@@ -128,18 +128,15 @@ api.core # Production API
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
``` ```
## Service Dependencies ## Service dependencies
Docker Compose manages startup order using health checks: Docker Compose manages startup order using health checks:
```mermaid ```mermaid
sqlserver (health check) graph TD
↓ A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
database.migrations (completes successfully) B --> C[database.seed: completes successfully]
↓ C --> D[api.core / tests: start when ready]
database.seed (completes successfully)
↓
api.core / tests (start when ready)
``` ```
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
@@ -168,7 +165,7 @@ api.core:
## Volumes ## Volumes
### Persistent Volumes ### Persistent volumes
**Development**: **Development**:
@@ -184,7 +181,7 @@ api.core:
- `sqlserverdata-prod` - Production database files - `sqlserverdata-prod` - Production database files
- `nuget-cache-prod` - Production NuGet cache - `nuget-cache-prod` - Production NuGet cache
### Mounted Volumes ### Mounted volumes
**Test Results**: **Test Results**:
@@ -203,7 +200,7 @@ Each environment uses isolated bridge networks:
- `testnet` - Testing network (fully isolated) - `testnet` - Testing network (fully isolated)
- `prodnet` - Production network - `prodnet` - Production network
## Environment Variables ## Environment variables
All containers are configured via environment variables from `.env` files: All containers are configured via environment variables from `.env` files:
@@ -224,9 +221,9 @@ environment:
For complete list, see [Environment Variables](environment-variables.md). For complete list, see [Environment Variables](environment-variables.md).
## Common Commands ## Common commands
### View Services ### View services
```bash ```bash
# Running services # Running services
@@ -236,7 +233,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
docker ps -a docker ps -a
``` ```
### View Logs ### View logs
```bash ```bash
# All services # All services
@@ -249,17 +246,17 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f ap
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
``` ```
### Execute Commands in Container ### Execute commands in container
```bash ```bash
# Interactive shell # Interactive shell
docker exec -it dev-env-api-core bash docker exec -it dev-env-api-core bash
# Run command # Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C
``` ```
### Restart Services ### Restart services
```bash ```bash
# Restart all services # Restart all services
@@ -272,7 +269,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart ap
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
``` ```
### Build Images ### Build images
```bash ```bash
# Build all images # Build all images
@@ -285,7 +282,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
``` ```
### Clean Up ### Clean up
```bash ```bash
# Stop and remove containers # Stop and remove containers
@@ -301,9 +298,9 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --
docker system prune -af --volumes docker system prune -af --volumes
``` ```
## Dockerfile Structure ## Dockerfile structure
### Multi-Stage Build ### Multi-stage build
```dockerfile ```dockerfile
# Stage 1: Build # Stage 1: Build
@@ -321,7 +318,7 @@ COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "Project.dll"] ENTRYPOINT ["dotnet", "Project.dll"]
``` ```
## Additional Resources ## Additional resources
- [Docker Compose Documentation](https://docs.docker.com/compose/) - [Docker Compose Documentation](https://docs.docker.com/compose/)
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet) - [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)

View File

@@ -1,4 +1,4 @@
# Environment Variables # Environment variables
This document covers the active environment variables used by the current This document covers the active environment variables used by the current
Biergarten stack. Biergarten stack.
@@ -11,7 +11,7 @@ The application uses environment variables for:
- **React Router website** - API base URL and session signing - **React Router website** - API base URL and session signing
- **Docker containers** - environment-specific orchestration - **Docker containers** - environment-specific orchestration
## Configuration Patterns ## Configuration patterns
### Backend (.NET API) ### Backend (.NET API)
@@ -30,9 +30,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.test` - Testing - `.env.test` - Testing
- `.env.prod` - Production - `.env.prod` - Production
## Backend Variables (.NET API) ## Backend variables (.NET API)
### Database Connection ### Database connection
**Option 1: Component-Based (Recommended for Docker)** **Option 1: Component-Based (Recommended for Docker)**
@@ -59,7 +59,7 @@ string is built from components.
**Implementation**: See `DefaultSqlConnectionFactory.cs` **Implementation**: See `DefaultSqlConnectionFactory.cs`
### JWT Authentication Secrets (Backend) ### JSON Web Token (JWT) authentication secrets (backend)
The backend uses separate secrets for different token types to enable The backend uses separate secrets for different token types to enable
independent key rotation and validation isolation. independent key rotation and validation isolation.
@@ -80,20 +80,34 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
**Security Requirements**: **Security Requirements**:
- Each secret should be minimum 32 characters - Use at least 32 characters per secret
- Recommend 127+ characters for production - Use 127 or more characters in production
- Generate using cryptographically secure random functions - Generate secrets with a cryptographically secure random function
- Never reuse secrets across token types or environments - Never reuse secrets across token types or environments
- Rotate secrets periodically in production - Rotate secrets periodically in production
**Generate Secrets**: **Generate Secrets**:
```bash **macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)**
# macOS/Linux - Generate 127-character base64 secret
openssl rand -base64 127
# Windows PowerShell ```bash
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256})) openssl rand -base64 127
```
**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw bytes)**
```powershell
$bytes = New-Object byte[] 127
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
$rng.GetBytes($bytes)
[Convert]::ToBase64String($bytes)
$rng.Dispose()
```
**Windows - PowerShell 7+ compatible (cryptographically secure, 127 raw bytes)**
```powershell
[Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(127))
``` ```
**Token Expiration**: **Token Expiration**:
@@ -110,19 +124,20 @@ openssl rand -base64 127
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- **Validation**: Token signature, expiration, and malformed token checks - **Validation**: Token signature, expiration, and malformed token checks
### Migration Control ### Migration control
```bash ```bash
CLEAR_DATABASE=true CLEAR_DATABASE=true
``` ```
- **Required**: No - **Required**: No
- **Default**: false - **Default**: `false`
- **Effect**: If "true", drops and recreates database during migrations - **Effect**: If set to `true`, drops and recreates the database during
- **Usage**: Development and testing environments ONLY migrations
- **Warning**: NEVER use in production - **Usage**: Development and testing environments only
- **Warning**: Never use in production
### ASP.NET Core Configuration ### ASP.NET Core configuration
```bash ```bash
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
@@ -130,7 +145,7 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
``` ```
## Frontend Variables (`web/frontend`) ## Frontend variables (`web/frontend`)
The active website does not use the old Next.js/Prisma environment model. Its The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are: core runtime variables are:
@@ -141,7 +156,7 @@ SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode NODE_ENV=development # Standard Node runtime mode
``` ```
### Frontend Variable Details ### Frontend variable details
#### `API_BASE_URL` #### `API_BASE_URL`
@@ -163,10 +178,10 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test` - **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode - **Purpose**: Controls secure cookie behavior and runtime mode
### SMTP Configuration (Backend) ### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
and account emails. account emails.
```bash ```bash
SMTP_HOST=mailpit # Required, no default SMTP_HOST=mailpit # Required, no default
@@ -182,12 +197,12 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web - **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025) UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, - **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
etc.) so on)
## Docker-Specific Variables ## Docker-specific variables
### SQL Server Container ### SQL Server container
```bash ```bash
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
@@ -201,9 +216,9 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
- Uppercase, lowercase, digits, and special characters - Uppercase, lowercase, digits, and special characters
- Maps to `DB_PASSWORD` for application containers - Maps to `DB_PASSWORD` for application containers
## Environment File Structure ## Environment file structure
### Backend/Docker (`web/` Directory) ### Backend/Docker (`web/` directory)
``` ```
web/.env.example # Template (tracked in Git) web/.env.example # Template (tracked in Git)
@@ -219,7 +234,7 @@ cp web/.env.example web/.env.dev
# Edit web/.env.dev with your values # Edit web/.env.dev with your values
``` ```
## Legacy Frontend Variables ## Legacy frontend variables
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
been removed from this active reference, since that app is retained for been removed from this active reference, since that app is retained for
@@ -231,10 +246,10 @@ reference only and is not run as part of the active stack.
- `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod` - `web/docker-compose.prod.yaml` → `web/.env.prod`
## Variable Reference Table ## Variable reference table
| Variable | Backend | Frontend | Docker | Required | Notes | | Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- | | ----------------------------- | :-----: | :------: | :----: | :------: | ---------------------------- |
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address | | `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name | | `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username | | `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
@@ -268,18 +283,17 @@ reference only and is not run as part of the active stack.
## Validation ## Validation
### Backend Validation ### Backend validation
Variables are validated at startup: Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
values) cause the application to fail with an `InvalidOperationException` values) cause the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the - No minimum length is enforced on the token secrets in code; the "minimum 32
"minimum 32 characters" guidance above is a recommendation, not an characters" guidance above is a recommendation, not an enforced check
enforced check
### Frontend Validation ### Frontend validation
The active website relies on runtime defaults for local development and the The active website relies on runtime defaults for local development and the
surrounding server environment in deployed environments. surrounding server environment in deployed environments.
@@ -288,9 +302,9 @@ surrounding server environment in deployed environments.
- `SESSION_SECRET` falls back to a development-only local secret - `SESSION_SECRET` falls back to a development-only local secret
- `NODE_ENV` controls secure cookie behavior - `NODE_ENV` controls secure cookie behavior
## Example Configuration Files ## Example configuration files
### `.env.dev` (Backend/Docker) ### `.env.dev` (backend/Docker)
```bash ```bash
# Database # Database

View File

@@ -1,4 +1,4 @@
# Getting Started # Getting started
This guide covers local setup for the current Biergarten stack: the .NET backend This guide covers local setup for the current Biergarten stack: the .NET backend
in `web/backend` and the active React Router frontend in `web/frontend`. in `web/backend` and the active React Router frontend in `web/frontend`.
@@ -10,16 +10,16 @@ in `web/backend` and the active React Router frontend in `web/frontend`.
- **Docker Desktop** or equivalent Docker Engine setup - **Docker Desktop** or equivalent Docker Engine setup
- **Java 8+** if you want to regenerate PlantUML diagrams - **Java 8+** if you want to regenerate PlantUML diagrams
## Recommended Path: Docker for Backend, Node for Frontend ## Recommended path: Docker for backend, Node for frontend
### 1. Clone the Repository ### 1. Clone the repository
```bash ```bash
git clone <repository-url> git clone https://github.com/aaronpo97/the-biergarten-app
cd the-biergarten-app cd the-biergarten-app
``` ```
### 2. Configure Backend Environment Variables ### 2. Configure backend environment variables
```bash ```bash
cp web/.env.example web/.env.dev cp web/.env.example web/.env.dev
@@ -40,13 +40,14 @@ WEBSITE_BASE_URL=http://localhost:3000
See [Environment Variables](environment-variables.md) for the full list. See [Environment Variables](environment-variables.md) for the full list.
### 3. Start the Backend Stack ### 3. Start the backend stack
```bash ```bash
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
``` ```
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP). This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
SMTP).
Available endpoints: Available endpoints:
@@ -54,7 +55,7 @@ Available endpoints:
- Health Check: http://localhost:8080/health - Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025 - Mailpit UI: http://localhost:8025
### 4. Start the Active Frontend ### 4. Start the active frontend
```bash ```bash
cd web/frontend cd web/frontend
@@ -69,7 +70,7 @@ Required frontend runtime variables for local work:
- `API_BASE_URL` - Base URL for the .NET API - `API_BASE_URL` - Base URL for the .NET API
- `SESSION_SECRET` - Cookie session signing secret for the website server - `SESSION_SECRET` - Cookie session signing secret for the website server
### 5. Optional: Run Storybook ### 5. Optional: run Storybook
```bash ```bash
cd web/frontend cd web/frontend
@@ -78,7 +79,7 @@ npm run storybook
Storybook runs at http://localhost:6006 by default. Storybook runs at http://localhost:6006 by default.
## Useful Commands ## Useful commands
### Backend ### Backend
@@ -99,11 +100,11 @@ npm run test:storybook
npm run test:storybook:playwright npm run test:storybook:playwright
``` ```
## Manual Backend Setup ## Manual backend setup
If you do not want to use Docker, you can run the backend locally. If you do not want to use Docker, you can run the backend locally.
### 1. Set Environment Variables ### 1. Set environment variables
```bash ```bash
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;" export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
@@ -113,7 +114,7 @@ export CONFIRMATION_TOKEN_SECRET="<generated>"
export WEBSITE_BASE_URL="http://localhost:3000" export WEBSITE_BASE_URL="http://localhost:3000"
``` ```
### 2. Run Migrations and Seed ### 2. Run migrations and seed
```bash ```bash
cd web/backend cd web/backend
@@ -127,12 +128,12 @@ dotnet run --project Database/Database.Seed/Database.Seed.csproj
dotnet run --project API/API.Core/API.Core.csproj dotnet run --project API/API.Core/API.Core.csproj
``` ```
## Legacy Frontend Note ## Legacy frontend note
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
only and is not the active website. only and is not the active website.
## Next Steps ## Next steps
- Review [Architecture](../architecture.md) - Review [Architecture](../architecture.md)
- Run backend and frontend checks from [Testing](testing.md) - Run backend and frontend checks from [Testing](testing.md)

View File

@@ -7,19 +7,19 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend: The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against - **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
a live, seeded database live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice - **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`, (`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that `Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
slice's command/query handlers and its own repository (mocked with Moq / slice's command/query handlers, with dependencies mocked via Moq (no real
DbMocker, no real database required) database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
components components
## Running Tests with Docker (Recommended) ## Running tests with Docker (recommended)
The easiest way to run all tests is using Docker Compose, which sets up an The easiest way to run all tests is using Docker Compose, which sets up an
isolated test environment: isolated test environment:
@@ -37,7 +37,7 @@ This command:
5. Exports results to `./test-results/` 5. Exports results to `./test-results/`
6. Exits when tests complete 6. Exits when tests complete
### View Test Results ### View test results
```bash ```bash
# List test result files # List test result files
@@ -51,18 +51,18 @@ cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx cat test-results/Features.Emails.Tests.trx
``` ```
### Clean Up ### Clean up
```bash ```bash
# Remove test containers and volumes # Remove test containers and volumes
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
## Running Tests Locally ## Running tests locally
You can run individual test projects locally without Docker: You can run individual test projects locally without Docker:
### Integration Tests (API.Specs) ### Integration tests (API.Specs)
```bash ```bash
cd web/backend cd web/backend
@@ -75,10 +75,10 @@ dotnet test API/API.Specs/API.Specs.csproj
- Database migrated and seeded - Database migrated and seeded
- Environment variables set (DB connection, JWT secret) - Environment variables set (DB connection, JWT secret)
### Feature Slice Unit Tests ### Feature slice unit tests
Each feature slice has its own test project, covering its command/query Each feature slice has its own test project, covering its command/query
handlers and repository: handlers:
```bash ```bash
cd web/backend cd web/backend
@@ -93,10 +93,10 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
**Requirements**: **Requirements**:
- No database required (handlers use Moq; repository tests use DbMocker to - No database required (handlers are tested with their repository/service
simulate SQL Server responses) dependencies mocked via Moq)
### Frontend Storybook Tests ### Frontend Storybook tests
```bash ```bash
cd web/frontend cd web/frontend
@@ -110,7 +110,7 @@ npm run test:storybook
toasts, and the theme gallery toasts, and the theme gallery
- Runs in browser mode via Vitest and Storybook integration - Runs in browser mode via Vitest and Storybook integration
### Frontend Playwright Storybook Tests ### Frontend Playwright Storybook tests
```bash ```bash
cd web/frontend cd web/frontend
@@ -125,9 +125,9 @@ npm run test:storybook:playwright
- The command will start or reuse the Storybook server defined in - The command will start or reuse the Storybook server defined in
`playwright.storybook.config.ts` `playwright.storybook.config.ts`
## Test Coverage ## Test coverage
### Current Coverage ### Current coverage
**Features.Auth.Tests**: **Features.Auth.Tests**:
@@ -137,7 +137,6 @@ npm run test:storybook:playwright
- Email confirmation and confirmation-email resend - Email confirmation and confirmation-email resend
- Refresh token exchange - Refresh token exchange
- JWT token generation, validation, and claims handling - JWT token generation, validation, and claims handling
- `IAuthRepository` data access (DbMocker-backed)
- Invalid credentials and 404 error responses (via API.Specs) - Invalid credentials and 404 error responses (via API.Specs)
**Features.Breweries.Tests**: **Features.Breweries.Tests**:
@@ -160,7 +159,7 @@ npm run test:storybook:playwright
- Theme gallery rendering across Biergarten themes - Theme gallery rendering across Biergarten themes
- Toast interactions and themed notification display - Toast interactions and themed notification display
### Planned Coverage ### Planned coverage
- [ ] Password reset functionality - [ ] Password reset functionality
- [ ] Beer post operations - [ ] Beer post operations
@@ -168,12 +167,12 @@ npm run test:storybook:playwright
- [ ] Image upload service - [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories - [ ] Frontend route integration coverage beyond Storybook stories
## Testing Frameworks & Tools ## Testing frameworks and tools
### xUnit ### xUnit
- Primary unit testing framework - Primary unit testing framework
- Used for Repository and Service layer tests - Used for handler and service layer tests
- Supports parallel test execution - Supports parallel test execution
### Reqnroll (Gherkin/BDD) ### Reqnroll (Gherkin/BDD)
@@ -194,15 +193,9 @@ npm run test:storybook:playwright
- Used in Service layer tests - Used in Service layer tests
- Enables isolated unit testing - Enables isolated unit testing
### DbMocker ## Test structure
- Database mocking for repository tests ### API.Specs (integration tests)
- Simulates SQL Server responses
- No real database required for unit tests
## Test Structure
### API.Specs (Integration Tests)
``` ```
API.Specs/ API.Specs/
@@ -248,9 +241,7 @@ Features.Auth.Tests/
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs │ └── RefreshTokenHandlerTests.cs
├── Queries/ ├── Queries/
│ └── LoginHandlerTests.cs │ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
├── Repository/
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
└── Services/ └── Services/
├── TokenServiceRefreshTests.cs ├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs └── TokenServiceValidationTests.cs
@@ -260,9 +251,9 @@ Each of the other three slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same `Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
shape: a `Commands/`/`Queries/` folder with one test file per handler. shape: a `Commands/`/`Queries/` folder with one test file per handler.
## Writing Tests ## Writing tests
### Unit Test Example (xUnit) ### Unit test example (xUnit)
```csharp ```csharp
public class LoginHandlerTests public class LoginHandlerTests
@@ -278,7 +269,7 @@ public class LoginHandlerTests
// ...set up mocks for a known user/credential... // ...set up mocks for a known user/credential...
// Act // Act
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None); var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -287,7 +278,7 @@ public class LoginHandlerTests
} }
``` ```
### Integration Test Example (Reqnroll) ### Integration test example (Reqnroll)
```gherkin ```gherkin
Scenario: User login with valid credentials Scenario: User login with valid credentials
@@ -297,7 +288,7 @@ Scenario: User login with valid credentials
And the response should contain a JWT token And the response should contain a JWT token
``` ```
## Continuous Integration ## Continuous integration
Tests run automatically in CI/CD pipelines using the test Docker Compose Tests run automatically in CI/CD pipelines using the test Docker Compose
configuration: configuration:
@@ -326,7 +317,7 @@ npm run test:storybook:playwright
## Troubleshooting ## Troubleshooting
### Tests Failing Due to Database Connection ### Tests failing due to database connection
Ensure SQL Server is running and environment variables are set: Ensure SQL Server is running and environment variables are set:
@@ -334,12 +325,12 @@ Ensure SQL Server is running and environment variables are set:
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
``` ```
### Port Conflicts ### Port conflicts
If port 1433 is in use, stop other SQL Server instances or modify the port in If port 1433 is in use, stop other SQL Server instances or modify the port in
`docker-compose.test.yaml`. `docker-compose.test.yaml`.
### Stale Test Data ### Stale test data
Clean up test database: Clean up test database:
@@ -347,13 +338,13 @@ Clean up test database:
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
### View Container Logs ### View container logs
```bash ```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name> docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
``` ```
## Best Practices ## Best practices
1. **Isolation**: Each test should be independent and not rely on other tests 1. **Isolation**: Each test should be independent and not rely on other tests
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup 2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup

View File

@@ -1,8 +1,8 @@
# Token Validation Architecture # Token validation architecture
## Overview ## Overview
The Core project validates JWTs across three token types: The Core project validates JSON Web Tokens (JWTs) across three token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication - **Access Tokens**: Short-lived (1 hour) tokens for API authentication
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access - **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
@@ -12,7 +12,7 @@ The Core project validates JWTs across three token types:
## Components ## Components
### Infrastructure Layer ### Infrastructure layer
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs) #### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
@@ -30,12 +30,12 @@ Low-level JWT operations.
- Algorithm: HS256 (HMAC-SHA256) - Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness - Validates token lifetime, signature, and well-formedness
### Features.Auth Slice ### Features.Auth slice
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs) #### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
Both token generation and validation live on the same slice-internal Both token generation and validation live on the same slice-internal service
service (there is no separate validation service/class). (there is no separate validation service/class).
**Generation methods:** **Generation methods:**
@@ -56,20 +56,20 @@ service (there is no separate validation service/class).
- `Username` (string) - `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims - `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs) **Implementation:**
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
- Reads token secrets from environment variables - Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName) - Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure - Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Auth`'s own `TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
`AddFeaturesAuth()` extension method, except for the lower-level extension method, except for the lower-level `ITokenInfrastructure` (JWT
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is signing/verification) it depends on, which is registered by the host
registered by the host (`API.Core/Program.cs`) since (`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
`JwtAuthenticationHandler` (host-level auth middleware) also depends on middleware) also depends on it directly.
it directly.
### Integration Points ### Integration points
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs) #### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
@@ -97,9 +97,9 @@ it directly.
**Flow:** **Flow:**
1. Receives a refresh token via `RefreshTokenCommand` 1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the 2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
and issues a new access/refresh token pair issues a new access/refresh token pair
3. Maps the result onto `LoginPayload` 3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs) #### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
@@ -112,9 +112,9 @@ it directly.
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email - `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token - `POST /api/auth/refresh` - Refresh access token
## Validation Security ## Validation security
### Token Secrets ### Token secrets
Three independent secrets enable: Three independent secrets enable:
@@ -130,7 +130,7 @@ REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
``` ```
### Validation Checks ### Validation checks
Each token is validated for: Each token is validated for:
@@ -139,7 +139,7 @@ Each token is validated for:
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present 3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
4. **Claims Format** - UserId claim must be a valid GUID 4. **Claims Format** - UserId claim must be a valid GUID
### Error Handling ### Error handling
Validation failures return HTTP 401 Unauthorized: Validation failures return HTTP 401 Unauthorized:
@@ -148,9 +148,9 @@ Validation failures return HTTP 401 Unauthorized:
- Missing claims → "Invalid token" - Missing claims → "Invalid token"
- Malformed claims → "Invalid token" - Malformed claims → "Invalid token"
## Token Lifecycle ## Token lifecycle
### Access Token Lifecycle ### Access token lifecycle
1. **Generation**: During login (1-hour validity) 1. **Generation**: During login (1-hour validity)
2. **Usage**: Included in Authorization header on API requests 2. **Usage**: Included in Authorization header on API requests
@@ -158,16 +158,16 @@ Validation failures return HTTP 401 Unauthorized:
4. **Expiration**: Token becomes invalid after 1 hour 4. **Expiration**: Token becomes invalid after 1 hour
5. **Refresh**: Use refresh token to obtain new access token 5. **Refresh**: Use refresh token to obtain new access token
### Refresh Token Lifecycle ### Refresh token lifecycle
1. **Generation**: During login (21-day validity) 1. **Generation**: During login (21-day validity)
2. **Storage**: Client-side (secure storage) 2. **Storage**: Client-side (secure storage)
3. **Usage**: Posted to `/api/auth/refresh` endpoint 3. **Usage**: Posted to `/api/auth/refresh` endpoint
4. **Validation**: Validated by RefreshTokenService 4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()`
5. **Rotation**: New refresh token issued on successful refresh 5. **Rotation**: New refresh token issued on successful refresh
6. **Expiration**: Token becomes invalid after 21 days 6. **Expiration**: Token becomes invalid after 21 days
### Confirmation Token Lifecycle ### Confirmation token lifecycle
1. **Generation**: During user registration (30-minute validity) 1. **Generation**: During user registration (30-minute validity)
2. **Delivery**: Emailed to user in confirmation link 2. **Delivery**: Emailed to user in confirmation link
@@ -180,7 +180,7 @@ Validation failures return HTTP 401 Unauthorized:
All of the following live in `Features.Auth.Tests`. All of the following live in `Features.Auth.Tests`.
### Unit Tests ### Unit tests
**Services/TokenServiceValidationTests.cs** **Services/TokenServiceValidationTests.cs**
@@ -210,7 +210,7 @@ All of the following live in `Features.Auth.Tests`.
- Sends a fresh confirmation email when the user exists and is unverified - Sends a fresh confirmation email when the user exists and is unverified
- No-ops when the user doesn't exist or is already verified - No-ops when the user doesn't exist or is already verified
### BDD Tests (Reqnroll) ### BDD tests (Reqnroll)
**TokenRefresh.feature** **TokenRefresh.feature**
@@ -230,19 +230,19 @@ All of the following live in `Features.Auth.Tests`.
- Invalid/expired access token rejection - Invalid/expired access token rejection
- Token type mismatch (refresh used as access token) - Token type mismatch (refresh used as access token)
## Future Enhancements ## Future enhancements
### Stretch Goals ### Stretch goals
1. **Middleware for Access Token Validation** 1. **Middleware for Access Token Validation**
- Automatically validate access tokens on protected routes - Automatically validate access tokens on protected routes
- Populate HttpContext.User from token claims - Populate HttpContext.User from token claims
- Return 401 for invalid/missing tokens - Return 401 for invalid/missing tokens
2. **Token Blacklisting** 2. **Token Denylisting**
- Implement token revocation (e.g., on logout) - Implement token revocation (for example, on logout)
- Store blacklisted tokens in cache/database - Store denylisted tokens in cache/database
- Check blacklist during validation - Check the denylist during validation
3. **Refresh Token Rotation Strategy** 3. **Refresh Token Rotation Strategy**
- Detect token reuse (replay attacks) - Detect token reuse (replay attacks)

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pipeline Results Viewer</title>
</head>
<body>
<div class="dialog" id="window">
<div class="dialog-header">
<p>Pipeline Results Viewer</p>
</div>
<div class="tab-strip" role="tablist">
<button
type="button"
class="tab is-active"
id="tab-tables"
role="tab"
aria-selected="true"
aria-controls="panel-tables"
>
Tables
</button>
<button
type="button"
class="tab"
id="tab-query"
role="tab"
aria-selected="false"
aria-controls="panel-query"
>
Query
</button>
</div>
<div class="dialog-body">
<div
class="tab-panel"
id="panel-tables"
role="tabpanel"
aria-labelledby="tab-tables"
>
<div class="field-group">
<label for="table-select">Table</label>
<select id="table-select"></select>
</div>
<p id="status"></p>
<div id="table-container"></div>
</div>
<div
class="tab-panel"
id="panel-query"
role="tabpanel"
aria-labelledby="tab-query"
hidden
>
<div class="field-group field-group--stacked">
<label for="query-input">SQL Query</label>
<textarea
id="query-input"
rows="4"
spellcheck="false"
placeholder="SELECT * FROM ..."
></textarea>
</div>
<button type="button" id="query-run" class="btn">Run Query</button>
<p id="query-status"></p>
<div id="query-container"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "pipeline-results-viewer",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/sql.js": "^1.4.11",
"sass": "^1.101.0",
"typescript": "^6.0.3",
"vite": "^8.0.12"
},
"dependencies": {
"sql.js": "^1.14.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,147 @@
import initSqlJs, { type Database, type SqlValue } from "sql.js";
import sqlWasmUrl from "sql.js/dist/sql-wasm.wasm?url";
import "./style.scss";
const DB_URL = "/biergarten.sqlite";
const tabs = document.querySelectorAll<HTMLButtonElement>("[role='tab']");
const panels = document.querySelectorAll<HTMLDivElement>("[role='tabpanel']");
const tableSelect = document.querySelector<HTMLSelectElement>("#table-select")!;
const tableContainer =
document.querySelector<HTMLDivElement>("#table-container")!;
const status = document.querySelector<HTMLParagraphElement>("#status")!;
const queryInput = document.querySelector<HTMLTextAreaElement>("#query-input")!;
const queryRun = document.querySelector<HTMLButtonElement>("#query-run")!;
const queryStatus = document.querySelector<HTMLParagraphElement>("#query-status")!;
const queryContainer =
document.querySelector<HTMLDivElement>("#query-container")!;
for (const tab of tabs) {
tab.addEventListener("click", () => {
for (const otherTab of tabs) {
const isActive = otherTab === tab;
otherTab.classList.toggle("is-active", isActive);
otherTab.setAttribute("aria-selected", String(isActive));
}
for (const panel of panels) {
panel.hidden = panel.id !== tab.getAttribute("aria-controls");
}
});
}
const renderResultTable = (
container: HTMLDivElement,
columns: string[],
values: SqlValue[][],
) => {
const table = document.createElement("table");
const head = table.createTHead().insertRow();
for (const column of columns) {
const th = document.createElement("th");
th.textContent = column;
head.append(th);
}
const body = table.createTBody();
for (const row of values) {
const tr = body.insertRow();
for (const cell of row) {
const td = document.createElement("td");
td.textContent = String(cell ?? "");
tr.append(td);
}
}
container.append(table);
};
const renderTable = (db: Database, name: string) => {
const result = db.exec(`SELECT * FROM "${name}"`);
tableContainer.replaceChildren();
if (result.length === 0) {
tableContainer.append(
Object.assign(document.createElement("p"), { textContent: "No rows." }),
);
return;
}
const { columns, values } = result[0];
renderResultTable(tableContainer, columns, values);
};
const runQuery = (db: Database) => {
const sql = queryInput.value.trim();
queryContainer.replaceChildren();
if (!sql) {
queryStatus.textContent = "Enter a query to run.";
return;
}
try {
const results = db.exec(sql);
if (results.length === 0) {
queryStatus.textContent = "Query executed. No rows returned.";
return;
}
const { columns, values } = results[results.length - 1];
renderResultTable(queryContainer, columns, values);
queryStatus.textContent = `${values.length} row${values.length === 1 ? "" : "s"} returned.`;
} catch (error) {
queryStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
}
};
async function main(): Promise<void> {
status.textContent = "Loading database...";
const sqlJs = await initSqlJs({ locateFile: () => sqlWasmUrl });
const response = await fetch(DB_URL);
const buffer = await response.arrayBuffer();
const db = new sqlJs.Database(new Uint8Array(buffer));
const tables =
db
.exec(
`SELECT name
FROM sqlite_master
WHERE
type = 'table'
AND
name NOT LIKE 'sqlite_%'
ORDER BY name`,
)[0]
?.values.map((row) => String(row[0])) ?? [];
for (const name of tables) {
tableSelect.append(new Option(name, name));
}
tableSelect.addEventListener("change", () =>
renderTable(db, tableSelect.value),
);
if (tables.length > 0) {
renderTable(db, tables[0]);
}
status.textContent = "";
queryRun.addEventListener("click", () => runQuery(db));
queryInput.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
runQuery(db);
}
});
}
main().catch((error: unknown) => {
status.textContent = `Failed to load database: ${String(error)}`;
});

View File

@@ -0,0 +1,267 @@
$gray-100: #dedede;
$gray-300: #c3c3c3;
$gray-500: #808080;
$white: #ffffff;
$black: #000000;
$navy: #000080;
$blue: #1084d0;
$teal: #008080;
$color-surface: $gray-300;
$color-surface-alt: $gray-100;
$color-surface-sunken: $white;
$color-border-highlight: $gray-100;
$color-border-lowlight: $gray-500;
$color-border-light: $white;
$color-border-dark: $black;
$color-text: $black;
$color-text-on-accent: $white;
$color-text-status: $navy;
$color-accent-start: $navy;
$color-accent-end: $blue;
$color-desktop-bg: $teal;
$color-row-stripe: $gray-100;
$color-shadow: rgba($black, 0.4);
$color-focus-ring: $black;
$bevel-size: 2px;
$font-stack: Tahoma, "MS Sans Serif", Verdana, sans-serif;
@mixin bevel-raised($size: $bevel-size) {
border-style: solid;
border-width: $size;
border-top-color: $color-border-highlight;
border-left-color: $color-border-highlight;
border-right-color: $color-border-dark;
border-bottom-color: $color-border-dark;
box-shadow:
inset $size $size 0 0 $color-border-light,
inset (-$size) (-$size) 0 0 $color-border-lowlight;
}
@mixin bevel-sunken($size: $bevel-size) {
border-style: solid;
border-width: $size;
border-top-color: $color-border-lowlight;
border-left-color: $color-border-lowlight;
border-right-color: $color-border-light;
border-bottom-color: $color-border-light;
box-shadow:
inset $size $size 0 0 $color-border-dark,
inset (-$size) (-$size) 0 0 $color-surface-alt;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
background: $color-desktop-bg;
font-family: $font-stack;
color: $color-text;
min-height: 100vh;
}
#window {
width: 100%;
max-width: 1600px;
margin: 0 auto;
background: $color-surface;
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
@include bevel-raised(3px);
box-shadow: 8px 8px 0 0 $color-shadow;
}
.dialog-header {
background: linear-gradient(90deg, $color-accent-start, $color-accent-end);
color: $color-text-on-accent;
padding: 6px 8px;
display: flex;
align-items: center;
p {
margin: 0;
font-size: 14px;
font-weight: bold;
letter-spacing: 0.5px;
text-transform: uppercase;
}
}
.tab-strip {
display: flex;
gap: 4px;
padding: 8px 8px 0;
}
.tab {
position: relative;
top: 3px;
margin-bottom: -2px;
font-family: $font-stack;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
padding: 6px 14px;
background: $color-surface;
color: $color-text;
border-style: solid;
border-width: 2px;
border-top-color: $color-border-light;
border-left-color: $color-border-light;
border-right-color: $color-border-dark;
border-bottom-color: $color-border-dark;
border-radius: 0;
cursor: pointer;
&.is-active {
top: 0;
z-index: 1;
border-bottom-color: $color-surface;
}
&:focus {
outline: 1px dotted $color-focus-ring;
outline-offset: -4px;
}
}
.dialog-body {
border-top: 2px solid $color-border-dark;
padding: 12px;
}
.tab-panel {
display: flex;
flex-direction: column;
gap: 12px;
&[hidden] {
display: none;
}
}
.field-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
&.field-group--stacked {
flex-direction: column;
align-items: stretch;
}
}
select#table-select {
font-family: $font-stack;
font-size: 13px;
padding: 3px 6px;
background: $color-surface-sunken;
color: $color-text;
border-radius: 0;
@include bevel-sunken();
&:focus {
outline: 1px dotted $color-focus-ring;
outline-offset: 2px;
}
}
textarea#query-input {
font-family: $font-stack;
font-size: 13px;
padding: 6px;
background: $color-surface-sunken;
color: $color-text;
border-radius: 0;
resize: vertical;
@include bevel-sunken();
&:focus {
outline: 1px dotted $color-focus-ring;
outline-offset: 2px;
}
}
.btn {
align-self: flex-start;
font-family: $font-stack;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
padding: 6px 16px;
background: $color-surface;
color: $color-text;
cursor: pointer;
@include bevel-raised();
&:active {
@include bevel-sunken();
}
&:focus {
outline: 1px dotted $color-focus-ring;
outline-offset: -4px;
}
}
#status,
#query-status {
margin: 0;
font-size: 12px;
font-style: italic;
color: $color-text-status;
}
#table-container,
#query-container {
background: $color-surface-sunken;
padding: 4px;
overflow-x: auto;
@include bevel-sunken();
}
table {
border-collapse: collapse;
margin: 0 auto;
font-family: $font-stack;
}
th,
td {
border: 1px solid $color-border-lowlight;
padding: 4px 8px;
text-align: left;
font-size: 13px;
}
th {
background: $color-surface;
white-space: nowrap;
font-weight: bold;
text-transform: uppercase;
@include bevel-raised(1px);
}
td {
min-width: 120px;
max-width: 800px;
white-space: normal;
word-break: break-word;
background: $color-surface-sunken;
}
tbody tr:nth-child(even) td {
background: $color-row-stripe;
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -1,5 +1,5 @@
--- ---
BasedOnStyle: Google BasedOnStyle: Google
ColumnLimit: 80 ColumnLimit: 80
IndentWidth: 2 IndentWidth: 3
... ...

View File

@@ -1,12 +1,6 @@
Checks: > Checks: >
-*, -*, bugprone-*, google-*, modernize-*, readability-*, cppcoreguidelines-*,
bugprone-*, -modernize-use-trailing-return-type, -google-runtime-references
google-*,
modernize-*,
readability-*,
cppcoreguidelines-*,
-modernize-use-trailing-return-type,
-google-runtime-references
CheckOptions: CheckOptions:
# Enforce Google Naming Conventions with valid clang-tidy strings # Enforce Google Naming Conventions with valid clang-tidy strings

View File

@@ -111,6 +111,9 @@ if(NOT BIERGARTEN_MOCK_ONLY)
) )
FetchContent_MakeAvailable(llama-cpp) FetchContent_MakeAvailable(llama-cpp)
endif() endif()
if (NOT APPLE)
find_package(OpenMP REQUIRED)
endif()
endif() endif()
# spdlog # spdlog
@@ -139,6 +142,7 @@ FetchContent_MakeAvailable(cpp-httplib)
# 5. Executable & Sources # 5. Executable & Sources
add_executable(${PROJECT_NAME} add_executable(${PROJECT_NAME}
includes/services/enrichment/mock_enrichment.h includes/services/enrichment/mock_enrichment.h
includes/services/curated_data/mock_curated_data_service.h
includes/json_handling/pretty_print.h) includes/json_handling/pretty_print.h)
# --- Entry point --- # --- Entry point ---
@@ -148,7 +152,12 @@ target_sources(${PROJECT_NAME} PRIVATE
# --- json_handling --- # --- json_handling ---
target_sources(${PROJECT_NAME} PRIVATE target_sources(${PROJECT_NAME} PRIVATE
src/json_handling/json_loader.cc src/services/curated_data/curated_json_data_service.cc
)
# --- services: curated_data ---
target_sources(${PROJECT_NAME} PRIVATE
src/services/curated_data/mock_curated_data_service.cc
) )
# --- application_options --- # --- application_options ---
@@ -161,6 +170,7 @@ target_sources(${PROJECT_NAME} PRIVATE
src/biergarten_pipeline_orchestrator/log_results.cc src/biergarten_pipeline_orchestrator/log_results.cc
src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc
src/biergarten_pipeline_orchestrator/generate_breweries.cc src/biergarten_pipeline_orchestrator/generate_breweries.cc
src/biergarten_pipeline_orchestrator/generate_users.cc
src/biergarten_pipeline_orchestrator/run.cc src/biergarten_pipeline_orchestrator/run.cc
src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc
) )
@@ -201,9 +211,22 @@ target_sources(${PROJECT_NAME} PRIVATE
src/services/enrichment/wikipedia/get_summary.cc src/services/enrichment/wikipedia/get_summary.cc
) )
# --- services: postal_code ---
target_sources(${PROJECT_NAME} PRIVATE
src/services/postal_code/xeger_postal_code_service.cc
)
# --- services: xeger ---
target_sources(${PROJECT_NAME} PRIVATE
src/services/xeger/xeger.cc
src/services/xeger/xeger_parser.cc
src/services/xeger/xeger_generator.cc
)
# --- services: sqlite --- # --- services: sqlite ---
target_sources(${PROJECT_NAME} PRIVATE target_sources(${PROJECT_NAME} PRIVATE
src/services/sqlite/process_record.cc src/services/sqlite/process_record.cc
src/services/sqlite/process_user_record.cc
src/services/sqlite/sqlite_export_service.cc src/services/sqlite/sqlite_export_service.cc
src/services/sqlite/finalize.cc src/services/sqlite/finalize.cc
src/services/sqlite/initialize.cc src/services/sqlite/initialize.cc
@@ -227,8 +250,10 @@ target_include_directories(${PROJECT_NAME} PRIVATE
includes includes
) )
target_link_libraries(${PROJECT_NAME} PRIVATE target_link_libraries(${PROJECT_NAME} PRIVATE
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama> $<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama>
$<TARGET_NAME_IF_EXISTS:OpenMP::OpenMP_CXX>
boost::di boost::di
Boost::json Boost::json
Boost::program_options Boost::program_options
@@ -260,6 +285,21 @@ configure_file(
${CMAKE_BINARY_DIR}/locations.json ${CMAKE_BINARY_DIR}/locations.json
COPYONLY COPYONLY
) )
configure_file(
${CMAKE_SOURCE_DIR}/personas.json
${CMAKE_BINARY_DIR}/personas.json
COPYONLY
)
configure_file(
${CMAKE_SOURCE_DIR}/forenames-by-country.json
${CMAKE_BINARY_DIR}/forenames-by-country.json
COPYONLY
)
configure_file(
${CMAKE_SOURCE_DIR}/surnames-by-country.json
${CMAKE_BINARY_DIR}/surnames-by-country.json
COPYONLY
)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/prompts ${CMAKE_SOURCE_DIR}/prompts

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
# Walks every directory under src/ and includes/ (including nested
# subdirectories), runs clang-format on the C/C++ files in that directory
# only (non-recursive), and commits the result with its own commit.
#
# Usage: ./format-per-directory.sh [-y]
# -y skip the confirmation prompt
set -euo pipefail
SKIP_CONFIRM=false
if [[ "${1:-}" == "-y" ]]; then
SKIP_CONFIRM=true
fi
cd "$(dirname "$0")"
if [ ! -f .clang-format ]; then
echo "ERROR: .clang-format file not found."
exit 1
fi
if ! command -v clang-format &>/dev/null; then
echo "ERROR: clang-format not found."
exit 1
fi
if ! command -v git &>/dev/null; then
echo "ERROR: git not found."
exit 1
fi
echo "WARNING: This script will format .cpp, .h, .cxx, .cc, .c, .hpp files"
echo "directory-by-directory under src/ and includes/, committing after each"
echo "directory is formatted."
if [[ "$SKIP_CONFIRM" == false ]]; then
read -p "Do you want to continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "ERROR: working tree is not clean. Commit or stash your changes first."
exit 1
fi
dirs=$(find src includes -type d | sort)
for dir in $dirs; do
files=$(find "$dir" -maxdepth 1 -type f \( -name "*.cpp" -o -name "*.hpp" -o -name "*.h" -o -name "*.c" -o -name "*.cc" -o -name "*.cxx" \))
if [[ -z "$files" ]]; then
continue
fi
echo "Formatting $dir..."
echo "$files" | xargs clang-format -i
if [[ -n "$(git status --porcelain -- "$dir")" ]]; then
git add -- "$dir"
git commit -m "Formatted $dir"
else
echo "No changes in $dir, skipping commit."
fi
done
echo "Done."

View File

@@ -0,0 +1,80 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_H_
/**
* @file biergarten_pipeline.h
* @brief Master umbrella header — includes every public header in the
* Biergarten pipeline.
*/
// --- orchestrator ---
#include "biergarten_pipeline_orchestrator.h"
// --- concurrency ---
#include "concurrency/bounded_channel.h"
// --- data_generation ---
#include "data_generation/data_generator.h"
#include "data_generation/json_grammars.h"
#ifndef BIERGARTEN_MOCK_ONLY
#include "data_generation/llama_generator.h"
#include "data_generation/llama_generator_helpers.h"
#endif
#include "data_generation/mock_generator.h"
#include "data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.h"
#include "data_generation/prompt_formatting/prompt_formatter.h"
// --- data_model ---
#include "data_model/generated_models.h"
#include "data_model/models.h"
// --- json_handling ---
#include "json_handling/pretty_print.h"
#include "services/curated_data/curated_json_data_service.h"
// --- llama backend ---
#ifndef BIERGARTEN_MOCK_ONLY
#include "llama_backend_state.h"
#endif
// --- services: curated_data ---
#include "services/curated_data/curated_data_service.h"
#include "services/curated_data/mock_curated_data_service.h"
// --- services: database ---
#include "services/database/export_service.h"
#include "services/database/sqlite_connection_helpers.h"
#include "services/database/sqlite_export_service.h"
#include "services/database/sqlite_export_service_helpers.h"
#include "services/database/sqlite_handle_types.h"
#include "services/database/sqlite_statement_helpers.h"
// --- services: datetime ---
#include "services/datetime/date_time_provider.h"
#include "services/datetime/timer.h"
// --- services: enrichment ---
#include "services/enrichment/enrichment_service.h"
#include "services/enrichment/mock_enrichment.h"
#include "services/enrichment/wikipedia_service.h"
// --- services: postal_code ---
#include "services/postal_code/mock_postal_code_service.h"
#include "services/postal_code/postal_code_service.h"
#include "services/postal_code/xeger_postal_code_service.h"
#include "services/xeger/xeger.h"
// --- services: logging ---
#include "services/logging/log_dispatcher.h"
#include "services/logging/log_entry.h"
#include "services/logging/log_producer.h"
#include "services/logging/logger.h"
// --- services: prompting ---
#include "services/prompting/prompt_directory.h"
// --- web_client ---
#include "web_client/http_web_client.h"
#include "web_client/web_client.h"
#endif // BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_H_

View File

@@ -1,12 +1,9 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_ #ifndef BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_ORCHESTRATOR_H_
#define BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_ #define BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_ORCHESTRATOR_H_
/** /**
* @file biergarten_data_generator.h * @file biergarten_pipeline_orchestrator.h
* @brief Orchestration for end-to-end brewery data generation pipeline. * @brief Orchestration for end-to-end brewery and user data generation.
*
* Intent: Coordinates location loading, enrichment, and generation phases
* to produce a complete dataset. Coordinates dependencies via composition root.
*/ */
#include <memory> #include <memory>
@@ -15,25 +12,31 @@
#include "data_generation/data_generator.h" #include "data_generation/data_generator.h"
#include "data_model/generated_models.h" #include "data_model/generated_models.h"
#include "services/curated_data/curated_data_service.h"
#include "services/database/export_service.h" #include "services/database/export_service.h"
#include "services/enrichment/enrichment_service.h" #include "services/enrichment/enrichment_service.h"
#include "services/logging/logger.h" #include "services/logging/logger.h"
#include "services/postal_code/postal_code_service.h"
/** /**
* @brief Main data generator class for the Biergarten pipeline. * @brief Main orchestrator for the Biergarten data generation pipeline.
* *
* This class encapsulates the core logic for generating brewery data. * Handles location loading, city enrichment, and brewery/user generation.
* It handles location loading, city enrichment, and brewery generation.
*/ */
class BiergartenPipelineOrchestrator { class BiergartenPipelineOrchestrator {
public: public:
/** /**
* @brief Constructs the orchestrator with injected pipeline dependencies. * @brief Constructs the orchestrator with injected pipeline dependencies.
* *
* @param logger Sink for pipeline diagnostics.
* @param context_service Provides regional context for locations. * @param context_service Provides regional context for locations.
* @param generator Implementation (Llama or Mock) for brewery/user generation. * @param generator Implementation (Llama or Mock) for brewery/user
* generation.
* @param exporter Database backend for persisting generated records. * @param exporter Database backend for persisting generated records.
* @param curated_data_service Loads curated location, persona, and name
* data used to seed generation.
* @param postal_code_service Resolves a postal code for a city from its
* curated postal-code data.
* @param application_options CLI configuration and paths. * @param application_options CLI configuration and paths.
*/ */
BiergartenPipelineOrchestrator( BiergartenPipelineOrchestrator(
@@ -41,6 +44,8 @@ class BiergartenPipelineOrchestrator {
std::unique_ptr<IEnrichmentService> context_service, std::unique_ptr<IEnrichmentService> context_service,
std::unique_ptr<DataGenerator> generator, std::unique_ptr<DataGenerator> generator,
std::unique_ptr<IExportService> exporter, std::unique_ptr<IExportService> exporter,
std::unique_ptr<ICuratedDataService> curated_data_service,
std::unique_ptr<IPostalCodeService> postal_code_service,
const ApplicationOptions& application_options); const ApplicationOptions& application_options);
/** /**
@@ -49,40 +54,45 @@ class BiergartenPipelineOrchestrator {
* Performs the following steps: * Performs the following steps:
* 1. Load curated locations from JSON * 1. Load curated locations from JSON
* 2. Resolve context for each city using the injected context service * 2. Resolve context for each city using the injected context service
* 3. Generate brewery data for sampled cities * 3. Generate brewery and user data for sampled cities
* *
* @note STRUCTURAL CONCURRENCY REQUIREMENT: * @note STRUCTURAL CONCURRENCY REQUIREMENT:
* When transitioned to a multithreaded design, this method MUST structurally * When transitioned to a multithreaded design, this method MUST
* enforce that all deployed worker threads are joined before returning (e.g. * structurally enforce that all deployed worker threads are joined before
* by using std::jthread or a structured concurrency primitive). This ensures * returning (e.g. by using std::jthread or a structured concurrency
* workers do not attempt to log to a closed channel during application teardown. * primitive). This ensures workers do not attempt to log to a closed
* channel during application teardown.
* *
* @return true if successful, false if not * @return true if successful, false if not
*/ */
bool Run(); bool Run();
private: private:
/// @brief Logger instance for emitting pipeline messages.
std::shared_ptr<ILogger> logger_; std::shared_ptr<ILogger> logger_;
/// @brief Owning context provider dependency.
std::unique_ptr<IEnrichmentService> context_service_; std::unique_ptr<IEnrichmentService> context_service_;
/// @brief Generator dependency selected in the composition root. /**
* @brief Generator implementation selected at the composition root (Llama
* or Mock).
*/
std::unique_ptr<DataGenerator> generator_; std::unique_ptr<DataGenerator> generator_;
/// @brief Storage backend for generated brewery records. /**
* @brief Storage backend for generated brewery and user records.
*/
std::unique_ptr<IExportService> exporter_; std::unique_ptr<IExportService> exporter_;
std::unique_ptr<ICuratedDataService> curated_data_service_;
std::unique_ptr<IPostalCodeService> postal_code_service_;
/// @brief CLI configuration: paths, model settings, generation parameters.
ApplicationOptions application_options_; ApplicationOptions application_options_;
/** /**
* @brief Load locations from JSON and sample cities. * @brief Load locations from JSON and sample cities.
* *
* @return Vector of sampled locations capped at 50 entries. * @return Vector of locations randomly sampled per
* PipelineOptions::location_count.
*/ */
std::vector<Location> QueryCitiesWithCountries(); std::vector<City> QueryLocations();
/** /**
* @brief Generate breweries for enriched cities. * @brief Generate breweries for enriched cities.
@@ -92,11 +102,20 @@ class BiergartenPipelineOrchestrator {
void GenerateBreweries(std::span<const EnrichedCity> cities); void GenerateBreweries(std::span<const EnrichedCity> cities);
/** /**
* @brief Log the generated brewery results. * @brief Generate users grounded in sampled names and personas for
* enriched cities.
*
* @param cities Span of enriched city data.
*/
void GenerateUsers(std::span<const EnrichedCity> cities);
/**
* @brief Log the generated brewery and user results.
*/ */
void LogResults() const; void LogResults() const;
/// @brief Stores generated brewery data. std::vector<BreweryRecord> generated_breweries_;
std::vector<GeneratedBrewery> generated_breweries_; std::vector<UserRecord> generated_users_;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_
#endif // BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_PIPELINE_ORCHESTRATOR_H_

View File

@@ -9,10 +9,12 @@
/** /**
* @file bounded_channel.h * @file bounded_channel.h
* @brief Thread-safe, bounded multi-producer/multi-consumer synchronous channel. * @brief Thread-safe, bounded multi-producer/multi-consumer synchronous
* channel.
* *
* Intent: Enables asynchronous inter-thread communication with backpressure. * Intent: Enables asynchronous inter-thread communication with backpressure.
* Models a synchronous channel where producers/consumers block on capacity limits. * Models a synchronous channel where producers/consumers block on capacity
* limits.
*/ */
/** /**
@@ -24,9 +26,7 @@
*/ */
template <typename T> template <typename T>
class BoundedChannel { class BoundedChannel {
// -------------------------------------------------------------------------
// Internal state — all access must be guarded by mutex_. // Internal state — all access must be guarded by mutex_.
// -------------------------------------------------------------------------
std::queue<T> queue_; std::queue<T> queue_;

View File

@@ -1,4 +1,4 @@
#include "bounded_channel.h" #include "concurrency/bounded_channel.h"
template <typename T> template <typename T>
void BoundedChannel<T>::Send(T item) { void BoundedChannel<T>::Send(T item) {

View File

@@ -20,20 +20,22 @@ class DataGenerator {
/** /**
* @brief Generates brewery data for a location. * @brief Generates brewery data for a location.
* *
* @param location Location data * @param city Enriched city the brewery is associated with.
* @param region_context Additional regional context text.
* @return Brewery generation result. * @return Brewery generation result.
*/ */
virtual BreweryResult GenerateBrewery(const Location& location, virtual BreweryResult GenerateBrewery(const EnrichedCity& city) = 0;
const std::string& region_context) = 0;
/** /**
* @brief Generates a user profile for a locale. * @brief Generates a user profile grounded in a sampled name and persona.
* *
* @param locale Locale hint used by generator. * @param city Enriched city the user is associated with.
* @param persona Persona archetype grounding the generated bio.
* @param name Sampled first/last name (and gender)
* @return User generation result. * @return User generation result.
*/ */
virtual UserResult GenerateUser(const std::string& locale) = 0; virtual UserResult GenerateUser(const EnrichedCity& city,
const UserPersona& persona,
const Name& name) = 0;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_DATA_GENERATOR_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_DATA_GENERATOR_H_

View File

@@ -0,0 +1,52 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_JSON_GRAMMARS_H_
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_JSON_GRAMMARS_H_
/**
* @file data_generation/json_grammars.h
* @brief GBNF grammars constraining structured JSON output from
* LlamaGenerator inference calls.
*/
#include <string_view>
// GBNF grammar for structured user JSON output.
// thought-block permits the model to emit free-form reasoning before the
// JSON object (the prompts explicitly invite this); only the "{...}" tail is
// constrained to the expected shape.
inline constexpr std::string_view kUserJsonGrammar = R"json_user(
root ::= thought-block (
"{" ws
"\"username\"" ws ":" ws string ws "," ws
"\"bio\"" ws ":" ws string ws "," ws
"\"activity_weight\"" ws ":" ws number ws
"}" ws
)
thought-block ::= [^{]*
ws ::= [ \t\n\r]*
string ::= "\"" char+ "\""
char ::= [^"\\\x7F\x00-\x1F] | [\\] escape
escape ::= ["\\/bfnrt] | "u" hex hex hex hex
hex ::= [0-9a-fA-F]
number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)?
)json_user";
// GBNF grammar for structured brewery JSON output (see thought-block note
// above).
inline constexpr std::string_view kBreweryJsonGrammar = R"json_brewery(
root ::= thought-block (
"{" ws
"\"name_en\"" ws ":" ws string ws "," ws
"\"description_en\"" ws ":" ws string ws "," ws
"\"name_local\"" ws ":" ws string ws "," ws
"\"description_local\"" ws ":" ws string ws
"}" ws
)
thought-block ::= [^{]*
ws ::= [ \t\n\r]*
string ::= "\"" char+ "\""
char ::= [^"\\\x7F\x00-\x1F] | [\\] escape
escape ::= ["\\/bfnrt] | "u" hex hex hex hex
hex ::= [0-9a-fA-F]
)json_brewery";
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_JSON_GRAMMARS_H_

View File

@@ -1,24 +1,23 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_ #ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_ #define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_
#include <filesystem>
/** /**
* @file data_generation/llama_generator.h * @file data_generation/llama_generator.h
* @brief llama.cpp-backed implementation of DataGenerator. * @brief llama.cpp-backed implementation of DataGenerator.
*/ */
#include <cstdint> #include <cstdint>
#include <filesystem>
#include <memory> #include <memory>
#include <random> #include <random>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include "../services/prompting/prompt_directory.h"
#include "data_generation/data_generator.h" #include "data_generation/data_generator.h"
#include "data_generation/prompt_formatting/prompt_formatter.h" #include "data_generation/prompt_formatting/prompt_formatter.h"
#include "data_model/models.h" #include "data_model/models.h"
#include "services/logging/logger.h" #include "services/logging/logger.h"
#include "services/prompting/prompt_directory.h"
struct llama_model; struct llama_model;
struct llama_context; struct llama_context;
@@ -38,39 +37,37 @@ class LlamaGenerator final : public DataGenerator {
* @param prompt_directory Directory service for loading named prompt files. * @param prompt_directory Directory service for loading named prompt files.
*/ */
LlamaGenerator(const ApplicationOptions& options, LlamaGenerator(const ApplicationOptions& options,
const std::string& model_path, std::shared_ptr<ILogger> logger, const std::string& model_path,
std::shared_ptr<ILogger> logger,
std::unique_ptr<IPromptFormatter> prompt_formatter, std::unique_ptr<IPromptFormatter> prompt_formatter,
std::unique_ptr<IPromptDirectory> prompt_directory); std::unique_ptr<IPromptDirectory> prompt_directory);
~LlamaGenerator() override; ~LlamaGenerator() override;
// disable copy constructor
LlamaGenerator(const LlamaGenerator&) = delete; LlamaGenerator(const LlamaGenerator&) = delete;
// disable copy assignment operator
LlamaGenerator& operator=(const LlamaGenerator&) = delete; LlamaGenerator& operator=(const LlamaGenerator&) = delete;
// disable move constructor
LlamaGenerator(LlamaGenerator&&) = delete; LlamaGenerator(LlamaGenerator&&) = delete;
// disable move assignment operator
LlamaGenerator& operator=(LlamaGenerator&&) = delete; LlamaGenerator& operator=(LlamaGenerator&&) = delete;
/** /**
* @brief Generates brewery data for a specific location. * @brief Generates brewery data for a specific location.
* *
* @param location Location object. * @param location City object.
* @param region_context Additional regional context. * @param region_context Additional regional context.
* @return Generated brewery result. * @return Generated brewery result.
*/ */
BreweryResult GenerateBrewery(const Location& location, BreweryResult GenerateBrewery(const EnrichedCity&) override;
const std::string& region_context) override;
/** /**
* @brief Generates a user profile for the provided locale. * @brief Generates a user profile grounded in a sampled name and persona.
* *
* @param locale Locale hint. * @param city Enriched city the user is associated with.
* @param persona Persona archetype grounding the generated bio.
* @param name Sampled first/last name -- not LLM-invented.
* @return Generated user profile. * @return Generated user profile.
*/ */
UserResult GenerateUser(const std::string& locale) override; UserResult GenerateUser(const EnrichedCity& city, const UserPersona& persona,
const Name& name) override;
private: private:
static constexpr int32_t kDefaultMaxTokens = 10000; static constexpr int32_t kDefaultMaxTokens = 10000;
@@ -107,7 +104,8 @@ class LlamaGenerator final : public DataGenerator {
* @param grammar Optional GBNF grammar constraining generated output. * @param grammar Optional GBNF grammar constraining generated output.
* @return Generated text. * @return Generated text.
*/ */
std::string Infer(const std::string& system_prompt, const std::string& prompt, std::string Infer(const std::string& system_prompt,
const std::string& prompt,
int max_tokens = kDefaultMaxTokens, int max_tokens = kDefaultMaxTokens,
std::string_view grammar = {}); std::string_view grammar = {});

View File

@@ -47,4 +47,18 @@ void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
std::optional<std::string> ValidateBreweryJson(const std::string& raw, std::optional<std::string> ValidateBreweryJson(const std::string& raw,
BreweryResult& brewery_out); BreweryResult& brewery_out);
/**
* @brief Validates and parses user JSON output.
*
* Only populates `username`, `bio`, and `activity_weight` -- `first_name`
* and `last_name` are not LLM-authored and are set separately from the
* sampled Name.
*
* @param raw Raw model output.
* @param user_out Parsed user payload.
* @return Validation error message if invalid, or std::nullopt on success.
*/
std::optional<std::string> ValidateUserJson(const std::string& raw,
UserResult& user_out);
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_

View File

@@ -20,20 +20,19 @@ class MockGenerator final : public DataGenerator {
/** /**
* @brief Generates deterministic brewery data for a location. * @brief Generates deterministic brewery data for a location.
* *
* @param location City and country names. * @param enriched_city
* @param region_context Unused for mock generation.
* @return Generated brewery result. * @return Generated brewery result.
*/ */
BreweryResult GenerateBrewery(const Location& location,
const std::string& region_context) override;
BreweryResult GenerateBrewery(const EnrichedCity& enriched_city) override;
/** /**
* @brief Generates deterministic user data for a locale. * @brief Generates deterministic user data grounded in a sampled name and
* * persona.
* @param locale Locale hint.
* @return Generated user result. * @return Generated user result.
*/ */
UserResult GenerateUser(const std::string& locale) override; UserResult GenerateUser(const EnrichedCity& city, const UserPersona& persona,
const Name& name) override;
private: private:
/** /**
@@ -42,7 +41,19 @@ class MockGenerator final : public DataGenerator {
* @param location City and country names. * @param location City and country names.
* @return Deterministic hash value. * @return Deterministic hash value.
*/ */
static size_t DeterministicHash(const Location& location); static size_t DeterministicHash(const City& location);
/**
* @brief Combines city, persona, and name into a stable hash value.
*
* @param location City and country names.
* @param persona Persona archetype.
* @param name Sampled first/last name.
* @return Deterministic hash value.
*/
static size_t DeterministicHash(const City& location,
const UserPersona& persona,
const Name& name);
// Hash stride constants for deterministic distribution across fixed-size // Hash stride constants for deterministic distribution across fixed-size
// arrays. These coprime strides spread hash values uniformly without // arrays. These coprime strides spread hash values uniformly without
@@ -50,6 +61,8 @@ class MockGenerator final : public DataGenerator {
static constexpr size_t kNounHashStride = 7; static constexpr size_t kNounHashStride = 7;
static constexpr size_t kDescriptionHashStride = 13; static constexpr size_t kDescriptionHashStride = 13;
static constexpr size_t kBioHashStride = 11; static constexpr size_t kBioHashStride = 11;
static constexpr size_t kActivityWeightHashStride = 17;
static constexpr size_t kActivityWeightHashRange = 1000;
static constexpr std::array<std::string_view, 18> kBreweryAdjectives = { static constexpr std::array<std::string_view, 18> kBreweryAdjectives = {
"Craft", "Heritage", "Local", "Artisan", "Pioneer", "Golden", "Craft", "Heritage", "Local", "Artisan", "Pioneer", "Golden",
@@ -124,7 +137,8 @@ class MockGenerator final : public DataGenerator {
"Visits breweries for the stories, stays for the flagship pours.", "Visits breweries for the stories, stays for the flagship pours.",
"Craft beer fan mapping tasting notes and favorite brew routes.", "Craft beer fan mapping tasting notes and favorite brew routes.",
"Always ready to trade recommendations for underrated local breweries.", "Always ready to trade recommendations for underrated local breweries.",
"Keeping a running list of must-try collab releases and tap takeovers."}; "Keeping a running list of must-try collab releases and tap "
"takeovers."};
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_MOCK_GENERATOR_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_MOCK_GENERATOR_H_

View File

@@ -11,7 +11,8 @@ class Gemma4JinjaPromptFormatter final : public IPromptFormatter {
Gemma4JinjaPromptFormatter() = default; Gemma4JinjaPromptFormatter() = default;
~Gemma4JinjaPromptFormatter() override = default; ~Gemma4JinjaPromptFormatter() override = default;
[[nodiscard]] std::string Format(std::string_view system_prompt, [[nodiscard]] std::string Format(
std::string_view system_prompt,
std::string_view user_prompt) const override; std::string_view user_prompt) const override;
}; };

View File

@@ -3,32 +3,38 @@
/** /**
* @file data_model/generated_models.h * @file data_model/generated_models.h
* @brief Generated output models from the pipeline: brewery/user results, enriched data, * @brief Generated output models from the pipeline: brewery/user results,
* and complete generation results. * enriched data, and complete generation results.
*/ */
#include <string> #include <string>
#include "data_model/models.h" #include "data_model/models.h"
// ============================================================================
// Generation Output Models // Generation Output Models
// ============================================================================
/** /**
* @brief Generated brewery payload. * @brief Generated brewery payload.
*/ */
struct BreweryResult { struct BreweryResult {
/// @brief Brewery display name in English. /**
* @brief Brewery display name in English.
*/
std::string name_en; std::string name_en;
/// @brief Brewery description text in English. /**
* @brief Brewery description text in English.
*/
std::string description_en; std::string description_en;
/// @brief Brewery display name in the local language. /**
* @brief Brewery display name in the local language.
*/
std::string name_local; std::string name_local;
/// @brief Brewery description text in the local language. /**
* @brief Brewery description text in the local language.
*/
std::string description_local; std::string description_local;
}; };
@@ -36,31 +42,89 @@ struct BreweryResult {
* @brief Generated user profile payload. * @brief Generated user profile payload.
*/ */
struct UserResult { struct UserResult {
/// @brief Username handle. /**
* @brief First (given) name, copied from the sampled Name -- not
* LLM-invented.
*/
std::string first_name{};
/**
* @brief Last (family) name, copied from the sampled Name -- not
* LLM-invented.
*/
std::string last_name{};
/**
* @brief Gender associated with the sampled first name, copied from the
* sampled Name -- not LLM-invented.
*/
std::string gender{};
/**
* @brief Username handle.
*/
std::string username{}; std::string username{};
/// @brief Short user biography. /**
* @brief Short user biography.
*/
std::string bio{}; std::string bio{};
/**
* @brief Relative check-in/activity weight for this user, used to drive
* a J-curve activity profile in later pipeline phases.
*/
float activity_weight{};
}; };
// ============================================================================
// Pipeline Data Models // Pipeline Data Models
// ============================================================================
/** /**
* @brief Enriched city data with Wikipedia context. * @brief Enriched city data with Wikipedia context.
*/ */
struct EnrichedCity { struct EnrichedCity {
Location location; City location;
std::string region_context{}; std::string region_context{};
}; };
/**
* @brief A physical address: the city plus a concrete postal code selected
* from that city's curated postal-code examples.
*/
struct BreweryAddress {
City city;
std::string postal_code{};
};
/**
* @brief A physical address for a generated user: the city plus a concrete
* postal code selected from that city's curated postal-code examples.
*/
struct UserAddress {
City city;
std::string postal_code{};
};
/** /**
* @brief Helper struct to store generated brewery data. * @brief Helper struct to store generated brewery data.
*/ */
struct GeneratedBrewery { struct BreweryRecord {
Location location; BreweryAddress address;
BreweryResult brewery; BreweryResult brewery;
}; };
/**
* @brief Helper struct to store generated user data.
*
* `email` and `date_of_birth` are programmatically generated by the
* orchestrator (never LLM-authored) so a downstream auth-account seeding
* consumer can register real accounts from the pipeline's SQLite export.
*/
struct UserRecord {
UserAddress address;
UserResult user;
std::string email{};
std::string date_of_birth{};
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATED_MODELS_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATED_MODELS_H_

View File

@@ -10,6 +10,7 @@
#include <boost/program_options.hpp> #include <boost/program_options.hpp>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <functional>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -20,74 +21,166 @@ class ILogger;
namespace prog_opts = boost::program_options; namespace prog_opts = boost::program_options;
// ============================================================================ // City Models
// Location Models
// ============================================================================
/** /**
* @brief Canonical location record for city-level generation. * @brief Curated postal-code metadata for a city, mirroring the
* `postal_code` object in the locations dataset.
*/ */
struct Location { struct PostalCodeSpec {
/// @brief City name. /**
std::string city{}; * @brief Permissive country-wide postal-code format regex, used as the
* validation fallback for a selected example.
*/
std::string country_format_regex{};
/// @brief State or province name. /**
* @brief City-specific postal-code regexes (more precise than the country
* format).
*/
std::vector<std::string> city_regexes{};
/**
* @brief Concrete example postal codes for this city. The postal code
* service selects from these rather than synthesizing codes.
*/
std::vector<std::string> examples{};
};
/**
* @brief Canonical city record for city-level generation.
*/
struct City {
std::string city{};
std::string state_province{}; std::string state_province{};
/// @brief ISO 3166-2 subdivision code. /**
* @brief ISO 3166-2 subdivision code.
*/
std::string iso3166_2{}; std::string iso3166_2{};
/// @brief Country name.
std::string country{}; std::string country{};
/// @brief ISO 3166-1 country code. /**
* @brief ISO 3166-1 country code.
*/
std::string iso3166_1{}; std::string iso3166_1{};
/// @brief Local language codes in priority order. /**
* @brief Local language codes in priority order.
*/
std::vector<std::string> local_languages{}; std::vector<std::string> local_languages{};
/// @brief Latitude in decimal degrees. /**
double latitude{}; * @brief Postal-code metadata (format regex, city regexes, and concrete
* examples) sourced directly from the curated locations dataset.
*/
PostalCodeSpec postal_code{};
};
/// @brief Longitude in decimal degrees. // Name / Persona Models
double longitude{};
/**
* @brief A sampled first/last name pair, with the source forename's gender.
*
* Produced by the SampleName() helper in generate_users.cc.
*/
struct Name {
std::string first_name{};
std::string last_name{};
/**
* @brief Gender associated with the sampled forename (e.g. "M", "F"), as
* reported by the source dataset.
*/
std::string gender{};
}; };
/** /**
* @brief Non-owning brewery location input. * @brief A single forename entry from the names-by-country fixture data.
*/ */
struct BreweryLocation { struct ForenameEntry {
/// @brief City name. /**
std::string_view city_name; * @brief Romanized forename.
*/
std::string name{};
/// @brief Country name. /**
std::string_view country_name; * @brief Gender associated with this forename, as reported by the source
* dataset (e.g. "M", "F").
*/
std::string gender{};
bool operator==(const ForenameEntry& other) const {
return name == other.name && gender == other.gender;
}
};
namespace std {
template <>
struct hash<ForenameEntry> {
size_t operator()(const ForenameEntry& entry) const noexcept {
const size_t name_hash = std::hash<std::string>{}(entry.name);
const size_t gender_hash = std::hash<std::string>{}(entry.gender);
return name_hash ^ (gender_hash << 1);
}
};
} // namespace std
/**
* @brief A persona archetype used to ground LLM-generated user bios.
*/
struct UserPersona {
/**
* @brief Persona display name (e.g. "Hophead Explorer").
*/
std::string name{};
/**
* @brief Short description of the persona's interests and voice.
*/
std::string description{};
/**
* @brief Beer styles this persona gravitates toward.
*/
std::vector<std::string> style_affinities{};
}; };
// ============================================================================
// Configuration Models // Configuration Models
// ============================================================================
/** /**
* @brief LLM sampling parameters. * @brief LLM sampling parameters.
*/ */
struct SamplingOptions { struct SamplingOptions {
/// @brief LLM sampling temperature (0.0 to 1.0, higher = more random). /**
* @brief LLM sampling temperature (higher = more random).
*/
float temperature = 1.0F; float temperature = 1.0F;
/// @brief LLM nucleus sampling top-p parameter. /**
* @brief LLM nucleus sampling top-p parameter.
*/
float top_p = 0.95F; float top_p = 0.95F;
/// @brief LLM top-k sampling parameter. /**
* @brief LLM top-k sampling parameter.
*/
uint32_t top_k = 64; uint32_t top_k = 64;
/// @brief Context window size (tokens). /**
* @brief Context window size (tokens).
*/
uint32_t n_ctx = 8192; uint32_t n_ctx = 8192;
/// @brief Random seed (-1 for random, otherwise non-negative). /**
* @brief Random seed (-1 for random, otherwise non-negative).
*/
int seed = -1; int seed = -1;
/// @brief Number of layers to offload to GPU. /**
* @brief Number of layers to offload to GPU.
*/
int n_gpu_layers = 0; int n_gpu_layers = 0;
}; };
@@ -95,16 +188,20 @@ struct SamplingOptions {
* @brief Configuration for the LLM generator component. * @brief Configuration for the LLM generator component.
*/ */
struct GeneratorOptions { struct GeneratorOptions {
/// @brief Path to the LLM model file (gguf format). /**
* @brief Path to the LLM model file (gguf format).
*/
std::filesystem::path model_path; std::filesystem::path model_path;
/// @brief Use mocked generator instead of actual LLM inference. /**
* @brief Use mocked generator instead of actual LLM inference.
*/
bool use_mocked = false; bool use_mocked = false;
/**
* @brief Specific sampling parameters for this generator.
/// @brief Specific sampling parameters for this generator. * If nullopt, the application should use global defaults.
/// If nullopt, the application should use global defaults. */
std::optional<SamplingOptions> sampling; std::optional<SamplingOptions> sampling;
}; };
@@ -112,18 +209,23 @@ struct GeneratorOptions {
* @brief Configuration for the pipeline execution and output. * @brief Configuration for the pipeline execution and output.
*/ */
struct PipelineOptions { struct PipelineOptions {
/// @brief Directory for generated artifacts. /**
* @brief Directory for generated artifacts.
*/
std::filesystem::path output_path; std::filesystem::path output_path;
/// @brief Directory that contains named prompt files (e.g. /**
/// BREWERY_GENERATION.md). * @brief Directory that contains named prompt files (e.g.
* BREWERY_GENERATION.md).
*/
std::filesystem::path prompt_dir; std::filesystem::path prompt_dir;
/// @brief Path for application logs.
std::filesystem::path log_path; std::filesystem::path log_path;
/// @brief Number of locations to sample from the dataset /**
/// More locations -> more users/more breweries * @brief Number of locations to sample from the dataset
* More locations -> more users/more breweries
*/
uint32_t location_count; uint32_t location_count;
}; };
@@ -135,11 +237,9 @@ struct ApplicationOptions {
PipelineOptions pipeline; PipelineOptions pipeline;
}; };
// ============================================================================
// Function Declarations // Function Declarations
// ============================================================================
std::optional<ApplicationOptions> ParseArguments(const int argc, char** argv, std::optional<ApplicationOptions> ParseArguments(
std::shared_ptr<ILogger> logger = nullptr); const int argc, char** argv, std::shared_ptr<ILogger> logger = nullptr);
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_MODELS_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_MODELS_H_

View File

@@ -1,25 +0,0 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
#define BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
/**
* @file json_handling/json_loader.h
* @brief Loader API for curated location data.
*/
#include <filesystem>
#include <memory>
#include <vector>
#include "data_model/models.h"
#include "services/logging/logger.h"
/// @brief Loads curated world locations from a JSON file into memory.
class JsonLoader {
public:
/// @brief Parses a JSON array file and returns all location records.
static std::vector<Location> LoadLocations(
const std::filesystem::path& filepath,
std::shared_ptr<ILogger> logger = nullptr);
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_

View File

@@ -39,7 +39,8 @@ inline void PrettyPrint(std::ostream& outstream,
if (!obj.empty()) { if (!obj.empty()) {
const auto* iter = obj.begin(); const auto* iter = obj.begin();
for (;;) { for (;;) {
outstream << *indent << boost::json::serialize(iter->key()) << " : "; outstream << *indent << boost::json::serialize(iter->key())
<< " : ";
PrettyPrint(outstream, iter->value(), indent); PrettyPrint(outstream, iter->value(), indent);
iter = std::next(iter); iter = std::next(iter);
if (iter == obj.end()) { if (iter == obj.end()) {
@@ -106,4 +107,4 @@ inline void PrettyPrint(std::ostream& outstream,
} }
} }
#endif #endif // BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_PRETTY_PRINT_H_

View File

@@ -16,16 +16,10 @@
*/ */
class LlamaBackendState { class LlamaBackendState {
public: public:
/// @brief Initializes global llama backend state.
LlamaBackendState() { llama_backend_init(); } LlamaBackendState() { llama_backend_init(); }
/// @brief Cleans up global llama backend state.
~LlamaBackendState() { llama_backend_free(); } ~LlamaBackendState() { llama_backend_free(); }
/// @brief Non-copyable type.
LlamaBackendState(const LlamaBackendState&) = delete; LlamaBackendState(const LlamaBackendState&) = delete;
/// @brief Non-copyable type.
LlamaBackendState& operator=(const LlamaBackendState&) = delete; LlamaBackendState& operator=(const LlamaBackendState&) = delete;
}; };

View File

@@ -0,0 +1,50 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_CURATED_DATA_SERVICE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_CURATED_DATA_SERVICE_H_
/**
* @file services/curated_data/curated_data_service.h
* @brief Abstraction for loading curated location, persona, and name data.
*/
#include <filesystem>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "data_model/models.h"
using ForenameList = std::vector<ForenameEntry>;
using SurnameList = std::vector<std::string>;
using LocationsList = std::vector<City>;
using PersonasList = std::vector<UserPersona>;
using ForenamesByCountryMap = std::unordered_map<std::string, ForenameList>;
using SurnamesByCountryMap = std::unordered_map<std::string, SurnameList>;
/**
* @brief Interface for services that load curated data used to seed
* brewery/user generation.
*/
class ICuratedDataService {
public:
ICuratedDataService() = default;
virtual ~ICuratedDataService() = default;
ICuratedDataService(const ICuratedDataService&) = delete;
ICuratedDataService& operator=(const ICuratedDataService&) = delete;
ICuratedDataService(ICuratedDataService&&) = delete;
ICuratedDataService& operator=(ICuratedDataService&&) = delete;
/**
* @brief Loads all curated location records.
*/
virtual const LocationsList& LoadLocations() = 0;
/**
* @brief Loads all curated persona records.
*/
virtual const PersonasList& LoadPersonas() = 0;
virtual const ForenamesByCountryMap& LoadForenamesByCountry() = 0;
virtual const SurnamesByCountryMap& LoadSurnamesByCountry() = 0;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_CURATED_DATA_SERVICE_H_

View File

@@ -0,0 +1,63 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
#define BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
/**
* @file json_handling/json_loader.h
* @brief JSON-backed implementation of ICuratedDataService.
*/
#include <filesystem>
#include "data_model/models.h"
#include "services/curated_data/curated_data_service.h"
/**
* @brief File locations for the curated JSON fixtures consumed by
* CuratedJsonDataService.
*/
struct CuratedDataFilePaths {
std::filesystem::path locations_path;
std::filesystem::path personas_path;
std::filesystem::path forenames_path;
std::filesystem::path surnames_path;
};
/**
* @brief Loads curated location, persona, and name data from JSON files.
*/
class CuratedJsonDataService final : public ICuratedDataService {
struct cache {
LocationsList locations;
PersonasList personas;
ForenamesByCountryMap forenames_by_country;
SurnamesByCountryMap surnames_by_country;
cache() = default;
~cache() = default;
};
CuratedDataFilePaths filepaths_;
cache cache_;
public:
explicit CuratedJsonDataService(CuratedDataFilePaths filepaths);
/**
* @brief Parses a JSON array file and returns all location records.
*/
const LocationsList& LoadLocations() override;
/**
* @brief Parses a JSON array file and returns all persona records.
*/
const PersonasList& LoadPersonas() override;
const ForenamesByCountryMap& LoadForenamesByCountry() override;
/**
* @brief Parses a JSON file and returns all the forenames per country.
*/
const SurnamesByCountryMap& LoadSurnamesByCountry() override;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_

View File

@@ -0,0 +1,38 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_MOCK_CURATED_DATA_SERVICE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_MOCK_CURATED_DATA_SERVICE_H_
/**
* @file services/curated_data/mock_curated_data_service.h
* @brief In-memory ICuratedDataService backed by a small fixed dataset, used
* when file-backed curated data is disabled (mock mode).
*/
#include <filesystem>
#include "data_model/models.h"
#include "services/curated_data/curated_data_service.h"
/**
* @brief Curated data service returning a small fixed in-memory dataset in
* place of the JSON fixture files used by JsonLoader.
*/
class MockCuratedDataService final : public ICuratedDataService {
public:
MockCuratedDataService();
const LocationsList& LoadLocations() override;
const PersonasList& LoadPersonas() override;
const ForenamesByCountryMap& LoadForenamesByCountry() override;
const SurnamesByCountryMap& LoadSurnamesByCountry() override;
private:
LocationsList locations_;
PersonasList personas_;
ForenamesByCountryMap forenames_by_country_;
SurnamesByCountryMap surnames_by_country_;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_CURATED_DATA_MOCK_CURATED_DATA_SERVICE_H_

View File

@@ -16,8 +16,6 @@
class IExportService { class IExportService {
public: public:
IExportService() = default; IExportService() = default;
/// @brief Virtual destructor for polymorphic cleanup.
virtual ~IExportService() = default; virtual ~IExportService() = default;
IExportService(const IExportService&) = delete; IExportService(const IExportService&) = delete;
@@ -25,7 +23,9 @@ class IExportService {
IExportService(IExportService&&) = delete; IExportService(IExportService&&) = delete;
IExportService& operator=(IExportService&&) = delete; IExportService& operator=(IExportService&&) = delete;
/// @brief Prepares the export destination for a new run. /**
* @brief Prepares the export destination for a new run.
*/
virtual void Initialize() = 0; virtual void Initialize() = 0;
/** /**
@@ -33,9 +33,18 @@ class IExportService {
* *
* @param brewery Generated brewery payload to store. * @param brewery Generated brewery payload to store.
*/ */
virtual uint64_t ProcessRecord(const GeneratedBrewery& brewery) = 0; virtual uint64_t ProcessRecord(const BreweryRecord& brewery) = 0;
/// @brief Finalizes the export destination. /**
* @brief Persists one generated user record.
*
* @param user Generated user payload to store.
*/
virtual uint64_t ProcessRecord(const UserRecord& user) = 0;
/**
* @brief Finalizes the export destination.
*/
virtual void Finalize() = 0; virtual void Finalize() = 0;
}; };

View File

@@ -12,7 +12,7 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
#include "sqlite_handle_types.h" #include "services/database/sqlite_handle_types.h"
namespace sqlite_export_service_internal { namespace sqlite_export_service_internal {

View File

@@ -12,9 +12,9 @@
#include <unordered_map> #include <unordered_map>
#include "data_model/models.h" #include "data_model/models.h"
#include "../datetime/date_time_provider.h" #include "services/database/export_service.h"
#include "export_service.h" #include "services/database/sqlite_export_service_helpers.h"
#include "sqlite_export_service_helpers.h" #include "services/datetime/date_time_provider.h"
/** /**
* @brief Persists generated brewery records into a fresh SQLite database. * @brief Persists generated brewery records into a fresh SQLite database.
@@ -30,7 +30,8 @@ class SqliteExportService final : public IExportService {
SqliteExportService& operator=(SqliteExportService&&) = delete; SqliteExportService& operator=(SqliteExportService&&) = delete;
void Initialize() override; void Initialize() override;
uint64_t ProcessRecord(const GeneratedBrewery& brewery) override; uint64_t ProcessRecord(const BreweryRecord& brewery) override;
uint64_t ProcessRecord(const UserRecord& user) override;
void Finalize() override; void Finalize() override;
private: private:
@@ -44,17 +45,29 @@ class SqliteExportService final : public IExportService {
void RollbackAndCloseNoThrow() noexcept; void RollbackAndCloseNoThrow() noexcept;
[[nodiscard]] std::filesystem::path BuildDatabasePath() const; [[nodiscard]] std::filesystem::path BuildDatabasePath() const;
[[nodiscard]] static std::string BuildLocationKey(const Location& location); [[nodiscard]] static std::string BuildCityKey(const City& city);
/**
* @brief Returns the row id for @p city, inserting it first if it has
* not already been seen during this run.
*
* Shared by both ProcessRecord() overloads so brewery and user addresses
* referencing the same city resolve to the same row.
*/
[[nodiscard]] sqlite3_int64 ResolveCityId(const City& city);
std::unique_ptr<IDateTimeProvider> date_time_provider_; std::unique_ptr<IDateTimeProvider> date_time_provider_;
std::filesystem::path output_path_; std::filesystem::path output_path_;
std::string run_timestamp_utc_; std::string run_timestamp_utc_;
std::filesystem::path database_path_; std::filesystem::path database_path_;
SqliteDatabaseHandle db_handle_; SqliteDatabaseHandle db_handle_;
SqliteStatementHandle insert_location_stmt_; SqliteStatementHandle insert_city_stmt_;
SqliteStatementHandle insert_brewery_stmt_; SqliteStatementHandle insert_brewery_stmt_;
SqliteStatementHandle insert_brewery_address_stmt_;
SqliteStatementHandle insert_user_stmt_;
SqliteStatementHandle insert_user_address_stmt_;
bool transaction_open_ = false; bool transaction_open_ = false;
std::unordered_map<std::string, sqlite3_int64> location_cache_; std::unordered_map<std::string, sqlite3_int64> city_cache_;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_DATABASE_SQLITE_EXPORT_SERVICE_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_DATABASE_SQLITE_EXPORT_SERVICE_H_

View File

@@ -3,8 +3,8 @@
/* Umbrella header for backward compatibility. */ /* Umbrella header for backward compatibility. */
#include "sqlite_connection_helpers.h" #include "services/database/sqlite_connection_helpers.h"
#include "sqlite_handle_types.h" #include "services/database/sqlite_handle_types.h"
#include "sqlite_statement_helpers.h" #include "services/database/sqlite_statement_helpers.h"
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_DATABASE_SQLITE_EXPORT_SERVICE_HELPERS_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_DATABASE_SQLITE_EXPORT_SERVICE_HELPERS_H_

View File

@@ -24,8 +24,10 @@ using SqliteDatabaseHandle = std::unique_ptr<sqlite3, SqliteDatabaseDeleter>;
using SqliteStatementHandle = using SqliteStatementHandle =
std::unique_ptr<sqlite3_stmt, SqliteStatementDeleter>; std::unique_ptr<sqlite3_stmt, SqliteStatementDeleter>;
// Represents a parameter that is bound to a prepared SQLite statement.
// N.B. indices are 1 based.
template <typename T> template <typename T>
struct BindParam { struct BoundParam {
int index; int index;
T value; T value;
std::string_view action; std::string_view action;

View File

@@ -13,13 +13,13 @@
#include <string_view> #include <string_view>
#include <vector> #include <vector>
#include "sqlite_handle_types.h" #include "services/database/sqlite_handle_types.h"
namespace sqlite_export_service_internal { namespace sqlite_export_service_internal {
inline constexpr std::string_view kCreateLocationsTableSql = R"sql( inline constexpr std::string_view kCreateCitiesTableSql = R"sql(
CREATE TABLE IF NOT EXISTS locations ( CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL, city TEXT NOT NULL,
state_province TEXT NOT NULL, state_province TEXT NOT NULL,
@@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS locations (
country TEXT NOT NULL, country TEXT NOT NULL,
iso3166_1 TEXT NOT NULL, iso3166_1 TEXT NOT NULL,
local_languages_json TEXT NOT NULL, local_languages_json TEXT NOT NULL,
latitude REAL NOT NULL, postal_code_country_format_regex TEXT NOT NULL,
longitude REAL NOT NULL, postal_code_city_regex_json TEXT NOT NULL,
UNIQUE(city, state_province, iso3166_2, country, latitude, longitude) UNIQUE(city, state_province, iso3166_2, country)
); );
)sql"; )sql";
@@ -38,55 +38,159 @@ inline constexpr std::string_view kCreateBreweriesTableSql = R"sql(
CREATE TABLE IF NOT EXISTS breweries ( CREATE TABLE IF NOT EXISTS breweries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
name_en TEXT NOT NULL, name_en TEXT NOT NULL,
description_en TEXT NOT NULL, description_en TEXT NOT NULL,
name_local TEXT NOT NULL, name_local TEXT NOT NULL,
description_local TEXT NOT NULL, description_local TEXT NOT NULL
FOREIGN KEY(location_id) REFERENCES locations(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_breweries_location_id ON breweries(location_id);
)sql"; )sql";
inline constexpr std::string_view kInsertLocationSql = R"sql( inline constexpr std::string_view kCreateBreweryAddressesTableSql = R"sql(
INSERT INTO locations (
CREATE TABLE IF NOT EXISTS brewery_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
brewery_id INTEGER NOT NULL,
city_id INTEGER NOT NULL,
postal_code TEXT NOT NULL,
FOREIGN KEY(brewery_id) REFERENCES breweries(id) ON DELETE CASCADE,
FOREIGN KEY(city_id) REFERENCES cities(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_brewery_addresses_brewery_id
ON brewery_addresses(brewery_id);
CREATE INDEX IF NOT EXISTS idx_brewery_addresses_city_id
ON brewery_addresses(city_id);
)sql";
inline constexpr std::string_view kCreateUsersTableSql = R"sql(
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
gender TEXT NOT NULL,
username TEXT NOT NULL,
bio TEXT NOT NULL,
activity_weight REAL NOT NULL,
email TEXT NOT NULL UNIQUE,
date_of_birth TEXT NOT NULL
);
)sql";
inline constexpr std::string_view kCreateUserAddressesTableSql = R"sql(
CREATE TABLE IF NOT EXISTS user_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
city_id INTEGER NOT NULL,
postal_code TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(city_id) REFERENCES cities(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_addresses_user_id
ON user_addresses(user_id);
CREATE INDEX IF NOT EXISTS idx_user_addresses_city_id
ON user_addresses(city_id);
)sql";
inline constexpr std::string_view kInsertCitySql = R"sql(
INSERT INTO cities (
city, city,
state_province, state_province,
iso3166_2, iso3166_2,
country, country,
iso3166_1, iso3166_1,
local_languages_json, local_languages_json,
latitude, postal_code_country_format_regex,
longitude postal_code_city_regex_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?); ) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
)sql"; )sql";
inline constexpr std::string_view kInsertBrewerySql = R"sql( inline constexpr std::string_view kInsertBrewerySql = R"sql(
INSERT INTO breweries ( INSERT INTO breweries (
location_id,
name_en, name_en,
description_en, description_en,
name_local, name_local,
description_local description_local
) VALUES (?, ?, ?, ?, ?); ) VALUES (?, ?, ?, ?);
)sql"; )sql";
inline constexpr int kLocationCityBindIndex = 1; inline constexpr std::string_view kInsertBreweryAddressSql = R"sql(
inline constexpr int kLocationStateProvinceBindIndex = 2; INSERT INTO brewery_addresses (
inline constexpr int kLocationIso31662BindIndex = 3; brewery_id,
inline constexpr int kLocationCountryBindIndex = 4; city_id,
inline constexpr int kLocationIso31661BindIndex = 5; postal_code
inline constexpr int kLocationLanguagesBindIndex = 6; ) VALUES (?, ?, ?);
inline constexpr int kLocationLatitudeBindIndex = 7; )sql";
inline constexpr int kLocationLongitudeBindIndex = 8;
inline constexpr int kBreweryLocationIdBindIndex = 1; inline constexpr std::string_view kInsertUserSql = R"sql(
inline constexpr int kBreweryEnglishNameBindIndex = 2; INSERT INTO users (
inline constexpr int kBreweryEnglishDescriptionBindIndex = 3; first_name,
inline constexpr int kBreweryLocalNameBindIndex = 4; last_name,
inline constexpr int kBreweryLocalDescriptionBindIndex = 5; gender,
username,
bio,
activity_weight,
email,
date_of_birth
) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
)sql";
inline constexpr std::string_view kInsertUserAddressSql = R"sql(
INSERT INTO user_addresses (
user_id,
city_id,
postal_code
) VALUES (?, ?, ?);
)sql";
// sqlite3_bind_*() parameter indices are 1-based, matching the "?"
// placeholder order in the SQL above.
enum CityBindIndex {
kCityCityBindIndex = 1,
kCityStateProvinceBindIndex,
kCityIso31662BindIndex,
kCityCountryBindIndex,
kCityIso31661BindIndex,
kCityLanguagesBindIndex,
kCityPostalCodeCountryFormatRegexBindIndex,
kCityPostalCodeCityRegexJsonBindIndex,
};
enum BreweryBindIndex {
kBreweryEnglishNameBindIndex = 1,
kBreweryEnglishDescriptionBindIndex,
kBreweryLocalNameBindIndex,
kBreweryLocalDescriptionBindIndex,
};
enum BreweryAddressBindIndex {
kBreweryAddressBreweryIdBindIndex = 1,
kBreweryAddressCityIdBindIndex,
kBreweryAddressPostalCodeBindIndex,
};
enum UserBindIndex {
kUserFirstNameBindIndex = 1,
kUserLastNameBindIndex,
kUserGenderBindIndex,
kUserUsernameBindIndex,
kUserBioBindIndex,
kUserActivityWeightBindIndex,
kUserEmailBindIndex,
kUserDateOfBirthBindIndex,
};
enum UserAddressBindIndex {
kUserAddressUserIdBindIndex = 1,
kUserAddressCityIdBindIndex,
kUserAddressPostalCodeBindIndex,
};
SqliteStatementHandle PrepareStatement(const SqliteDatabaseHandle& db_handle, SqliteStatementHandle PrepareStatement(const SqliteDatabaseHandle& db_handle,
std::string_view sql, std::string_view sql,
@@ -95,13 +199,13 @@ SqliteStatementHandle PrepareStatement(const SqliteDatabaseHandle& db_handle,
void ResetStatement(SqliteStatementHandle& statement); void ResetStatement(SqliteStatementHandle& statement);
void Bind(const SqliteStatementHandle& statement, void Bind(const SqliteStatementHandle& statement,
const BindParam<std::string_view>& param); const BoundParam<std::string_view>& param);
void Bind(const SqliteStatementHandle& statement, void Bind(const SqliteStatementHandle& statement,
const BindParam<double>& param); const BoundParam<double>& param);
void Bind(const SqliteStatementHandle& statement, void Bind(const SqliteStatementHandle& statement,
const BindParam<sqlite3_int64>& param); const BoundParam<sqlite3_int64>& param);
void StepStatement(const SqliteDatabaseHandle& db_handle, void StepStatement(const SqliteDatabaseHandle& db_handle,
const SqliteStatementHandle& statement, const SqliteStatementHandle& statement,

View File

@@ -15,10 +15,12 @@
/** /**
* @brief Interface for UTC timestamp providers. * @brief Interface for UTC timestamp providers.
*
* @todo Yagni, consider removing this and just use SystemDateTimeProvider
* directly
*/ */
class IDateTimeProvider { class IDateTimeProvider {
public: public:
/// @brief Virtual destructor for polymorphic cleanup.
virtual ~IDateTimeProvider() = default; virtual ~IDateTimeProvider() = default;
IDateTimeProvider() = default; IDateTimeProvider() = default;

View File

@@ -15,16 +15,15 @@
*/ */
class IEnrichmentService { class IEnrichmentService {
public: public:
/// @brief Virtual destructor for polymorphic cleanup.
virtual ~IEnrichmentService() = default; virtual ~IEnrichmentService() = default;
/** /**
* @brief Resolves contextual enrichment for a location. * @brief Resolves contextual enrichment for a location.
* *
* @param loc Location to enrich. * @param loc City to enrich.
* @return Context text, or an empty string if unavailable. * @return Context text, or an empty string if unavailable.
*/ */
virtual std::string GetLocationContext(const Location& loc) = 0; virtual std::string GetLocationContext(const City& loc) = 0;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_ENRICHMENT_SERVICE_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_ENRICHMENT_SERVICE_H_

View File

@@ -1,17 +1,20 @@
//
// Created by aaronpo on 13/05/2026.
//
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_ #ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_ #define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_
/**
* @file services/enrichment/mock_enrichment.h
* @brief No-op IEnrichmentService used when network enrichment is disabled.
*/
#include <string> #include <string>
#include "enrichment_service.h" #include "services/enrichment/enrichment_service.h"
/**
* @brief Enrichment service that returns no context for any location.
*/
class MockEnrichmentService final : public IEnrichmentService { class MockEnrichmentService final : public IEnrichmentService {
public: public:
std::string GetLocationContext(const Location& /*loc*/) override { std::string GetLocationContext(const City& /*loc*/) override { return {}; }
return {};
}
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_MOCK_ENRICHMENT_H_

View File

@@ -11,25 +11,37 @@
#include <string_view> #include <string_view>
#include <unordered_map> #include <unordered_map>
#include "enrichment_service.h" #include "services/enrichment/enrichment_service.h"
#include "services/logging/logger.h" #include "services/logging/logger.h"
#include "web_client/web_client.h" #include "web_client/web_client.h"
/// @brief Provides Wikipedia summary lookups backed by cached raw extracts. /**
* @brief Provides Wikipedia summary lookups backed by cached raw extracts.
*/
class WikipediaEnrichmentService final : public IEnrichmentService { class WikipediaEnrichmentService final : public IEnrichmentService {
public: public:
/// @brief Creates a new Wikipedia service with the provided web client. /**
* @brief Creates a new Wikipedia service with the provided web client.
*/
explicit WikipediaEnrichmentService(std::unique_ptr<WebClient> client, explicit WikipediaEnrichmentService(std::unique_ptr<WebClient> client,
std::shared_ptr<ILogger> logger); std::shared_ptr<ILogger> logger);
/// @brief Returns the Wikipedia-derived context for a location. /**
[[nodiscard]] std::string GetLocationContext(const Location& loc) override; * @brief Returns the Wikipedia-derived context for a location.
*/
[[nodiscard]] std::string GetLocationContext(const City& loc) override;
private: private:
std::string FetchExtract(std::string_view query); std::string FetchExtract(std::string_view query);
std::unique_ptr<WebClient> client_; std::unique_ptr<WebClient> client_;
std::shared_ptr<ILogger> logger_; std::shared_ptr<ILogger> logger_;
/// @brief Canonical cache for raw Wikipedia query extracts. /**
* @brief Cache for raw Wikipedia query extracts, keyed by query string.
*
* GetLocationContext() always queries "brewing" and reuses "beer in
* {country}" for every city in that country, so caching avoids refetching
* the same extract across locations in a run.
*/
std::unordered_map<std::string, std::string> extract_cache_; std::unordered_map<std::string, std::string> extract_cache_;
}; };

View File

@@ -20,10 +20,22 @@
* @brief Severity levels supported by the logging infra. * @brief Severity levels supported by the logging infra.
*/ */
enum class LogLevel { enum class LogLevel {
Debug, ///< Development/debugging information. /**
Info, ///< General informational messages. * @brief Development/debugging information.
Warn, ///< Warning conditions. */
Error, ///< Error conditions. Debug,
/**
* @brief General informational messages.
*/
Info,
/**
* @brief Warning conditions.
*/
Warn,
/**
* @brief Error conditions.
*/
Error,
}; };
/** /**
@@ -34,18 +46,44 @@ enum class LogLevel {
* pipeline that emitted it. * pipeline that emitted it.
*/ */
enum class PipelinePhase { enum class PipelinePhase {
Startup, ///< Initialization and validation. /**
UserGeneration, ///< User profile generation. * @brief Initialization and validation.
BreweryAndBeerGeneration, ///< Brewery and beer data generation. */
CheckinGeneration, ///< Checkin (visit) record generation. Startup,
RatingGeneration, ///< Rating and review generation. /**
FollowGeneration, ///< Follow relationship generation. * @brief City/context enrichment (e.g. Wikipedia lookups).
Teardown, ///< Finalization and cleanup. */
Enrichment,
/**
* @brief User profile generation.
*/
UserGeneration,
/**
* @brief Brewery and beer data generation.
*/
BreweryAndBeerGeneration,
/**
* @brief Checkin (visit) record generation.
*/
CheckinGeneration,
/**
* @brief Rating and review generation.
*/
RatingGeneration,
/**
* @brief Follow relationship generation.
*/
FollowGeneration,
/**
* @brief Finalization and cleanup.
*/
Teardown,
}; };
/** /**
* @struct LogDTO * @struct LogDTO
* @brief User-provided subset of log fields. Used to capture call-site info transparently. * @brief User-provided subset of log fields. Used to capture call-site info
* transparently.
*/ */
struct LogDTO { struct LogDTO {
LogLevel level; LogLevel level;
@@ -64,25 +102,35 @@ struct LogDTO {
* before the entry is dispatched. * before the entry is dispatched.
*/ */
struct LogEntry { struct LogEntry {
/// @brief Timestamp when the entry was created. /**
* @brief Timestamp when the entry was created.
*/
std::chrono::system_clock::time_point timestamp{}; std::chrono::system_clock::time_point timestamp{};
/// @brief Source location where the log call was made. /**
* @brief Source location where the log call was made.
*/
std::source_location origin{}; std::source_location origin{};
/// @brief Thread responsible for emitting the log. /**
* @brief Thread responsible for emitting the log.
*/
std::thread::id thread_id{}; std::thread::id thread_id{};
/**
/// @brief Severity level of this entry. * @brief Severity level of this entry.
*/
LogLevel level; LogLevel level;
/// @brief Pipeline phase associated with the entry. /**
* @brief Pipeline phase associated with the entry.
*/
PipelinePhase phase; PipelinePhase phase;
/// @brief Log message text. /**
* @brief Log message text.
*/
std::string message; std::string message;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_LOG_ENTRY_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_LOG_ENTRY_H_

View File

@@ -6,8 +6,8 @@
* a bounded channel for later processing by the dispatcher. * a bounded channel for later processing by the dispatcher.
*/ */
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_CHANNEL_LOGGER_H_ #ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_LOG_PRODUCER_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_CHANNEL_LOGGER_H_ #define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_LOG_PRODUCER_H_
#include <string_view> #include <string_view>
@@ -50,4 +50,4 @@ class LogProducer final : public ILogger {
BoundedChannel<LogEntry>& channel_; BoundedChannel<LogEntry>& channel_;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_CHANNEL_LOGGER_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_LOGGING_LOG_PRODUCER_H_

View File

@@ -39,7 +39,8 @@ class ILogger {
*/ */
void Log(LogDTO payload, void Log(LogDTO payload,
std::source_location origin = std::source_location::current(), std::source_location origin = std::source_location::current(),
std::chrono::system_clock::time_point timestamp = std::chrono::system_clock::now(), std::chrono::system_clock::time_point timestamp =
std::chrono::system_clock::now(),
std::thread::id thread_id = std::this_thread::get_id()) { std::thread::id thread_id = std::this_thread::get_id()) {
LogEntry entry; LogEntry entry;
entry.timestamp = timestamp; entry.timestamp = timestamp;

View File

@@ -0,0 +1,80 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_
/**
* @file services/postal_code/mock_postal_code_service.h
* @brief Data-driven postal code service that selects the first curated
* example for a city and validates it against the city's postal-code regex.
*/
#include <format>
#include <regex>
#include <stdexcept>
#include <string>
#include "services/postal_code/postal_code_service.h"
/**
* @brief Postal code service backed entirely by the curated locations
* dataset: it returns the city's first example postal code after checking it
* against the curated postal-code regex.
*
* A precise city regex is preferred when present; otherwise the permissive
* country format regex is used as the fallback. A city with no examples, or an
* example that fails validation, throws so the caller can skip the city.
*/
class MockPostalCodeService final : public IPostalCodeService {
public:
std::string GeneratePostalCode(const City& city) override {
const PostalCodeSpec& spec = city.postal_code;
if (spec.examples.empty()) {
throw std::runtime_error(std::format(
"No postal-code examples available for city '{}'", city.city));
}
const std::string& candidate = spec.examples.front();
if (!MatchesAnyPattern(candidate, spec)) {
throw std::runtime_error(
std::format("Postal code '{}' for city '{}' does not match the "
"curated postal-code regex",
candidate, city.city));
}
return candidate;
}
private:
/**
* @brief Returns true if @p candidate matches any city regex, or the
* country format regex when no city regex matches. When the spec carries no
* usable regex at all, validation is skipped (returns true).
*/
static bool MatchesAnyPattern(const std::string& candidate,
const PostalCodeSpec& spec) {
bool has_pattern = false;
for (const std::string& pattern : spec.city_regexes) {
if (pattern.empty()) {
continue;
}
has_pattern = true;
if (std::regex_match(candidate, std::regex(pattern))) {
return true;
}
}
if (!spec.country_format_regex.empty()) {
has_pattern = true;
if (std::regex_match(candidate,
std::regex(spec.country_format_regex))) {
return true;
}
}
return !has_pattern;
}
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_

View File

@@ -0,0 +1,35 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_
/**
* @file services/postal_code/postal_code_service.h
* @brief Abstraction for resolving a postal code for a city.
*/
#include <string>
#include "data_model/models.h"
/**
* @brief Interface for services that resolve a postal code for a city.
*/
class IPostalCodeService {
public:
IPostalCodeService() = default;
virtual ~IPostalCodeService() = default;
IPostalCodeService(const IPostalCodeService&) = delete;
IPostalCodeService& operator=(const IPostalCodeService&) = delete;
IPostalCodeService(IPostalCodeService&&) = delete;
IPostalCodeService& operator=(IPostalCodeService&&) = delete;
/**
* @brief Resolves a postal code for @p city.
*
* @param city City whose curated postal-code examples supply the result.
* @return A postal code string, or an empty string if none is available.
*/
virtual std::string GeneratePostalCode(const City& city) = 0;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_

View File

@@ -0,0 +1,32 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_XEGER_POSTAL_CODE_SERVICE_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_XEGER_POSTAL_CODE_SERVICE_H_
/**
* @file services/postal_code/xeger_postal_code_service.h
* @brief Postal code service that generates a fresh, format-conformant code
* per call from the curated regex patterns, instead of returning a fixed
* example.
*/
#include <random>
#include "services/postal_code/postal_code_service.h"
/**
* @brief Postal code service using regex-driven generation (xeger).
*
* Picks among the city's curated `city_regexes` (falls back to
* `country_format_regex`), then generates a random string matching that pattern
* via GenerateStringFromRegex().
*/
class XegerPostalCodeService final : public IPostalCodeService {
public:
XegerPostalCodeService();
std::string GeneratePostalCode(const City& city) override;
private:
std::mt19937 rng_;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_XEGER_POSTAL_CODE_SERVICE_H_

View File

@@ -0,0 +1,38 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_H_
#include <random>
#include <string>
#include <string_view>
/**
* @brief Generates a random string matching @p pattern.
*
* Supported syntax:
* - literals
* - `.`
* - alternation `|`
* - groups `(...)` and non-capturing groups `(?:...)`
* - quantifiers `*` `+` `?` `{n}` `{n,}` `{n,m}`
* - character classes `[...]` / `[^...]`
* - shorthand escapes `\d \D \w \W \s \S \n \t \r`
*
* Not supported: lookaround, backreferences, and lazy quantifiers.
*
* @param pattern Regular expression to generate a matching
* string for.
* @param rng Random source driving alternation/quantifier/
* class choices.
* @param unbounded_repeat_cap Extra repeats allowed on top of the declared
* minimum for unbounded quantifiers
* (`*`, `+`, `{n,}`).
*
* @return A string satisfying `std::regex_match(result, std::regex(pattern))`
* for any @p pattern within the supported syntax subset.
*
* @throws std::runtime_error if @p pattern cannot be parsed.
*/
std::string GenerateStringFromRegex(std::string_view pattern, std::mt19937& rng,
int unbounded_repeat_cap = 8);
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_H_

View File

@@ -0,0 +1,201 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_INTERNAL_H_
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_INTERNAL_H_
/**
* @file services/xeger/xeger_internal.h
* @brief Declarations shared by the xeger parser and generator.
*
* 1. Parser Turns the pattern text into an AST (Literal, AnyChar,
* CharClass, Concat, Alternation, Repeat nodes).
* 2. Visitor Double-dispatch contract -- each AST-walking
* operation implements this.
* 3. XegerGenerator a Visitor that emits a random character wherever the
* AST says a character belongs, respecting alternation
* choices and repeat counts.
*/
#include <memory>
#include <optional>
#include <random>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace xeger_internal {
// Regex syntax characters
constexpr char kAnchorStart = '^';
constexpr char kAnchorEnd = '$';
constexpr char kAlternation = '|';
constexpr char kGroupOpen = '(';
constexpr char kGroupClose = ')';
constexpr char kNonCapturingMarker = '?';
constexpr char kNonCapturingColon = ':';
constexpr char kClassOpen = '[';
constexpr char kClassClose = ']';
constexpr char kClassNegate = '^';
constexpr char kClassRange = '-';
constexpr char kAnyChar = '.';
constexpr char kEscape = '\\';
constexpr char kStar = '*';
constexpr char kPlus = '+';
constexpr char kOptional = '?';
constexpr char kBraceOpen = '{';
constexpr char kBraceClose = '}';
constexpr char kBraceComma = ',';
// AST
struct Literal;
struct AnyChar;
struct CharClass;
struct Concat;
struct Alternation;
struct Repeat;
struct Visitor {
virtual void Visit(const Literal&) = 0;
virtual void Visit(const AnyChar&) = 0;
virtual void Visit(const CharClass&) = 0;
virtual void Visit(const Concat&) = 0;
virtual void Visit(const Alternation&) = 0;
virtual void Visit(const Repeat&) = 0;
virtual ~Visitor() = default;
};
struct Node {
virtual void Accept(Visitor&) const = 0;
virtual ~Node() = default;
};
using NodePtr = std::unique_ptr<Node>;
template <typename Derived>
struct Visitable : Node {
void Accept(Visitor& visitor) const override {
visitor.Visit(static_cast<const Derived&>(*this));
}
};
// One fixed character, e.g. 'a'.
struct Literal : Visitable<Literal> {
char ch;
explicit Literal(const char c) : ch(c) {}
};
// The "." wildcard: any single printable character.
struct AnyChar : Visitable<AnyChar> {};
// A set of allowed characters, stored as inclusive ranges. A single
// character 'x' is the range {'x', 'x'}. `negated` flips the meaning to
// "any character NOT listed" (i.e. `[^...]`).
struct CharClass : Visitable<CharClass> {
std::vector<std::pair<char, char>> ranges;
bool negated = false;
};
// "Do these children one after another."
struct Concat : Visitable<Concat> {
std::vector<NodePtr> parts;
};
// "Pick exactly one of these options at random" -- the `|` operator.
struct Alternation : Visitable<Alternation> {
std::vector<NodePtr> options;
};
// "Repeat one child between min and max times."
// a* -> min 0, max infinite
// a+ -> min 1, max infinite
// a? -> min 0, max 1
// a{2,5} -> min 2, max 5
struct Repeat : Visitable<Repeat> {
NodePtr child;
unsigned int min = 0;
std::optional<unsigned int> max; // std::nullopt means "no upper bound"
};
// Parser (recursive descent)
//
// Precedence, loosest to tightest binding:
// ParseAlternation ::= a|b|c
// ParseConcat ::= abc
// ParseRepeat ::= a* a{2,5}
// ParseAtom ::= a . [..] (..) \d
class Parser {
public:
explicit Parser(std::string_view pattern);
NodePtr Parse();
private:
std::string src_;
size_t pos_ = 0;
// Strips anchors (leading '^' and trailing '$') as they are not needed for
// generation.
static std::string StripAnchors(std::string_view pattern);
[[nodiscard]] bool AtEnd() const;
[[nodiscard]] char Peek() const;
char Advance();
bool Match(char c);
void Expect(char c);
// Level 1: alternation -- ( concat | concat | concat )
NodePtr ParseAlternation();
// Level 2: concatenation -- a run of items back to back
NodePtr ParseConcat();
// Level 3: an atom plus an optional quantifier
NodePtr ParseRepeat();
// Curly-brace quantifiers: {n}, {n,}, {n,m}
NodePtr ParseBrace(NodePtr atom);
// Level 4: an atom -- the smallest standalone unit
NodePtr ParseAtom();
static NodePtr MakeClass(std::vector<std::pair<char, char>> ranges,
bool negated);
NodePtr ParseEscape();
NodePtr ParseCharClass();
// Reads one character inside [ ], turning \] \n etc. into the real
// character they represent.
char ClassChar();
};
// Generator -- a Visitor that emits a random matching string
class XegerGenerator final : public Visitor {
public:
XegerGenerator(std::mt19937& rng, int unbounded_cap);
std::string Run(const Node& root);
void Visit(const Literal& node) override;
void Visit(const AnyChar&) override;
void Visit(const CharClass& node) override;
void Visit(const Concat& node) override;
void Visit(const Alternation& node) override;
void Visit(const Repeat& node) override;
private:
std::mt19937& rng_;
int unbounded_cap_;
std::string out_;
static bool InRanges(const CharClass& node, char c);
char PickFromClass(const CharClass& node);
};
} // namespace xeger_internal
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_XEGER_XEGER_INTERNAL_H_

View File

@@ -1,19 +1,18 @@
/** /**
* @file web_client/http_web_client.h * @file web_client/http_web_client.h
* @brief cpp-httplib implementation of the WebClient interface. * @brief cpp-httplib implementation of the WebClient interface.
*/ */
#ifndef BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_HTTP_WEB_CLIENT_H_ #ifndef BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_HTTP_WEB_CLIENT_H_
#define BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_HTTP_WEB_CLIENT_H_ #define BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_HTTP_WEB_CLIENT_H_
#include "web_client/web_client.h"
#include "services/logging/logger.h"
#include <memory> #include <memory>
#include <string> #include <string>
#include <utility> #include <utility>
#include "services/logging/logger.h"
#include "web_client/web_client.h"
/** /**
* @brief WebClient implementation backed by cpp-httplib. * @brief WebClient implementation backed by cpp-httplib.
* *
@@ -26,7 +25,7 @@
* bound to a single origin at construction time. * bound to a single origin at construction time.
*/ */
class HttpWebClient final : public WebClient { class HttpWebClient final : public WebClient {
public: public:
explicit HttpWebClient(std::shared_ptr<ILogger> logger) explicit HttpWebClient(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {} : logger_(std::move(logger)) {}
~HttpWebClient() override = default; ~HttpWebClient() override = default;
@@ -34,7 +33,8 @@ public:
/** /**
* @brief Executes a blocking HTTP/HTTPS GET request against a full URL. * @brief Executes a blocking HTTP/HTTPS GET request against a full URL.
* *
* @param url Fully-qualified URL, e.g. "https://en.wikipedia.org/api/rest_v1/page/summary/Berlin" * @param url Fully-qualified URL, e.g.
* "https://en.wikipedia.org/api/rest_v1/page/summary/Berlin"
* @return Response body on HTTP 2xx; throws std::runtime_error otherwise. * @return Response body on HTTP 2xx; throws std::runtime_error otherwise.
*/ */
std::string Get(const std::string& url) override; std::string Get(const std::string& url) override;
@@ -52,5 +52,4 @@ public:
std::shared_ptr<ILogger> logger_; std::shared_ptr<ILogger> logger_;
}; };
#endif // BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_HTTP_WEB_CLIENT_H_
#endif

Some files were not shown because too many files have changed in this diff Show More