mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Compare commits
13 Commits
seeding-up
...
3fd531c9f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fd531c9f0 | ||
|
|
ef27d6f553 | ||
|
|
4b3f3dc50a | ||
|
|
7c97825f91 | ||
|
|
4e48089c18 | ||
|
|
f6dc64b88b | ||
|
|
769c717405 | ||
|
|
c5571fcf47 | ||
|
|
c20be03f89 | ||
|
|
d1fedc72af | ||
|
|
b850d1047e | ||
|
|
250e5f2c9c | ||
|
|
0ab2eaaec9 |
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"csharpier": {
|
||||
"version": "1.2.1",
|
||||
"commands": [
|
||||
"csharpier"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"$schema": "https://json.schemastore.org/csharpier.json",
|
||||
|
||||
"printWidth": 80,
|
||||
"useTabs": false,
|
||||
"indentSize": 4,
|
||||
"endOfLine": "lf",
|
||||
@@ -49,10 +49,8 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
||||
# provider (e.g., SendGrid, Mailgun, Amazon SES).
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=ayerble
|
||||
SMTP_PASSWORD=checkers
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||
SMTP_FROM_NAME=The Biergarten App
|
||||
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
archive/** linguist-vendored
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,7 +7,6 @@ assignees: []
|
||||
---
|
||||
|
||||
## User Story
|
||||
|
||||
**As a** (who wants to accomplish something)
|
||||
**I want to** (what they want to accomplish)
|
||||
**So that** (why they want to accomplish that thing)
|
||||
@@ -16,18 +15,29 @@ assignees: []
|
||||
|
||||
### Scenario 1
|
||||
|
||||
Given ... When ... Then ...
|
||||
|
||||
Given ...
|
||||
When ...
|
||||
Then ...
|
||||
|
||||
|
||||
### Scenario 2
|
||||
|
||||
Given ... When ... Then ...
|
||||
|
||||
Given ...
|
||||
When ...
|
||||
Then ...
|
||||
|
||||
|
||||
### Scenario 3
|
||||
|
||||
Given ... When ... Then ...
|
||||
|
||||
Given ...
|
||||
When ...
|
||||
Then ...
|
||||
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [ ] Task 3
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -15,14 +15,6 @@
|
||||
# production
|
||||
/build
|
||||
|
||||
# project-specific build artifacts
|
||||
/src/Website/build/
|
||||
/src/Website/storybook-static/
|
||||
/src/Website/.react-router/
|
||||
/src/Website/playwright-report/
|
||||
/src/Website/test-results/
|
||||
/test-results/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
@@ -50,9 +42,6 @@ next-env.d.ts
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
/cloudinary-images
|
||||
|
||||
@@ -498,6 +487,3 @@ FodyWeavers.xsd
|
||||
.env.dev
|
||||
.env.test
|
||||
.env.prod
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
903
LICENSE.md
903
LICENSE.md
File diff suppressed because it is too large
Load Diff
319
README.md
319
README.md
@@ -1,146 +1,261 @@
|
||||
# The Biergarten App
|
||||
|
||||
The Biergarten App is a full-stack directory and discovery platform for
|
||||
breweries. It features a robust user authentication system, a searchable
|
||||
database of brewery locations, and a custom offline data-generation pipeline
|
||||
that uses LLMs (Llama.cpp) and Wikipedia to synthesize realistic seed data.
|
||||
A social platform for craft beer enthusiasts to discover breweries, share reviews, and
|
||||
connect with fellow beer lovers.
|
||||
|
||||
It features:
|
||||
**Documentation**
|
||||
|
||||
- A .NET backend (Web API + database migrations/seed) under `web/backend/`
|
||||
- A server-rendered React website (React Router + Vite) under `web/frontend/`
|
||||
- A C++20 “pipeline” CLI for generating seed data under `tooling/pipeline/`
|
||||
- [Getting Started](docs/getting-started.md) - Setup and installation
|
||||
- [Architecture](docs/architecture.md) - System design and patterns
|
||||
- [Database](docs/database.md) - Schema and stored procedures
|
||||
- [Docker Guide](docs/docker.md) - Container deployment
|
||||
- [Testing](docs/testing.md) - Test strategy and commands
|
||||
- [Environment Variables](docs/environment-variables.md) - Configuration reference
|
||||
|
||||
Specialized documentation (setup, architecture, docker, testing, diagrams, and
|
||||
pipeline notes) lives under `docs/`.
|
||||
**Diagrams**
|
||||
|
||||
## Documentation (Start Here)
|
||||
- [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture
|
||||
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology
|
||||
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence
|
||||
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships
|
||||
|
||||
Website + backend (active stack):
|
||||
## Project Status
|
||||
|
||||
- [Getting Started](docs/website/getting-started.md)
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Docker Guide](docs/website/docker.md)
|
||||
- [Testing](docs/website/testing.md)
|
||||
- [Environment Variables](docs/website/environment-variables.md)
|
||||
- [Token Validation](docs/website/token-validation.md)
|
||||
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
|
||||
|
||||
Data generation pipeline (C++):
|
||||
- Core authentication and user management APIs
|
||||
- Database schema with migrations and seeding
|
||||
- Layered architecture (Domain, Service, Infrastructure, Repository, API)
|
||||
- Comprehensive test suite (unit + integration)
|
||||
- Frontend integration with .NET API (in progress)
|
||||
- Migration from Next.js serverless functions
|
||||
|
||||
- [Pipeline README](docs/pipeline/README.md)
|
||||
- [Ethics & Known Issues](docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md)
|
||||
|
||||
## Diagrams
|
||||
|
||||
- [Architecture](docs/website/diagrams-out/architecture.svg)
|
||||
- [Deployment](docs/website/diagrams-out/deployment.svg)
|
||||
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
|
||||
- [Database Schema](docs/website/diagrams-out/database-schema.svg)
|
||||
|
||||
## Current Status
|
||||
|
||||
Active areas in the repository:
|
||||
|
||||
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
|
||||
- React 19 website (React Router 7 + Vite)
|
||||
- Shared Biergarten theme system + Storybook coverage
|
||||
- Auth flows and account/email integration (local Mailpit in dev compose)
|
||||
- Data generation pipeline with C++ and Llama.cpp
|
||||
|
||||
Archived/reference areas:
|
||||
|
||||
- `archive/next-js-web-app/` contains an older Next.js frontend retained for
|
||||
reference
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
- **Infrastructure**: Docker, Docker Compose
|
||||
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation
|
||||
tokens
|
||||
- **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp
|
||||
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
|
||||
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
|
||||
(HS256)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
||||
This section is the shortest path to a working dev environment.
|
||||
### Prerequisites
|
||||
|
||||
### Backend (Docker)
|
||||
- [.NET SDK 10+](https://dotnet.microsoft.com/download)
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js 18+](https://nodejs.org/) (for frontend)
|
||||
|
||||
### Start Development Environment
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||
cd the-biergarten-app
|
||||
|
||||
cp web/.env.example web/.env.dev
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
|
||||
# Configure environment
|
||||
cp .env.example .env.dev
|
||||
|
||||
# Start all services
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.dev.yaml logs -f
|
||||
```
|
||||
|
||||
Backend access:
|
||||
**Access**:
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
- Mailpit UI (dev SMTP): http://localhost:8025
|
||||
- API: http://localhost:8080/swagger
|
||||
- Health: http://localhost:8080/health
|
||||
|
||||
### Frontend (Node)
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm install
|
||||
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
Optional frontend tools:
|
||||
Results are in `./test-results/`
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm run storybook
|
||||
npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```text
|
||||
web/
|
||||
backend/ .NET API: API.Core (host), Features.* (vertical slices),
|
||||
Shared.*, Domain.*, Infrastructure.*, Database.* projects
|
||||
frontend/ React Router website + Storybook + Playwright/Vitest
|
||||
|
||||
tooling/
|
||||
pipeline/ C++20 seed-data generation CLI (CMake)
|
||||
|
||||
docs/
|
||||
architecture.md High-level architecture overview
|
||||
website/ Backend/frontend setup, docker, testing, diagrams
|
||||
pipeline/ Pipeline docs, ethics notes, PlantUML diagrams
|
||||
|
||||
archive/
|
||||
next-js-web-app/ Older Next.js frontend (reference only)
|
||||
```
|
||||
src/Core/ # Backend (.NET)
|
||||
├── API/
|
||||
│ ├── API.Core/ # ASP.NET Core Web API
|
||||
│ └── API.Specs/ # Integration tests (Reqnroll)
|
||||
├── Database/
|
||||
│ ├── Database.Migrations/ # DbUp migrations
|
||||
│ └── Database.Seed/ # Data seeding
|
||||
├── Domain.Entities/ # Domain models
|
||||
├── Infrastructure/ # Cross-cutting concerns
|
||||
│ ├── Infrastructure.Jwt/
|
||||
│ ├── Infrastructure.PasswordHashing/
|
||||
│ ├── Infrastructure.Email/
|
||||
│ ├── Infrastructure.Repository/
|
||||
│ └── Infrastructure.Repository.Tests/
|
||||
└── Service/ # Business logic
|
||||
├── Service.Auth/
|
||||
├── Service.Auth.Tests/
|
||||
└── Service.UserManagement/
|
||||
|
||||
Website/ # Frontend (Next.js)
|
||||
docs/ # Documentation
|
||||
docs/diagrams/ # PlantUML diagrams
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Implemented
|
||||
|
||||
- User registration and authentication
|
||||
- JWT token-based auth
|
||||
- Argon2id password hashing
|
||||
- SQL Server with stored procedures
|
||||
- Database migrations (DbUp)
|
||||
- Docker containerization
|
||||
- Comprehensive test suite
|
||||
- Swagger/OpenAPI documentation
|
||||
- Health checks
|
||||
|
||||
### Planned
|
||||
|
||||
- [ ] Brewery discovery and management
|
||||
- [ ] Beer reviews and ratings
|
||||
- [ ] Social following/followers
|
||||
- [ ] Geospatial brewery search
|
||||
- [ ] Image upload (Cloudinary)
|
||||
- [ ] Email notifications
|
||||
- [ ] OAuth integration
|
||||
|
||||
---
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Layered Architecture
|
||||
|
||||
```
|
||||
API Layer (Controllers)
|
||||
│
|
||||
Service Layer (Business Logic)
|
||||
│
|
||||
Infrastructure Layer (Repositories, JWT, Email)
|
||||
│
|
||||
Domain Layer (Entities)
|
||||
│
|
||||
Database (SQL Server + Stored Procedures)
|
||||
```
|
||||
|
||||
### SQL-First Approach
|
||||
|
||||
- All queries via stored procedures
|
||||
- No ORM (no Entity Framework)
|
||||
- Version-controlled schema
|
||||
|
||||
### Security
|
||||
|
||||
- **Password Hashing**: Argon2id (64MB memory, 4 iterations)
|
||||
- **JWT Tokens**: HS256 with configurable expiration
|
||||
- **Credential Rotation**: Built-in password change support
|
||||
|
||||
See [Architecture Guide](docs/architecture.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run the backend test stack with Docker:
|
||||
The project includes three test suites:
|
||||
|
||||
| Suite | Type | Framework | Purpose |
|
||||
| ---------------------- | ----------- | -------------- | ---------------------- |
|
||||
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing |
|
||||
| **Repository.Tests** | Unit | xUnit | Data access layer |
|
||||
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
|
||||
|
||||
**Run All Tests**:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
See [Testing](docs/website/testing.md) for the full command list.
|
||||
**Run Individual Test Suite**:
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet test API/API.Specs/API.Specs.csproj
|
||||
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
|
||||
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
||||
```
|
||||
|
||||
See [Testing Guide](docs/testing.md) for more information.
|
||||
|
||||
---
|
||||
|
||||
## Docker Environments
|
||||
|
||||
The project uses three Docker Compose configurations:
|
||||
|
||||
| File | Purpose | Features |
|
||||
| ---------------------------- | ------------- | ------------------------------------------------- |
|
||||
| **docker-compose.dev.yaml** | Development | Persistent data, hot reload, Swagger UI |
|
||||
| **docker-compose.test.yaml** | CI/CD Testing | Isolated DB, auto-exit, test results export |
|
||||
| **docker-compose.prod.yaml** | Production | Optimized builds, health checks, restart policies |
|
||||
|
||||
**Common Commands**:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
|
||||
# Testing
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
|
||||
# Build
|
||||
docker compose -f docker-compose.dev.yaml build
|
||||
docker compose -f docker-compose.dev.yaml build --no-cache
|
||||
```
|
||||
|
||||
See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Common active variables:
|
||||
### Required Environment Variables
|
||||
|
||||
- Backend/Docker: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`,
|
||||
`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`,
|
||||
`WEBSITE_BASE_URL`
|
||||
- Frontend runtime: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
||||
**Backend** (`.env.dev`):
|
||||
|
||||
See [Environment Variables](docs/website/environment-variables.md) for details.
|
||||
```bash
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=YourStrong!Passw0rd
|
||||
JWT_SECRET=<min-32-chars>
|
||||
```
|
||||
|
||||
**Frontend** (`.env.local`):
|
||||
|
||||
```bash
|
||||
BASE_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||
RESET_PASSWORD_TOKEN_SECRET=<generated>
|
||||
SESSION_SECRET=<generated>
|
||||
# + External services (Cloudinary, Mapbox, SparkPost)
|
||||
```
|
||||
|
||||
See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -149,3 +264,15 @@ See [Environment Variables](docs/website/environment-variables.md) for details.
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Start development environment: `docker compose -f docker-compose.dev.yaml up -d`
|
||||
2. Make changes to code
|
||||
3. Run tests: `docker compose -f docker-compose.test.yaml up --abort-on-container-exit`
|
||||
4. Rebuild if needed: `docker compose -f docker-compose.dev.yaml up -d --build api.core`
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: [docs/](docs/)
|
||||
- **Architecture**: See [Architecture Guide](docs/architecture.md)
|
||||
|
||||
@@ -13,11 +13,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-dev:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
|
||||
]
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -32,7 +28,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend/Database
|
||||
context: ./src/Core/Database
|
||||
dockerfile: Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -56,7 +52,7 @@ services:
|
||||
database.migrations:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -13,11 +13,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-dev:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
|
||||
]
|
||||
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -32,7 +28,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend/Database
|
||||
context: ./src/Core/Database
|
||||
dockerfile: Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -56,7 +52,7 @@ services:
|
||||
database.migrations:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -79,7 +75,7 @@ services:
|
||||
database.seed:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: API/API.Core/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -13,11 +13,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-dev:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
|
||||
]
|
||||
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -32,7 +28,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend/Database
|
||||
context: ./src/Core/Database
|
||||
dockerfile: Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -70,7 +66,7 @@ services:
|
||||
database.migrations:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -11,11 +11,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-prod:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
|
||||
]
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -31,7 +27,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend/Database
|
||||
context: ./src/Core/Database
|
||||
dockerfile: Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -54,7 +50,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: API/API.Core/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -12,11 +12,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-test:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1",
|
||||
]
|
||||
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -32,7 +28,7 @@ services:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend/Database
|
||||
context: ./src/Core/Database
|
||||
dockerfile: Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -56,7 +52,7 @@ services:
|
||||
database.migrations:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -79,7 +75,7 @@ services:
|
||||
database.seed:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./src/Core
|
||||
dockerfile: API/API.Specs/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
@@ -99,16 +95,36 @@ services:
|
||||
networks:
|
||||
- testnet
|
||||
|
||||
unit.tests:
|
||||
repository.tests:
|
||||
env_file: ".env.test"
|
||||
image: unit.tests
|
||||
container_name: test-env-unit-tests
|
||||
image: repository.tests
|
||||
container_name: test-env-repository-tests
|
||||
depends_on:
|
||||
database.seed:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.tests
|
||||
context: ./src/Core
|
||||
dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
environment:
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
volumes:
|
||||
- ./test-results:/app/test-results
|
||||
restart: "no"
|
||||
networks:
|
||||
- testnet
|
||||
|
||||
service.auth.tests:
|
||||
env_file: ".env.test"
|
||||
image: service.auth.tests
|
||||
container_name: test-env-service-auth-tests
|
||||
depends_on:
|
||||
database.seed:
|
||||
condition: service_completed_successfully
|
||||
build:
|
||||
context: ./src/Core
|
||||
dockerfile: Service/Service.Auth.Tests/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
environment:
|
||||
@@ -1,71 +1,64 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the active architecture of The Biergarten App.
|
||||
This document describes the architecture patterns and design decisions for The Biergarten
|
||||
App.
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
The Biergarten App is a monorepo with a clear split between the backend and the
|
||||
active website:
|
||||
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
|
||||
between backend and frontend:
|
||||
|
||||
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
|
||||
slices with MediatR
|
||||
- **Frontend**: React 19 + React Router 7 website in `web/frontend`
|
||||
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
||||
frontend
|
||||
|
||||
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
|
||||
for reference only.
|
||||
- **Backend**: .NET 10 Web API with SQL Server
|
||||
- **Frontend**: Next.js with TypeScript
|
||||
- **Architecture Style**: Layered architecture with SQL-first approach
|
||||
|
||||
## Diagrams
|
||||
|
||||
For visual representations, see:
|
||||
|
||||
- [architecture.svg](website/diagrams-out/architecture.svg) - Vertical-slice
|
||||
architecture diagram
|
||||
- [deployment.svg](website/diagrams-out/deployment.svg) - Docker deployment
|
||||
diagram
|
||||
- [authentication-flow.svg](website/diagrams-out/authentication-flow.svg) -
|
||||
Authentication workflow
|
||||
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
|
||||
relationships
|
||||
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram
|
||||
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram
|
||||
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication
|
||||
workflow
|
||||
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
|
||||
|
||||
Generate diagrams with: `make diagrams`
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Vertical Slice Architecture Pattern
|
||||
### Layered Architecture Pattern
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns
|
||||
its own controller, MediatR commands/queries/handlers, validators, and
|
||||
repository, end to end:
|
||||
The backend follows a strict layered architecture:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ API.Core (thin host) │
|
||||
│ - Program.cs wiring: MediatR + AddApplicationPart per slice │
|
||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
↓ discovers controllers via AddApplicationPart
|
||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
||||
│ │Breweries │ UserManagement │ (no controller, │
|
||||
│ Controller │ Controller │ Controller │ internal only) │
|
||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
||||
│ Handlers │ Handlers │ Handlers │ │
|
||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||
│ (ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions │
|
||||
│ │ cross-slice email cmds) │ │
|
||||
└─────────────────────────┴─────────────────────────┴────────────────────┘
|
||||
┌─────────────────────────────────────┐
|
||||
│ API Layer (Controllers) │
|
||||
│ - HTTP Endpoints │
|
||||
│ - Request/Response mapping │
|
||||
│ - Swagger/OpenAPI │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing, │
|
||||
│ Infrastructure.Email, Infrastructure.Email.Templates │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────┐
|
||||
│ Service Layer (Business Logic) │
|
||||
│ - Authentication logic │
|
||||
│ - User management │
|
||||
│ - Validation & orchestration │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Infrastructure Layer (Tools) │
|
||||
│ - JWT token generation │
|
||||
│ - Password hashing (Argon2id) │
|
||||
│ - Email services │
|
||||
│ - Repository implementations │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Domain Layer (Entities) │
|
||||
│ - UserAccount, UserCredential │
|
||||
│ - Pure POCO classes │
|
||||
│ - No external dependencies │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Database (SQL Server) │
|
||||
@@ -74,123 +67,75 @@ repository, end to end:
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Slices never reference each other's project. The one cross-slice
|
||||
interaction (`Features.Auth` triggering a confirmation email handled by
|
||||
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
|
||||
whose contract lives in `Shared.Application`, so neither slice takes a
|
||||
project reference on the other.
|
||||
|
||||
### Layer Responsibilities
|
||||
|
||||
#### API Layer (`API.Core`)
|
||||
|
||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
|
||||
its own
|
||||
**Purpose**: HTTP interface and request handling
|
||||
|
||||
**Components**:
|
||||
|
||||
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
|
||||
FluentValidation, and uses `AddApplicationPart` so each slice's controllers
|
||||
are discovered by MVC
|
||||
- `GlobalException.cs`: global exception filter (host-level cross-cutting
|
||||
concern)
|
||||
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
|
||||
- Swagger/OpenAPI documentation, health check endpoints
|
||||
- Controllers (`AuthController`, `UserController`)
|
||||
- Middleware for error handling
|
||||
- Swagger/OpenAPI documentation
|
||||
- Health check endpoints
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Every `Features.*` project (for controller/MediatR discovery)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- `Infrastructure.Jwt` (for the auth middleware)
|
||||
- Service layer
|
||||
- ASP.NET Core framework
|
||||
|
||||
**Rules**:
|
||||
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- Exists purely to host and wire up the feature slices
|
||||
- No business logic
|
||||
- Only request/response transformation
|
||||
- Delegates all work to Service layer
|
||||
|
||||
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
||||
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
|
||||
|
||||
**Purpose**: Each slice is the complete vertical for one business capability
|
||||
|
||||
**Components** (per slice):
|
||||
|
||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
|
||||
as the request contract
|
||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
|
||||
operation, each containing the Command/Query record, its
|
||||
`IRequestHandler`, and (for commands) a FluentValidation validator
|
||||
- `Repository/`: the slice's own stored-procedure repository
|
||||
implementation
|
||||
- `Dtos/`: response shapes returned by query handlers (never the raw
|
||||
domain entity)
|
||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
|
||||
the slice's repository/services
|
||||
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via
|
||||
MediatR commands sent from other slices, never over HTTP.
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- `Domain.Entities`, `Domain.Exceptions`
|
||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
|
||||
infrastructure project the slice needs (`Infrastructure.Jwt`/
|
||||
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
|
||||
`Infrastructure.Email.Templates` for Emails)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
- **Never** another `Features.*` project
|
||||
|
||||
**Rules**:
|
||||
|
||||
- All business logic for that feature lives in its command/query handlers
|
||||
- No direct controller-to-repository calls; everything flows through
|
||||
MediatR
|
||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||
|
||||
#### Shared Projects (`Shared.Contracts`, `Shared.Application`)
|
||||
|
||||
**Purpose**: The minimum cross-slice surface area required because every
|
||||
slice needs it, or because duplicating it four times would be worse than
|
||||
sharing it
|
||||
**Purpose**: Business logic and orchestration
|
||||
|
||||
**Components**:
|
||||
|
||||
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
||||
envelope every controller returns
|
||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
|
||||
MediatR pipeline behavior that runs FluentValidation before a handler
|
||||
executes) and the cross-slice email commands
|
||||
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
|
||||
- Authentication services (login, registration)
|
||||
- User management services
|
||||
- Business rule validation
|
||||
- Transaction coordination
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Infrastructure layer (repositories, JWT, password hashing)
|
||||
- Domain entities
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Kept deliberately small: this is the exception to "no slice depends on
|
||||
another slice," not a general-purpose dumping ground
|
||||
- Contains all business logic
|
||||
- Coordinates multiple infrastructure components
|
||||
- No direct database access (uses repositories)
|
||||
- Returns domain models, not DTOs
|
||||
|
||||
#### Infrastructure Layer
|
||||
|
||||
**Purpose**: Technical capabilities and external integrations, shared by
|
||||
whichever slices need them
|
||||
**Purpose**: Technical capabilities and external integrations
|
||||
|
||||
**Components**:
|
||||
|
||||
- **Infrastructure.Sql**: generic ADO.NET connection/command plumbing
|
||||
(`ISqlConnectionFactory`, the abstract `Repository<T>` base class), not
|
||||
domain-specific; each slice's own `Repository/` folder builds on this
|
||||
- **Infrastructure.Repository**: Data access via stored procedures
|
||||
- **Infrastructure.Jwt**: JWT token generation and validation
|
||||
- **Infrastructure.PasswordHashing**: Argon2id password hashing
|
||||
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
|
||||
- **Infrastructure.Email.Templates**: Email template rendering (Razor
|
||||
components)
|
||||
- **Infrastructure.Email**: Email sending capabilities
|
||||
- **Infrastructure.Email.Templates**: Email template rendering
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- Domain entities
|
||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
|
||||
- External libraries (ADO.NET, JWT, Argon2, etc.)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Implements technical concerns only, no business logic
|
||||
- Reusable across slices
|
||||
- Implements technical concerns
|
||||
- No business logic
|
||||
- Reusable across services
|
||||
|
||||
#### Domain Layer (`Domain.Entities`)
|
||||
|
||||
@@ -215,55 +160,21 @@ whichever slices need them
|
||||
|
||||
### Design Patterns
|
||||
|
||||
#### Vertical Slice + MediatR
|
||||
|
||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||
everything needed to understand or change one capability lives in one
|
||||
project
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in
|
||||
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (e.g. `GetBreweryByIdQuery`)
|
||||
- Controllers bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
- Query handlers map to a dedicated response `Dto`, so domain entities never
|
||||
leak over the wire
|
||||
|
||||
**Example**:
|
||||
|
||||
```csharp
|
||||
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
|
||||
: IRequest<BreweryDto>;
|
||||
|
||||
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Repository Pattern
|
||||
|
||||
**Purpose**: Abstract database access behind interfaces
|
||||
|
||||
**Implementation**: each slice owns its own repository, scoped to that
|
||||
feature only:
|
||||
**Implementation**:
|
||||
|
||||
- `Features.Auth/Repository/IAuthRepository.cs`
|
||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
||||
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
|
||||
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
|
||||
connection factory every slice's repository builds on
|
||||
- `IAuthRepository` - Authentication queries
|
||||
- `IUserAccountRepository` - User account queries
|
||||
- `DefaultSqlConnectionFactory` - Connection management
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- Testable (easy to mock)
|
||||
- SQL-first approach (stored procedures)
|
||||
- Each slice's data access logic is self-contained
|
||||
- Centralized data access logic
|
||||
|
||||
**Example**:
|
||||
|
||||
@@ -279,19 +190,17 @@ public interface IAuthRepository
|
||||
|
||||
**Purpose**: Loose coupling and testability
|
||||
|
||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
|
||||
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
|
||||
extension method that registers its repository and slice-internal services
|
||||
**Configuration**: `Program.cs` registers all services
|
||||
|
||||
**Lifetimes**:
|
||||
|
||||
- Scoped: Repositories, slice-internal services (per request)
|
||||
- Singleton: `ISqlConnectionFactory`
|
||||
- Scoped: Repositories, Services (per request)
|
||||
- Singleton: Connection factories, JWT configuration
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### SQL-First Approach
|
||||
|
||||
**Purpose**: Push complex logic into the database
|
||||
**Purpose**: Leverage database capabilities
|
||||
|
||||
**Strategy**:
|
||||
|
||||
@@ -308,51 +217,39 @@ extension method that registers its repository and slice-internal services
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Active Website (`web/frontend`)
|
||||
### Next.js Application Structure
|
||||
|
||||
The current website is a React Router 7 application with server-side rendering
|
||||
enabled.
|
||||
|
||||
```text
|
||||
web/frontend/
|
||||
├── app/
|
||||
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
||||
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
||||
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
||||
│ ├── root.tsx App shell and global providers
|
||||
│ └── app.css Theme tokens and global styling
|
||||
├── .storybook/ Storybook config and preview setup
|
||||
├── stories/ Storybook stories for shared UI and themes
|
||||
├── tests/playwright/ Storybook Playwright coverage
|
||||
└── package.json Frontend scripts and dependencies
|
||||
```
|
||||
Website/src/
|
||||
├── components/ # React components
|
||||
├── pages/ # Next.js routes
|
||||
├── contexts/ # React context providers
|
||||
├── hooks/ # Custom React hooks
|
||||
├── controllers/ # Business logic layer
|
||||
├── services/ # API communication
|
||||
├── requests/ # API request builders
|
||||
├── validation/ # Form validation schemas
|
||||
├── config/ # Configuration & env vars
|
||||
└── prisma/ # Database schema (current)
|
||||
```
|
||||
|
||||
### Frontend Responsibilities
|
||||
### Migration Strategy
|
||||
|
||||
- Render the auth demo and theme guide routes
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, and confirmation
|
||||
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||
- Supply Storybook documentation and browser-based component verification
|
||||
The frontend is **transitioning** from a standalone architecture to integrate with the
|
||||
.NET API:
|
||||
|
||||
### Theme System
|
||||
**Current State**:
|
||||
|
||||
The active website uses semantic DaisyUI theme tokens backed by four Biergarten
|
||||
themes:
|
||||
- Uses Prisma ORM with Postgres (Neon)
|
||||
- Has its own server-side API routes
|
||||
- Direct database access from Next.js
|
||||
|
||||
- Biergarten Lager
|
||||
- Biergarten Stout
|
||||
- Biergarten Cassis
|
||||
- Biergarten Weizen
|
||||
**Target State**:
|
||||
|
||||
All component styling should prefer semantic tokens such as `primary`,
|
||||
`success`, `surface`, and `highlight` instead of hard-coded color values.
|
||||
|
||||
### Legacy Frontend
|
||||
|
||||
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
||||
for reference only. Active product and engineering documentation should point
|
||||
to `web/frontend`.
|
||||
- Pure client-side Next.js app
|
||||
- All data via .NET API
|
||||
- No server-side database access
|
||||
- JWT-based authentication
|
||||
|
||||
## Security Architecture
|
||||
|
||||
@@ -477,18 +374,18 @@ scripts/
|
||||
- Testing (`docker-compose.test.yaml`)
|
||||
- Production (`docker-compose.prod.yaml`)
|
||||
|
||||
For details, see [Docker Guide](website/docker.md).
|
||||
For details, see [Docker Guide](docker.md).
|
||||
|
||||
### Health Checks
|
||||
|
||||
**SQL Server**: Validates database connectivity **API**: Checks service health
|
||||
and dependencies
|
||||
**SQL Server**: Validates database connectivity **API**: Checks service health and
|
||||
dependencies
|
||||
|
||||
**Configuration**:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||
test: ['CMD-SHELL', 'sqlcmd health check']
|
||||
interval: 10s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
@@ -503,10 +400,11 @@ healthcheck:
|
||||
│ Integration │ ← API.Specs (Reqnroll)
|
||||
│ Tests │
|
||||
├──────────────┤
|
||||
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
|
||||
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
|
||||
│ handlers + │ (commands/queries/handlers + that slice's own
|
||||
│ repository) │ repository, mocked with Moq/DbMocker)
|
||||
│ Unit Tests │ ← Service.Auth.Tests
|
||||
│ (Service) │ Repository.Tests
|
||||
├──────────────┤
|
||||
│ Unit Tests │
|
||||
│ (Repository) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
@@ -517,4 +415,4 @@ healthcheck:
|
||||
- Mock external dependencies
|
||||
- Test database for integration tests
|
||||
|
||||
For details, see [Testing Guide](website/testing.md).
|
||||
For details, see [Testing Guide](testing.md).
|
||||
|
||||
1
docs/diagrams-out/architecture.svg
Normal file
1
docs/diagrams-out/architecture.svg
Normal file
File diff suppressed because one or more lines are too long
75
docs/diagrams-src/architecture.puml
Normal file
75
docs/diagrams-src/architecture.puml
Normal file
@@ -0,0 +1,75 @@
|
||||
@startuml architecture
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title The Biergarten App - Layered Architecture
|
||||
|
||||
package "API Layer" #E3F2FD {
|
||||
[API.Core\nASP.NET Core Web API] as API
|
||||
note right of API
|
||||
- Controllers (Auth, User)
|
||||
- Swagger/OpenAPI
|
||||
- Middleware
|
||||
- Health Checks
|
||||
end note
|
||||
}
|
||||
|
||||
package "Service Layer" #F3E5F5 {
|
||||
[Service.Auth] as AuthSvc
|
||||
[Service.UserManagement] as UserSvc
|
||||
note right of AuthSvc
|
||||
- Business Logic
|
||||
- Validation
|
||||
- Orchestration
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #FFF3E0 {
|
||||
[Infrastructure.Repository] as Repo
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
[Infrastructure.Email] as Email
|
||||
}
|
||||
|
||||
package "Domain Layer" #E8F5E9 {
|
||||
[Domain.Entities] as Domain
|
||||
note right of Domain
|
||||
- UserAccount
|
||||
- UserCredential
|
||||
- UserVerification
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Stored Procedures] as SP
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
' Relationships
|
||||
API --> AuthSvc
|
||||
API --> UserSvc
|
||||
|
||||
AuthSvc --> Repo
|
||||
AuthSvc --> JWT
|
||||
AuthSvc --> PwdHash
|
||||
AuthSvc --> Email
|
||||
|
||||
UserSvc --> Repo
|
||||
|
||||
Repo --> SP
|
||||
Repo --> Domain
|
||||
SP --> Tables
|
||||
|
||||
AuthSvc --> Domain
|
||||
UserSvc --> Domain
|
||||
|
||||
' Notes
|
||||
note left of Repo
|
||||
SQL-first approach
|
||||
All queries via
|
||||
stored procedures
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -112,32 +112,38 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||
end note
|
||||
}
|
||||
|
||||
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
||||
component "xUnit + Moq + DbMocker" as UnitComp
|
||||
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
|
||||
component "xUnit + DbMocker" as RepoComp
|
||||
note right
|
||||
Builds Dockerfile.tests, which
|
||||
restores/builds against Core.slnx
|
||||
then runs `dotnet test` for every
|
||||
Features/*.Tests project in turn:
|
||||
- Features.Auth.Tests
|
||||
- Features.Breweries.Tests
|
||||
- Features.UserManagement.Tests
|
||||
- Features.Emails.Tests
|
||||
Tests:
|
||||
- AuthRepository
|
||||
- UserAccountRepository
|
||||
- SQL command building
|
||||
|
||||
Uses: Moq for handlers,
|
||||
DbMocker for repositories
|
||||
Uses: Mock connections
|
||||
No real database needed
|
||||
end note
|
||||
}
|
||||
|
||||
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
|
||||
component "xUnit + Moq" as SvcComp
|
||||
note right
|
||||
Tests:
|
||||
- RegisterService
|
||||
- LoginService
|
||||
- Token generation
|
||||
|
||||
Uses: Mocked dependencies
|
||||
No database or infrastructure
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
folder "test-results/\n(mounted volume)" as Results {
|
||||
file "api-specs/\n results.trx" as Result1
|
||||
file "Features.Auth.Tests.trx" as Result2
|
||||
file "Features.Breweries.Tests.trx" as Result3
|
||||
file "Features.UserManagement.Tests.trx" as Result4
|
||||
file "Features.Emails.Tests.trx" as Result5
|
||||
file "repository-tests/\n results.trx" as Result2
|
||||
file "service-auth-tests/\n results.trx" as Result3
|
||||
|
||||
note bottom
|
||||
TRX format
|
||||
@@ -164,16 +170,17 @@ DevAPI .up.> DevSeed : depends_on
|
||||
TestMig --> TestDB : 1. Migrate
|
||||
TestSeed --> TestDB : 2. Seed
|
||||
Specs --> TestDB : 3. Integration test
|
||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection)
|
||||
RepoTests ..> TestDB : Mock (no connection)
|
||||
SvcTests ..> TestDB : Mock (no connection)
|
||||
|
||||
TestMig .up.> TestDB : depends_on
|
||||
TestSeed .up.> TestMig : depends_on
|
||||
Specs .up.> TestSeed : depends_on
|
||||
UnitTests .up.> TestSeed : depends_on
|
||||
|
||||
' Test results export
|
||||
Specs --> Results : Export TRX
|
||||
UnitTests --> Results : Export TRX (one .trx per slice)
|
||||
RepoTests --> Results : Export TRX
|
||||
SvcTests --> Results : Export TRX
|
||||
|
||||
' Network notes
|
||||
note bottom of DevDB
|
||||
@@ -1,7 +1,7 @@
|
||||
# Docker Guide
|
||||
|
||||
This document covers Docker deployment, configuration, and troubleshooting for
|
||||
The Biergarten App.
|
||||
This document covers Docker deployment, configuration, and troubleshooting for The
|
||||
Biergarten App.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,8 +13,7 @@ The project uses Docker Compose to orchestrate multiple services:
|
||||
- .NET API
|
||||
- Test runners
|
||||
|
||||
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
||||
representation.
|
||||
See the [deployment diagram](diagrams/pdf/deployment.pdf) for visual representation.
|
||||
|
||||
## Docker Compose Environments
|
||||
|
||||
@@ -25,10 +24,9 @@ representation.
|
||||
**Features**:
|
||||
|
||||
- Persistent SQL Server volume
|
||||
- NuGet package cache volume (speeds up rebuilds)
|
||||
- Hot reload support
|
||||
- Swagger UI enabled
|
||||
- Seed data included
|
||||
- Local Mailpit SMTP server for dev email testing
|
||||
- `CLEAR_DATABASE=true` (drops and recreates schema)
|
||||
|
||||
**Services**:
|
||||
@@ -38,30 +36,28 @@ sqlserver # SQL Server 2022 (port 1433)
|
||||
database.migrations # DbUp migrations
|
||||
database.seed # Seed initial data
|
||||
api.core # Web API (ports 8080, 8081)
|
||||
mailpit # Local dev SMTP server + UI (port 8025)
|
||||
```
|
||||
|
||||
**Start Development Environment**:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
**Access**:
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
- Mailpit UI: http://localhost:8025
|
||||
- SQL Server: localhost:1433 (sa credentials from .env.dev)
|
||||
|
||||
**Stop Environment**:
|
||||
|
||||
```bash
|
||||
# Stop services (keep volumes)
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
|
||||
# Stop and remove volumes (fresh start)
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
```
|
||||
|
||||
### 2. Testing (`docker-compose.test.yaml`)
|
||||
@@ -83,22 +79,24 @@ sqlserver # Test database
|
||||
database.migrations # Fresh schema
|
||||
database.seed # Test data
|
||||
api.specs # Reqnroll BDD tests
|
||||
unit.tests # All Features.*.Tests unit test projects
|
||||
repository.tests # Repository unit tests
|
||||
service.auth.tests # Service unit tests
|
||||
```
|
||||
|
||||
**Run Tests**:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
|
||||
# View results
|
||||
ls -la test-results/
|
||||
cat test-results/api-specs/results.trx
|
||||
cat test-results/Features.Auth.Tests.trx
|
||||
cat test-results/repository-tests/results.trx
|
||||
cat test-results/service-auth-tests/results.trx
|
||||
|
||||
# Clean up
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
### 3. Production (`docker-compose.prod.yaml`)
|
||||
@@ -125,7 +123,7 @@ api.core # Production API
|
||||
**Deploy Production**:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
||||
docker compose -f docker-compose.prod.yaml up -d
|
||||
```
|
||||
|
||||
## Service Dependencies
|
||||
@@ -146,11 +144,7 @@ api.core / tests (start when ready)
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'",
|
||||
]
|
||||
test: ['CMD-SHELL', "sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
@@ -195,6 +189,13 @@ volumes:
|
||||
|
||||
Test results are written to host filesystem for CI/CD integration.
|
||||
|
||||
**Code Volumes** (development only):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./src:/app/src # Hot reload for development
|
||||
```
|
||||
|
||||
## Networks
|
||||
|
||||
Each environment uses isolated bridge networks:
|
||||
@@ -208,18 +209,16 @@ Each environment uses isolated bridge networks:
|
||||
All containers are configured via environment variables from `.env` files:
|
||||
|
||||
```yaml
|
||||
env_file: ".env.dev" # or .env.test, .env.prod
|
||||
env_file: '.env.dev' # or .env.test, .env.prod
|
||||
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "Development"
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
DB_SERVER: "${DB_SERVER}"
|
||||
DB_NAME: "${DB_NAME}"
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
ASPNETCORE_ENVIRONMENT: 'Development'
|
||||
DOTNET_RUNNING_IN_CONTAINER: 'true'
|
||||
DB_SERVER: '${DB_SERVER}'
|
||||
DB_NAME: '${DB_NAME}'
|
||||
DB_USER: '${DB_USER}'
|
||||
DB_PASSWORD: '${DB_PASSWORD}'
|
||||
JWT_SECRET: '${JWT_SECRET}'
|
||||
```
|
||||
|
||||
For complete list, see [Environment Variables](environment-variables.md).
|
||||
@@ -230,7 +229,7 @@ For complete list, see [Environment Variables](environment-variables.md).
|
||||
|
||||
```bash
|
||||
# Running services
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
|
||||
docker compose -f docker-compose.dev.yaml ps
|
||||
|
||||
# All containers (including stopped)
|
||||
docker ps -a
|
||||
@@ -240,13 +239,13 @@ docker ps -a
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
|
||||
docker compose -f docker-compose.dev.yaml logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
|
||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
||||
|
||||
# Last 100 lines
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
|
||||
docker compose -f docker-compose.dev.yaml logs --tail=100 api.core
|
||||
```
|
||||
|
||||
### Execute Commands in Container
|
||||
@@ -263,39 +262,39 @@ docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -
|
||||
|
||||
```bash
|
||||
# Restart all services
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
|
||||
docker compose -f docker-compose.dev.yaml restart
|
||||
|
||||
# Restart specific service
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
|
||||
docker compose -f docker-compose.dev.yaml restart api.core
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
|
||||
docker compose -f docker-compose.dev.yaml up -d --build api.core
|
||||
```
|
||||
|
||||
### Build Images
|
||||
|
||||
```bash
|
||||
# Build all images
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
|
||||
docker compose -f docker-compose.dev.yaml build
|
||||
|
||||
# Build specific service
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
|
||||
docker compose -f docker-compose.dev.yaml build api.core
|
||||
|
||||
# Build without cache
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
|
||||
docker compose -f docker-compose.dev.yaml build --no-cache
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Stop and remove containers
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
|
||||
# Remove containers and volumes
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
|
||||
# Remove containers, volumes, and images
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
|
||||
docker compose -f docker-compose.dev.yaml down -v --rmi all
|
||||
|
||||
# System-wide cleanup
|
||||
docker system prune -af --volumes
|
||||
405
docs/environment-variables.md
Normal file
405
docs/environment-variables.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Environment Variables
|
||||
|
||||
Complete documentation for all environment variables used in The Biergarten App.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses environment variables for configuration across:
|
||||
|
||||
- **.NET API Backend** - Database connections, JWT secrets
|
||||
- **Next.js Frontend** - External services, authentication
|
||||
- **Docker Containers** - Runtime configuration
|
||||
|
||||
## Configuration Patterns
|
||||
|
||||
### Backend (.NET API)
|
||||
|
||||
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
|
||||
validation.
|
||||
|
||||
### Docker
|
||||
|
||||
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
||||
|
||||
- `.env.dev` - Development
|
||||
- `.env.test` - Testing
|
||||
- `.env.prod` - Production
|
||||
|
||||
## Backend Variables (.NET API)
|
||||
|
||||
### Database Connection
|
||||
|
||||
**Option 1: Component-Based (Recommended for Docker)**
|
||||
|
||||
Build connection string from individual components:
|
||||
|
||||
```bash
|
||||
DB_SERVER=sqlserver,1433 # SQL Server host and port
|
||||
DB_NAME=Biergarten # Database name
|
||||
DB_USER=sa # SQL Server username
|
||||
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
|
||||
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
|
||||
```
|
||||
|
||||
**Option 2: Full Connection String (Local Development)**
|
||||
|
||||
Provide complete connection string:
|
||||
|
||||
```bash
|
||||
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
```
|
||||
|
||||
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection string is
|
||||
built from components.
|
||||
|
||||
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
||||
|
||||
### JWT Authentication Secrets (Backend)
|
||||
|
||||
The backend uses separate secrets for different token types to enable independent key rotation and validation isolation.
|
||||
|
||||
```bash
|
||||
# Access token secret (1-hour tokens)
|
||||
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
|
||||
|
||||
# Refresh token secret (21-day tokens)
|
||||
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
|
||||
|
||||
# Confirmation token secret (30-minute tokens)
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
|
||||
|
||||
# Website base URL (used in confirmation emails)
|
||||
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
```
|
||||
|
||||
**Security Requirements**:
|
||||
|
||||
- Each secret should be minimum 32 characters
|
||||
- Recommend 127+ characters for production
|
||||
- Generate using cryptographically secure random functions
|
||||
- Never reuse secrets across token types or environments
|
||||
- Rotate secrets periodically in production
|
||||
|
||||
**Generate Secrets**:
|
||||
|
||||
```bash
|
||||
# macOS/Linux - Generate 127-character base64 secret
|
||||
openssl rand -base64 127
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
||||
```
|
||||
|
||||
**Token Expiration**:
|
||||
|
||||
- **Access tokens**: 1 hour
|
||||
- **Refresh tokens**: 21 days
|
||||
- **Confirmation tokens**: 30 minutes
|
||||
|
||||
(Defined in `TokenServiceExpirationHours` class)
|
||||
|
||||
**JWT Implementation**:
|
||||
|
||||
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||
- **Validation**: Token signature, expiration, and malformed token checks
|
||||
|
||||
### Migration Control
|
||||
|
||||
```bash
|
||||
CLEAR_DATABASE=true
|
||||
```
|
||||
|
||||
- **Required**: No
|
||||
- **Default**: false
|
||||
- **Effect**: If "true", drops and recreates database during migrations
|
||||
- **Usage**: Development and testing environments ONLY
|
||||
- **Warning**: NEVER use in production
|
||||
|
||||
### ASP.NET Core Configuration
|
||||
|
||||
```bash
|
||||
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||
```
|
||||
|
||||
## Frontend Variables (Next.js)
|
||||
|
||||
Create `.env.local` in the `Website/` directory.
|
||||
|
||||
### Base Configuration
|
||||
|
||||
```bash
|
||||
BASE_URL=http://localhost:3000 # Application base URL
|
||||
NODE_ENV=development # Environment: development, production, test
|
||||
```
|
||||
|
||||
### Authentication & Sessions
|
||||
|
||||
```bash
|
||||
# Token signing secrets (use openssl rand -base64 127)
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Email confirmation tokens
|
||||
RESET_PASSWORD_TOKEN_SECRET=<generated-secret> # Password reset tokens
|
||||
SESSION_SECRET=<generated-secret> # Session cookie signing
|
||||
|
||||
# Session configuration
|
||||
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
|
||||
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
|
||||
```
|
||||
|
||||
**Security Requirements**:
|
||||
|
||||
- All secrets should be 127+ characters
|
||||
- Generate using cryptographically secure random functions
|
||||
- Never reuse secrets across environments
|
||||
- Rotate secrets periodically in production
|
||||
|
||||
### Database (Current - Prisma/Postgres)
|
||||
|
||||
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API.
|
||||
|
||||
```bash
|
||||
POSTGRES_PRISMA_URL=postgresql://user:pass@host/db?pgbouncer=true # Pooled connection
|
||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@host/db # Direct connection (migrations)
|
||||
SHADOW_DATABASE_URL=postgresql://user:pass@host/shadow_db # Prisma shadow DB (optional)
|
||||
```
|
||||
|
||||
### External Services
|
||||
|
||||
#### Cloudinary (Image Hosting)
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # Public, client-accessible
|
||||
CLOUDINARY_KEY=your-api-key # Server-side API key
|
||||
CLOUDINARY_SECRET=your-api-secret # Server-side secret
|
||||
```
|
||||
|
||||
**Setup Steps**:
|
||||
|
||||
1. Sign up at [cloudinary.com](https://cloudinary.com)
|
||||
2. Navigate to Dashboard
|
||||
3. Copy Cloud Name, API Key, and API Secret
|
||||
|
||||
**Note**: `NEXT_PUBLIC_` prefix makes variable accessible in client-side code.
|
||||
|
||||
#### Mapbox (Maps & Geocoding)
|
||||
|
||||
```bash
|
||||
MAPBOX_ACCESS_TOKEN=pk.your-public-token
|
||||
```
|
||||
|
||||
**Setup Steps**:
|
||||
|
||||
1. Create account at [mapbox.com](https://mapbox.com)
|
||||
2. Navigate to Account → Tokens
|
||||
3. Create new token with public scopes
|
||||
4. Copy access token
|
||||
|
||||
#### SparkPost (Email Service)
|
||||
|
||||
```bash
|
||||
SPARKPOST_API_KEY=your-api-key
|
||||
SPARKPOST_SENDER_ADDRESS=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Setup Steps**:
|
||||
|
||||
1. Sign up at [sparkpost.com](https://sparkpost.com)
|
||||
2. Verify sending domain or use sandbox
|
||||
3. Create API key with "Send via SMTP" permission
|
||||
4. Configure sender address (must match verified domain)
|
||||
|
||||
### Admin Account (Seeding)
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
|
||||
```
|
||||
|
||||
- **Required**: No (only needed for seeding)
|
||||
- **Purpose**: Sets admin account password during database seeding
|
||||
- **Security**: Use strong password, change immediately in production
|
||||
|
||||
## Docker-Specific Variables
|
||||
|
||||
### SQL Server Container
|
||||
|
||||
```bash
|
||||
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
|
||||
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
|
||||
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
|
||||
```
|
||||
|
||||
**Password Requirements**:
|
||||
|
||||
- Minimum 8 characters
|
||||
- Uppercase, lowercase, digits, and special characters
|
||||
- Maps to `DB_PASSWORD` for application containers
|
||||
|
||||
## Environment File Structure
|
||||
|
||||
### Backend/Docker (Root Directory)
|
||||
|
||||
```
|
||||
.env.example # Template (tracked in Git)
|
||||
.env.dev # Development config (gitignored)
|
||||
.env.test # Testing config (gitignored)
|
||||
.env.prod # Production config (gitignored)
|
||||
```
|
||||
|
||||
**Setup**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.dev
|
||||
# Edit .env.dev with your values
|
||||
```
|
||||
|
||||
**Docker Compose Mapping**:
|
||||
|
||||
- `docker-compose.dev.yaml` → `.env.dev`
|
||||
- `docker-compose.test.yaml` → `.env.test`
|
||||
- `docker-compose.prod.yaml` → `.env.prod`
|
||||
|
||||
### Frontend (Website Directory)
|
||||
|
||||
```
|
||||
.env.local # Local development (gitignored)
|
||||
.env.production # Production (gitignored)
|
||||
```
|
||||
|
||||
**Setup**:
|
||||
|
||||
```bash
|
||||
cd Website
|
||||
touch .env.local
|
||||
# Add frontend variables
|
||||
```
|
||||
|
||||
## Variable Reference Table
|
||||
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- |
|
||||
| **Database** |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| **Authentication (Backend - JWT)** |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| **Authentication (Frontend)** |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
|
||||
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Session signing |
|
||||
| `SESSION_TOKEN_NAME` | | ✓ | | No | Default: "biergarten" |
|
||||
| `SESSION_MAX_AGE` | | ✓ | | No | Default: 604800 |
|
||||
| **Base Configuration** |
|
||||
| `BASE_URL` | | ✓ | | Yes | App base URL |
|
||||
| `NODE_ENV` | | ✓ | | Yes | Node environment |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| **Database (Frontend - Current)** |
|
||||
| `POSTGRES_PRISMA_URL` | | ✓ | | Yes | Pooled connection |
|
||||
| `POSTGRES_URL_NON_POOLING` | | ✓ | | Yes | Direct connection |
|
||||
| `SHADOW_DATABASE_URL` | | ✓ | | No | Prisma shadow DB |
|
||||
| **External Services** |
|
||||
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | | ✓ | | Yes | Public, client-side |
|
||||
| `CLOUDINARY_KEY` | | ✓ | | Yes | Server-side |
|
||||
| `CLOUDINARY_SECRET` | | ✓ | | Yes | Server-side |
|
||||
| `MAPBOX_ACCESS_TOKEN` | | ✓ | | Yes | Maps/geocoding |
|
||||
| `SPARKPOST_API_KEY` | | ✓ | | Yes | Email service |
|
||||
| `SPARKPOST_SENDER_ADDRESS` | | ✓ | | Yes | From address |
|
||||
| **Other** |
|
||||
| `ADMIN_PASSWORD` | | ✓ | | No | Seeding only |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test only |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||
|
||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
||||
`DB_USER`, `DB_PASSWORD`) must be provided.
|
||||
|
||||
## Validation
|
||||
|
||||
### Backend Validation
|
||||
|
||||
Variables are validated at startup:
|
||||
|
||||
- Missing required variables cause application to fail
|
||||
- JWT_SECRET length is enforced (min 32 chars)
|
||||
- Connection string format is validated
|
||||
|
||||
### Frontend Validation
|
||||
|
||||
Zod schemas validate variables at runtime:
|
||||
|
||||
- Type checking (string, number, URL, etc.)
|
||||
- Format validation (email, URL patterns)
|
||||
- Required vs optional enforcement
|
||||
|
||||
**Location**: `src/Website/src/config/env/index.ts`
|
||||
|
||||
## Example Configuration Files
|
||||
|
||||
### `.env.dev` (Backend/Docker)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=Dev_Password_123!
|
||||
|
||||
# JWT Authentication Secrets
|
||||
ACCESS_TOKEN_SECRET=<generated-with-openssl>
|
||||
REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
|
||||
# Migration
|
||||
CLEAR_DATABASE=true
|
||||
|
||||
# ASP.NET Core
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
|
||||
# SQL Server Container
|
||||
SA_PASSWORD=Dev_Password_123!
|
||||
ACCEPT_EULA=Y
|
||||
MSSQL_PID=Express
|
||||
```
|
||||
|
||||
### `.env.local` (Frontend)
|
||||
|
||||
```bash
|
||||
# Base
|
||||
BASE_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Authentication
|
||||
SESSION_SECRET=<generated-with-openssl>
|
||||
|
||||
# Database (current Prisma setup)
|
||||
POSTGRES_PRISMA_URL=postgresql://user:pass@db.neon.tech/biergarten?pgbouncer=true
|
||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@db.neon.tech/biergarten
|
||||
|
||||
# External Services
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=my-cloud
|
||||
CLOUDINARY_KEY=123456789012345
|
||||
CLOUDINARY_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
MAPBOX_ACCESS_TOKEN=pk.eyJ...
|
||||
SPARKPOST_API_KEY=abc123...
|
||||
SPARKPOST_SENDER_ADDRESS=noreply@biergarten.app
|
||||
|
||||
# Admin (for seeding)
|
||||
ADMIN_PASSWORD=Admin_Dev_Password_123!
|
||||
```
|
||||
195
docs/getting-started.md
Normal file
195
docs/getting-started.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Getting Started
|
||||
|
||||
This guide will help you set up and run The Biergarten App in your development
|
||||
environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download)
|
||||
- **Node.js 18+** - [Download](https://nodejs.org/)
|
||||
- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop)
|
||||
(recommended)
|
||||
- **Java 8+** - Required for generating diagrams from PlantUML (optional)
|
||||
|
||||
## Quick Start with Docker (Recommended)
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd the-biergarten-app
|
||||
```
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.dev
|
||||
```
|
||||
|
||||
Edit `.env.dev` with your configuration:
|
||||
|
||||
```bash
|
||||
# Database (component-based for Docker)
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=YourStrong!Passw0rd
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=your-secret-key-minimum-32-characters-required
|
||||
```
|
||||
|
||||
> For a complete list of environment variables, see
|
||||
> [Environment Variables](environment-variables.md).
|
||||
|
||||
### 3. Start the Development Environment
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Start SQL Server container
|
||||
- Run database migrations
|
||||
- Seed initial data
|
||||
- Start the API on http://localhost:8080
|
||||
|
||||
### 4. Access the API
|
||||
|
||||
- **Swagger UI**: http://localhost:8080/swagger
|
||||
- **Health Check**: http://localhost:8080/health
|
||||
|
||||
### 5. View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.dev.yaml logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
||||
```
|
||||
|
||||
### 6. Stop the Environment
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
|
||||
# Remove volumes (fresh start)
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker)
|
||||
|
||||
If you prefer to run services locally without Docker:
|
||||
|
||||
### Backend Setup
|
||||
|
||||
#### 1. Start SQL Server
|
||||
|
||||
You can use a local SQL Server instance or a cloud-hosted one. Ensure it's accessible and
|
||||
you have the connection details.
|
||||
|
||||
#### 2. Set Environment Variables
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
export JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
||||
|
||||
# Windows PowerShell
|
||||
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
||||
```
|
||||
|
||||
#### 3. Run Database Migrations
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||
```
|
||||
|
||||
#### 4. Seed the Database
|
||||
|
||||
```bash
|
||||
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||
```
|
||||
|
||||
#### 5. Start the API
|
||||
|
||||
```bash
|
||||
dotnet run --project API/API.Core/API.Core.csproj
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5000 (or the port specified in
|
||||
launchSettings.json).
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
> **Note**: The frontend is currently transitioning from its standalone Prisma/Postgres
|
||||
> backend to the .NET API. Some features may still use the old backend.
|
||||
|
||||
#### 1. Navigate to Website Directory
|
||||
|
||||
```bash
|
||||
cd Website
|
||||
```
|
||||
|
||||
#### 2. Create Environment File
|
||||
|
||||
Create `.env.local` with frontend variables. See
|
||||
[Environment Variables - Frontend](environment-variables.md#frontend-variables) for the
|
||||
complete list.
|
||||
|
||||
```bash
|
||||
BASE_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Generate secrets
|
||||
CONFIRMATION_TOKEN_SECRET=$(openssl rand -base64 127)
|
||||
RESET_PASSWORD_TOKEN_SECRET=$(openssl rand -base64 127)
|
||||
SESSION_SECRET=$(openssl rand -base64 127)
|
||||
|
||||
# External services (you'll need to register for these)
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
||||
CLOUDINARY_KEY=your-api-key
|
||||
CLOUDINARY_SECRET=your-api-secret
|
||||
NEXT_PUBLIC_MAPBOX_KEY=your-mapbox-token
|
||||
|
||||
# Database URL (current Prisma setup)
|
||||
DATABASE_URL=your-postgres-connection-string
|
||||
```
|
||||
|
||||
#### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 4. Run Prisma Migrations
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
#### 5. Start Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will be available at http://localhost:3000.
|
||||
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints
|
||||
- **Run Tests**: See [Testing Guide](testing.md)
|
||||
- **Learn the Architecture**: Read [Architecture Overview](architecture.md)
|
||||
- **Understand Docker Setup**: See [Docker Guide](docker.md)
|
||||
- **Database Details**: Check [Database Schema](database.md)
|
||||
@@ -1,364 +0,0 @@
|
||||
# Ethics, Bias, and Known Issues
|
||||
|
||||
This document covers the ethical context of the Biergarten Pipeline's output,
|
||||
the model's biases, and known issues including hallucinated brewing science and
|
||||
low-resource language failures.
|
||||
|
||||
> Note that all testing was used using `google_gemma-4-E4B-it-Q6_K.gguf`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What This Dataset Is](#what-this-dataset-is)
|
||||
- [What This Dataset Is Not](#what-this-dataset-is-not)
|
||||
- [Model Bias and Language Quality](#model-bias-and-language-quality)
|
||||
- [Western and Eurocentric Lens](#western-and-eurocentric-lens)
|
||||
- [Wikipedia Enrichment](#wikipedia-enrichment)
|
||||
- [Names-by-Country Dataset](#names-by-country-dataset)
|
||||
- [The "Avoid AI Phrases" Prompt Instruction](#the-avoid-ai-phrases-prompt-instruction)
|
||||
- [Known Issues](#known-issues)
|
||||
- [Hallucinated Brewing Techniques](#hallucinated-brewing-techniques)
|
||||
- [Low-Resource Language Hallucination](#low-resource-language-hallucination)
|
||||
|
||||
---
|
||||
|
||||
## What This Dataset Is
|
||||
|
||||
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
|
||||
pipeline must be told upfront that the content is AI-generated.
|
||||
|
||||
---
|
||||
|
||||
## What This Dataset Is Not
|
||||
|
||||
The pipeline is not intended to produce accurate brewing science, faithful
|
||||
cultural representation, or reliable local-language text. Hallucinations such as
|
||||
invented fermentation techniques, or incoherent local-language prose, are
|
||||
expected, observed, and partially documented in [Known Issues](#known-issues)
|
||||
below.
|
||||
|
||||
Human control sits at the context layer (i.e. prompt design, Wikipedia
|
||||
enrichment). Statistical output shapes in future pipeline stages (check-in
|
||||
distributions, rating skews, activity profiles) will be handled the same way.
|
||||
|
||||
**Treat this data as an exercise in prompt engineering and model behaviour, not
|
||||
as a source of truth for brewing techniques or cultural representation.**
|
||||
|
||||
**Natural language processing, although a powerful tool for data analysis and
|
||||
generation is to be taken with scrutiny. Human language is not simply just data
|
||||
points to be analyzed, but it also carries deep cultural and human meaning that
|
||||
artificial intelligence is incapable of.**
|
||||
|
||||
---
|
||||
|
||||
## Model Bias and Language Quality
|
||||
|
||||
The underlying model's training biases surface within this pipeline. Output
|
||||
quality tracks with how well a language is represented in the training corpus:
|
||||
standard French (`fr-FR`) produces coherent text; regional variants like `fr-CD`
|
||||
and `fr-CI` are noticeably weaker; low-resource languages like Welsh, Māori, and
|
||||
Sicilian produce output that is syntactically plausible but often semantically
|
||||
broken.
|
||||
|
||||
This is a property of the training distribution, not something that can be
|
||||
mitigated through prompt design. This is a well-documented characteristic of
|
||||
large language models trained predominantly on English-language
|
||||
material.[^llm-bias]
|
||||
|
||||
Mitigations are documented in
|
||||
[Known Issues: Low-Resource Language Hallucination](#low-resource-language-hallucination).
|
||||
|
||||
### Western and Eurocentric Lens
|
||||
|
||||
The model's training data skews heavily Western and North American. When
|
||||
generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it
|
||||
defaults to framing and cultural reference points drawn from that perspective
|
||||
rather than from the lived context of those cities. Wikipedia enrichment grounds
|
||||
some generation in city-specific material, but it does not eliminate the skew.
|
||||
|
||||
**Output should be read with an understanding of this bias.**
|
||||
|
||||
---
|
||||
|
||||
## Wikipedia Enrichment
|
||||
|
||||
City and beer context is fetched from the Wikipedia API. Wikipedia text is
|
||||
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)** and the **GNU Free Documentation License
|
||||
(GFDL)**.[^wp-license]
|
||||
|
||||
Wikipedia's own accuracy limitations and editorial biases can propagate into
|
||||
generated descriptions.
|
||||
|
||||
---
|
||||
|
||||
## 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 `cities.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 `cities.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
|
||||
patterns. This is a prompt engineering experiment:
|
||||
|
||||
> How far can a model be pushed against its own stylistic defaults?
|
||||
|
||||
This is not an attempt to disguise the content as human-written. All downstream
|
||||
consumers are informed of the AI-generated origin before engagement.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Hallucinated Brewing Techniques
|
||||
|
||||
When forced by the system prompt to generate a "highly specialized technical
|
||||
brewing detail," the model frequently hallucinates fermentation science and
|
||||
brewing chemistry. While the resulting sounds confident, it will be nonsensical
|
||||
to reader with brewing and/or scientific expertise.
|
||||
|
||||
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
||||
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
||||
failure mode (Certain Hallucinations Overriding Known Evidence) [^llm-choke]
|
||||
where a specific-sounding prompt causes a model to produce confident, coherent,
|
||||
and factually wrong output.
|
||||
|
||||
#### Example — Osaka, produced using Gemma 4 E4B
|
||||
|
||||
```log
|
||||
[2026-04-21 15:04:40.258] [info] 11. city="Osaka" country="Japan" state="Osaka" iso3166_2=JP-27 lat=34.6937 lon=135.5023
|
||||
[2026-04-21 15:04:40.258] [info] brewery_name_en="Kani-no-Kuni Brewing"
|
||||
[2026-04-21 15:04:40.258] [info] brewery_description_en="The humid scent of takoyaki and savory grilled squid always settles over the neighborhood early in the morning, mingling strangely with the metallic tang of spent grain outside our drying shed. We decided to build this place here because Osaka is a city that never pauses, a place built around the constant exchange of goods and tastes, and that is what we want our beer to reflect. Many people here are accustomed to the dependable, clean taste of the major pale lagers, but we are interested in exploring the deeper, more textured expressions of fermentation. Our base malts are specifically crafted by incorporating strains of local rice, like Yamada Nishiki, which we malt and use not for sake, but for its complex, buttery flavor profile during the mash. This combination, followed by a slow, anaerobic aging process, gives our beers a profound, satisfying depth that stands apart from the hurried mass-market style. The heavy, deep red brick of the original warehouse wall has absorbed decades of Kansai humidity and seasonal rains, creating a patina that tells the exact story of this district's tireless movement. We chose this structure not for its charm, but for its resilience and the sheer density of the local history held within its mortar. Our goal is simply to serve a drink worthy of this powerful trading city. If you are looking for a quiet spot away from the main thoroughfare, look for us just off the side street near Shinsekai."
|
||||
[2026-04-21 15:04:40.258] [info] brewery_name_local="カニの国ブルワリー"
|
||||
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
|
||||
```
|
||||
|
||||
A review of the following text for brewing techniques reveals several
|
||||
inaccuracies, and no comments could be made on the local-language version due to
|
||||
my own lack of proficiency in Japanese:
|
||||
|
||||
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour
|
||||
|
||||
**Incorrect.**
|
||||
|
||||
Diacetyl is a fermentation byproduct of yeast metabolism, not a malt-derived
|
||||
compound.[^diacetyl-source] Diacetyl produces a buttery or butterscotch
|
||||
off-flavour and is carefully managed in many beer styles, in particular lighter
|
||||
beers, through a process called a _diacetyl rest_. In this process, fermentation
|
||||
temperature is briefly raised to allow yeast to reabsorb the compound before
|
||||
packaging.[^diacetyl-rest]
|
||||
|
||||
The Oxford Companion to Beer claims that, while low levels are tolerable in some
|
||||
ales and stouts, diacetyl is considered undesirable at any perceptible
|
||||
concentration when it results from bacterial contamination or stressed
|
||||
fermentation.[^oxford-beer]
|
||||
|
||||
#### 2. Yamada Nishiki sake rice described as a self-saccharifying base malt
|
||||
|
||||
**Incorrect.**
|
||||
|
||||
Yamada Nishiki (_山田錦_) is a short-grain Japanese rice bred specifically for
|
||||
sake production.[^yn-wiki] Its value lies in its large starchy core
|
||||
(_shinpaku_), low protein content, and amenability to _koji_ mold penetration
|
||||
during saccharification.[^yn-sakestreet] Sake brewing does not use the grain's
|
||||
own enzymatic activity for saccharification — it relies on _Aspergillus oryzae_
|
||||
(koji mold) grown on a portion of the steamed rice to convert starches to
|
||||
fermentable sugars.[^yn-sakeonline]
|
||||
|
||||
#### 3. "Anaerobic aging" presented as a differentiating technique
|
||||
|
||||
**Misleading**
|
||||
|
||||
Anaerobic conditions during packaging and aging are not differentiating
|
||||
technique. Anaerobic conditions are the standard baseline for all commercial
|
||||
beer production. Breweries exclude oxygen as a top priority for packaging and
|
||||
shelf stability; published research in _Microbiology Spectrum_ confirms that
|
||||
packaged beer constitutes an anaerobic environment by definition.[^anaerobic]
|
||||
Professional packaging lines use CO_2 purges and closed transfers specifically
|
||||
to maintain this state.[^packaging] Framing anaerobic aging as a distinctive
|
||||
practice is misleading and suggests hallucinated output.
|
||||
|
||||
### Low-Resource Language Hallucination
|
||||
|
||||
The generation pipeline passes local language codes to the model to retrieve a
|
||||
translated `description_local`. Output quality is reliable for high-resource
|
||||
languages such as French, though it may struggle with regional variants and
|
||||
idiomatic phrasing.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"city": "Kinshasa",
|
||||
"state_province": "Kinshasa",
|
||||
"iso3166_2": "CD-KN",
|
||||
"country": "Democratic Republic of the Congo",
|
||||
"iso3166_1": "CD",
|
||||
"latitude": -4.4419,
|
||||
"longitude": 15.2663,
|
||||
"local_languages": ["fr-CD", "ln"]
|
||||
},
|
||||
{
|
||||
"city": "Paris",
|
||||
"state_province": "Île-de-France",
|
||||
"iso3166_2": "FR-IDF",
|
||||
"country": "France",
|
||||
"iso3166_1": "FR",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"local_languages": ["fr-FR"]
|
||||
},
|
||||
{
|
||||
"city": "Abidjan",
|
||||
"state_province": "Abidjan",
|
||||
"iso3166_2": "CI-AB",
|
||||
"country": "Ivory Coast",
|
||||
"iso3166_1": "CI",
|
||||
"latitude": 5.36,
|
||||
"longitude": -4.0083,
|
||||
"local_languages": ["fr-CI"]
|
||||
},
|
||||
{
|
||||
"city": "Montreal",
|
||||
"state_province": "Quebec",
|
||||
"iso3166_2": "CA-QC",
|
||||
"country": "Canada",
|
||||
"iso3166_1": "CA",
|
||||
"latitude": 45.5017,
|
||||
"longitude": -73.5673,
|
||||
"local_languages": ["fr-CA"]
|
||||
},
|
||||
{
|
||||
"city": "Brussels",
|
||||
"state_province": "Brussels-Capital Region",
|
||||
"iso3166_2": "BE-BRU",
|
||||
"country": "Belgium",
|
||||
"iso3166_1": "BE",
|
||||
"latitude": 50.8503,
|
||||
"longitude": 4.3517,
|
||||
"local_languages": ["fr-BE", "nl-BE"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This dataset, when fed into the pipeline will often times reason that a local
|
||||
variant of French is needed, but will often times just default to a standardized
|
||||
dialect of French, devoid of any cultural or linguistic nuance.
|
||||
|
||||
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
||||
(Sicily, Italy), the model can generate text that looks syntactically plausible
|
||||
but is semantically incoherent. This comes from limited training-data coverage
|
||||
rather than prompt engineering.
|
||||
|
||||
Output sample:
|
||||
[./french-cities.example](french-cities.example)
|
||||
|
||||
#### Proposed Mitigations
|
||||
|
||||
- **Prevention via allowlist:** introduce a high-resource language allowlist. If
|
||||
a location's code is unlisted, skip `description_local` generation and fall
|
||||
back to English.
|
||||
- **Upstream sanitization:** strip known low-resource language codes from the
|
||||
`cities.json` payload before generation.
|
||||
- **Downstream flagging:** add a `description_local_confidence` column to the
|
||||
SQLite schema so downstream applications can filter or flag potentially
|
||||
hallucinated text by language tier.
|
||||
|
||||
---
|
||||
|
||||
## Footnotes
|
||||
|
||||
[^llm-choke]:
|
||||
CHOKE (Certain Hallucinations Overriding Known Evidence) is a hallucination
|
||||
failure mode defined by Simhi et al. (2025), in which a model that can
|
||||
consistently answer a question correctly produces a confident, wrong
|
||||
response when the prompt is trivially perturbed. Source: Trust Me, I'm
|
||||
Wrong: LLMs Hallucinate with Certainty Despite Knowing the Answer — Adi
|
||||
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
||||
|
||||
[^llm-bias]:
|
||||
e.g., Blasi et al. (2022), "Systematic Inequalities in Language Technology
|
||||
Performance across the World's Languages," _ACL Anthology_. The pattern is
|
||||
consistent with models trained predominantly on English-language web
|
||||
corpora.
|
||||
|
||||
[^wp-license]:
|
||||
Source:
|
||||
[Wikipedia:FAQ/Copyright](https://en.wikipedia.org/wiki/Wikipedia:FAQ/Copyright).
|
||||
|
||||
[^cc-sa]:
|
||||
Creative Commons CC BY-SA 4.0 deed: "If you remix, transform, or build upon
|
||||
the material, you must distribute your contributions under the same license
|
||||
as the original." Source:
|
||||
[creativecommons.org/licenses/by-sa/4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.en).
|
||||
|
||||
[^diacetyl-source]:
|
||||
White Labs confirms that diacetyl is a yeast-derived fermentation byproduct:
|
||||
specifically, a compound produced during amino acid metabolism that leaks
|
||||
out of the yeast cell and oxidises into its characteristic buttery
|
||||
off-flavour. It is generally considered undesirable at any perceived level
|
||||
in most styles, though low levels are tolerated in some English ales and
|
||||
European lagers. Source:
|
||||
[whitelabs.com — Compound Spotlight: Diacetyl](https://www.whitelabs.com/news-update-detail?id=54).
|
||||
|
||||
[^diacetyl-rest]:
|
||||
Brewing Science Institute: diacetyl "is produced during the fermentation
|
||||
process, primarily as a byproduct of yeast metabolism… generally considered
|
||||
a flaw in most beer styles." Source:
|
||||
[brewingscience.com — Diacetyl: Understanding Its Role as an Off-Flavor in Beer](https://brewingscience.com/diacetyl-understanding-its-role-as-an-off-flavor-in-beer/).
|
||||
|
||||
[^oxford-beer]:
|
||||
Oxford Companion to Beer via _Beer & Brewing_: "At low to moderate levels,
|
||||
diacetyl can be perceived as a positive flavor characteristic in some ales
|
||||
and stouts" but "particularly unwelcome in lager-style beers." Source:
|
||||
[beerandbrewing.com — diacetyl](https://www.beerandbrewing.com/dictionary/48TDqQibPi).
|
||||
|
||||
[^yn-wiki]:
|
||||
Wikipedia: "Yamada Nishiki (山田錦) is a short-grain Japanese rice famous
|
||||
for its use in high-quality sake." Source:
|
||||
[en.wikipedia.org/wiki/Yamada_Nishiki](https://en.wikipedia.org/wiki/Yamada_Nishiki).
|
||||
|
||||
[^yn-sakestreet]:
|
||||
Sake Street: Yamadanishiki's large _shinpaku_ allows koji mold to penetrate
|
||||
to the centre of the rice grain, making it "particularly suitable for
|
||||
producing good koji." Source:
|
||||
[sakestreet.com — What is Yamadanishiki?](https://sakestreet.com/en/media/what-is-yamadanishiki).
|
||||
|
||||
[^yn-sakeonline]:
|
||||
Sake Online: "Steamed rice is added to make koji (rice malt) and yeast
|
||||
starter, which promotes alcohol fermentation." Source:
|
||||
[sakeonline.com.au — Types of Sake Rice: Yamada Nishiki](https://sakeonline.com.au/blogs/news/types-of-sake-rice-yamada-nishiki-and-its-characteristics).
|
||||
|
||||
[^anaerobic]:
|
||||
Pai et al. (2022): "Breweries have recognized oxygen exclusion as a top
|
||||
priority for the proper packaging and aging of beer… packaged beer is an
|
||||
anaerobic environment." _Microbiology Spectrum._ Source:
|
||||
[journals.asm.org](https://journals.asm.org/doi/10.1128/spectrum.02656-22).
|
||||
|
||||
[^packaging]:
|
||||
Beer Production Processes (oboe.com): Professional packaging lines use
|
||||
double CO_2 pre-evacuation cycles and closed transfers "so the beer moves in
|
||||
a completely anaerobic environment." Source:
|
||||
[oboe.com — Flavor Quality Control](https://oboe.com/learn/beer-production-processes-308lmf/flavor-quality-control-4).
|
||||
@@ -1,517 +0,0 @@
|
||||
# Biergarten Pipeline
|
||||
|
||||
A C++20 command-line pipeline that samples city records from local JSON,
|
||||
enriches each with Wikipedia context, and generates bilingual brewery names
|
||||
and 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
|
||||
> brewing techniques, cultural representation, or local-language accuracy. See
|
||||
> [ETHICS-AND-KNOWN-ISSUES.md](./ETHICS-AND-KNOWN-ISSUES.md) for a full
|
||||
> documentation of limitations, hallucination patterns, and bias.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [How It Fits The Main App](#how-it-fits-the-main-app)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Build](#build)
|
||||
- [Model](#model)
|
||||
- [Run](#run)
|
||||
- [Docker / RunPod](#docker--runpod)
|
||||
- [Architecture](#architecture)
|
||||
- [Pipeline Stages](#pipeline-stages)
|
||||
- [Key Components](#key-components)
|
||||
- [Runtime Behaviour](#runtime-behaviour)
|
||||
- [Generated Output](#generated-output)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Tested Hardware](#tested-hardware)
|
||||
- [Fixture Strategy](#fixture-strategy)
|
||||
- [Repo Layout](#repo-layout)
|
||||
- [Code Tour](#code-tour)
|
||||
- [Next Steps](#next-steps)
|
||||
|
||||
---
|
||||
|
||||
## How It Fits The Main App
|
||||
|
||||
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
|
||||
step.
|
||||
|
||||
| Planned app area | Pipeline contribution |
|
||||
| -------------------------------- | ------------------------------------------------------------------ |
|
||||
| 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 |
|
||||
| Social follow relationships | Repeatable brewery entities for feeds, follows, and saved lists |
|
||||
| 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
|
||||
|
||||
### Build
|
||||
|
||||
Requirements: C++20 compiler, CMake 3.31+, OpenSSL, Boost (JSON and
|
||||
ProgramOptions). SQLite is fetched from the upstream amalgamation, so no system
|
||||
SQLite package is required.
|
||||
|
||||
```bash
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
CMake automatically detects whether a compatible llama.cpp installation is
|
||||
present on the system (`libllama`, `libggml`, `libggml-base`, and `llama.h`
|
||||
visible on the default search paths). If found, it links against those
|
||||
libraries and skips the FetchContent build. If not found, it fetches and builds
|
||||
llama.cpp from source at tag `b9012`. No additional flags are required in
|
||||
either case.
|
||||
|
||||
Metal is enabled automatically on Apple Silicon. CUDA or HIP/ROCm is detected
|
||||
automatically on Linux when the relevant toolkit is present.
|
||||
|
||||
### Model
|
||||
|
||||
> Skip this step if you only need `--mocked`.
|
||||
|
||||
```bash
|
||||
mkdir -p models
|
||||
curl -L \
|
||||
-o models/google_gemma-4-E4B-it-Q6_K.gguf \
|
||||
https://huggingface.co/bartowski/google_gemma-4-E4B-it-GGUF/resolve/main/google_gemma-4-E4B-it-Q6_K.gguf?download=true
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
Run from `build/` so the copied `cities.json` and `prompts/` are available.
|
||||
Each run writes a fresh dated SQLite file such as
|
||||
`biergarten_seed_2026-04-19T15-30-45.123456Z.sqlite` into the working directory.
|
||||
|
||||
```bash
|
||||
./biergarten-pipeline --mocked
|
||||
|
||||
./biergarten-pipeline \
|
||||
--model ../models/google_gemma-4-E4B-it-Q6_K.gguf \
|
||||
--prompt-dir prompts \
|
||||
--location-count 25 \
|
||||
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
|
||||
```
|
||||
|
||||
#### CLI Flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--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. |
|
||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--location-count` | Number of cities to sample from `cities.json` per run. Default: `10`. |
|
||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||
| `--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. |
|
||||
|
||||
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
|
||||
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
||||
set.
|
||||
|
||||
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
|
||||
editing any prompt file.
|
||||
|
||||
---
|
||||
|
||||
## Docker / RunPod
|
||||
|
||||
The `tooling/pipeline/runpod/` directory contains a GPU-ready container
|
||||
configuration for running the pipeline on RunPod or any Docker host with an
|
||||
NVIDIA GPU.
|
||||
|
||||
### How it works
|
||||
|
||||
The container uses a two-stage build. The builder stage installs CMake/Ninja,
|
||||
clones the matching llama.cpp release tag for its headers only (installed into
|
||||
`/usr/local/include`), and copies prebuilt shared libraries (`libllama`,
|
||||
`libggml`, and CUDA/CPU backend plugins) from `ghcr.io/ggml-org/llama.cpp:full-cuda`
|
||||
into `/usr/local/lib`. With both headers and libraries present, CMake's
|
||||
system-library detection (see [Build](#build) above) finds them and skips the
|
||||
FetchContent source build, keeping image build times short.
|
||||
|
||||
The runtime stage copies the compiled binary, the same prebuilt shared
|
||||
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
|
||||
|
||||
Run from the `tooling/pipeline/` directory (the CMake project root), not from
|
||||
inside `runpod/`, so the `COPY . .` step picks up the full project context.
|
||||
|
||||
```bash
|
||||
docker build -t biergarten-pipeline:latest -f runpod/Dockerfile .
|
||||
```
|
||||
|
||||
To monitor the full build output and confirm CMake selects the system llama.cpp:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--no-cache \
|
||||
-t biergarten-pipeline:latest \
|
||||
-f runpod/Dockerfile \
|
||||
. 2>&1 | tee build.log
|
||||
```
|
||||
|
||||
Look for `[biergarten] Found system llama.cpp — skipping FetchContent` in the
|
||||
output to confirm the fast path was taken.
|
||||
|
||||
### Run the container
|
||||
|
||||
The container always runs the model-backed path; there is no `--mocked`
|
||||
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 is not already present at the configured path.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--runtime=nvidia \
|
||||
-v "$PWD/models:/workspace/models" \
|
||||
-v "$PWD/output:/workspace/output" \
|
||||
-v "$PWD/logs:/workspace/logs" \
|
||||
biergarten-pipeline:latest
|
||||
```
|
||||
|
||||
By default this downloads `google_gemma-4-E4B-it-Q6_K.gguf` to
|
||||
`./models/` on 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
|
||||
|
||||
Use a GPU pod template. Mount persistent storage for `/workspace/models`,
|
||||
`/workspace/output`, and `/workspace/logs`. See
|
||||
`tooling/pipeline/runpod/pod-template.yaml` for a starter template — set the
|
||||
environment variables listed above to match your run.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
| Stage | Implementation |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Load | `ICuratedDataService` (`CuratedJsonDataService`) reads `cities.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 | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). |
|
||||
| Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. |
|
||||
| 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. |
|
||||
| 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 `cities`, `users`, and `breweries` tables. |
|
||||
| Log | `spdlog` writes results and warnings to the console. |
|
||||
|
||||
If name sampling, enrichment, or generation fails for a city, that city is
|
||||
skipped and the pipeline continues. `GenerateUsers()` runs before
|
||||
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
|
||||
|
||||
### Key Components
|
||||
|
||||
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
||||
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
|
||||
`CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
|
||||
its constructor, then parses and validates curated location, persona, and
|
||||
forename/surname JSON, memoizing each result after its first load on a
|
||||
given instance. Each `cities.json` entry's `postal_code.city_regex` and
|
||||
`postal_code.examples` are parsed into `City::postal_regex` and
|
||||
`City::postal_code_examples`. `MockCuratedDataService` is the in-memory
|
||||
substitute (4 fixed locations, 3 personas, and name data for
|
||||
`US`/`DE`/`FR`/`BE`) used in `--mocked` runs, and carries matching
|
||||
`postal_regex`/`postal_code_examples` values for its 4 locations.
|
||||
- `WikipediaEnrichmentService` — queries Wikipedia extracts, caches results,
|
||||
returns empty context on failure. `MockEnrichmentService` is the no-op
|
||||
substitute used in `--mocked` runs.
|
||||
- `IPostalCodeService` — generates a postal code for a `City`, consumed by
|
||||
`GenerateBreweries()` and stored on `BreweryRecord::address` (an `Address`
|
||||
struct, currently just `postal_code`, mirroring the web backend's
|
||||
`BreweryPostLocation`). Only `MockPostalCodeService` exists today, which
|
||||
ignores `postal_regex` and returns `postal_code_examples.front()` — it's
|
||||
wired into the Boost.DI graph unconditionally (no `--mocked` branch yet,
|
||||
since there's no real implementation to switch to). A real implementation
|
||||
still needs a **xeger**-style generator — turning a `postal_regex` pattern
|
||||
into a random matching string — instead of always replaying a fixed
|
||||
example; see [ROADMAP.md §9](./ROADMAP.md#9-postal-code-generation).
|
||||
Street-address generation (`Address::address_line1`) has no fixture data or
|
||||
service yet and remains future work.
|
||||
- `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 successful user and brewery into normalized tables.
|
||||
- Brewery payloads include English and local-language name and description
|
||||
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
|
||||
|
||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a
|
||||
generic "brewing" extract and a "beer in `{country}`" extract. It does not
|
||||
currently query a city- or region-specific page. Each query string is cached
|
||||
after its first successful (or empty) lookup.
|
||||
|
||||
`GetLocationContext()` returns an empty string when the web client is
|
||||
unavailable or when lookup/parsing fails.
|
||||
|
||||
`LlamaGenerator` validates model output as structured JSON. On validation
|
||||
failure it retries up to three times, replaying the previous error message in
|
||||
the next prompt so the model can self-correct. All runs to date have produced
|
||||
valid output on the first pass; the retry path is kept for resilience.
|
||||
|
||||
`MockGenerator` uses stable hashes for repeatable output in demos and Storybook
|
||||
runs.
|
||||
|
||||
`CuratedJsonDataService` memoizes each of `LoadCities()`, `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 `cities.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
|
||||
|
||||

|
||||
|
||||
### Architectural Overview - Class Diagram
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Generated Output
|
||||
|
||||
Each successful run stores a `BreweryRecord` (source `City`, an `Address`,
|
||||
and a `BreweryResult` payload), and a `UserRecord` pair with the source
|
||||
`City` and a `UserResult` payload. The same generated records are also
|
||||
written to a fresh SQLite export file named with the current UTC timestamp.
|
||||
|
||||
| Field | Meaning |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
| `name_en` | Brewery name in English. |
|
||||
| `description_en` | Brewery description in English. |
|
||||
| `name_local` | Brewery name in the local language. |
|
||||
| `description_local` | Brewery description in the local language. |
|
||||
| `postal_code` | Postal code generated for the brewery's city (see `IPostalCodeService`, above). |
|
||||
|
||||
| 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
|
||||
code, latitude, and longitude for each entry.
|
||||
|
||||
### Consumer Data Shape
|
||||
|
||||
| Field | Why it matters |
|
||||
| ----------------------------------- | ------------------------------------------------ |
|
||||
| `city`, `state_province`, `country` | Human-readable location labels and page headings |
|
||||
| `iso3166_1`, `iso3166_2` | Filtering, regional grouping, locale matching |
|
||||
| `latitude`, `longitude` | Map pins and nearby brewery views |
|
||||
| `local_languages` | Locale-aware copy selection |
|
||||
| `name_en`, `description_en` | Default English display content |
|
||||
| `name_local`, `description_local` | Local-language display content |
|
||||
| `postal_code` | Brewery address matching, mirrors the web backend's `BreweryPostLocation.PostalCode` |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- C++20
|
||||
- CMake 3.31+
|
||||
- Boost.JSON, Boost.ProgramOptions, Boost.DI
|
||||
- spdlog
|
||||
- cpp-httplib (with OpenSSL)
|
||||
- SQLite amalgamation fetched and compiled via CMake FetchContent
|
||||
- llama.cpp (auto-detected from system install or fetched via FetchContent)
|
||||
- Docker with NVIDIA CUDA 12.6 base image for GPU container builds
|
||||
- RunPod for cloud GPU inference
|
||||
|
||||
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
|
||||
Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||
|
||||
> **Code Style:** Modern C++20 throughout — RAII for ownership,
|
||||
> `std::unique_ptr` for injected dependencies, `std::optional` for parse
|
||||
> outcomes, `std::span` for read-only views over generated city data, structured
|
||||
> 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
|
||||
|
||||
### ARM macOS — M1 Pro
|
||||
|
||||
| | |
|
||||
| --------- | --------------------------------- |
|
||||
| Host | MacBook Pro 14" (2021) |
|
||||
| CPU | Apple M1 Pro (8-core) |
|
||||
| GPU | Apple M1 Pro (14-core integrated) |
|
||||
| Memory | 16 GB |
|
||||
| Model | Gemma 4 E4B |
|
||||
| Inference | llama.cpp with Metal |
|
||||
|
||||
### x86_64 Linux — NVIDIA RTX 2000
|
||||
|
||||
| | |
|
||||
| --------- | ------------------------------ |
|
||||
| Host | ThinkPad P1 Gen 7 (Fedora 43) |
|
||||
| CPU | Intel Core Ultra 7 155H |
|
||||
| GPU | NVIDIA RTX 2000 Ada Generation |
|
||||
| Memory | 32 GB |
|
||||
| Model | Gemma 4 E4B |
|
||||
| Inference | llama.cpp with CUDA 12.x |
|
||||
|
||||
### x86_64 Linux — Docker / RunPod (NVIDIA CUDA)
|
||||
|
||||
| | |
|
||||
| --------- | ------------------------------------------- |
|
||||
| Host | RunPod GPU pod |
|
||||
| Base | nvidia/cuda:12.6.3-devel-ubuntu24.04 |
|
||||
| Model | Gemma 4 E4B Q6_K |
|
||||
| Inference | llama.cpp prebuilt CUDA backends via dlopen |
|
||||
|
||||
---
|
||||
|
||||
## Fixture Strategy
|
||||
|
||||
- `--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.
|
||||
- Keep `cities.json` structured enough to support discovery and future
|
||||
filtering.
|
||||
- `personas.json`, `forenames-by-country.json`, and
|
||||
`surnames-by-country.json` 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
|
||||
|
||||
| Path | Purpose |
|
||||
| -------------------------------------- | -------------------------------------------------- |
|
||||
| `tooling/pipeline/includes/` | Public headers and shared models. |
|
||||
| `tooling/pipeline/src/` | Implementation files. |
|
||||
| `tooling/pipeline/cities.json` | Curated city input copied into the build tree. |
|
||||
| `tooling/pipeline/personas.json` | Curated user persona archetypes copied into the build tree. |
|
||||
| `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. |
|
||||
| `docs/pipeline/diagrams/` | Architecture and pipeline diagrams. |
|
||||
| `docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md` | Ethics, bias, hallucination analysis, mitigations. |
|
||||
|
||||
---
|
||||
|
||||
## Code Tour
|
||||
|
||||
Paths below are relative to `tooling/pipeline/`.
|
||||
|
||||
- `src/main.cc` — argument parsing and DI composition root.
|
||||
- `src/biergarten_pipeline_orchestrator/` — orchestration, sampling, logging,
|
||||
and export.
|
||||
- `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.
|
||||
- `includes/services/postal_code/` — `IPostalCodeService` and
|
||||
`MockPostalCodeService` (header-only), consumed by `GenerateBreweries()`.
|
||||
The real xeger-based implementation and its `--mocked`-aware DI wiring are
|
||||
still to come.
|
||||
- `src/services/sqlite/` — SQLite export implementation.
|
||||
- `src/data_generation/llama/` — local inference, prompt loading, output
|
||||
validation.
|
||||
- `src/data_generation/mock/` — deterministic fallback.
|
||||
- `runpod/` — container build and runtime launcher.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
The pipeline currently produces city-aware brewery and user records and
|
||||
dated SQLite exports. The next passes add additional fixture types so the
|
||||
app can 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
|
||||
|
||||
- Unit test JSON validation and retry logic against malformed, truncated, and
|
||||
empty model outputs.
|
||||
- Integration test the enrichment pipeline with missing context, short context,
|
||||
and fake context inputs.
|
||||
- Adversarial context tests: feed plausible but geographically incorrect
|
||||
Wikipedia extracts and verify the model does not silently blend them with
|
||||
training data.
|
||||
- Verify bilingual enrichment behaviour when only an English extract is
|
||||
available versus when both extracts are present.
|
||||
- Confirm the retry path is reachable when the reasoning block consumes
|
||||
available token budget.
|
||||
|
||||
### Beer Generation
|
||||
|
||||
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
|
||||
enough to exercise search, sort, and category filters.
|
||||
|
||||
### Check-In System
|
||||
|
||||
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
|
||||
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||
|
||||
### Beer Ratings
|
||||
|
||||
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
|
||||
can compute averages, trends, and per-style comparisons.
|
||||
@@ -1,321 +0,0 @@
|
||||
# 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 `{location, address, brewery}` — see the
|
||||
`Location`→`City` rename and `Address` struct added in the
|
||||
`postal_regex`/§9 entries below).
|
||||
- [ ] 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`).
|
||||
- [x] Add `postal_regex` (`std::vector<std::string>`) and
|
||||
`postal_code_examples` (`std::vector<std::string>`) to `Location`
|
||||
(now `City` — see below), sourced from each `cities.json` entry's
|
||||
`postal_code.city_regex` / `postal_code.examples`. Not part of the
|
||||
originally planned `Location` shape in `diagrams/planned/class.puml` —
|
||||
added ahead of postal code generation work; see §9.
|
||||
- [x] Renamed `Location` → `City` throughout the pipeline (struct, all
|
||||
parameter/return types, `LocationsList`→`CityList`,
|
||||
`LoadLocations()`→`LoadCities()`) so the domain vocabulary matches the
|
||||
web backend's `City`/`CityId`. Field/variable names that merely hold a
|
||||
`City` instance (e.g. `EnrichedCity::location`, `BreweryRecord::location`)
|
||||
were left as-is — `City` itself has a `city` field (the city name
|
||||
string), so renaming the container field too would read as
|
||||
`enriched_city.city.city`. The SQLite export layer's own vocabulary
|
||||
(`locations` table, `location_id` column, `location_cache_`, etc.) was
|
||||
renamed to `cities`/`city_id` to match, since it's this pipeline's own
|
||||
throwaway export, not the backend's SQL Server schema (see §5).
|
||||
- [x] Added a new `Address` struct (`postal_code` only, for now) and a
|
||||
`BreweryRecord::address` member, mirroring the web backend's
|
||||
`BreweryPostLocation` (`AddressLine1`, `AddressLine2`, `PostalCode`,
|
||||
`CityId`). Only `postal_code` is populated — `address_line1`/
|
||||
`address_line2` generation has no fixture data or service yet. Users
|
||||
do not get an `Address`: the backend's `UserAccount` has no address
|
||||
fields, only `BreweryPostLocation` does.
|
||||
|
||||
## 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`
|
||||
— `LoadCities()`, `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 `cities.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 `cities.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.
|
||||
- [x] Parse `postal_code.city_regex` and `postal_code.examples` out of each
|
||||
`cities.json` entry's nested `postal_code` object in
|
||||
`CuratedJsonDataService::LoadCities()`, into `City::postal_regex`
|
||||
/ `City::postal_code_examples` (see §1). `MockCuratedDataService`'s
|
||||
4 fixed locations carry matching values pulled from the corresponding
|
||||
`cities.json` entries.
|
||||
|
||||
## 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 `city_id`.
|
||||
- [x] `ProcessRecord(const BreweryRecord&)` now also binds
|
||||
`BreweryRecord::address.postal_code` into a `postal_code` column on
|
||||
`breweries` (see §9).
|
||||
- [ ] 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()` (`cities`, `breweries`,
|
||||
and `users` already exist) — see `kCreateCitiesTableSql` /
|
||||
`kCreateBreweriesTableSql` / `kCreateUsersTableSql` in
|
||||
`includes/services/database/sqlite_statement_helpers.h` for the
|
||||
existing pattern to follow.
|
||||
- [ ] Add a `brewery_cache_` alongside the existing `city_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.
|
||||
|
||||
## 9. Postal Code Generation
|
||||
|
||||
`tooling/pipeline/includes/services/postal_code/`. Not part of the original
|
||||
`diagrams/planned/class.puml` sketch — added once `Location` (now `City`)
|
||||
gained `postal_regex`/`postal_code_examples` (§1, §2).
|
||||
|
||||
- [x] Add the `IPostalCodeService` interface —
|
||||
`GeneratePostalCode(location : const City&) : std::string`.
|
||||
- [x] Add `MockPostalCodeService`: ignores `location.postal_regex` entirely
|
||||
and returns `location.postal_code_examples.front()`.
|
||||
- [ ] **Implement a real, xeger-based `IPostalCodeService`.** A xeger
|
||||
generator turns a regex pattern (here, one of
|
||||
`City::postal_regex`) into a random string that matches it — the
|
||||
inverse of matching a string against a regex. Without it, every
|
||||
generated postal code is either a hardcoded mock value or absent
|
||||
entirely; a real implementation is required before postal codes can
|
||||
vary per generated brewery instead of always reusing the same
|
||||
curated example.
|
||||
- [x] Wire `IPostalCodeService` into `main.cc`'s Boost.DI injector — bound
|
||||
unconditionally to `MockPostalCodeService` (no `--mocked` branch yet,
|
||||
since a real implementation doesn't exist to switch to; add one once
|
||||
the xeger-based generator above lands, mirroring `IEnrichmentService`)
|
||||
— and into `BiergartenPipelineOrchestrator::GenerateBreweries()`,
|
||||
which calls `GeneratePostalCode(location)` per brewery and stores the
|
||||
result on `BreweryRecord::address` (a new `Address` struct — see §1),
|
||||
exported to the `breweries.postal_code` SQLite column (§5).
|
||||
Postal-code generation is per-brewery, not per-city or per-user: users
|
||||
don't have an address concept in the web backend, only breweries do
|
||||
(`BreweryPostLocation`), so `UserRecord` was deliberately not extended.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
6. Postal code generation (§9) is independent of the phases above — the
|
||||
xeger-based generator just needs `City::postal_regex`, already in
|
||||
place. Wiring the mock into brewery output has already landed (§9); the
|
||||
real generator can be swapped in once built.
|
||||
@@ -1,34 +0,0 @@
|
||||
skinparam shadowing false
|
||||
skinparam backgroundColor #FCFCF7
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontColor #14180C
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontColor #14180C
|
||||
skinparam ArrowColor #656F33
|
||||
skinparam NoteBackgroundColor #DBEEDD
|
||||
skinparam NoteFontColor #14180C
|
||||
skinparam NoteBorderColor #4A5837
|
||||
skinparam SwimlaneBorderColor #4A5837
|
||||
skinparam SwimlaneBorderThickness 1
|
||||
skinparam activityStartColor #EBECE3
|
||||
skinparam activityEndColor #4A5837
|
||||
skinparam activityStopColor #4A5837
|
||||
skinparam ActivityBackgroundColor #EBECE3
|
||||
skinparam ActivityBorderColor #4A5837
|
||||
skinparam ActivityDiamondBackgroundColor #CBD2B5
|
||||
skinparam ActivityDiamondBorderColor #4A5837
|
||||
skinparam packageStyle rectangle
|
||||
skinparam packageBackgroundColor #F1F3EA
|
||||
skinparam packageBorderColor #4A5837
|
||||
skinparam packageFontColor #14180C
|
||||
skinparam classBackgroundColor #EBECE3
|
||||
skinparam classBorderColor #4A5837
|
||||
skinparam classFontColor #14180C
|
||||
skinparam classAttributeFontColor #3F4724
|
||||
skinparam classStereotypeFontColor #4A5837
|
||||
skinparam interfaceBackgroundColor #DBEEDD
|
||||
skinparam interfaceBorderColor #4A5837
|
||||
skinparam interfaceFontColor #14180C
|
||||
skinparam enumBackgroundColor #E4E6D8
|
||||
skinparam enumBorderColor #4A5837
|
||||
skinparam enumFontColor #14180C
|
||||
@@ -1,235 +0,0 @@
|
||||
@startuml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
skinparam NoteBackgroundColor #EAF0E8
|
||||
skinparam NoteBorderColor #547461
|
||||
skinparam ActivityBackgroundColor #FAFCF9
|
||||
skinparam ActivityBorderColor #547461
|
||||
skinparam ActivityDiamondBackgroundColor #FAFCF9
|
||||
skinparam ActivityDiamondBorderColor #628A5B
|
||||
skinparam ActivityBarColor #628A5B
|
||||
skinparam SwimlaneBorderColor #547461
|
||||
skinparam SwimlaneBorderThickness 0.3
|
||||
|
||||
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
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);
|
||||
if (Are arguments valid?) then (no)
|
||||
:Log error / usage info;
|
||||
stop
|
||||
else (yes)
|
||||
endif
|
||||
|
||||
:Init OpenSSL global state & LlamaBackendState;
|
||||
:di::make_injector(...);
|
||||
note right
|
||||
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|BiergartenPipelineOrchestrator|
|
||||
:Initialize SQLite export;
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:GetUtcTimestamp() from SystemDateTimeProvider;
|
||||
:Initialize();
|
||||
note right
|
||||
Builds a fresh biergarten_seed_<UTC datetime>.sqlite filename
|
||||
Appends a numeric suffix if the timestamp already exists
|
||||
Opens DB Connection
|
||||
Executes Schema DDL
|
||||
Begins Transaction
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:QueryCitiesWithCountries();
|
||||
|
||||
|#E2EBDC|JsonLoader|
|
||||
:JsonLoader::LoadCities("cities.json");
|
||||
:std::ranges::sample(all_locations, --location-count);
|
||||
note right
|
||||
--location-count defaults to 10.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
while (For each sampled City?) is (Remaining cities)
|
||||
|#DCE8D8|IEnrichmentService|
|
||||
:GetLocationContext(loc);
|
||||
note right
|
||||
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{City, region_context};
|
||||
endif
|
||||
endwhile (Done)
|
||||
|
||||
|#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 (City in cache?) then (yes)
|
||||
:Reuse city_id;
|
||||
else (no)
|
||||
:Insert City & Cache ID;
|
||||
endif
|
||||
:Insert User (FK: city_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);
|
||||
|
||||
|#E5EDE1|DataGenerator|
|
||||
while (For each EnrichedCity?) is (Remaining cities)
|
||||
if (Generator Mode) then (MockGenerator)
|
||||
:DeterministicHash & Format;
|
||||
else (LlamaGenerator)
|
||||
:PrepareRegionContext;
|
||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||
note right
|
||||
Resolves to BREWERY_GENERATION.md inside --prompt-dir.
|
||||
end note
|
||||
repeat
|
||||
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
|
||||
:ValidateBreweryJson(raw, brewery);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 3?) is (yes)
|
||||
note right
|
||||
max_tokens is fixed across retries —
|
||||
it is not raised on truncation.
|
||||
end note
|
||||
endif
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Generation successful?) then (yes)
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:ProcessRecord(GeneratedBrewery);
|
||||
if (City in cache?) then (yes)
|
||||
:Reuse city_id;
|
||||
else (no)
|
||||
:Insert City & Cache ID;
|
||||
endif
|
||||
:Insert Brewery (FK: city_id);
|
||||
|
||||
if (Exception caught during insert?) then (yes)
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Log warning "SQLite export failed";
|
||||
note right
|
||||
Data loss is prevented per-record.
|
||||
The pipeline continues running.
|
||||
end note
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:Log warning "Generation failed, skipping...";
|
||||
endif
|
||||
|#E5EDE1|DataGenerator|
|
||||
endwhile (Done)
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:Finalize();
|
||||
note right
|
||||
Commits Transaction
|
||||
Closes Database Connection
|
||||
end note
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
:Close log_channel;
|
||||
:Join log dispatcher thread;
|
||||
:Return 0;
|
||||
stop
|
||||
|
||||
@enduml
|
||||
@@ -1,294 +0,0 @@
|
||||
@startuml
|
||||
skinparam style strictuml
|
||||
skinparam defaultFontName "DM Sans"
|
||||
skinparam defaultFontSize 14
|
||||
skinparam titleFontName "Volkhov"
|
||||
skinparam titleFontSize 20
|
||||
skinparam backgroundColor #FAFCF9
|
||||
skinparam defaultFontColor #28342A
|
||||
skinparam titleFontColor #28342A
|
||||
skinparam ArrowColor #628A5B
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor #FAFCF9
|
||||
HeaderBackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
ArrowColor #628A5B
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
skinparam note {
|
||||
BackgroundColor #EAF0E8
|
||||
BorderColor #547461
|
||||
FontColor #28342A
|
||||
}
|
||||
|
||||
title The Biergarten Data Pipeline - Class Diagram
|
||||
|
||||
class BiergartenPipelineOrchestrator {
|
||||
- logger_ : std::shared_ptr<ILogger>
|
||||
- context_service_ : std::unique_ptr<IEnrichmentService>
|
||||
- generator_ : std::unique_ptr<DataGenerator>
|
||||
- exporter_ : std::unique_ptr<IExportService>
|
||||
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
|
||||
- postal_code_service_ : std::unique_ptr<IPostalCodeService>
|
||||
- application_options_ : ApplicationOptions
|
||||
- generated_breweries_ : std::vector<BreweryRecord>
|
||||
- generated_users_ : std::vector<UserRecord>
|
||||
+ Run() : bool
|
||||
- QueryCitiesWithCountries() : std::vector<City>
|
||||
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
||||
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
|
||||
- LogResults() : void
|
||||
}
|
||||
|
||||
class LogLevel <<enumeration>> {
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
class PipelinePhase <<enumeration>> {
|
||||
Startup
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
struct LogDTO {
|
||||
+ 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
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
}
|
||||
|
||||
interface ILogger <<interface>> {
|
||||
+ Log(payload : LogDTO) : void
|
||||
- {abstract} DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
- DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Run() : void
|
||||
- ToSpdlogLevel(level) : spdlog::level::level_enum
|
||||
}
|
||||
|
||||
interface IEnrichmentService <<interface>> {
|
||||
+ GetLocationContext(loc : const City&) : std::string
|
||||
}
|
||||
|
||||
class MockEnrichmentService {
|
||||
+ GetLocationContext(loc : const City&) : std::string
|
||||
}
|
||||
|
||||
class WikipediaEnrichmentService {
|
||||
- client_ : std::unique_ptr<WebClient>
|
||||
- extract_cache_ : std::unordered_map<std::string, std::string>
|
||||
+ GetLocationContext(loc : const City&) : std::string
|
||||
- FetchExtract(query : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface WebClient <<interface>> {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
class HttpWebClient {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
interface DataGenerator <<interface>> {
|
||||
+ GenerateBrewery(location : const City&, region_context : const std::string&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+ GenerateBrewery(location : const City&, region_context : const std::string&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
- DeterministicHash(location : const City&) : size_t
|
||||
- DeterministicHash(location : const City&, persona : const UserPersona&, name : const Name&) : size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
- model_ : ModelHandle
|
||||
- context_ : ContextHandle
|
||||
- prompt_formatter_ : std::unique_ptr<IPromptFormatter>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- rng_ : std::mt19937
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
- Load(model_path : const std::string&) : void
|
||||
- Infer(...) : std::string
|
||||
- InferFormatted(...) : std::string
|
||||
}
|
||||
|
||||
interface IPromptFormatter <<interface>> {
|
||||
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class Gemma4JinjaPromptFormatter {
|
||||
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface IPromptDirectory <<interface>> {
|
||||
+ 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>> {
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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<City>
|
||||
- personas_ : std::vector<UserPersona>
|
||||
- forenames_by_country_ : std::unordered_map<std::string, forename_list>
|
||||
- surnames_by_country_ : std::unordered_map<std::string, surname_list>
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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 IPostalCodeService <<interface>> {
|
||||
+ GeneratePostalCode(location : const City&) : std::string
|
||||
}
|
||||
|
||||
class MockPostalCodeService {
|
||||
+ GeneratePostalCode(location : const City&) : std::string
|
||||
}
|
||||
|
||||
note right of MockPostalCodeService
|
||||
Ignores location.postal_regex and returns
|
||||
location.postal_code_examples.front(). Bound
|
||||
unconditionally in main.cc's DI graph (no
|
||||
--mocked branch yet -- no real implementation
|
||||
exists to switch to) and called from
|
||||
GenerateBreweries() per brewery, stored on
|
||||
BreweryRecord::address. A real implementation
|
||||
needs a xeger-style generator to turn
|
||||
postal_regex into a synthesized matching
|
||||
string. See ROADMAP.md §9.
|
||||
end note
|
||||
|
||||
interface IExportService <<interface>> {
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
- date_time_provider_ : std::unique_ptr<IDateTimeProvider>
|
||||
- run_timestamp_utc_ : std::string
|
||||
- database_path_ : std::filesystem::path
|
||||
- db_handle_ : SqliteDatabaseHandle
|
||||
- insert_city_stmt_ : SqliteStatementHandle
|
||||
- insert_brewery_stmt_ : SqliteStatementHandle
|
||||
- insert_user_stmt_ : SqliteStatementHandle
|
||||
- transaction_open_ : bool
|
||||
- city_cache_ : std::unordered_map<std::string, sqlite3_int64>
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
- InitializeSchema() : void
|
||||
- ResolveCityId(location : const City&) : sqlite3_int64
|
||||
}
|
||||
|
||||
interface IDateTimeProvider <<interface>> {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
' Structural Relationships / Dependency Injection
|
||||
BiergartenPipelineOrchestrator *-- ILogger : owns
|
||||
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator : owns
|
||||
BiergartenPipelineOrchestrator *-- IExportService : owns
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
|
||||
BiergartenPipelineOrchestrator *-- IPostalCodeService : owns
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
LogDTO *-- LogLevel
|
||||
LogDTO *-- PipelinePhase
|
||||
ILogger <|.. LogProducer : implements
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
|
||||
IEnrichmentService <|.. WikipediaEnrichmentService : implements
|
||||
IEnrichmentService <|.. MockEnrichmentService : implements
|
||||
WikipediaEnrichmentService *-- WebClient : owns
|
||||
|
||||
WebClient <|.. HttpWebClient : implements
|
||||
|
||||
DataGenerator <|.. MockGenerator : implements
|
||||
DataGenerator <|.. LlamaGenerator : implements
|
||||
|
||||
LlamaGenerator *-- IPromptFormatter : uses
|
||||
LlamaGenerator *-- IPromptDirectory : uses
|
||||
|
||||
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||
IPromptDirectory <|.. PromptDirectory : implements
|
||||
|
||||
ICuratedDataService <|.. JsonLoader : implements
|
||||
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||
|
||||
IPostalCodeService <|.. MockPostalCodeService : implements
|
||||
|
||||
IExportService <|.. SqliteExportService : implements
|
||||
SqliteExportService *-- IDateTimeProvider : owns
|
||||
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 86 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 94 KiB |
@@ -1,372 +0,0 @@
|
||||
@startuml biergarten_activity
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam defaultFontSize 13
|
||||
skinparam titleFontSize 20
|
||||
|
||||
title The Biergarten Data Pipeline — Activity Diagram
|
||||
|
||||
|Main|
|
||||
start
|
||||
:ParseArguments(argc, argv);
|
||||
if (Invalid args?) then (yes)
|
||||
:spdlog::error;
|
||||
stop
|
||||
else (no)
|
||||
endif
|
||||
:Init OpenSSL global state & LlamaBackendState;
|
||||
:Build DI injector;
|
||||
|
||||
:Initialize SqliteExportService;
|
||||
note right
|
||||
Opens SQLite connection.
|
||||
(Transactions are now managed
|
||||
per-phase via batching).
|
||||
end note
|
||||
|
||||
:Create BoundedChannel<LogEntry> log_ch;
|
||||
:Spawn Log Worker thread;
|
||||
note right
|
||||
Log worker drains log_ch for the
|
||||
entire pipeline lifetime.
|
||||
All workers emit LogEntry structs
|
||||
via PipelineLogger -- never spdlog directly.
|
||||
end note
|
||||
|
||||
:BiergartenPipelineOrchestrator::Run();
|
||||
|BiergartenPipelineOrchestrator::Run()|
|
||||
|
||||
fork
|
||||
:JsonLoader::LoadBeerStyles("beer-styles.json");
|
||||
:EnrichmentService::PreWarmBeerStyleCache(beer_styles);
|
||||
fork again
|
||||
:JsonLoader::LoadCities("cities.json");
|
||||
:EnrichmentService::PreWarmLocationCache(sampled_locations);
|
||||
end fork
|
||||
fork
|
||||
:JsonLoader::LoadForenamesByCountry(\n "forenames-by-country.json");
|
||||
fork again
|
||||
:JsonLoader::LoadSurnamesByCountry(\n "surnames-by-country.json");
|
||||
fork again
|
||||
:JsonLoader::LoadPersonas("personas.json");
|
||||
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
|
||||
' ═══════════════════════════════════════════
|
||||
|Orchestrator|
|
||||
:RunUserPhase(sampled_locations);
|
||||
:Create BoundedChannels\n(loc_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Send Locations -> loc_ch;
|
||||
:Close loc_ch;
|
||||
note right
|
||||
Producer closes loc_ch.
|
||||
LLM Worker while loop
|
||||
terminates on empty + closed.
|
||||
end note
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (loc_ch has items?) is (yes)
|
||||
:Receive City;
|
||||
|
||||
:GetLocationContextFromCache(location);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
end note
|
||||
|
||||
:IPersonaSelectionStrategy::SelectPersona(\n personas_palette_);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
Returns a Persona struct carrying
|
||||
style_affinities, abv_range,
|
||||
ibu_preference, checkin_weight.
|
||||
end note
|
||||
|
||||
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1, rng);
|
||||
note right
|
||||
Deterministic lookup -- no LLM involved.
|
||||
Free function (implemented today in
|
||||
generate_users.cc), not a class method.
|
||||
Name selected from pre-keyed maps
|
||||
and passed into the generation prompt.
|
||||
Skips the city if either map has no
|
||||
entry for iso3166_1.
|
||||
end note
|
||||
|
||||
:GenerateUser(enriched_city, persona, sampled_name)\nvia DataGenerator;
|
||||
note right
|
||||
LLM receives: EnrichedCity context + persona
|
||||
description + sampled name. Generates
|
||||
bio and preference signals grounded
|
||||
in locale and persona.
|
||||
end note
|
||||
|
||||
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "llm");
|
||||
:Send GeneratedUser -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
note right
|
||||
Producer closes exp_ch.
|
||||
SQLite Worker while loop
|
||||
terminates on empty + closed.
|
||||
end note
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive GeneratedUser;
|
||||
:ProcessUser(user);
|
||||
:PipelineLogger::Log(Info, UserGeneration,\n city, user_id, "sqlite");
|
||||
:Append -> user_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 1a — BREWERY GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunBreweryPhase(sampled_locations);
|
||||
:Create BoundedChannels\n(loc_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Sample User from user_pool_
|
||||
and pair with City;
|
||||
:Send BreweryTask(City, User) -> loc_ch;
|
||||
:Close loc_ch;
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (loc_ch has items?) is (yes)
|
||||
:Receive BreweryTask(City, User);
|
||||
|
||||
:GetLocationContextFromCache(task.location);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
end note
|
||||
|
||||
:GenerateBrewery(enriched_city, context, task.user)\nvia DataGenerator;
|
||||
note right
|
||||
KV cache stays warm.
|
||||
Brewery is linked to the sampled owner_user_id.
|
||||
end note
|
||||
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "llm");
|
||||
:Send GeneratedBrewery -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive GeneratedBrewery;
|
||||
:ProcessBrewery(brewery);
|
||||
:PipelineLogger::Log(Info,\n BreweryGeneration,\n city, brewery_id, "sqlite");
|
||||
:Append -> brewery_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
note right
|
||||
brewery_pool_ is now fully populated.
|
||||
Phase 1b may begin.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 1b — BEER GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunBeerPhase();
|
||||
:Create BoundedChannels\n(brew_ch, exp_ch);
|
||||
|
||||
fork
|
||||
|Orchestrator|
|
||||
:Loop: Send Breweries -> brew_ch;
|
||||
:Close brew_ch;
|
||||
fork again
|
||||
|LLM Worker|
|
||||
while (brew_ch has items?) is (yes)
|
||||
:Receive GeneratedBrewery;
|
||||
:IBeerSelectionStrategy::SelectStyles(\n brewery, beer_style_palette_);
|
||||
|
||||
while (For each selected BeerStyle?) is (remaining)
|
||||
:GetStyleContextFromCache(style);
|
||||
note right
|
||||
Guaranteed cache hit from startup.
|
||||
KV cache stays warm across all
|
||||
beer generations -- system prompt
|
||||
does not change within this phase.
|
||||
end note
|
||||
:GenerateBeer(brewery, style_context)\nvia DataGenerator;
|
||||
:Attach GeneratedBeer to bundle;
|
||||
endwhile (done)
|
||||
|
||||
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "llm");
|
||||
:Send BeersBundle -> exp_ch;
|
||||
endwhile (no)
|
||||
:Close exp_ch;
|
||||
fork again
|
||||
|SQLite Worker|
|
||||
:BEGIN TRANSACTION;
|
||||
while (exp_ch has items?) is (yes)
|
||||
:Receive BeersBundle;
|
||||
while (For each beer in bundle?) is (remaining)
|
||||
:Set beer.brewery_id from bundle;
|
||||
:ProcessBeer(beer);
|
||||
:Append -> beer_pool_;
|
||||
endwhile (done)
|
||||
:PipelineLogger::Log(Info,\n BeerGeneration,\n city, brewery_id, "sqlite");
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (no)
|
||||
:COMMIT (Final);
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join LLM Worker, SQLite Worker;
|
||||
note right
|
||||
Both brewery_pool_ and beer_pool_
|
||||
are now completely populated.
|
||||
Checkin and Follow phases may
|
||||
now run in parallel.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 2 — CHECKIN + FOLLOW GENERATION
|
||||
' (parallel — both depend only on user_pool_
|
||||
' and brewery_pool_ being fully populated)
|
||||
' ═══════════════════════════════════════════
|
||||
fork
|
||||
|Orchestrator|
|
||||
:RunCheckinPhase();
|
||||
:ICheckinDistributionStrategy::\nAssignActivityWeights(user_pool_);
|
||||
note right
|
||||
Weights seeded from each user's
|
||||
persona.checkin_weight. J-curve profile
|
||||
emerges from persona distribution.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
while (For each GeneratedUser in user_pool_?) is (remaining)
|
||||
:CheckinsForUser(user, brewery_pool_.size());
|
||||
while (For each checkin index?) is (remaining)
|
||||
:TimestampFor(user, index);
|
||||
:Select brewery from brewery_pool_;
|
||||
:GenerateCheckin(user, brewery, timestamp)\nvia DataGenerator;
|
||||
:ProcessCheckin(checkin);
|
||||
:PipelineLogger::Log(Info, CheckinGeneration,\n nullopt, checkin_id, "sqlite");
|
||||
:Append -> checkin_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (done)
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
fork again
|
||||
|Orchestrator|
|
||||
:RunFollowPhase();
|
||||
:IFollowGenerationStrategy::\nAssignFollowWeights(user_pool_);
|
||||
note right
|
||||
For RandomFollowStrategy, weights
|
||||
are uniform. For ActivityWeightedFollowStrategy,
|
||||
weights derived from user.activity_weight
|
||||
so high-activity users attract more followers.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
:IFollowGenerationStrategy::\nGenerateFollows(user_pool_);
|
||||
note right
|
||||
Self-follow constraint (follower_id != followed_id)
|
||||
enforced here and at the DB schema level.
|
||||
end note
|
||||
while (For each GeneratedFollow?) is (remaining)
|
||||
:ProcessFollow(follow);
|
||||
:PipelineLogger::Log(Info, FollowGeneration,\n nullopt, follower_id, "sqlite");
|
||||
:Append -> follow_pool_;
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
end fork
|
||||
|
||||
|Orchestrator|
|
||||
:Join CheckinPhase, FollowPhase;
|
||||
note right
|
||||
checkin_pool_ and follow_pool_
|
||||
are now fully populated.
|
||||
Rating phase may begin.
|
||||
end note
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' PHASE 3 — RATING GENERATION
|
||||
' ═══════════════════════════════════════════
|
||||
:RunRatingPhase();
|
||||
note right
|
||||
Beer selection biased by
|
||||
user.persona.style_affinities and abv_range.
|
||||
Rating skew modulated per persona.
|
||||
end note
|
||||
|
||||
:BEGIN TRANSACTION;
|
||||
while (For each GeneratedCheckin in checkin_pool_?) is (remaining)
|
||||
:Match brewery_id, select beer from beer_pool_\n(same brewery_id, biased by persona affinities);
|
||||
if (Beer exists for brewery?) then (yes)
|
||||
:GenerateRating(user, beer, checkin_id)\nvia DataGenerator;
|
||||
:ProcessRating(rating);
|
||||
:PipelineLogger::Log(Info, RatingGeneration,\n nullopt, rating_id, "sqlite");
|
||||
if (Batch size reached?) then (yes)
|
||||
:COMMIT & BEGIN;
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:PipelineLogger::Log(Warn, RatingGeneration,\n nullopt, brewery_id, "sqlite");
|
||||
:Skip -- brewery has no beers;
|
||||
endif
|
||||
endwhile (done)
|
||||
:COMMIT (Final);
|
||||
|
||||
' ═══════════════════════════════════════════
|
||||
' TEARDOWN
|
||||
' ═══════════════════════════════════════════
|
||||
|Orchestrator|
|
||||
:Finalize SqliteExportService;
|
||||
note right
|
||||
Safely closes the DB connection.
|
||||
end note
|
||||
:Close log_ch;
|
||||
|
||||
|Main|
|
||||
:spdlog::info "Pipeline complete in X ms";
|
||||
:Join Log Worker;
|
||||
note right
|
||||
Drain guarantees no LogEntry is
|
||||
dropped at shutdown.
|
||||
end note
|
||||
stop
|
||||
|
||||
@enduml
|
||||
@@ -1,704 +0,0 @@
|
||||
@startuml class_diagram
|
||||
|
||||
' ==========================================
|
||||
' CONFIGURATION & STYLING
|
||||
' ==========================================
|
||||
!include ../biergarten-weizen-theme.puml
|
||||
skinparam classAttributeFontSize 9
|
||||
skinparam defaultFontSize 25
|
||||
skinparam titleFontSize 30
|
||||
|
||||
title Biergarten Data Pipeline — Class Diagram
|
||||
|
||||
package "Domain: Models" {
|
||||
|
||||
class City {
|
||||
+ city : std::string
|
||||
+ state_province : std::string
|
||||
+ iso3166_2 : std::string
|
||||
+ country : std::string
|
||||
+ iso3166_1 : std::string
|
||||
+ postal_regex : std::vector<std::string>
|
||||
+ postal_code_examples : std::vector<std::string>
|
||||
+ local_languages : std::vector<std::string>
|
||||
+ latitude : double
|
||||
+ longitude : double
|
||||
}
|
||||
|
||||
note right of City
|
||||
postal_regex / postal_code_examples are
|
||||
sourced from each cities.json entry's
|
||||
postal_code.city_regex / postal_code.examples
|
||||
(implemented today, ahead of the rest of this
|
||||
diagram -- see ROADMAP.md §1/§2). Renamed from
|
||||
Location to City to match the web backend's
|
||||
City/CityId vocabulary -- see ROADMAP.md §1.
|
||||
end note
|
||||
|
||||
class Address {
|
||||
+ postal_code : std::string
|
||||
}
|
||||
|
||||
note right of Address
|
||||
Mirrors the web backend's BreweryPostLocation.
|
||||
Only postal_code is populated today --
|
||||
address_line1/address_line2 have no fixture
|
||||
data or generator yet. See ROADMAP.md §9.
|
||||
end note
|
||||
|
||||
class LocationContext {
|
||||
+ text : std::string
|
||||
+ completeness : Completeness
|
||||
+ char_count : size_t
|
||||
}
|
||||
|
||||
enum Completeness {
|
||||
Full
|
||||
Partial
|
||||
Absent
|
||||
}
|
||||
|
||||
class EnrichedCity {
|
||||
+ location : City
|
||||
+ context : LocationContext
|
||||
}
|
||||
|
||||
class BeerStyle {
|
||||
+ name : std::string
|
||||
+ description : std::string
|
||||
+ min_abv : float
|
||||
+ max_abv : float
|
||||
+ min_ibu : int
|
||||
+ max_ibu : int
|
||||
}
|
||||
|
||||
class BreweryResult {
|
||||
+ name_en : std::string
|
||||
+ description_en : std::string
|
||||
+ name_local : std::string
|
||||
+ description_local : std::string
|
||||
}
|
||||
|
||||
class BeerResult {
|
||||
+ name_en : std::string
|
||||
+ description_en : std::string
|
||||
+ name_local : std::string
|
||||
+ description_local : std::string
|
||||
+ style : std::string
|
||||
+ abv : float
|
||||
+ ibu : int
|
||||
}
|
||||
|
||||
class UserResult {
|
||||
+ first_name : std::string
|
||||
+ last_name : std::string
|
||||
+ gender : std::string
|
||||
+ username : std::string
|
||||
+ bio : std::string
|
||||
+ 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 {
|
||||
+ checked_in_at : std::string
|
||||
+ note : std::string
|
||||
}
|
||||
|
||||
class RatingResult {
|
||||
+ score : float
|
||||
+ note : std::string
|
||||
}
|
||||
|
||||
class GenerationMetadata {
|
||||
+ generation_id : uint64_t
|
||||
+ generated_time : std::string
|
||||
+ context_provided : bool
|
||||
+ generated_with : std::string
|
||||
}
|
||||
|
||||
class GeneratedBrewery {
|
||||
+ brewery_id : uint64_t
|
||||
+ location : City
|
||||
+ address : Address
|
||||
+ brewery : BreweryResult
|
||||
+ context_completeness : LocationContext::Completeness
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedBeer {
|
||||
+ beer_id : uint64_t
|
||||
+ brewery_id : uint64_t
|
||||
+ location : City
|
||||
+ style : BeerStyle
|
||||
+ beer : BeerResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedUser {
|
||||
+ user_id : uint64_t
|
||||
+ location : City
|
||||
+ user : UserResult
|
||||
+ email : std::string
|
||||
+ date_of_birth : std::string
|
||||
+ password : std::string
|
||||
+ 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 {
|
||||
+ checkin_id : uint64_t
|
||||
+ user_id : uint64_t
|
||||
+ brewery_id : uint64_t
|
||||
+ checkin : CheckinResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedRating {
|
||||
+ user_id : uint64_t
|
||||
+ beer_id : uint64_t
|
||||
+ checkin_id : uint64_t
|
||||
+ rating : RatingResult
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class GeneratedFollow {
|
||||
+ follower_id : uint64_t
|
||||
+ followed_id : uint64_t
|
||||
+ metadata : GenerationMetadata
|
||||
}
|
||||
|
||||
class UserPersona {
|
||||
+ name: std::string
|
||||
+ description: std::string
|
||||
+ style_affinities: std::vector<std::string>
|
||||
}
|
||||
|
||||
LocationContext *-- Completeness
|
||||
}
|
||||
|
||||
package "Domain: Application Configuration" {
|
||||
class SamplingOptions {
|
||||
+ temperature: float = 1.0F
|
||||
+ top_p: float = 0.95F
|
||||
+ top_k: uint32_t = 64
|
||||
+ n_ctx: uint32_t = 8192
|
||||
+ seed: int = -1
|
||||
}
|
||||
|
||||
class GeneratorOptions {
|
||||
+ model_path: std::filesystem::path
|
||||
+ use_mocked: bool = false
|
||||
+ sampling: std::optional<SamplingOptions>
|
||||
}
|
||||
|
||||
class PipelineOptions {
|
||||
+ output_path: std::filesystem::path
|
||||
+ log_path: std::filesystem::path
|
||||
}
|
||||
|
||||
class ApplicationOptions {
|
||||
+ generator: GeneratorOptions
|
||||
+ pipeline: PipelineOptions
|
||||
}
|
||||
|
||||
ApplicationOptions *-- GeneratorOptions
|
||||
ApplicationOptions *-- PipelineOptions
|
||||
GeneratorOptions o-- SamplingOptions
|
||||
}
|
||||
|
||||
package "Domain: Policy" {
|
||||
|
||||
interface ContextStrategy <<interface>> {
|
||||
+ QueriesFor(loc : const City&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
class BreweryContextStrategy {
|
||||
+ QueriesFor(loc : const City&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
class BeerContextStrategy {
|
||||
+ QueriesFor(loc : const City&) : std::vector<std::string>
|
||||
+ MaxContextChars() : size_t
|
||||
}
|
||||
|
||||
interface SamplingStrategy <<interface>> {
|
||||
+ Sample(locations : const std::vector<City>&) : std::vector<City>
|
||||
}
|
||||
|
||||
class UniformSamplingStrategy {
|
||||
- sample_size_ : size_t
|
||||
+ Sample(locations : const std::vector<City>&) : std::vector<City>
|
||||
}
|
||||
|
||||
interface BeerSelectionStrategy <<interface>> {
|
||||
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
|
||||
}
|
||||
|
||||
class RandomBeerSelectionStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_beers_ : size_t
|
||||
- max_beers_ : size_t
|
||||
+ SelectStyles(brewery : const GeneratedBrewery&,\n palette : std::span<const BeerStyle>) : std::vector<BeerStyle>
|
||||
}
|
||||
|
||||
interface CheckinDistributionStrategy <<interface>> {
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
class JCurveCheckinStrategy {
|
||||
- rng_ : std::mt19937
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
class RandomCheckinStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_checkins_ : size_t
|
||||
- max_checkins_ : size_t
|
||||
+ AssignActivityWeights(users : std::vector<GeneratedUser>&) : void
|
||||
+ CheckinsForUser(user : const GeneratedUser&,\n brewery_count : size_t) : size_t
|
||||
+ TimestampFor(user : const GeneratedUser&,\n index : size_t) : std::string
|
||||
}
|
||||
|
||||
interface FollowGenerationStrategy <<interface>> {
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
|
||||
class RandomFollowStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_follows_ : size_t
|
||||
- max_follows_ : size_t
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
|
||||
class ActivityWeightedFollowStrategy {
|
||||
- rng_ : std::mt19937
|
||||
- min_follows_ : size_t
|
||||
- max_follows_ : size_t
|
||||
+ GenerateFollows(users : const std::vector<GeneratedUser>&) : std::vector<GeneratedFollow>
|
||||
}
|
||||
}
|
||||
|
||||
package "Infrastructure: Logging" {
|
||||
enum LogLevel {
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
enum PipelinePhase {
|
||||
Startup
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
+ timestamp : std::chrono::system_clock::time_point
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
+ worker : std::optional<std::string>
|
||||
}
|
||||
|
||||
interface ILogger <<interface>> {
|
||||
+ Log(entry : const LogEntry&) : void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Log(entry : const LogEntry&) : void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Run() : void
|
||||
- ToSpdlogLevel(level) : spdlog::level::level_enum
|
||||
}
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
ILogger <|.. LogProducer
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
}
|
||||
|
||||
package "Infrastructure: Pipeline Channel" {
|
||||
|
||||
class "BoundedChannel<T>" as BoundedChannel {
|
||||
- queue_ : std::queue<T>
|
||||
- mutex_ : std::mutex
|
||||
- not_full_ : std::condition_variable
|
||||
- not_empty_ : std::condition_variable
|
||||
- capacity_ : size_t
|
||||
- closed_ : bool
|
||||
+ Send(item : T) : void
|
||||
+ Receive() : std::optional<T>
|
||||
+ Close() : void
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Preloading" {
|
||||
|
||||
interface ICuratedDataService <<interface>> {
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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>&
|
||||
}
|
||||
|
||||
class JsonLoader {
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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 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 {
|
||||
+ LoadCities(filepath : const std::filesystem::path&) : const std::vector<City>&
|
||||
+ 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" {
|
||||
|
||||
interface EnrichmentService <<interface>> {
|
||||
+ GetLocationContext(loc : const City&,\n strategy : const ContextStrategy&) : LocationContext
|
||||
}
|
||||
|
||||
class WikipediaService {
|
||||
- client_ : std::unique_ptr<WebClient>
|
||||
- extract_cache_ : std::unordered_map<std::string, std::string>
|
||||
+ GetLocationContext(loc : const City&,\n strategy : const ContextStrategy&) : LocationContext
|
||||
- FetchExtract(query : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface WebClient <<interface>> {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
class HttpWebClient {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Postal Code Generation" {
|
||||
|
||||
interface IPostalCodeService <<interface>> {
|
||||
+ GeneratePostalCode(location : const City&) : std::string
|
||||
}
|
||||
|
||||
class MockPostalCodeService {
|
||||
+ GeneratePostalCode(location : const City&) : std::string
|
||||
}
|
||||
|
||||
class XegerPostalCodeService {
|
||||
+ GeneratePostalCode(location : const City&) : std::string
|
||||
}
|
||||
|
||||
note right of MockPostalCodeService
|
||||
Implemented today: ignores postal_regex and
|
||||
returns postal_code_examples.front(). Not yet
|
||||
bound in main.cc's DI graph or used by
|
||||
BiergartenPipelineOrchestrator.
|
||||
end note
|
||||
|
||||
note right of XegerPostalCodeService
|
||||
Not implemented yet. Picks one of
|
||||
location.postal_regex and generates a random
|
||||
string matching it (a "xeger" -- the inverse of
|
||||
regex matching) instead of always replaying a
|
||||
fixed example. See ROADMAP.md §9.
|
||||
end note
|
||||
|
||||
IPostalCodeService <|.. MockPostalCodeService
|
||||
IPostalCodeService <|.. XegerPostalCodeService
|
||||
}
|
||||
|
||||
package "Infrastructure: Prompting" {
|
||||
|
||||
interface IPromptDirectory <<interface>> {
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class PromptDirectory {
|
||||
- prompt_dir_ : std::filesystem::path
|
||||
- cache_ : std::unordered_map<std::string, std::string>
|
||||
+ PromptDirectory(prompt_dir : const std::filesystem::path&)
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
IPromptDirectory <|.. PromptDirectory
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Generation" {
|
||||
|
||||
interface DataGenerator <<interface>> {
|
||||
+ GenerateBrewery(location : const City&,\n context : const LocationContext&) : BreweryResult
|
||||
+ GenerateBeer(brewery_id : uint64_t,\n location : const City&,\n context : const LocationContext&,\n style : const BeerStyle&) : BeerResult
|
||||
+ 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
|
||||
+ GenerateRating(user : const GeneratedUser&,\n beer : const GeneratedBeer&,\n checkin_id : uint64_t) : RatingResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateBeer(...) : BeerResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
+ GenerateCheckin(...) : CheckinResult
|
||||
+ GenerateRating(...) : RatingResult
|
||||
- DeterministicHash(location : const City&) : size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
- model_ : ModelHandle
|
||||
- context_ : ContextHandle
|
||||
- prompt_formatter_ : std::unique_ptr<PromptFormatter>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- rng_ : std::mt19937
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateBeer(...) : BeerResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
+ GenerateCheckin(...) : CheckinResult
|
||||
+ GenerateRating(...) : RatingResult
|
||||
- Load(opts : const GeneratorOptions&) : void
|
||||
- Infer(system_prompt, user_prompt,\n max_tokens, grammar) : std::string
|
||||
- ValidateModelArchitecture() : void
|
||||
}
|
||||
|
||||
interface PromptFormatter <<interface>> {
|
||||
+ Format(system_prompt : std::string_view,\n user_prompt : std::string_view) : std::string
|
||||
+ ExpectedArchitecture() : std::string_view
|
||||
}
|
||||
|
||||
class Gemma4JinjaPromptFormatter {
|
||||
+ Format(...) : std::string
|
||||
+ ExpectedArchitecture() : std::string_view
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Infrastructure: Data Export" {
|
||||
|
||||
interface ExportService <<interface>> {
|
||||
+ Initialize() : void
|
||||
+ ProcessBrewery(brewery : const GeneratedBrewery&) : uint64_t
|
||||
+ ProcessBeer(beer : const GeneratedBeer&) : uint64_t
|
||||
+ ProcessUser(user : const GeneratedUser&) : uint64_t
|
||||
+ ProcessCheckin(checkin : const GeneratedCheckin&) : uint64_t
|
||||
+ ProcessRating(rating : const GeneratedRating&) : void
|
||||
+ ProcessFollow(follow : const GeneratedFollow&) : void
|
||||
+ Finalize() : void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
- date_time_provider_ : std::unique_ptr<DateTimeProvider>
|
||||
- db_handle_ : SqliteDatabaseHandle
|
||||
- insert_location_stmt_ : SqliteStatementHandle
|
||||
- insert_brewery_stmt_ : SqliteStatementHandle
|
||||
- insert_beer_stmt_ : SqliteStatementHandle
|
||||
- insert_user_stmt_ : SqliteStatementHandle
|
||||
- insert_checkin_stmt_ : SqliteStatementHandle
|
||||
- insert_rating_stmt_ : SqliteStatementHandle
|
||||
- insert_follow_stmt_ : SqliteStatementHandle
|
||||
- transaction_open_ : bool
|
||||
- location_cache_ : std::unordered_map<std::string, uint64_t>
|
||||
- brewery_cache_ : std::unordered_map<std::string, uint64_t>
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const GeneratedBrewery&) : uint64_t
|
||||
+ ProcessRecord(beer : const GeneratedBeer&) : uint64_t
|
||||
+ ProcessRecord(user : const GeneratedUser&) : uint64_t
|
||||
+ ProcessRecord(checkin : const GeneratedCheckin&) : uint64_t
|
||||
+ ProcessRecord(rating : const GeneratedRating&) : void
|
||||
+ ProcessRecord(follow : const GeneratedFollow&) : void
|
||||
+ Finalize() : void
|
||||
- InitializeSchema() : void
|
||||
- PrepareStatements() : void
|
||||
- RollbackAndCloseNoThrow() : void
|
||||
- FinalizeStatements() : void
|
||||
}
|
||||
|
||||
interface DateTimeProvider <<interface>> {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BiergartenPipelineOrchestrator {
|
||||
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
|
||||
- enrichment_service_ : std::unique_ptr<EnrichmentService>
|
||||
- generator_ : std::unique_ptr<DataGenerator>
|
||||
- logger_ : std::unique_ptr<Logger>
|
||||
- exporter_ : std::unique_ptr<ExportService>
|
||||
- brewery_context_strategy_ : std::unique_ptr<ContextStrategy>
|
||||
- sampling_strategy_ : std::unique_ptr<SamplingStrategy>
|
||||
- beer_selection_strategy_ : std::unique_ptr<BeerSelectionStrategy>
|
||||
- checkin_strategy_ : std::unique_ptr<CheckinDistributionStrategy>
|
||||
- follow_strategy_ : std::unique_ptr<FollowGenerationStrategy>
|
||||
- beer_style_palette_ : std::vector<BeerStyle>
|
||||
- options_ : ApplicationOptions
|
||||
--
|
||||
- user_pool_ : std::vector<GeneratedUser>
|
||||
- brewery_pool_ : std::vector<GeneratedBrewery>
|
||||
- beer_pool_ : std::vector<GeneratedBeer>
|
||||
- checkin_pool_ : std::vector<GeneratedCheckin>
|
||||
- follow_pool_ : std::vector<GeneratedFollow>
|
||||
--
|
||||
+ Run() : bool
|
||||
- RunUserPhase(locations : const std::vector<City>&) : void
|
||||
- RunBreweryAndBeerPhase(locations : const std::vector<City>&) : void
|
||||
- RunCheckinPhase() : void
|
||||
- RunRatingPhase() : void
|
||||
- RunFollowPhase() : void
|
||||
}
|
||||
|
||||
' --- Orchestration Aggregations (Services & Strategies) ---
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService
|
||||
BiergartenPipelineOrchestrator *-- EnrichmentService
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator
|
||||
BiergartenPipelineOrchestrator *-- ExportService
|
||||
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
|
||||
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
|
||||
BiergartenPipelineOrchestrator *-- SamplingStrategy
|
||||
BiergartenPipelineOrchestrator *-- BeerSelectionStrategy
|
||||
BiergartenPipelineOrchestrator *-- ApplicationOptions
|
||||
BiergartenPipelineOrchestrator *-- Logger
|
||||
|
||||
' --- Orchestration Aggregations (Data Pools) ---
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedUser : user_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBrewery : brewery_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedBeer : beer_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedCheckin : checkin_pool_
|
||||
BiergartenPipelineOrchestrator *-- "0..*" GeneratedFollow : follow_pool_
|
||||
|
||||
' --- Interfaces & Implementations ---
|
||||
ICuratedDataService <|.. JsonLoader
|
||||
ICuratedDataService <|.. MockCuratedDataService
|
||||
Logger <|.. PipelineLogger
|
||||
ContextStrategy <|.. BreweryContextStrategy
|
||||
ContextStrategy <|.. BeerContextStrategy
|
||||
SamplingStrategy <|.. UniformSamplingStrategy
|
||||
BeerSelectionStrategy <|.. RandomBeerSelectionStrategy
|
||||
CheckinDistributionStrategy <|.. JCurveCheckinStrategy
|
||||
CheckinDistributionStrategy <|.. RandomCheckinStrategy
|
||||
FollowGenerationStrategy <|.. RandomFollowStrategy
|
||||
FollowGenerationStrategy <|.. ActivityWeightedFollowStrategy
|
||||
EnrichmentService <|.. WikipediaService
|
||||
WebClient <|.. HttpWebClient
|
||||
DataGenerator <|.. MockGenerator
|
||||
DataGenerator <|.. LlamaGenerator
|
||||
PromptFormatter <|.. Gemma4JinjaPromptFormatter
|
||||
ExportService <|.. SqliteExportService
|
||||
DateTimeProvider <|.. SystemDateTimeProvider
|
||||
|
||||
' --- Service Compositions & Dependencies ---
|
||||
WikipediaService *-- WebClient
|
||||
WikipediaService ..> ContextStrategy
|
||||
LlamaGenerator *-- PromptFormatter
|
||||
LlamaGenerator *-- IPromptDirectory
|
||||
LlamaGenerator ..> GeneratorOptions
|
||||
SqliteExportService *-- DateTimeProvider
|
||||
|
||||
' --- Cross-Component Aggregations (Held References) ---
|
||||
PipelineLogger o-- BoundedChannel : logs to
|
||||
LogWorker o-- BoundedChannel : drains from
|
||||
|
||||
' --- Domain Containment ---
|
||||
EnrichedCity *-- City
|
||||
EnrichedCity *-- LocationContext
|
||||
GeneratedBrewery *-- City
|
||||
GeneratedBrewery *-- Address
|
||||
GeneratedBrewery *-- BreweryResult
|
||||
GeneratedBrewery *-- GenerationMetadata
|
||||
GeneratedBeer *-- City
|
||||
GeneratedBeer *-- BeerStyle
|
||||
GeneratedBeer *-- BeerResult
|
||||
GeneratedBeer *-- GenerationMetadata
|
||||
GeneratedUser *-- City
|
||||
GeneratedUser *-- UserResult
|
||||
GeneratedUser *-- GenerationMetadata
|
||||
GeneratedCheckin *-- CheckinResult
|
||||
GeneratedCheckin *-- GenerationMetadata
|
||||
GeneratedRating *-- RatingResult
|
||||
GeneratedRating *-- GenerationMetadata
|
||||
GeneratedFollow *-- GenerationMetadata
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 142 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 257 KiB |
@@ -1,303 +0,0 @@
|
||||
[2026-04-18 14:36:49.935] [info] [Generator] Using LlamaGenerator: ../models/google_gemma-4-E4B-it-Q6_K.gguf (temperature=1, top-p=0.95, top-k=64, n_ctx=8192, seed=-1)
|
||||
[2026-04-18 14:36:54.526] [info] [LlamaGenerator] Loaded model: ../models/google_gemma-4-E4B-it-Q6_K.gguf
|
||||
[2026-04-18 14:36:54.527] [info]
|
||||
=== GEOGRAPHIC DATA OVERVIEW ===
|
||||
[2026-04-18 14:36:54.527] [info] [JsonLoader] Loaded 5 locations from locations.json
|
||||
[2026-04-18 14:36:54.527] [info] Locations available: 5
|
||||
[2026-04-18 14:36:54.527] [info] Sampled locations: 5
|
||||
[2026-04-18 14:36:57.218] [info]
|
||||
=== SAMPLE BREWERY GENERATION ===
|
||||
[2026-04-18 14:36:57.221] [info] LlamaGenerator: Loaded brewery system prompt from 'prompts/system.md' (6489 chars)
|
||||
[2026-04-18 14:40:04.206] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process that leads to the desired output:
|
||||
|
||||
1. **Analyze the Request & Constraints:**
|
||||
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
|
||||
* **Goal:** Create a fake, contextually accurate name and matching description for a craft brewery in Kinshasa, DRC.
|
||||
* **Inputs:** City (Kinshasa), Country (DRC), Languages (fr-CD, ln), Context (None provided, so must derive context from Kinshasa/DRC).
|
||||
* **Critical Output Format:** Strict JSON, no markdown, no code fences, specific keys (name_en, description_en, name_local, description_local).
|
||||
* **Length:** 225-300 words per description (continuous string).
|
||||
* **Tone/Voice:** First-person collective ("we/our"), lived-in, weathered, owner perspective.
|
||||
* **Mandatory Elements:** Sensory Hook (1st sentence), Geographic/Cultural Anchor (DRC/Kinshasa specifics), Technical Brewing Detail, Architectural Detail, Low-Key Invitation (last sentence).
|
||||
* **Strict Blocklist:** (e.g., 'hidden gem', 'passion', 'repurposed dairy tank', 'grab a stool', etc.).
|
||||
* **Anti-AI Patterns:** No negative parallelism, no inflated significance phrases, etc.
|
||||
* **Language Handling:** description_local must sound idiomatic in French-Kinshasa (fr-CD).
|
||||
|
||||
2. **Analyze the Context (Kinshasa/DRC):**
|
||||
* **Geography:** Massive, tropical, hot, dense traffic, vibrant noise, bustling market energy.
|
||||
* **Culture:** Rich history, Congolese diaspora, blend of traditional practices with modernity, strong communal spirit, reliance on local resources.
|
||||
* **Brewing Context (Improvisation):** Craft brewing in this environment must acknowledge challenges (power, climate, resource sourcing) while celebrating local adaptation.
|
||||
* **Potential Elements:** Local grains (millet, sorghum?), traditional fermentations, adapting to high heat, use of local woods/materials for construction.
|
||||
|
||||
3. **Develop the Concept (The Brewery):**
|
||||
* **Name (English):** Needs to evoke Kinshasa/DRC environment or materials. *Mosi* (King/Chief in some Bantu languages) or a place name. Let's go with something related to the river/heat. *Lekele* (a fictional, local-sounding name). Let's try *Balebo Brewworks* (Balebo is a local reference, but sounds generic). How about focusing on the energy? *Mwami Kivu* (Chief of Kivu, referencing regional pride). Let's simplify and make it sound like a place: *Plateau Flow*. No, too Western. Let's use a French/local blend. *Le Cœur Vert* (The Green Heart) or *Zaïre Flow* (historic reference). Let's pick *La Roue du Congo*.
|
||||
* **Name (Local/Fr-CD):** *La Roue du Congo*.
|
||||
* **Brewing Detail:** High heat management is crucial. Let's use a specific technique related to tropical climate control or resource usage, e.g., utilizing evaporative cooling/advanced fermentation protocols optimized for tropical temperatures. Or focusing on local grain adaptation (e.g., utilizing millet/sorghum mash). Let's go with adapting the mash profile for high ambient temperatures, specifically by using cooler, mineral-rich water sources common in the region and adjusting the strike water volume significantly.
|
||||
* **Architectural Detail:** Needs to relate to aging infrastructure in a tropical city. Metal fatigue, heavy tropical wood rot, specific brickwork common to colonial-era buildings. Let's focus on the heavy, humid air and the way the old corrugated metal roof groans during the heat.
|
||||
|
||||
4. **Drafting - English Description (Iterative Refinement):**
|
||||
* *Hook:* (Sensory observation) The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady hum of the city.
|
||||
* *Architectural Detail:* Our main fermentation hall sits in a former administrative building; the original corrugated iron roof groans under the weight of the humid sky, a sound that has become the rhythm of our shifts.
|
||||
* *Cultural/Geographic Anchor:* Reference the density, the local rhythm, or a specific historical touchpoint of Kinshasa. (The city's relentless energy, the resilience).
|
||||
* *Technical Detail:* The high heat requires unique adjustments. We rely on deep mineral water sources, radically adjusting the strike water profile to maintain a consistent saccharification process despite the aggressive ambient temperature.
|
||||
* *Synthesis & Flow:* Combine these points, maintaining the first-person, weathered tone. Check word count (aim for 250).
|
||||
* *Invitation:* Specific, low-key. (e.g., "The cooler benches near the back window are always open.")
|
||||
|
||||
5. **Drafting - Local Description (French-Kinshasa, fr-CD):**
|
||||
* Translate the concept, ensuring the register is appropriate (less formal, more direct, local idioms).
|
||||
* Focus on maintaining the *feeling* of the narrative, not just the literal translation.
|
||||
* Ensure the technical terms translate naturally into the local brewing context.
|
||||
* Check word count (aim for 250).
|
||||
|
||||
6. **Final Review against Constraints:**
|
||||
* JSON format? Yes.
|
||||
* No markdown/fences? Yes.
|
||||
* 4 specific keys? Yes.
|
||||
* Escape quotes/single quotes? Yes.
|
||||
* Length 225-300? Yes.
|
||||
* Hook, Anchor, Technical, Architecture, Invitation? Yes.
|
||||
* Blocklist checked? Yes.
|
||||
* AI patterns checked? Yes.
|
||||
* Voice (We/Our, weathered)? Yes.
|
||||
* Local language idiomatic? Yes (Fr-CD tone).
|
||||
|
||||
7. **Construct the final JSON.** (Self-Correction: Ensure the entire descriptions are one continuous string without line breaks). (Ready to output).<channel|>{
|
||||
"name_en": "La Roue du Congo",
|
||||
"description_en": "The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square.",
|
||||
"name_local": "La Roue du Congo",
|
||||
"description_local": "L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
|
||||
}
|
||||
[2026-04-18 14:40:04.208] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||
[2026-04-18 14:42:23.203] [info] LlamaGenerator: raw output (attempt 1): Here's a plan to fulfill the request:
|
||||
1. **Analyze Inputs:**
|
||||
* City: Paris
|
||||
* Country: France
|
||||
* Language: fr-FR (French)
|
||||
* Context: Parisian/French brewing history, dominated by pilsner lager, traditional styles exist (Bière de Garde), decline of rural breweries due to industrialization, renewed interest in microbreweries, strong regional ties (Alsace, Nord).
|
||||
2. **Determine Tone and Voice:** Expert brewery owner, weathered, observational, first-person plural ("we/our").
|
||||
3. **Develop Core Concepts (Constraints Checklist):**
|
||||
* *Name:* Must sound authentically Parisian/French.
|
||||
* *Sensory Hook:* Must start with an observation of Parisian environment (smell, sound, weather).
|
||||
* *Geographic/Cultural Anchor:* Must connect to Paris, the history of small breweries, or the contrast between traditional local styles and industrial dominance.
|
||||
* *Technical Brewing Detail:* Must be highly specialized (e.g., specific fermentation, water chemistry, unique grain handling).
|
||||
* *Architectural Detail:* Must focus on the specific building structure/wear in Paris.
|
||||
* *Invitation:* Last sentence must be a specific, low-key location point.
|
||||
* *Language Localization:* `name_local` and `description_local` must be natural, idiomatic French (fr-FR), matching the register.
|
||||
* *Word Count/Format:* 225-300 words each. Strict JSON format, no markdown, no escapes unless needed for quotes.
|
||||
* *Blocklist Check:* Avoid all forbidden words/phrases.
|
||||
|
||||
4. **Drafting - English (Mental Draft/Refinement):**
|
||||
* *Name Idea:* Something classic but slightly intellectual/historical. Maybe referencing a Parisian street or concept. (e.g., Le Moulin des Brasseurs, L'Usine de la Seine).
|
||||
* *Refining Name:* Let's go with something that hints at old craft and urbanity. "La Brasserie de l'Atelier Urbain."
|
||||
* *Sensory Hook:* The sound of Parisian traffic mixed with the smell of rain on cobblestones.
|
||||
* *Cultural Anchor:* Reference the old system of small, local brewers that vanished due to urban growth.
|
||||
* *Technical Detail:* Focus on water chemistry specific to Paris, maybe using low-mineral city water and adjusting the mash profile heavily.
|
||||
* *Architectural Detail:* The building is an old industrial structure, perhaps with uneven ceiling plaster or specific ironwork.
|
||||
* *Invitation:* Direct reference to a specific corner or bench.
|
||||
|
||||
5. **Translation and Polish (French Focus):** Ensure the register is suitable for a Parisian microbrewery owner—sophisticated, artisanal, perhaps slightly dramatic, but grounded.
|
||||
|
||||
6. **Final Review against constraints.** (The word count and strict JSON format are paramount.) (Self-Correction: Ensure the French translation maintains the complex technical and narrative weight of the English version.)<channel|>{
|
||||
"name_en": "La Brasserie de l'Atelier Urbain",
|
||||
"description_en": "The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop.",
|
||||
"name_local": "La Brasserie de l'Atelier Urbain",
|
||||
"description_local": "L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
|
||||
}
|
||||
[2026-04-18 14:42:23.205] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||
[2026-04-18 14:45:06.674] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
|
||||
|
||||
1. **Analyze the Request & Constraints:**
|
||||
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
|
||||
* **Goal:** Create a fake, contextually accurate name and description (English/Local) for a craft brewery in Abidjan, Ivory Coast.
|
||||
* **Format:** Strict JSON structure, NO Markdown fences, NO extra text.
|
||||
* **Length:** 225-300 words per description.
|
||||
* **Language:** French (fr-CI).
|
||||
* **Voice:** First-person ("we"/"our"), weathered, lived-in tone.
|
||||
* **Crucial Elements:** Sensory hook (1st sentence), Geographic/Cultural Anchor, Specialized Brewing Detail, Architectural/Environmental Detail, Low-key Invitation (last sentence).
|
||||
* **Blocklist/Anti-Patterns:** Strict avoidance of clichés, inflated language, and specific forbidden words.
|
||||
|
||||
2. **Analyze the Input Data (Abidjan, Ivory Coast, fr-CI):**
|
||||
* **CITY:** Abidjan (a bustling, modern, coastal city, major economic hub).
|
||||
* **COUNTRY:** Ivory Coast.
|
||||
* **LANGUAGE:** French (fr-CI).
|
||||
* **CONTEXT:** (Missing, but I must infer context based on the city and country). Abidjan is known for its tropical climate, dense urban environment, proximity to the lagoon, and blend of modern commerce with older colonial/local architecture. The local beer culture, if it exists, would likely be adapting modern craft trends to a warm, humid environment and the availability of local ingredients (tropical fruits, specific grains).
|
||||
|
||||
3. **Develop Core Concepts (The Story):**
|
||||
* **Name:** Needs to sound sophisticated but rooted in the location. Abidjan has a specific atmosphere. Let's focus on the lagoon or the environment. *Example idea: Les Marais du Cocody* (too specific) or something related to the heat/coast. Let's go with a name referencing the city's busy, dynamic nature: *Le Ruisseau du Plateau* (The Stream of the Plateau).
|
||||
* **Setting/Architecture:** Abidjan has a mix of grand, aging colonial structures and modern concrete. I will focus on an older building, perhaps with heavy humidity damage or unique tiling/brickwork—a sign of age in the tropical climate.
|
||||
* **Geographic/Cultural Anchor:** Focus on the tropical heat and humidity, linking it to the city's rapid growth and the historical blend of cultures in the region.
|
||||
* **Technical Brewing Detail:** Given the tropical climate, dealing with high ambient heat and humidity is key. Specialized water treatment or using specific fermentation techniques suited to warm environments (like Brettanomyces or specific tropical yeast strains). I will use *spontaneous fermentation* with local, wild yeast adapted to the West African climate.
|
||||
* **Sensory Hook:** The smell or sound of the tropical urban environment (e.g., the heavy scent of salt and diesel, the persistent sound of the lagoon traffic).
|
||||
|
||||
4. **Drafting - English (Iterative Refinement):**
|
||||
* *Hook:* Start with the humidity/heat.
|
||||
* *Architecture:* Describe the aging structure (e.g., heavy wrought iron, porous walls).
|
||||
* *Brewing Detail:* Spontaneous fermentation using local flora.
|
||||
* *Cultural/Geographic:* Tie it to the lagoon/coastal environment.
|
||||
* *Invitation:* Low-key directive.
|
||||
|
||||
5. **Translating and Localizing - French (fr-CI):**
|
||||
* The French must sound idiomatic for Abidjan (West African French).
|
||||
* Maintain the exact structure and length constraints.
|
||||
* The register must be conversational and rooted in local experience.
|
||||
|
||||
6. **Final Review against Constraints:**
|
||||
* JSON format? Yes.
|
||||
* No markdown/code fences? Yes.
|
||||
* Length (225-300 words)? Yes (checking word count for both).
|
||||
* First-person owner voice? Yes.
|
||||
* Hook, Anchor, Technical, Architectural, Invitation? All present.
|
||||
* Blocklist avoided? Yes.
|
||||
* Quotes handled correctly (escaped or single quotes)? Yes.
|
||||
* Continuous strings (no line breaks)? Yes.
|
||||
|
||||
*(Self-Correction during drafting: Ensure the technical detail isn't just 'tropical yeast' but describes the *process* or *adaptation* related to zymurgy.)*
|
||||
|
||||
7. **Generating the Output.** (Proceeding to JSON construction.)<channel|>{
|
||||
"name_en": "Le Ruisseau du Plateau",
|
||||
"description_en": "The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance.",
|
||||
"name_local": "Le Ruisseau du Plateau",
|
||||
"description_local": "La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
|
||||
}
|
||||
[2026-04-18 14:45:06.675] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||
[2026-04-18 14:47:55.003] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process to ensure all constraints are met:
|
||||
|
||||
1. **Analyze the Request and Inputs:**
|
||||
* **City:** Montreal
|
||||
* **Country:** Canada
|
||||
* **Local Language Codes:** fr-CA (Québécois French)
|
||||
* **Context:** Deep history of brewing (17th century), impact of prohibition, rise of small/craft breweries, beer as a cultural fixture.
|
||||
* **Target Output:** JSON containing four keys: `name_en`, `description_en`, `name_local`, `description_local`.
|
||||
* **Constraints Checklist:**
|
||||
* Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy. (Voice)
|
||||
* No Markdown/JSON wrapping/preamble. (Format)
|
||||
* Exact four keys, no renames. (Format)
|
||||
* Escape quotes or use single quotes. (Format)
|
||||
* No line breaks in strings. (Format)
|
||||
* Length: 225-300 words per description. (Length)
|
||||
* Hook: Sensory environmental observation (personal). (Content)
|
||||
* Anchor: Deeply tied to Montreal/Quebec culture/history. (Content)
|
||||
* Technical Detail: Highly specialized brewing method. (Content)
|
||||
* Architectural Detail: Specific, familiar, non-cliché. (Content)
|
||||
* Invitation: Low-key, concrete place, personal. (Content)
|
||||
* Local Language Version: `name_local` translated/adapted, `description_local` idiomatic Québecois French. (Localization)
|
||||
* Blocklist: No forbidden words/phrases. (Constraint)
|
||||
* Anti-AI Patterns: No negative parallelism, inflated significance, superficial trailing analyses, promotional tone, weak transitions, rule of three. (Constraint)
|
||||
* Voice: First person (We/Our, sparingly I). Lived-in, weathered tone. (Voice)
|
||||
|
||||
2. **Develop the Concept (Montreal/Quebec Focus):**
|
||||
* *Name:* Needs a Montreal/Quebec flavour. Something old-school but slightly artisanal. Focus on stone, climate, or old industry.
|
||||
* *Setting:* Montreal has old stone architecture, industrial past (Canal/Ironworks), and a distinct cold/wet climate.
|
||||
* *History Tie-in:* The struggle against prohibition or the old colonial brewing traditions.
|
||||
* *Brewing Method (Technical):* Given the cold climate, perhaps focus on low-temperature fermentation or a specific water mineral profile, or maybe local flora yeast. Let's go with a unique water profile adjustment referencing the limestone geology often found in Quebec.
|
||||
* *Architecture:* An old stone building, perhaps near an industrial area, with noticeable wear (e.g., specific brick crumbling, old window frames).
|
||||
|
||||
3. **Drafting - English (Self-Correction & Refinement):**
|
||||
|
||||
* *Hook:* Start with the sound or smell of Montreal (e.g., wet cobblestones, late-fall air, the distant roar of the city).
|
||||
* *Name Draft:* Les Piliers du Grand Fleuve (The Pillars of the Great River).
|
||||
* *Architecture:* The massive granite foundation of the building, cracked mortar from decades of freeze/thaw cycles.
|
||||
* *Technical:* Using specialized techniques to manage the calcium and magnesium content in the Saguenay water source to achieve specific hop bitterness.
|
||||
* *Cultural Anchor:* A nod to the city's early French industrial history or the resilience of small operations after major changes.
|
||||
* *Invitation:* Point to a specific corner or seating area.
|
||||
|
||||
4. **Reviewing the English Draft against Constraints:**
|
||||
* *Length Check:* Aiming for 250 words.
|
||||
* *Voice Check:* First person plural (We/Our).
|
||||
* *Blocklist Check:* No forbidden words.
|
||||
* *Flow Check:* Personal, lived-in tone.
|
||||
|
||||
5. **Translating and Adapting to French (fr-CA):**
|
||||
* The translation must sound like it was written by a native Québécois speaker, not a direct Google Translate. Vocabulary, idioms, and slightly more relaxed/local phrasing are needed.
|
||||
* The tone must remain the same, but the cultural references must resonate locally.
|
||||
|
||||
6. **Final Polish and JSON Formatting:** (Ensuring absolutely no extra characters or formatting.) (The drafting process below leads directly to the final output.)<channel|>{
|
||||
"name_en": "Les Piliers du Grand Fleuve",
|
||||
"description_en": "There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful.",
|
||||
"name_local": "Les Piliers du Grand Fleuve",
|
||||
"description_local": "Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
|
||||
}
|
||||
[2026-04-18 14:47:55.004] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||
[2026-04-18 14:50:56.464] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
|
||||
|
||||
1. **Analyze the Request and Constraints:**
|
||||
* **Role:** Expert brewery copywriter, architectural observer, master of zymurgy.
|
||||
* **Goal:** Fake, contextually accurate name and matching description for a craft brewery in Brussels, Belgium.
|
||||
* **Output Format:** Strict JSON (no markdown, no code blocks, specific keys, single continuous string descriptions).
|
||||
* **Length:** 225-300 words per description.
|
||||
* **Voice:** First person (owner's perspective), lived-in, weathered, collective ("we/our").
|
||||
* **Constraints Checklist:**
|
||||
* Hook (sensory environmental observation).
|
||||
* Geographic/Cultural Anchor (Brussels/Belgian history/culture).
|
||||
* Technical Brewing Detail (highly specialized, varied).
|
||||
* Architectural Detail (specific, familiar wear/structure).
|
||||
* Invitation (low-key, specific location).
|
||||
* Local Language Version (fr-BE or nl-BE, idiomatic register).
|
||||
* Blocklist adherence (No forbidden words/patterns).
|
||||
* No internal line breaks (\n).
|
||||
* Quotes must be handled correctly.
|
||||
|
||||
2. **Analyze the Input Data (Brussels/Belgium):**
|
||||
* **City:** Brussels.
|
||||
* **Country:** Belgium.
|
||||
* **Local Languages:** fr-BE, nl-BE. (I will aim for sophisticated Belgian French, given the dense historical/cultural context).
|
||||
* **Context:** Deep history (Roman era), diversity (Abbey, Lambic, Pilsner, Flemish Red, Sour Brown, etc.), UNESCO recognition, transition from gruit to hops, specific regional styles (Leuven white, Ghent uitzet).
|
||||
|
||||
3. **Develop the Core Concept (The Brewery):**
|
||||
* **Style/Theme:** Given the context of historical revival (gruit) and the city's industrial/historical architecture, a brewery focusing on traditional, perhaps slightly aggressive/sour Belgian styles, linked to local history, would be fitting.
|
||||
* **Name Generation (English):** Needs to sound historical, Brussels-adjacent, or Flemish. *Example: The Old Zythology Club, Zythos Atelier, Grand Rue Malt House.* Let's go with something that hints at the building's age and the craft.
|
||||
* **Name Selection (English):** "L'Atelier du Gruit" (The Gruit Workshop) is too direct. How about referencing a specific local building characteristic? "Rue des Mains Anciennes" (Street of Old Hands). Let's focus on a historical trade or place.
|
||||
* *Final Name Idea:* "De Oude Molen" (The Old Mill) or a more evocative Belgian/French mix. Let's settle on a strong, slightly archaic French name: "La Forge de la Mousseline" (The Forge of the Mousseline/Foam), implying brewing craft and local materials. Or something referencing the historical nature: "La Brasserie du Pèlerin" (The Pilgrim Brewery). Let's use a name referencing historical processes: "Le Moulin de Zythos."
|
||||
|
||||
4. **Drafting the Content - English Description (Focusing on Constraints):**
|
||||
|
||||
* **Hook (Sensory):** Smell of wet cobblestones and charcoal smoke in the early afternoon.
|
||||
* **Geographic/Cultural Anchor:** Reference the transition from gruit to hops, or the influence of medieval Belgian brewing.
|
||||
* **Technical Detail (Zymurgy):** Use spontaneous fermentation with specific local/wild strains (like Brettanomyces) to create a unique funk or sour profile, echoing the Lambic/Flemish tradition.
|
||||
* **Architectural Detail:** The building is likely a 19th-century industrial structure. Mention the heavy, worn stone and specific structural elements (e.g., massive supporting beams, stained brickwork).
|
||||
* **Invitation (Low-key):** Direct reference to a specific spot (e.g., the reading nook by the back window, the counter in the cellar).
|
||||
* **Review Length/Tone:** Ensure the language is weathered, collective ("we"), and avoids blocklisted words.
|
||||
|
||||
*(Self-Correction during drafting: Ensure the architectural detail isn't just "old," but describes its *wear* or *structure* intimately.)*
|
||||
|
||||
5. **Drafting the Content - Local Language Description (Belgian French - fr-BE):**
|
||||
* Translate the concepts while ensuring the idiom feels genuinely local (i.e., not a literal translation of the English academic tone). Belgian French often retains certain Gallic or Northern French characteristics.
|
||||
* Maintain the structure and length (225-300 words).
|
||||
|
||||
6. **Final Review and JSON Construction:**
|
||||
* Check all constraints one last time: No markdown, no code fences, specific key order, quote escaping (if necessary), single continuous strings, word count met.
|
||||
|
||||
*(Proceeding with the drafting based on these steps.)*<channel|>{
|
||||
"name_en": "Le Moulin de Zythos",
|
||||
"description_en": "The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied.",
|
||||
"name_local": "Le Moulin de Zythos",
|
||||
"description_local": "La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
|
||||
}
|
||||
[2026-04-18 14:50:56.466] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||
[2026-04-18 14:50:56.466] [info]
|
||||
=== GENERATED DATA DUMP ===
|
||||
[2026-04-18 14:50:56.466] [info] 1. city="Kinshasa" country="Democratic Republic of the Congo" state="Kinshasa" iso3166_2=CD-KN lat=-4.4419 lon=15.2663
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Roue du Congo"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_en="The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square."
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Roue du Congo"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
|
||||
[2026-04-18 14:50:56.466] [info] 2. city="Paris" country="France" state="Île-de-France" iso3166_2=FR-IDF lat=48.8566 lon=2.3522
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Brasserie de l'Atelier Urbain"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_en="The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop."
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Brasserie de l'Atelier Urbain"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
|
||||
[2026-04-18 14:50:56.466] [info] 3. city="Abidjan" country="Ivory Coast" state="Abidjan" iso3166_2=CI-AB lat=5.36 lon=-4.0083
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Ruisseau du Plateau"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_en="The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance."
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Ruisseau du Plateau"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_local="La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
|
||||
[2026-04-18 14:50:56.466] [info] 4. city="Montreal" country="Canada" state="Quebec" iso3166_2=CA-QC lat=45.5017 lon=-73.5673
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_en="Les Piliers du Grand Fleuve"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_en="There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful."
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_local="Les Piliers du Grand Fleuve"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_local="Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
|
||||
[2026-04-18 14:50:56.466] [info] 5. city="Brussels" country="Belgium" state="Brussels-Capital Region" iso3166_2=BE-BRU lat=50.8503 lon=4.3517
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Moulin de Zythos"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_en="The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied."
|
||||
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Moulin de Zythos"
|
||||
[2026-04-18 14:50:56.466] [info] brewery_description_local="La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
|
||||
[2026-04-18 14:50:56.467] [info] Pipeline executed successfully
|
||||
293
docs/testing.md
Normal file
293
docs/testing.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Testing
|
||||
|
||||
This document describes the testing strategy and how to run tests for The Biergarten App.
|
||||
|
||||
## Overview
|
||||
|
||||
The project uses a multi-layered testing approach:
|
||||
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
||||
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
||||
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
||||
|
||||
## Running Tests with Docker (Recommended)
|
||||
|
||||
The easiest way to run all tests is using Docker Compose, which sets up an isolated test
|
||||
environment:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Starts a fresh SQL Server instance
|
||||
2. Runs database migrations
|
||||
3. Seeds test data
|
||||
4. Executes all test suites in parallel
|
||||
5. Exports results to `./test-results/`
|
||||
6. Exits when tests complete
|
||||
|
||||
### View Test Results
|
||||
|
||||
```bash
|
||||
# List test result files
|
||||
ls -la test-results/
|
||||
|
||||
# View specific test results
|
||||
cat test-results/api-specs/results.trx
|
||||
cat test-results/repository-tests/results.trx
|
||||
cat test-results/service-auth-tests/results.trx
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Remove test containers and volumes
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Integration Tests (API.Specs)
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet test API/API.Specs/API.Specs.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- SQL Server instance running
|
||||
- Database migrated and seeded
|
||||
- Environment variables set (DB connection, JWT secret)
|
||||
|
||||
### Repository Tests
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- SQL Server instance running (uses mock data)
|
||||
|
||||
### Service Tests
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- No database required (uses Moq for mocking)
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Current Coverage
|
||||
|
||||
**Authentication & User Management**:
|
||||
|
||||
- User registration with validation
|
||||
- User login with JWT token generation
|
||||
- Password hashing and verification (Argon2id)
|
||||
- JWT token generation and claims
|
||||
- Invalid credentials handling
|
||||
- 404 error responses
|
||||
|
||||
**Repository Layer**:
|
||||
|
||||
- User account creation
|
||||
- User credential management
|
||||
- GetUserByUsername queries
|
||||
- Stored procedure execution
|
||||
|
||||
**Service Layer**:
|
||||
|
||||
- Login service with password verification
|
||||
- Register service with validation
|
||||
- Business logic for authentication flow
|
||||
|
||||
### Planned Coverage
|
||||
|
||||
- [ ] Email verification workflow
|
||||
- [ ] Password reset functionality
|
||||
- [ ] Token refresh mechanism
|
||||
- [ ] Brewery data management
|
||||
- [ ] Beer post operations
|
||||
- [ ] User follow/unfollow
|
||||
- [ ] Image upload service
|
||||
|
||||
## Testing Frameworks & Tools
|
||||
|
||||
### xUnit
|
||||
|
||||
- Primary unit testing framework
|
||||
- Used for Repository and Service layer tests
|
||||
- Supports parallel test execution
|
||||
|
||||
### Reqnroll (Gherkin/BDD)
|
||||
|
||||
- Behavior-driven development framework
|
||||
- Used for API integration tests
|
||||
- Human-readable test scenarios in `.feature` files
|
||||
|
||||
### FluentAssertions
|
||||
|
||||
- Expressive assertion library
|
||||
- Makes test assertions more readable
|
||||
- Used across all test projects
|
||||
|
||||
### Moq
|
||||
|
||||
- Mocking framework for .NET
|
||||
- Used in Service layer tests
|
||||
- Enables isolated unit testing
|
||||
|
||||
### DbMocker
|
||||
|
||||
- Database mocking for repository tests
|
||||
- Simulates SQL Server responses
|
||||
- No real database required for unit tests
|
||||
|
||||
## Test Structure
|
||||
|
||||
### API.Specs (Integration Tests)
|
||||
|
||||
```
|
||||
API.Specs/
|
||||
├── Features/
|
||||
│ ├── Authentication.feature # Login/register scenarios
|
||||
│ └── UserManagement.feature # User CRUD scenarios
|
||||
├── Steps/
|
||||
│ ├── AuthenticationSteps.cs # Step definitions
|
||||
│ └── UserManagementSteps.cs
|
||||
└── Mocks/
|
||||
└── TestApiFactory.cs # Test server setup
|
||||
```
|
||||
|
||||
**Example Feature**:
|
||||
|
||||
```gherkin
|
||||
Feature: User Authentication
|
||||
As a user
|
||||
I want to register and login
|
||||
So that I can access the platform
|
||||
|
||||
Scenario: Successful user registration
|
||||
Given I have valid registration details
|
||||
When I register a new account
|
||||
Then I should receive a JWT token
|
||||
And my account should be created
|
||||
```
|
||||
|
||||
### Infrastructure.Repository.Tests
|
||||
|
||||
```
|
||||
Infrastructure.Repository.Tests/
|
||||
├── AuthRepositoryTests.cs # Auth repository tests
|
||||
├── UserAccountRepositoryTests.cs # User account tests
|
||||
└── TestFixtures/
|
||||
└── DatabaseFixture.cs # Shared test setup
|
||||
```
|
||||
|
||||
### Service.Auth.Tests
|
||||
|
||||
```
|
||||
Service.Auth.Tests/
|
||||
├── LoginService.test.cs # Login business logic tests
|
||||
└── RegisterService.test.cs # Registration business logic tests
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example (xUnit)
|
||||
|
||||
```csharp
|
||||
public class LoginServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoginAsync_ValidCredentials_ReturnsToken()
|
||||
{
|
||||
// Arrange
|
||||
var mockRepo = new Mock<IAuthRepository>();
|
||||
var mockJwt = new Mock<IJwtService>();
|
||||
var service = new AuthService(mockRepo.Object, mockJwt.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.LoginAsync("testuser", "password123");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Token.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Example (Reqnroll)
|
||||
|
||||
```gherkin
|
||||
Scenario: User login with valid credentials
|
||||
Given a registered user with username "testuser"
|
||||
When I POST to "/api/auth/login" with valid credentials
|
||||
Then the response status should be 200
|
||||
And the response should contain a JWT token
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests run automatically in CI/CD pipelines using the test Docker Compose configuration:
|
||||
|
||||
```bash
|
||||
# CI/CD command
|
||||
docker compose -f docker-compose.test.yaml build
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0` - All tests passed
|
||||
- Non-zero - Test failures occurred
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Failing Due to Database Connection
|
||||
|
||||
Ensure SQL Server is running and environment variables are set:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml ps
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||
`docker-compose.test.yaml`.
|
||||
|
||||
### Stale Test Data
|
||||
|
||||
Clean up test database:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
### View Container Logs
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml logs <service-name>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Isolation**: Each test should be independent and not rely on other tests
|
||||
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||
3. **Mocking**: Mock external dependencies in unit tests
|
||||
4. **Descriptive Names**: Use clear, descriptive test method names
|
||||
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
|
||||
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios
|
||||
@@ -2,114 +2,85 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Core project validates JWTs across three token types:
|
||||
The Core project implements comprehensive JWT token validation across three token types:
|
||||
|
||||
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
||||
tokens
|
||||
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email
|
||||
confirmation
|
||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access tokens
|
||||
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email confirmation
|
||||
|
||||
## Components
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||
|
||||
Low-level JWT operations.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `GenerateJwt()` - Creates signed JWT tokens
|
||||
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
|
||||
|
||||
**Implementation:**
|
||||
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
|
||||
|
||||
**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
|
||||
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||
- Algorithm: HS256 (HMAC-SHA256)
|
||||
- Validates token lifetime, signature, and well-formedness
|
||||
|
||||
### Features.Auth Slice
|
||||
### Service Layer
|
||||
|
||||
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
||||
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
|
||||
|
||||
Both token generation and validation live on the same slice-internal
|
||||
service (there is no separate validation service/class).
|
||||
|
||||
**Generation methods:**
|
||||
|
||||
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
||||
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
||||
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation
|
||||
token
|
||||
|
||||
**Validation methods:**
|
||||
High-level token validation with context (token type, user extraction).
|
||||
|
||||
**Methods:**
|
||||
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
|
||||
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
|
||||
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
|
||||
|
||||
**Returns (validation):** `ValidatedToken` record containing:
|
||||
|
||||
**Returns:** `ValidatedToken` record containing:
|
||||
- `UserId` (Guid)
|
||||
- `Username` (string)
|
||||
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||
|
||||
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
||||
|
||||
**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
|
||||
- Reads token secrets from environment variables
|
||||
- Extracts and validates claims (Sub, UniqueName)
|
||||
- Throws `UnauthorizedException` on validation failure
|
||||
|
||||
`TokenService` is registered by `Features.Auth`'s own
|
||||
`AddFeaturesAuth()` extension method, except for the lower-level
|
||||
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
|
||||
registered by the host (`API.Core/Program.cs`) since
|
||||
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
|
||||
it directly.
|
||||
#### [ITokenService](Service.Auth/ITokenService.cs)
|
||||
|
||||
Token generation (existing service extended).
|
||||
|
||||
**Methods:**
|
||||
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
||||
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
||||
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
|
||||
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Receives confirmation token from user via `ConfirmUserCommand`
|
||||
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
|
||||
1. Receives confirmation token from user
|
||||
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
|
||||
3. Extracts user ID from validated token
|
||||
4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database
|
||||
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
|
||||
5. Returns confirmation result
|
||||
|
||||
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
|
||||
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
|
||||
|
||||
**Flow:**
|
||||
1. Receives refresh token from user
|
||||
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
|
||||
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
|
||||
4. Issues new access and refresh tokens via `TokenService`
|
||||
5. Returns new token pair
|
||||
|
||||
1. Receives a user ID via `ResendConfirmationEmailCommand`
|
||||
2. Looks up the user and checks they aren't already verified
|
||||
3. Generates a fresh confirmation token via `ITokenService`
|
||||
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
|
||||
`Features.Emails` slice, with no direct project reference between the two
|
||||
slices
|
||||
|
||||
#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs)
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Receives a refresh token via `RefreshTokenCommand`
|
||||
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
|
||||
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
|
||||
and issues a new access/refresh token pair
|
||||
3. Maps the result onto `LoginPayload`
|
||||
|
||||
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
|
||||
#### [AuthController](API.Core/Controllers/AuthController.cs)
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Authenticate user
|
||||
- `POST /api/auth/confirm?token=...` - Confirm email
|
||||
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
||||
- `POST /api/auth/refresh` - Refresh access token
|
||||
|
||||
## Validation Security
|
||||
@@ -117,13 +88,11 @@ it directly.
|
||||
### Token Secrets
|
||||
|
||||
Three independent secrets enable:
|
||||
|
||||
- **Key rotation** - Rotate each secret type independently
|
||||
- **Isolation** - Compromise of one secret doesn't affect others
|
||||
- **Different expiration** - Different token types can expire at different rates
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
```bash
|
||||
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
|
||||
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
||||
@@ -142,7 +111,6 @@ Each token is validated for:
|
||||
### Error Handling
|
||||
|
||||
Validation failures return HTTP 401 Unauthorized:
|
||||
|
||||
- Invalid signature → "Invalid token"
|
||||
- Expired token → "Invalid token" (message doesn't reveal reason for security)
|
||||
- Missing claims → "Invalid token"
|
||||
@@ -172,60 +140,42 @@ Validation failures return HTTP 401 Unauthorized:
|
||||
1. **Generation**: During user registration (30-minute validity)
|
||||
2. **Delivery**: Emailed to user in confirmation link
|
||||
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
||||
4. **Validation**: Validated by `ConfirmUserHandler`
|
||||
4. **Validation**: Validated by ConfirmationService
|
||||
5. **Completion**: User account marked as confirmed
|
||||
6. **Expiration**: Token becomes invalid after 30 minutes
|
||||
|
||||
## Testing
|
||||
|
||||
All of the following live in `Features.Auth.Tests`.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Services/TokenServiceValidationTests.cs**
|
||||
|
||||
**TokenValidationService.test.cs**
|
||||
- Happy path: Valid token extraction
|
||||
- Error cases: Invalid, expired, malformed tokens
|
||||
- Missing/invalid claims scenarios
|
||||
|
||||
**Services/TokenServiceRefreshTests.cs**
|
||||
|
||||
**RefreshTokenService.test.cs**
|
||||
- Successful refresh with valid token
|
||||
- Invalid/expired refresh token rejection
|
||||
- Non-existent user handling
|
||||
|
||||
**Commands/RefreshTokenHandlerTests.cs**
|
||||
|
||||
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
|
||||
`LoginPayload`
|
||||
|
||||
**Commands/ConfirmUserHandlerTests.cs**
|
||||
|
||||
**ConfirmationService.test.cs**
|
||||
- Successful confirmation with valid token
|
||||
- Token validation failures
|
||||
- User not found scenarios
|
||||
|
||||
**Commands/ResendConfirmationEmailHandlerTests.cs**
|
||||
|
||||
- Sends a fresh confirmation email when the user exists and is unverified
|
||||
- No-ops when the user doesn't exist or is already verified
|
||||
|
||||
### BDD Tests (Reqnroll)
|
||||
|
||||
**TokenRefresh.feature**
|
||||
|
||||
- Successful token refresh
|
||||
- Invalid/expired token rejection
|
||||
- Missing token validation
|
||||
|
||||
**Confirmation.feature**
|
||||
|
||||
- Successful email confirmation
|
||||
- Expired/tampered token rejection
|
||||
- Missing token validation
|
||||
|
||||
**AccessTokenValidation.feature**
|
||||
|
||||
- Protected endpoint access token validation
|
||||
- Invalid/expired access token rejection
|
||||
- Token type mismatch (refresh used as access token)
|
||||
File diff suppressed because one or more lines are too long
@@ -1,126 +0,0 @@
|
||||
@startuml architecture
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title The Biergarten App - Vertical Slice Architecture
|
||||
|
||||
package "API.Core (thin host)" #E3F2FD {
|
||||
[API.Core\nASP.NET Core Web API host] as API
|
||||
note right of API
|
||||
- Program.cs wiring only
|
||||
- MediatR + AddApplicationPart
|
||||
per Features.* assembly
|
||||
- Swagger/OpenAPI, health checks
|
||||
- JWT auth middleware
|
||||
- Global exception filter
|
||||
end note
|
||||
}
|
||||
|
||||
package "Feature Slices" #F3E5F5 {
|
||||
[Features.Auth] as AuthSlice
|
||||
[Features.Breweries] as BrewerySlice
|
||||
[Features.UserManagement] as UserSlice
|
||||
[Features.Emails] as EmailsSlice
|
||||
note right of AuthSlice
|
||||
Each slice owns its own:
|
||||
- Controller (HTTP endpoints)
|
||||
- Commands/Queries + Handlers
|
||||
- Validators
|
||||
- Repository
|
||||
end note
|
||||
note right of EmailsSlice
|
||||
No controller - invoked only
|
||||
via MediatR commands sent
|
||||
from other slices
|
||||
end note
|
||||
}
|
||||
|
||||
package "Shared" #FCE4EC {
|
||||
[Shared.Contracts] as SharedContracts
|
||||
[Shared.Application] as SharedApp
|
||||
note right of SharedApp
|
||||
- ValidationBehavior
|
||||
(MediatR pipeline)
|
||||
- Cross-slice email commands
|
||||
(SendRegistrationEmailCommand,
|
||||
SendResendConfirmationEmailCommand)
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #FFF3E0 {
|
||||
[Infrastructure.Sql] as Sql
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
[Infrastructure.Email] as Email
|
||||
[Infrastructure.Email.Templates] as EmailTemplates
|
||||
}
|
||||
|
||||
package "Domain Layer" #E8F5E9 {
|
||||
[Domain.Entities] as Domain
|
||||
[Domain.Exceptions] as DomainExceptions
|
||||
note right of Domain
|
||||
- UserAccount
|
||||
- UserCredential
|
||||
- UserVerification
|
||||
- BreweryPost
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Stored Procedures] as SP
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
' Relationships
|
||||
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
|
||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
||||
|
||||
AuthSlice --> Sql
|
||||
AuthSlice --> JWT
|
||||
AuthSlice --> PwdHash
|
||||
AuthSlice --> SharedContracts
|
||||
AuthSlice --> SharedApp
|
||||
|
||||
BrewerySlice --> Sql
|
||||
BrewerySlice --> SharedContracts
|
||||
BrewerySlice --> SharedApp
|
||||
|
||||
UserSlice --> Sql
|
||||
UserSlice --> SharedContracts
|
||||
UserSlice --> SharedApp
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
|
||||
' The one cross-slice interaction: a MediatR command contract, not a project reference
|
||||
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||
|
||||
Sql --> SP
|
||||
Sql --> Domain
|
||||
SP --> Tables
|
||||
|
||||
AuthSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
UserSlice --> Domain
|
||||
AuthSlice --> DomainExceptions
|
||||
|
||||
' Notes
|
||||
note left of Sql
|
||||
SQL-first approach
|
||||
All queries via
|
||||
stored procedures
|
||||
end note
|
||||
|
||||
note bottom of AuthSlice
|
||||
No Features.* project
|
||||
ever references another
|
||||
Features.* project directly
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,334 +0,0 @@
|
||||
# Environment Variables
|
||||
|
||||
This document covers the active environment variables used by the current
|
||||
Biergarten stack.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses environment variables for:
|
||||
|
||||
- **.NET API backend** - database connections, token secrets, runtime settings
|
||||
- **React Router website** - API base URL and session signing
|
||||
- **Docker containers** - environment-specific orchestration
|
||||
|
||||
## Configuration Patterns
|
||||
|
||||
### Backend (.NET API)
|
||||
|
||||
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||
|
||||
### Frontend (`web/frontend`)
|
||||
|
||||
The active website reads runtime values from the server environment for its auth
|
||||
and API integration.
|
||||
|
||||
### Docker
|
||||
|
||||
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
||||
|
||||
- `.env.dev` - Development
|
||||
- `.env.test` - Testing
|
||||
- `.env.prod` - Production
|
||||
|
||||
## Backend Variables (.NET API)
|
||||
|
||||
### Database Connection
|
||||
|
||||
**Option 1: Component-Based (Recommended for Docker)**
|
||||
|
||||
Build connection string from individual components:
|
||||
|
||||
```bash
|
||||
DB_SERVER=sqlserver,1433 # SQL Server host and port
|
||||
DB_NAME=Biergarten # Database name
|
||||
DB_USER=sa # SQL Server username
|
||||
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
|
||||
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
|
||||
```
|
||||
|
||||
**Option 2: Full Connection String (Local Development)**
|
||||
|
||||
Provide complete connection string:
|
||||
|
||||
```bash
|
||||
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
```
|
||||
|
||||
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection
|
||||
string is built from components.
|
||||
|
||||
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
||||
|
||||
### JWT Authentication Secrets (Backend)
|
||||
|
||||
The backend uses separate secrets for different token types to enable
|
||||
independent key rotation and validation isolation.
|
||||
|
||||
```bash
|
||||
# Access token secret (1-hour tokens)
|
||||
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
|
||||
|
||||
# Refresh token secret (21-day tokens)
|
||||
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
|
||||
|
||||
# Confirmation token secret (30-minute tokens)
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
|
||||
|
||||
# Website base URL (used in confirmation emails)
|
||||
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
```
|
||||
|
||||
**Security Requirements**:
|
||||
|
||||
- Each secret should be minimum 32 characters
|
||||
- Recommend 127+ characters for production
|
||||
- Generate using cryptographically secure random functions
|
||||
- Never reuse secrets across token types or environments
|
||||
- Rotate secrets periodically in production
|
||||
|
||||
**Generate Secrets**:
|
||||
|
||||
```bash
|
||||
# macOS/Linux - Generate 127-character base64 secret
|
||||
openssl rand -base64 127
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
||||
```
|
||||
|
||||
**Token Expiration**:
|
||||
|
||||
- **Access tokens**: 1 hour
|
||||
- **Refresh tokens**: 21 days
|
||||
- **Confirmation tokens**: 30 minutes
|
||||
|
||||
(Defined in `TokenServiceExpirationHours` class)
|
||||
|
||||
**JWT Implementation**:
|
||||
|
||||
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||
- **Validation**: Token signature, expiration, and malformed token checks
|
||||
|
||||
### Migration Control
|
||||
|
||||
```bash
|
||||
CLEAR_DATABASE=true
|
||||
```
|
||||
|
||||
- **Required**: No
|
||||
- **Default**: false
|
||||
- **Effect**: If "true", drops and recreates database during migrations
|
||||
- **Usage**: Development and testing environments ONLY
|
||||
- **Warning**: NEVER use in production
|
||||
|
||||
### ASP.NET Core Configuration
|
||||
|
||||
```bash
|
||||
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||
```
|
||||
|
||||
## Frontend Variables (`web/frontend`)
|
||||
|
||||
The active website does not use the old Next.js/Prisma environment model. Its
|
||||
core runtime variables are:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
||||
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||
NODE_ENV=development # Standard Node runtime mode
|
||||
```
|
||||
|
||||
### Frontend Variable Details
|
||||
|
||||
#### `API_BASE_URL`
|
||||
|
||||
- **Required**: Yes for local development
|
||||
- **Default in code**: `http://localhost:8080`
|
||||
- **Used by**: `web/frontend/app/lib/auth.server.ts`
|
||||
- **Purpose**: Routes website auth actions to the .NET API
|
||||
|
||||
#### `SESSION_SECRET`
|
||||
|
||||
- **Required**: Strongly recommended in all environments
|
||||
- **Default in local code path**: `dev-secret-change-me`
|
||||
- **Used by**: React Router cookie session storage in `auth.server.ts`
|
||||
- **Purpose**: Signs and validates the website session cookie
|
||||
|
||||
#### `NODE_ENV`
|
||||
|
||||
- **Required**: No
|
||||
- **Typical values**: `development`, `production`, `test`
|
||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||
|
||||
### SMTP Configuration (Backend)
|
||||
|
||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
||||
and account emails.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=mailpit # Required, no default
|
||||
SMTP_PORT=1025 # Optional, defaults to 587
|
||||
SMTP_USERNAME=<username> # Optional, no default
|
||||
SMTP_PASSWORD=<password> # Optional, no default
|
||||
SMTP_USE_SSL=false # Optional, defaults to true
|
||||
SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
|
||||
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
|
||||
```
|
||||
|
||||
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
|
||||
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
|
||||
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
||||
UI on http://localhost:8025)
|
||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
|
||||
etc.)
|
||||
|
||||
## Docker-Specific Variables
|
||||
|
||||
### SQL Server Container
|
||||
|
||||
```bash
|
||||
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
|
||||
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
|
||||
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
|
||||
```
|
||||
|
||||
**Password Requirements**:
|
||||
|
||||
- Minimum 8 characters
|
||||
- Uppercase, lowercase, digits, and special characters
|
||||
- Maps to `DB_PASSWORD` for application containers
|
||||
|
||||
## Environment File Structure
|
||||
|
||||
### Backend/Docker (`web/` Directory)
|
||||
|
||||
```
|
||||
web/.env.example # Template (tracked in Git)
|
||||
web/.env.dev # Development config (gitignored)
|
||||
web/.env.test # Testing config (gitignored)
|
||||
web/.env.prod # Production config (gitignored)
|
||||
```
|
||||
|
||||
**Setup**:
|
||||
|
||||
```bash
|
||||
cp web/.env.example web/.env.dev
|
||||
# Edit web/.env.dev with your values
|
||||
```
|
||||
|
||||
## Legacy Frontend Variables
|
||||
|
||||
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
|
||||
reference only and is not run as part of the active stack.
|
||||
|
||||
**Docker Compose Mapping**:
|
||||
|
||||
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||
|
||||
## Variable Reference Table
|
||||
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
|
||||
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
|
||||
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
|
||||
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
|
||||
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||
|
||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
||||
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
||||
|
||||
## Validation
|
||||
|
||||
### Backend Validation
|
||||
|
||||
Variables are validated at startup:
|
||||
|
||||
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
||||
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
|
||||
values) cause the application to fail with an `InvalidOperationException`
|
||||
- No minimum length is enforced on the token secrets in code; the
|
||||
"minimum 32 characters" guidance above is a recommendation, not an
|
||||
enforced check
|
||||
|
||||
### Frontend Validation
|
||||
|
||||
The active website relies on runtime defaults for local development and the
|
||||
surrounding server environment in deployed environments.
|
||||
|
||||
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||
- `SESSION_SECRET` falls back to a development-only local secret
|
||||
- `NODE_ENV` controls secure cookie behavior
|
||||
|
||||
## Example Configuration Files
|
||||
|
||||
### `.env.dev` (Backend/Docker)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=Dev_Password_123!
|
||||
|
||||
# JWT Authentication Secrets
|
||||
ACCESS_TOKEN_SECRET=<generated-with-openssl>
|
||||
REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
|
||||
# SMTP (Mailpit in dev)
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||
SMTP_FROM_NAME=The Biergarten App
|
||||
|
||||
# Migration
|
||||
CLEAR_DATABASE=true
|
||||
|
||||
# ASP.NET Core
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
|
||||
# SQL Server Container
|
||||
SA_PASSWORD=Dev_Password_123!
|
||||
ACCEPT_EULA=Y
|
||||
MSSQL_PID=Express
|
||||
```
|
||||
|
||||
### Frontend local runtime example
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:8080
|
||||
SESSION_SECRET=<generated-with-openssl>
|
||||
NODE_ENV=development
|
||||
```
|
||||
@@ -1,139 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
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`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.NET SDK 10+**
|
||||
- **Node.js 18+**
|
||||
- **Docker Desktop** or equivalent Docker Engine setup
|
||||
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||
|
||||
## Recommended Path: Docker for Backend, Node for Frontend
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd the-biergarten-app
|
||||
```
|
||||
|
||||
### 2. Configure Backend Environment Variables
|
||||
|
||||
```bash
|
||||
cp web/.env.example web/.env.dev
|
||||
```
|
||||
|
||||
At minimum, ensure `.env.dev` includes valid database and token values:
|
||||
|
||||
```bash
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=YourStrong!Passw0rd
|
||||
ACCESS_TOKEN_SECRET=<generated>
|
||||
REFRESH_TOKEN_SECRET=<generated>
|
||||
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
See [Environment Variables](environment-variables.md) for the full list.
|
||||
|
||||
### 3. Start the Backend Stack
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
||||
Available endpoints:
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
- Mailpit UI: http://localhost:8025
|
||||
|
||||
### 4. Start the Active Frontend
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm install
|
||||
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||
```
|
||||
|
||||
The website will be available at the local address printed by React Router dev.
|
||||
|
||||
Required frontend runtime variables for local work:
|
||||
|
||||
- `API_BASE_URL` - Base URL for the .NET API
|
||||
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||
|
||||
### 5. Optional: Run Storybook
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
Storybook runs at http://localhost:6006 by default.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run format:check
|
||||
npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
## Manual Backend Setup
|
||||
|
||||
If you do not want to use Docker, you can run the backend locally.
|
||||
|
||||
### 1. Set Environment Variables
|
||||
|
||||
```bash
|
||||
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
export ACCESS_TOKEN_SECRET="<generated>"
|
||||
export REFRESH_TOKEN_SECRET="<generated>"
|
||||
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
||||
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||
```
|
||||
|
||||
### 2. Run Migrations and Seed
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||
```
|
||||
|
||||
### 3. Start the API
|
||||
|
||||
```bash
|
||||
dotnet run --project API/API.Core/API.Core.csproj
|
||||
```
|
||||
|
||||
## Legacy Frontend Note
|
||||
|
||||
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
|
||||
only and is not the active website.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Architecture](../architecture.md)
|
||||
- Run backend and frontend checks from [Testing](testing.md)
|
||||
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||
@@ -1,363 +0,0 @@
|
||||
# Testing
|
||||
|
||||
This document describes the testing strategy and how to run tests for The
|
||||
Biergarten App.
|
||||
|
||||
## Overview
|
||||
|
||||
The project uses a multi-layered testing approach across backend and frontend:
|
||||
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
|
||||
a live, seeded database
|
||||
- **Features.\*.Tests** - One unit test project per backend feature slice
|
||||
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
|
||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
|
||||
slice's command/query handlers and its own repository (mocked with Moq /
|
||||
DbMocker, no real database required)
|
||||
- **Storybook Vitest project** - Browser-based interaction tests for shared
|
||||
website stories
|
||||
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
||||
components
|
||||
|
||||
## Running Tests with Docker (Recommended)
|
||||
|
||||
The easiest way to run all tests is using Docker Compose, which sets up an
|
||||
isolated test environment:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Starts a fresh SQL Server instance
|
||||
2. Runs database migrations
|
||||
3. Seeds test data
|
||||
4. Executes all test suites in parallel
|
||||
5. Exports results to `./test-results/`
|
||||
6. Exits when tests complete
|
||||
|
||||
### View Test Results
|
||||
|
||||
```bash
|
||||
# List test result files
|
||||
ls -la test-results/
|
||||
|
||||
# View specific test results
|
||||
cat test-results/api-specs/results.trx
|
||||
cat test-results/Features.Auth.Tests.trx
|
||||
cat test-results/Features.Breweries.Tests.trx
|
||||
cat test-results/Features.UserManagement.Tests.trx
|
||||
cat test-results/Features.Emails.Tests.trx
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Remove test containers and volumes
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Integration Tests (API.Specs)
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
dotnet test API/API.Specs/API.Specs.csproj
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- SQL Server instance running
|
||||
- Database migrated and seeded
|
||||
- Environment variables set (DB connection, JWT secret)
|
||||
|
||||
### Feature Slice Unit Tests
|
||||
|
||||
Each feature slice has its own test project, covering its command/query
|
||||
handlers and repository:
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
|
||||
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
|
||||
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
|
||||
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
|
||||
|
||||
# Or run all of them at once via the solution:
|
||||
for proj in Features/*.Tests; do dotnet test "$proj"; done
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- No database required (handlers use Moq; repository tests use DbMocker to
|
||||
simulate SQL Server responses)
|
||||
|
||||
### Frontend Storybook Tests
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm install
|
||||
npm run test:storybook
|
||||
```
|
||||
|
||||
**Purpose**:
|
||||
|
||||
- Verifies shared stories such as form fields, submit buttons, navbar states,
|
||||
toasts, and the theme gallery
|
||||
- Runs in browser mode via Vitest and Storybook integration
|
||||
|
||||
### Frontend Playwright Storybook Tests
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm install
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
|
||||
- Storybook dependencies installed
|
||||
- Playwright browser dependencies installed
|
||||
- The command will start or reuse the Storybook server defined in
|
||||
`playwright.storybook.config.ts`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Current Coverage
|
||||
|
||||
**Features.Auth.Tests**:
|
||||
|
||||
- User registration with validation
|
||||
- User login with JWT token generation
|
||||
- Password hashing and verification (Argon2id)
|
||||
- Email confirmation and confirmation-email resend
|
||||
- Refresh token exchange
|
||||
- JWT token generation, validation, and claims handling
|
||||
- `IAuthRepository` data access (DbMocker-backed)
|
||||
- Invalid credentials and 404 error responses (via API.Specs)
|
||||
|
||||
**Features.Breweries.Tests**:
|
||||
|
||||
- Brewery create/update/delete commands and get-by-id/get-all queries
|
||||
|
||||
**Features.UserManagement.Tests**:
|
||||
|
||||
- User get-by-id/get-all queries and the (currently unrouted) update command
|
||||
|
||||
**Features.Emails.Tests**:
|
||||
|
||||
- Registration and resend-confirmation email dispatch handlers
|
||||
|
||||
**Frontend UI Coverage**:
|
||||
|
||||
- Shared submit button states
|
||||
- Form field happy path and error presentation
|
||||
- Navbar guest, authenticated, and mobile behavior
|
||||
- Theme gallery rendering across Biergarten themes
|
||||
- Toast interactions and themed notification display
|
||||
|
||||
### Planned Coverage
|
||||
|
||||
- [ ] Password reset functionality
|
||||
- [ ] Beer post operations
|
||||
- [ ] User follow/unfollow
|
||||
- [ ] Image upload service
|
||||
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||
|
||||
## Testing Frameworks & Tools
|
||||
|
||||
### xUnit
|
||||
|
||||
- Primary unit testing framework
|
||||
- Used for Repository and Service layer tests
|
||||
- Supports parallel test execution
|
||||
|
||||
### Reqnroll (Gherkin/BDD)
|
||||
|
||||
- Behavior-driven development framework
|
||||
- Used for API integration tests
|
||||
- Human-readable test scenarios in `.feature` files
|
||||
|
||||
### FluentAssertions
|
||||
|
||||
- Expressive assertion library
|
||||
- Makes test assertions more readable
|
||||
- Used across all test projects
|
||||
|
||||
### Moq
|
||||
|
||||
- Mocking framework for .NET
|
||||
- Used in Service layer tests
|
||||
- Enables isolated unit testing
|
||||
|
||||
### DbMocker
|
||||
|
||||
- Database mocking for repository tests
|
||||
- Simulates SQL Server responses
|
||||
- No real database required for unit tests
|
||||
|
||||
## Test Structure
|
||||
|
||||
### API.Specs (Integration Tests)
|
||||
|
||||
```
|
||||
API.Specs/
|
||||
├── Features/
|
||||
│ ├── Registration.feature # Registration scenarios
|
||||
│ ├── Login.feature # Login scenarios
|
||||
│ ├── Confirmation.feature # Email confirmation scenarios
|
||||
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
||||
│ ├── TokenRefresh.feature # Refresh token scenarios
|
||||
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
||||
│ └── NotFound.feature # 404 handling
|
||||
├── Steps/
|
||||
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||
├── Mocks/
|
||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
|
||||
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
||||
```
|
||||
|
||||
**Example Feature**:
|
||||
|
||||
```gherkin
|
||||
Feature: User Registration
|
||||
As a user
|
||||
I want to register
|
||||
So that I can access the platform
|
||||
|
||||
Scenario: Successful user registration
|
||||
Given I have valid registration details
|
||||
When I register a new account
|
||||
Then I should receive a JWT token
|
||||
And my account should be created
|
||||
```
|
||||
|
||||
### Features.Auth.Tests
|
||||
|
||||
```
|
||||
Features.Auth.Tests/
|
||||
├── Commands/
|
||||
│ ├── RegisterUserHandlerTests.cs
|
||||
│ ├── ConfirmUserHandlerTests.cs
|
||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||
│ └── RefreshTokenHandlerTests.cs
|
||||
├── Queries/
|
||||
│ └── LoginHandlerTests.cs
|
||||
├── Repository/
|
||||
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
|
||||
└── Services/
|
||||
├── TokenServiceRefreshTests.cs
|
||||
└── TokenServiceValidationTests.cs
|
||||
```
|
||||
|
||||
Each of the other three slices (`Features.Breweries.Tests`,
|
||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
|
||||
shape: a `Commands/`/`Queries/` folder with one test file per handler.
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example (xUnit)
|
||||
|
||||
```csharp
|
||||
public class LoginHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||
{
|
||||
// Arrange
|
||||
var authRepoMock = new Mock<IAuthRepository>();
|
||||
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
var tokenServiceMock = new Mock<ITokenService>();
|
||||
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
|
||||
// ...set up mocks for a known user/credential...
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.AccessToken.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Example (Reqnroll)
|
||||
|
||||
```gherkin
|
||||
Scenario: User login with valid credentials
|
||||
Given a registered user with username "testuser"
|
||||
When I POST to "/api/auth/login" with valid credentials
|
||||
Then the response status should be 200
|
||||
And the response should contain a JWT token
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
||||
configuration:
|
||||
|
||||
```bash
|
||||
# CI/CD command
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
|
||||
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 down -v
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0` - All tests passed
|
||||
- Non-zero - Test failures occurred
|
||||
|
||||
Frontend UI checks should also be included in CI for the active website
|
||||
workspace:
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm ci
|
||||
npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Failing Due to Database Connection
|
||||
|
||||
Ensure SQL Server is running and environment variables are set:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||
`docker-compose.test.yaml`.
|
||||
|
||||
### Stale Test Data
|
||||
|
||||
Clean up test database:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
### View Container Logs
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Isolation**: Each test should be independent and not rely on other tests
|
||||
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||
3. **Mocking**: Mock external dependencies in unit tests
|
||||
4. **Descriptive Names**: Use clear, descriptive test method names
|
||||
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
|
||||
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios
|
||||
2411
misc/raw-data/beers.csv
Normal file
2411
misc/raw-data/beers.csv
Normal file
File diff suppressed because it is too large
Load Diff
559
misc/raw-data/breweries.csv
Normal file
559
misc/raw-data/breweries.csv
Normal file
@@ -0,0 +1,559 @@
|
||||
,name,city,state
|
||||
0,NorthGate Brewing ,Minneapolis, MN
|
||||
1,Against the Grain Brewery,Louisville, KY
|
||||
2,Jack's Abby Craft Lagers,Framingham, MA
|
||||
3,Mike Hess Brewing Company,San Diego, CA
|
||||
4,Fort Point Beer Company,San Francisco, CA
|
||||
5,COAST Brewing Company,Charleston, SC
|
||||
6,Great Divide Brewing Company,Denver, CO
|
||||
7,Tapistry Brewing,Bridgman, MI
|
||||
8,Big Lake Brewing,Holland, MI
|
||||
9,The Mitten Brewing Company,Grand Rapids, MI
|
||||
10,Brewery Vivant,Grand Rapids, MI
|
||||
11,Petoskey Brewing,Petoskey, MI
|
||||
12,Blackrocks Brewery,Marquette, MI
|
||||
13,Perrin Brewing Company,Comstock Park, MI
|
||||
14,Witch's Hat Brewing Company,South Lyon, MI
|
||||
15,Founders Brewing Company,Grand Rapids, MI
|
||||
16,Flat 12 Bierwerks,Indianapolis, IN
|
||||
17,Tin Man Brewing Company,Evansville, IN
|
||||
18,Black Acre Brewing Co.,Indianapolis, IN
|
||||
19,Brew Link Brewing,Plainfield, IN
|
||||
20,Bare Hands Brewery,Granger, IN
|
||||
21,Three Pints Brewing,Martinsville, IN
|
||||
22,Four Fathers Brewing ,Valparaiso, IN
|
||||
23,Indiana City Brewing,Indianapolis, IN
|
||||
24,Burn 'Em Brewing,Michigan City, IN
|
||||
25,Sun King Brewing Company,Indianapolis, IN
|
||||
26,Evil Czech Brewery,Mishawaka, IN
|
||||
27,450 North Brewing Company,Columbus, IN
|
||||
28,Taxman Brewing Company,Bargersville, IN
|
||||
29,Cedar Creek Brewery,Seven Points, TX
|
||||
30,SanTan Brewing Company,Chandler, AZ
|
||||
31,Boulevard Brewing Company,Kansas City, MO
|
||||
32,James Page Brewing Company,Stevens Point, WI
|
||||
33,The Dudes' Brewing Company,Torrance, CA
|
||||
34,Ballast Point Brewing Company,San Diego, CA
|
||||
35,Anchor Brewing Company,San Francisco, CA
|
||||
36,Figueroa Mountain Brewing Company,Buellton, CA
|
||||
37,Avery Brewing Company,Boulder, CO
|
||||
38,Twisted X Brewing Company,Dripping Springs, TX
|
||||
39,Gonzo's BiggDogg Brewing,Kalamazoo, MI
|
||||
40,Big Muddy Brewing,Murphysboro, IL
|
||||
41,Lost Nation Brewing,East Fairfield, VT
|
||||
42,Rising Tide Brewing Company,Portland, ME
|
||||
43,Rivertowne Brewing Company,Export, PA
|
||||
44,Revolution Brewing Company,Chicago, IL
|
||||
45,Tallgrass Brewing Company,Manhattan, KS
|
||||
46,Sixpoint Craft Ales,Brooklyn, NY
|
||||
47,White Birch Brewing,Hooksett, NH
|
||||
48,Firestone Walker Brewing Company,Paso Robles, CA
|
||||
49,SweetWater Brewing Company,Atlanta, GA
|
||||
50,Flying Mouse Brewery,Troutville, VA
|
||||
51,Upslope Brewing Company,Boulder, CO
|
||||
52,Pipeworks Brewing Company,Chicago, IL
|
||||
53,Bent Brewstillery,Roseville, MN
|
||||
54,Flesk Brewing Company,Lombard, IL
|
||||
55,Pollyanna Brewing Company,Lemont, IL
|
||||
56,BuckleDown Brewing,Lyons, IL
|
||||
57,Destihl Brewery,Bloomington, IL
|
||||
58,Summit Brewing Company,St. Paul, MN
|
||||
59,Latitude 42 Brewing Company,Portage, MI
|
||||
60,4 Hands Brewing Company,Saint Louis, MO
|
||||
61,Surly Brewing Company,Brooklyn Center, MN
|
||||
62,Against The Grain Brewery,Louisville, KY
|
||||
63,Crazy Mountain Brewing Company,Edwards, CO
|
||||
64,SlapShot Brewing Company,Chicago, IL
|
||||
65,Mikerphone Brewing,Chicago, IL
|
||||
66,Freetail Brewing Company,San Antonio, TX
|
||||
67,3 Daughters Brewing,St Petersburg, FL
|
||||
68,Red Shedman Farm Brewery and Hop...,Mt. Airy, MD
|
||||
69,Appalachian Mountain Brewery,Boone, NC
|
||||
70,Birdsong Brewing Company,Charlotte, NC
|
||||
71,Union Craft Brewing,Baltimore, MD
|
||||
72,Atwater Brewery,Detroit, MI
|
||||
73,Ale Asylum,Madison, WI
|
||||
74,Two Brothers Brewing Company,Warrenville, IL
|
||||
75,Bent Paddle Brewing Company,Duluth, MN
|
||||
76,Bell's Brewery,Kalamazoo, MI
|
||||
77,Blue Owl Brewing,Austin, TX
|
||||
78,Speakasy Ales & Lagers,San Francisco, CA
|
||||
79,Black Tooth Brewing Company,Sheridan, WY
|
||||
80,Hopworks Urban Brewery,Portland, OR
|
||||
81,Epic Brewing,Denver, CO
|
||||
82,New Belgium Brewing Company,Fort Collins, CO
|
||||
83,Sierra Nevada Brewing Company,Chico, CA
|
||||
84,Keweenaw Brewing Company,Houghton, MI
|
||||
85,Brewery Terra Firma,Traverse City, MI
|
||||
86,Grey Sail Brewing Company,Westerly, RI
|
||||
87,Kirkwood Station Brewing Company,Kirkwood, MO
|
||||
88,Goose Island Brewing Company,Chicago, IL
|
||||
89,Broad Brook Brewing LLC,East Windsor, CT
|
||||
90,The Lion Brewery,Wilkes-Barre, PA
|
||||
91,Madtree Brewing Company,Cincinnati, OH
|
||||
92,Jackie O's Pub & Brewery,Athens, OH
|
||||
93,Rhinegeist Brewery,Cincinnati, OH
|
||||
94,Warped Wing Brewing Company,Dayton, OH
|
||||
95,Blackrocks Brewery,Marquette, MA
|
||||
96,Catawba Valley Brewing Company,Morganton, NC
|
||||
97,Tröegs Brewing Company,Hershey, PA
|
||||
98,Mission Brewery,San Diego, CA
|
||||
99,Christian Moerlein Brewing Company,Cincinnati, OH
|
||||
100,West Sixth Brewing,Lexington, KY
|
||||
101,Coastal Extreme Brewing Company,Newport, RI
|
||||
102,King Street Brewing Company,Anchorage, AK
|
||||
103,Beer Works Brewery,Lowell, MA
|
||||
104,Lone Tree Brewing Company,Lone Tree, CO
|
||||
105,Four String Brewing Company,Columbus, OH
|
||||
106,Glabrous Brewing Company,Pineland, ME
|
||||
107,Bonfire Brewing Company,Eagle, CO
|
||||
108,Thomas Hooker Brewing Company,Bloomfield, CT
|
||||
109,"Woodstock Inn, Station & Brewery",North Woodstock, NH
|
||||
110,Renegade Brewing Company,Denver, CO
|
||||
111,Mother Earth Brew Company,Vista, CA
|
||||
112,Black Market Brewing Company,Temecula, CA
|
||||
113,Vault Brewing Company,Yardley, PA
|
||||
114,Jailbreak Brewing Company,Laurel, MD
|
||||
115,Smartmouth Brewing Company,Norfolk, VA
|
||||
116,Base Camp Brewing Co.,Portland, OR
|
||||
117,Alameda Brewing,Portland, OR
|
||||
118,Southern Star Brewing Company,Conroe, TX
|
||||
119,Steamworks Brewing Company,Durango, CO
|
||||
120,Horny Goat Brew Pub,Milwaukee, WI
|
||||
121,Cheboygan Brewing Company,Cheboygan, MI
|
||||
122,Center of the Universe Brewing C...,Ashland, VA
|
||||
123,Ipswich Ale Brewery,Ipswich, MA
|
||||
124,Griffin Claw Brewing Company,Birmingham, MI
|
||||
125,Karbach Brewing Company,Houston, TX
|
||||
126,Uncle Billy's Brewery and Smokeh...,Austin, TX
|
||||
127,Deep Ellum Brewing Company,Dallas, TX
|
||||
128,Real Ale Brewing Company,Blanco, TX
|
||||
129,Straub Brewery,St Mary's, PA
|
||||
130,Shebeen Brewing Company,Wolcott, CT
|
||||
131,Stevens Point Brewery,Stevens Point, WI
|
||||
132,Weston Brewing Company,Weston, MO
|
||||
133,Southern Prohibition Brewing Com...,Hattiesburg, MS
|
||||
134,Minhas Craft Brewery,Monroe, WI
|
||||
135,Pug Ryan's Brewery,Dillon, CO
|
||||
136,Hops & Grains Brewing Company,Austin, TX
|
||||
137,Sietsema Orchards and Cider Mill,Ada, MI
|
||||
138,Summit Brewing Company,St Paul, MN
|
||||
139,Core Brewing & Distilling Company,Springdale, AR
|
||||
140,Independence Brewing Company,Austin, TX
|
||||
141,Cigar City Brewing Company,Tampa, FL
|
||||
142,Third Street Brewhouse,Cold Spring, MN
|
||||
143,Narragansett Brewing Company,Providence, RI
|
||||
144,Grimm Brothers Brewhouse,Loveland, CO
|
||||
145,Cisco Brewers,Nantucket, MA
|
||||
146,Angry Minnow,Hayward, WI
|
||||
147,Platform Beer Company,Cleveland, OH
|
||||
148,Odyssey Beerwerks,Arvada, CO
|
||||
149,Lonerider Brewing Company,Raleigh, NC
|
||||
150,Oakshire Brewing,Eugene, OR
|
||||
151,Fort Pitt Brewing Company,Latrobe, PA
|
||||
152,Tin Roof Brewing Company,Baton Rouge, LA
|
||||
153,Three Creeks Brewing,Sisters, OR
|
||||
154,2 Towns Ciderhouse,Corvallis, OR
|
||||
155,Caldera Brewing Company,Ashland, OR
|
||||
156,Greenbrier Valley Brewing Company,Lewisburg, WV
|
||||
157,Phoenix Ale Brewery,Phoenix, AZ
|
||||
158,Lumberyard Brewing Company,Flagstaff, AZ
|
||||
159,Uinta Brewing Company,Salt Lake City, UT
|
||||
160,Four Peaks Brewing Company,Tempe, AZ
|
||||
161,Martin House Brewing Company,Fort Worth, TX
|
||||
162,Right Brain Brewery,Traverse City, MI
|
||||
163,Sly Fox Brewing Company,Phoenixville, PA
|
||||
164,Round Guys Brewing,Lansdale, PA
|
||||
165,Great Crescent Brewery,Aurora, IN
|
||||
166,Oskar Blues Brewery,Longmont, CO
|
||||
167,Boxcar Brewing Company,West Chester, PA
|
||||
168,High Hops Brewery,Windsor, CO
|
||||
169,Crooked Fence Brewing Company,Garden City, ID
|
||||
170,Everybody's Brewing,White Salmon, WA
|
||||
171,Anderson Valley Brewing Company,Boonville, CA
|
||||
172,Fiddlehead Brewing Company,Shelburne, VT
|
||||
173,Evil Twin Brewing,Brooklyn, NY
|
||||
174,New Orleans Lager & Ale Brewing ...,New Orleans, LA
|
||||
175,Spiteful Brewing Company,Chicago, IL
|
||||
176,Rahr & Sons Brewing Company,Fort Worth, TX
|
||||
177,18th Street Brewery,Gary, IN
|
||||
178,Cambridge Brewing Company,Cambridge, MA
|
||||
179,Carolina Brewery,Pittsboro, NC
|
||||
180,Frog Level Brewing Company,Waynesville, NC
|
||||
181,Wild Wolf Brewing Company,Nellysford, VA
|
||||
182,COOP Ale Works,Oklahoma City, OK
|
||||
183,Seventh Son Brewing Company,Columbus, OH
|
||||
184,Oasis Texas Brewing Company,Austin, TX
|
||||
185,Vander Mill Ciders,Spring Lake, MI
|
||||
186,St. Julian Winery,Paw Paw, MI
|
||||
187,Pedernales Brewing Company,Fredericksburg, TX
|
||||
188,Mother's Brewing,Springfield, MO
|
||||
189,Modern Monks Brewery,Lincoln, NE
|
||||
190,Two Beers Brewing Company,Seattle, WA
|
||||
191,Snake River Brewing Company,Jackson, WY
|
||||
192,Capital Brewery,Middleton, WI
|
||||
193,Anthem Brewing Company,Oklahoma City, OK
|
||||
194,Goodlife Brewing Co.,Bend, OR
|
||||
195,Breakside Brewery,Portland, OR
|
||||
196,Goose Island Brewery Company,Chicago, IL
|
||||
197,Burnside Brewing Co.,Portland, OR
|
||||
198,Hop Valley Brewing Company,Springfield, OR
|
||||
199,Worthy Brewing Company,Bend, OR
|
||||
200,Occidental Brewing Company,Portland, OR
|
||||
201,Fearless Brewing Company,Estacada, OR
|
||||
202,Upland Brewing Company,Bloomington, IN
|
||||
203,Mehana Brewing Co.,Hilo, HI
|
||||
204,Hawai'i Nui Brewing Co.,Hilo, HI
|
||||
205,People's Brewing Company,Lafayette, IN
|
||||
206,Fort George Brewery,Astoria, OR
|
||||
207,Branchline Brewing Company,San Antonio, TX
|
||||
208,Kalona Brewing Company,Kalona, IA
|
||||
209,Modern Times Beer,San Diego, CA
|
||||
210,Temperance Beer Company,Evanston, IL
|
||||
211,Wisconsin Brewing Company,Verona, WI
|
||||
212,Crow Peak Brewing Company,Spearfish, SD
|
||||
213,Grapevine Craft Brewery,Farmers Branch, TX
|
||||
214,Buffalo Bayou Brewing Company,Houston, TX
|
||||
215,Texian Brewing Co.,Richmond, TX
|
||||
216,Orpheus Brewing,Atlanta, GA
|
||||
217,Forgotten Boardwalk,Cherry Hill, NJ
|
||||
218,Laughing Dog Brewing Company,Ponderay, ID
|
||||
219,Bozeman Brewing Company,Bozeman, MT
|
||||
220,Big Choice Brewing,Broomfield, CO
|
||||
221,Big Storm Brewing Company,Odessa, FL
|
||||
222,Carton Brewing Company,Atlantic Highlands, NJ
|
||||
223,Midnight Sun Brewing Company,Anchorage, AK
|
||||
224,Fat Head's Brewery,Middleburg Heights, OH
|
||||
225,Refuge Brewery,Temecula, CA
|
||||
226,Chatham Brewing,Chatham, NY
|
||||
227,DC Brau Brewing Company,Washington, DC
|
||||
228,Geneva Lake Brewing Company,Lake Geneva, WI
|
||||
229,Rochester Mills Brewing Company,Rochester, MI
|
||||
230,Cape Ann Brewing Company,Gloucester, MA
|
||||
231,Borderlands Brewing Company,Tucson, AZ
|
||||
232,College Street Brewhouse and Pub,Lake Havasu City, AZ
|
||||
233,Joseph James Brewing Company,Henderson, NV
|
||||
234,Harpoon Brewery,Boston, MA
|
||||
235,Back East Brewing Company,Bloomfield, CT
|
||||
236,Champion Brewing Company,Charlottesville, VA
|
||||
237,Devil's Backbone Brewing Company,Lexington, VA
|
||||
238,Newburgh Brewing Company,Newburgh, NY
|
||||
239,Wiseacre Brewing Company,Memphis, TN
|
||||
240,Golden Road Brewing,Los Angeles, CA
|
||||
241,New Republic Brewing Company,College Station, TX
|
||||
242,Infamous Brewing Company,Austin, TX
|
||||
243,Two Henrys Brewing Company,Plant City, FL
|
||||
244,Lift Bridge Brewing Company,Stillwater, MN
|
||||
245,Lucky Town Brewing Company,Jackson, MS
|
||||
246,Quest Brewing Company,Greenville, SC
|
||||
247,Creature Comforts,Athens, GA
|
||||
248,Half Full Brewery,Stamford, CT
|
||||
249,Southampton Publick House,Southampton, NY
|
||||
250,Chapman's Brewing,Angola, IN
|
||||
251,Barrio Brewing Company,Tucson, AZ
|
||||
252,Santa Cruz Mountain Brewing,Santa Cruz, CA
|
||||
253,Frankenmuth Brewery,Frankenmuth, MI
|
||||
254,Meckley's Cidery,Somerset Center, MI
|
||||
255,Stillwater Artisanal Ales,Baltimore, MD
|
||||
256,Finch's Beer Company,Chicago, IL
|
||||
257,South Austin Brewery,South Austin, TX
|
||||
258,Bauhaus Brew Labs,Minneapolis, MN
|
||||
259,Ozark Beer Company,Rogers, AR
|
||||
260,Mountain Town Brewing Company ,Mount Pleasant, MI
|
||||
261,Otter Creek Brewing,Waterbury, VT
|
||||
262,The Brewer's Art,Baltimore, MD
|
||||
263,Denver Beer Company,Denver, CO
|
||||
264,Ska Brewing Company,Durango, CO
|
||||
265,Tractor Brewing Company,Albuquerque, NM
|
||||
266,Peak Organic Brewing Company,Portland, ME
|
||||
267,Cape Cod Beer,Hyannis, MA
|
||||
268,Long Trail Brewing Company,Bridgewater Corners, VT
|
||||
269,Great Raft Brewing Company,Shreveport, LA
|
||||
270,Alaskan Brewing Company,Juneau, AK
|
||||
271,Notch Brewing Company,Ipswich, MA
|
||||
272,The Alchemist,Waterbury, VT
|
||||
273,Three Notch'd Brewing Company,Charlottesville, VA
|
||||
274,Portside Brewery,Cleveland, OH
|
||||
275,Otter Creek Brewing,Middlebury, VT
|
||||
276,Montauk Brewing Company,Montauk, NY
|
||||
277,Indeed Brewing Company,Minneapolis, MN
|
||||
278,Berkshire Brewing Company,South Deerfield, MA
|
||||
279,Foolproof Brewing Company,Pawtucket, RI
|
||||
280,Headlands Brewing Company,Mill Valley, CA
|
||||
281,Bolero Snort Brewery,Ridgefield Park, NJ
|
||||
282,Thunderhead Brewing Company,Kearney, NE
|
||||
283,Defiance Brewing Company,Hays, KS
|
||||
284,Milwaukee Brewing Company,Milwaukee, WI
|
||||
285,Catawba Island Brewing,Port Clinton, OH
|
||||
286,Back Forty Beer Company,Gadsden, AL
|
||||
287,Four Corners Brewing Company,Dallas, TX
|
||||
288,Saint Archer Brewery,San Diego, CA
|
||||
289,Rogue Ales,Newport, OR
|
||||
290,Hale's Ales,Seattle, WA
|
||||
291,Tommyknocker Brewery,Idaho Springs, CO
|
||||
292,Baxter Brewing Company,Lewiston, ME
|
||||
293,Northampton Brewery,Northamtpon, MA
|
||||
294,Black Shirt Brewing Company,Denver, CO
|
||||
295,Wachusett Brewing Company,Westminster, MA
|
||||
296,Widmer Brothers Brewing Company,Portland, OR
|
||||
297,Hop Farm Brewing Company,Pittsburgh, PA
|
||||
298,Liquid Hero Brewery,York, PA
|
||||
299,Matt Brewing Company,Utica, NY
|
||||
300,Boston Beer Company,Boston, MA
|
||||
301,Old Forge Brewing Company,Danville, PA
|
||||
302,Utah Brewers Cooperative,Salt Lake City, UT
|
||||
303,Magic Hat Brewing Company,South Burlington, VT
|
||||
304,Blue Hills Brewery,Canton, MA
|
||||
305,Night Shift Brewing,Everett, MA
|
||||
306,Beach Brewing Company,Virginia Beach, VA
|
||||
307,Payette Brewing Company,Garden City, ID
|
||||
308,Brew Bus Brewing,Tampa, FL
|
||||
309,Sockeye Brewing Company,Boise, ID
|
||||
310,Pine Street Brewery,San Francisco, CA
|
||||
311,Dirty Bucket Brewing Company,Woodinville, WA
|
||||
312,Jackalope Brewing Company,Nashville, TN
|
||||
313,Slanted Rock Brewing Company,Meridian, ID
|
||||
314,Piney River Brewing Company,Bucryus, MO
|
||||
315,Cutters Brewing Company,Avon, IN
|
||||
316,Iron Hill Brewery & Restaurant,Wilmington, DE
|
||||
317,Marshall Wharf Brewing Company,Belfast, ME
|
||||
318,Banner Beer Company,Williamsburg, MA
|
||||
319,Dick's Brewing Company,Centralia, WA
|
||||
320,Claremont Craft Ales,Claremont, CA
|
||||
321,Rivertown Brewing Company,Lockland, OH
|
||||
322,Voodoo Brewery,Meadville, PA
|
||||
323,D.L. Geary Brewing Company,Portland, ME
|
||||
324,Pisgah Brewing Company,Black Mountain, NC
|
||||
325,Neshaminy Creek Brewing Company,Croydon, PA
|
||||
326,Morgan Street Brewery,Saint Louis, MO
|
||||
327,Half Acre Beer Company,Chicago, IL
|
||||
328,The Just Beer Project,Burlington, VT
|
||||
329,The Bronx Brewery,Bronx, NY
|
||||
330,Dead Armadillo Craft Brewing,Tulsa, OK
|
||||
331,Catawba Brewing Company,Morganton, NC
|
||||
332,La Cumbre Brewing Company,Albuquerque, NM
|
||||
333,David's Ale Works,Diamond Springs, CA
|
||||
334,The Traveler Beer Company,Burlington, VT
|
||||
335,Fargo Brewing Company,Fargo, ND
|
||||
336,Big Sky Brewing Company,Missoula, MT
|
||||
337,Nebraska Brewing Company,Papillion, NE
|
||||
338,Uncle John's Fruit House Winery,St. John's, MI
|
||||
339,Wormtown Brewery,Worcester, MA
|
||||
340,Due South Brewing Company,Boynton Beach, FL
|
||||
341,Palisade Brewing Company,Palisade, CO
|
||||
342,KelSo Beer Company,Brooklyn, NY
|
||||
343,Hardywood Park Craft Brewery,Richmond, VA
|
||||
344,Wolf Hills Brewing Company,Abingdon, VA
|
||||
345,Lavery Brewing Company,Erie, PA
|
||||
346,Manzanita Brewing Company,Santee, CA
|
||||
347,Fullsteam Brewery,Durham, NC
|
||||
348,Four Horsemen Brewing Company,South Bend, IN
|
||||
349,Hinterland Brewery,Green Bay, WI
|
||||
350,Central Coast Brewing Company,San Luis Obispo, CA
|
||||
351,Westfield River Brewing Company,Westfield, MA
|
||||
352,Elevator Brewing Company,Columbus, OH
|
||||
353,Aslan Brewing Company,Bellingham, WA
|
||||
354,Kulshan Brewery,Bellingham, WA
|
||||
355,Pikes Peak Brewing Company,Monument, CO
|
||||
356,Manayunk Brewing Company,Philadelphia, PA
|
||||
357,Buckeye Brewing,Cleveland, OH
|
||||
358,Daredevil Brewing Company,Shelbyville, IN
|
||||
359,NoDa Brewing Company,Charlotte, NC
|
||||
360,Aviator Brewing Company,Fuquay-Varina, NC
|
||||
361,Wild Onion Brewing Company,Lake Barrington, IL
|
||||
362,Hilliard's Beer,Seattle, WA
|
||||
363,Mikkeller,Pottstown, PA
|
||||
364,Bohemian Brewery,Midvale, UT
|
||||
365,Great River Brewery,Davenport, IA
|
||||
366,Mustang Brewing Company,Mustang, OK
|
||||
367,Airways Brewing Company,Kent, WA
|
||||
368,21st Amendment Brewery,San Francisco, CA
|
||||
369,Eddyline Brewery & Restaurant,Buena Vista, CO
|
||||
370,Pizza Port Brewing Company,Carlsbad, CA
|
||||
371,Sly Fox Brewing Company,Pottstown, PA
|
||||
372,Spring House Brewing Company,Conestoga, PA
|
||||
373,7venth Sun,Dunedin, FL
|
||||
374,Astoria Brewing Company,Astoria, OR
|
||||
375,Maui Brewing Company,Lahaina, HI
|
||||
376,RoughTail Brewing Company,Midwest City, OK
|
||||
377,Lucette Brewing Company,Menominee, WI
|
||||
378,Bold City Brewery,Jacksonville, FL
|
||||
379,Grey Sail Brewing of Rhode Island,Westerly, RI
|
||||
380,Blue Blood Brewing Company,Lincoln, NE
|
||||
381,Swashbuckler Brewing Company,Manheim, PA
|
||||
382,Blue Mountain Brewery,Afton, VA
|
||||
383,Starr Hill Brewery,Crozet, VA
|
||||
384,Westbrook Brewing Company,Mt. Pleasant, SC
|
||||
385,Shipyard Brewing Company,Portland, ME
|
||||
386,Revolution Brewing,Paonia, CO
|
||||
387,Natian Brewery,Portland, OR
|
||||
388,Alltech's Lexington Brewing Company,Lexington, KY
|
||||
389,Oskar Blues Brewery (North Carol...,Brevard, NC
|
||||
390,Orlison Brewing Company,Airway Heights, WA
|
||||
391,Breckenridge Brewery,Denver, CO
|
||||
392,Santa Fe Brewing Company,Santa Fe, NM
|
||||
393,Miami Brewing Company,Miami, FL
|
||||
394,Schilling & Company,Seattle, WA
|
||||
395,Hops & Grain Brewery,Austin, TX
|
||||
396,White Flame Brewing Company,Hudsonville, MI
|
||||
397,Ruhstaller Beer Company,Sacramento, CA
|
||||
398,Saugatuck Brewing Company,Douglas, MI
|
||||
399,Moab Brewery,Moab, UT
|
||||
400,Macon Beer Company,Macon, GA
|
||||
401,Amnesia Brewing Company,Washougal, WA
|
||||
402,Wolverine State Brewing Company,Ann Arbor, MI
|
||||
403,Red Tank Cider Company,Bend, OR
|
||||
404,Cascadia Ciderworks United,Portland, OR
|
||||
405,Fate Brewing Company,Boulder, CO
|
||||
406,Lazy Monk Brewing,Eau Claire, WI
|
||||
407,Bitter Root Brewing,Hamilton, MT
|
||||
408,10 Barrel Brewing Company,Bend, OR
|
||||
409,Tamarack Brewing Company,Lakeside, MT
|
||||
410,New England Brewing Company,Woodbridge, CT
|
||||
411,Seattle Cider Company,Seattle, WA
|
||||
412,Straight to Ale,Huntsville, AL
|
||||
413,Austin Beerworks,Austin, TX
|
||||
414,Blue Mountain Brewery,Arrington, VA
|
||||
415,Coastal Empire Beer Company,Savannah, GA
|
||||
416,Jack's Hard Cider (Hauser Estate...,Biglerville, PA
|
||||
417,Boulder Beer Company,Boulder, CO
|
||||
418,Coalition Brewing Company,Portland, OR
|
||||
419,Sanitas Brewing Company,Boulder, CO
|
||||
420,Gore Range Brewery,Edwards, CO
|
||||
421,Redstone Meadery,Boulder, CO
|
||||
422,Blue Dog Mead,Eugene, OR
|
||||
423,Hess Brewing Company,San Diego, CA
|
||||
424,Wynkoop Brewing Company,Denver, CO
|
||||
425,Ciderboys,Stevens Point, WI
|
||||
426,Armadillo Ale Works,Denton, TX
|
||||
427,Roanoke Railhouse Brewery,Roanoke, VA
|
||||
428,Schlafly Brewing Company,Saint Louis, MO
|
||||
429,Asher Brewing Company,Boulder, CO
|
||||
430,Lost Rhino Brewing Company,Ashburn, VA
|
||||
431,North Country Brewing Company,Slippery Rock, PA
|
||||
432,Seabright Brewery,Santa Cruz, CA
|
||||
433,French Broad Brewery,Asheville, NC
|
||||
434,Angry Orchard Cider Company,Cincinnati, OH
|
||||
435,Two Roads Brewing Company,Stratford, CT
|
||||
436,Southern Oregon Brewing Company,Medford, OR
|
||||
437,Brooklyn Brewery,Brooklyn, NY
|
||||
438,The Right Brain Brewery,Traverse City, MI
|
||||
439,Kona Brewing Company,Kona, HI
|
||||
440,MillKing It Productions,Royal Oak, MI
|
||||
441,Pateros Creek Brewing Company,Fort Collins, CO
|
||||
442,O'Fallon Brewery,O'Fallon, MO
|
||||
443,Marble Brewery,Albuquerque, NM
|
||||
444,Big Wood Brewery,Vadnais Heights, MN
|
||||
445,Howard Brewing Company,Lenoir, NC
|
||||
446,Downeast Cider House,Leominster, MA
|
||||
447,Swamp Head Brewery,Gainesville, FL
|
||||
448,Mavericks Beer Company,Half Moon Bay, CA
|
||||
449,TailGate Beer,San Diego, CA
|
||||
450,Northwest Brewing Company,Pacific, WA
|
||||
451,Dad & Dude's Breweria,Aurora, CO
|
||||
452,Centennial Beer Company,Edwards, CO
|
||||
453,Denali Brewing Company,Talkeetna, AK
|
||||
454,Deschutes Brewery,Bend, OR
|
||||
455,Sunken City Brewing Company,Hardy, VA
|
||||
456,Lucette Brewing Company,Menominie, WI
|
||||
457,The Black Tooth Brewing Company,Sheridan, WY
|
||||
458,Kenai River Brewing Company,Soldotna, AK
|
||||
459,River North Brewery,Denver, CO
|
||||
460,Fremont Brewing Company,Seattle, WA
|
||||
461,Armstrong Brewing Company,South San Francisco, CA
|
||||
462,AC Golden Brewing Company,Golden, CO
|
||||
463,Big Bend Brewing Company,Alpine, TX
|
||||
464,Good Life Brewing Company,Bend, OR
|
||||
465,Engine 15 Brewing,Jacksonville Beach, FL
|
||||
466,Green Room Brewing,Jacksonville, FL
|
||||
467,Brindle Dog Brewing Company,Tampa Bay, FL
|
||||
468,Peace Tree Brewing Company,Knoxville, IA
|
||||
469,Terrapin Brewing Company,Athens, GA
|
||||
470,Pete's Brewing Company,San Antonio, TX
|
||||
471,Okoboji Brewing Company,Spirit Lake, IA
|
||||
472,Crystal Springs Brewing Company,Boulder, CO
|
||||
473,Engine House 9,Tacoma, WA
|
||||
474,Tonka Beer Company,Minnetonka, MN
|
||||
475,Red Hare Brewing Company,Marietta, GA
|
||||
476,Hangar 24 Craft Brewery,Redlands, CA
|
||||
477,Big Elm Brewing,Sheffield, MA
|
||||
478,Good People Brewing Company,Birmingham, AL
|
||||
479,Heavy Seas Beer,Halethorpe, MD
|
||||
480,Telluride Brewing Company,Telluride, CO
|
||||
481,7 Seas Brewing Company,Gig Harbor, WA
|
||||
482,Confluence Brewing Company,Des Moines, IA
|
||||
483,Bale Breaker Brewing Company,Yakima, WA
|
||||
484,The Manhattan Brewing Company,New York, NY
|
||||
485,MacTarnahans Brewing Company,Portland, OR
|
||||
486,Stillmank Beer Company,Green Bay, WI
|
||||
487,Redhook Brewery,Woodinville, WA
|
||||
488,Dock Street Brewery,Philadelphia, PA
|
||||
489,Blue Point Brewing Company,Patchogue, NY
|
||||
490,Tampa Bay Brewing Company,Tampa, FL
|
||||
491,Devil's Canyon Brewery,Belmont, CA
|
||||
492,Stone Coast Brewing Company,Portland, ME
|
||||
493,Broken Tooth Brewing Company,Anchorage, AK
|
||||
494,Seven Brides Brewery,Silverton, OR
|
||||
495,Newburyport Brewing Company,Newburyport, MA
|
||||
496,Dry Dock Brewing Company,Aurora, CO
|
||||
497,Cans Bar and Canteen,Charlotte, NC
|
||||
498,Sprecher Brewing Company,Glendale, WI
|
||||
499,Wildwood Brewing Company,Stevensville, MT
|
||||
500,High Noon Saloon And Brewery,Leavenworth, KS
|
||||
501,Woodchuck Hard Cider,Middlebury, VT
|
||||
502,Sea Dog Brewing Company,Portland, ME
|
||||
503,Oskar Blues Brewery,Lyons, CO
|
||||
504,Carolina Beer & Beverage,Mooresville, NC
|
||||
505,Krebs Brewing Company (Pete's Pl...,Krebs, OK
|
||||
506,Warbird Brewing Company,Fort Wayne, IN
|
||||
507,Mudshark Brewing Company,Lake Havasu City, AZ
|
||||
508,Spilker Ales,Cortland, NE
|
||||
509,Wingman Brewers,Tacoma, WA
|
||||
510,Kettle House Brewing Company,Missoula, MT
|
||||
511,Sherwood Forest Brewers,Marlborough, MA
|
||||
512,Cottrell Brewing,Pawcatuck, CT
|
||||
513,Arctic Craft Brewery,Colorado Springs, CO
|
||||
514,Monkey Paw Pub & Brewery,San Diego, CA
|
||||
515,Crabtree Brewing Company,Greeley, CO
|
||||
516,Emerald City Beer Company,Seattle, WA
|
||||
517,Butcher's Brewing,Carlsbad, CA
|
||||
518,New South Brewing Company,Myrtle Beach, SC
|
||||
519,Big River Brewing Company,Chattanooga, TN
|
||||
520,Twisted Pine Brewing Company,Boulder, CO
|
||||
521,Flying Dog Brewery,Frederick, MD
|
||||
522,Uncommon Brewers,Santa Cruz, CA
|
||||
523,Aspen Brewing Company,Aspen, CO
|
||||
524,Triangle Brewing Company,Durham, NC
|
||||
525,Bomb Beer Company,New York, NY
|
||||
526,Churchkey Can Company,Seattle, WA
|
||||
527,Intuition Ale Works,Jacksonville, FL
|
||||
528,Asheville Brewing Company,Asheville, NC
|
||||
529,Northwoods Brewpub,Eau Claire, WI
|
||||
530,Buckbean Brewing Company,Reno, NV
|
||||
531,Dolores River Brewery,Dolores, CO
|
||||
532,Flat Rock Brewing Company,Smithton, PA
|
||||
533,Abita Brewing Company,Abita Springs, LA
|
||||
534,Mammoth Brewing Company,Mammoth Lakes, CA
|
||||
535,Harvest Moon Brewing Company,Belt, MT
|
||||
536,Grand Canyon Brewing Company,Williams, AZ
|
||||
537,Lewis and Clark Brewing Company,Helena, MT
|
||||
538,Dundee Brewing Company,Rochester, NY
|
||||
539,Twin Lakes Brewing Company,Greenville, DE
|
||||
540,Mother Earth Brewing Company,Kinston, NC
|
||||
541,Arcadia Brewing Company,Battle Creek, MI
|
||||
542,Angry Minnow Brewing Company,Hayward, WI
|
||||
543,Great Northern Brewing Company,Whitefish, MT
|
||||
544,Pyramid Breweries,Seattle, WA
|
||||
545,Lancaster Brewing Company,Lancaster, PA
|
||||
546,Upstate Brewing Company,Elmira, NY
|
||||
547,Moat Mountain Smoke House & Brew...,North Conway, NH
|
||||
548,Prescott Brewing Company,Prescott, AZ
|
||||
549,Mogollon Brewing Company,Flagstaff, AZ
|
||||
550,Wind River Brewing Company,Pinedale, WY
|
||||
551,Silverton Brewery,Silverton, CO
|
||||
552,Mickey Finn's Brewery,Libertyville, IL
|
||||
553,Covington Brewhouse,Covington, LA
|
||||
554,Dave's Brewfarm,Wilson, WI
|
||||
555,Ukiah Brewing Company,Ukiah, CA
|
||||
556,Butternuts Beer and Ale,Garrattsville, NY
|
||||
557,Sleeping Lady Brewing Company,Anchorage, AK
|
||||
|
162686
misc/raw-data/breweries.json
Normal file
162686
misc/raw-data/breweries.json
Normal file
File diff suppressed because it is too large
Load Diff
578
misc/raw-data/ontariobreweries.json
Normal file
578
misc/raw-data/ontariobreweries.json
Normal file
@@ -0,0 +1,578 @@
|
||||
[
|
||||
{
|
||||
"text": "100 Acre Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/100-acre-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "All My Friends Beer Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/all-my-friends-beer-co/"
|
||||
},
|
||||
{
|
||||
"text": "All or Nothing Brewhouse",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/all-or-nothing-brewhouse/"
|
||||
},
|
||||
{
|
||||
"text": "Anderson Craft Ales",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/anderson-craft-ales/"
|
||||
},
|
||||
{
|
||||
"text": "Badlands Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/badlands-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Bancroft Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bancroft-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Banded Goose Brewing Comany",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/banded-goose-brewing-comany/"
|
||||
},
|
||||
{
|
||||
"text": "Beau’s Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/beaus-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "BeerLab! London",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/beerlab-london/"
|
||||
},
|
||||
{
|
||||
"text": "Bellwoods Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bellwoods-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Bench Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bench-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Beyond The Pale Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/beyond-the-pale-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Bicycle Craft Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bicycle-craft-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Big Rig Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rig-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Big Rock Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rock-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Black Gold Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/black-gold-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Black Oak Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/black-oak-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Block 3 Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/block-3-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Blood Brothers Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/blood-brothers-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Bobcaygeon Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bobcaygeon-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Boshkung Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/boshkung-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Brauwerk Hoffman – Rockland",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/brauwerk-hoffman-rockland/"
|
||||
},
|
||||
{
|
||||
"text": "Bridge Masters Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/bridge-masters-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Broadhead Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/broadhead-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Broken Rail Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/broken-rail-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Burdock Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/burdock-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "C’est What Durham Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/cest-what-durham-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Calabogie Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/calabogie-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Cameron’s Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/camerons-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Canvas Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/canvas-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Caps Off Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/caps-off-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Century Barn Brewing & Beverage Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/century-barn-brewing-and-beverage-company/"
|
||||
},
|
||||
{
|
||||
"text": "Chronicle Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/chronicle-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Clifford Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/clifford-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Cold Bear Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/cold-bear-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Collective Arts Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/collective-arts-brewing-ltd/"
|
||||
},
|
||||
{
|
||||
"text": "Common Good Beer Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/common-good-beer-co/"
|
||||
},
|
||||
{
|
||||
"text": "Couchiching Craft Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/couchiching-craft-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Cowbell Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/cowbell-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Cured Craft Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/cured-craft-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Daft Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/daft-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Dog House Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/dog-house-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Dominion City Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/dominion-city-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Eastbound Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/eastbound-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Equals Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/equals-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Fairweather Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/fairweather-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Farm League Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/farm-league-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Fixed Gear Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/fixed-gear-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Flying Monkeys Craft Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/flying-monkeys-craft-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Focal Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/focal-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Foundry Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/foundry-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Four Fathers Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/four-fathers-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Frank Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/frank-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Freddy’s",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/freddys/"
|
||||
},
|
||||
{
|
||||
"text": "Full Beard Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/1068-2/"
|
||||
},
|
||||
{
|
||||
"text": "Furnace Room Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/furnace-room-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Gateway City Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/gateway-city-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Glasstown Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/glasstown-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Godspeed Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/godspeed-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Goldenfield Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/goldenfield-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Grand River Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/grand-river-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Granite Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/granite-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Great Lakes Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/great-lakes-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Haliburton Highlands Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/haliburton-highlands-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Imperial City Brew House",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/imperial-city-brew-house/"
|
||||
},
|
||||
{
|
||||
"text": "Indie Ale House",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/indie-ale-house/"
|
||||
},
|
||||
{
|
||||
"text": "Jobsite Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/jobsite-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Kichesippi Beer Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/kichesippi-beer-co/"
|
||||
},
|
||||
{
|
||||
"text": "Kick and Push Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/kick-and-push-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Lake of Bays Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-bays-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Lake Of The Woods Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-the-woods-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Left Field Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/left-field-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Lightcaster Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/lightcaster-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "MacKinnon Brothers Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/mackinnon-brothers-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Maclean’s Ales Inc.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/macleans-ales-inc/"
|
||||
},
|
||||
{
|
||||
"text": "Magnotta Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/magnotta-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Market Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/market-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Mascot Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/mascot-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Matron Fine Beer",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/matron-fine-beer/"
|
||||
},
|
||||
{
|
||||
"text": "Meyers Creek Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/meyers-creek-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Midtown Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/midtown-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Miski Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/miski-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Muddy York Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/muddy-york-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Muskoka Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/muskoka-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Natterjack Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/natterjack-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Newark Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/newark-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Niagara Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Niagara College Teaching Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-college-teaching-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Niagara Oast House Brewers",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-oast-house-brewers/"
|
||||
},
|
||||
{
|
||||
"text": "Nickel Brook Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/nickel-brook-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Northern Superior Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/northern-superior-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Old Credit Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/old-credit-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Old Flame Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/1239-2/"
|
||||
},
|
||||
{
|
||||
"text": "Orléans Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/orleans-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Overflow Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/overflow-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Parsons Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/parsons-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Perth Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/perth-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Prince Eddy’s Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/prince-eddys-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Quayle’s Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/quayles-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Quetico Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/quetico-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Railway City Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/railway-city-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Ramblin’ Road Brewery Farm",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/ramblin-road-brewery-farm/"
|
||||
},
|
||||
{
|
||||
"text": "Red Barn Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/red-barn-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Refined Fool Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/refined-fool-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Rouge River Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/rouge-river-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Royal City Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/royal-city-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Sassy Britches Brewing Co Ltd",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/sassy-britches-brewing-co-ltd/"
|
||||
},
|
||||
{
|
||||
"text": "Sawdust City Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/sawdust-city-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Shawn & Ed Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/shawn-ed-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Silversmith Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/silversmith-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Slake Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/slake-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Sleeping Giant Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/sleeping-giant-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Something in the Water Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/something-in-the-water-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Sonnen Hill Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/sonnen-hill-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Sons of Kent Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/sons-of-kent-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Spark Beer",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/spark-beer/"
|
||||
},
|
||||
{
|
||||
"text": "Split Rail Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/split-rail-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Stack Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/stack-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Steam Whistle Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/steam-whistle-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Steel Wheel Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/steel-wheel-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Stonehooker Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/stonehooker-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Stonepicker Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/stonepicker-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Stray Dog Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/stray-dog-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "The Exchange Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/the-exchange-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "The Grove Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/the-grove-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "The Second Wedge Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/the-second-wedge-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Thornbury Village Craft Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/thornbury-village-craft-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Three Sheets Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/three-sheets-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Tooth and Nail Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/tooth-and-nail-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Torched Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/torched-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Town Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/town-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Trestle Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/trestle-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "True History Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/true-history-brewing/"
|
||||
},
|
||||
{
|
||||
"text": "Upper Thames Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/upper-thames-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Vimy Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/vimy-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Walkerville Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/walkerville-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Wave Maker Craft Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/wave-maker-craft-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Wellington Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/wellington-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Whiprsnapr Brewing Co.",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/whiprsnapr-brewing-co/"
|
||||
},
|
||||
{
|
||||
"text": "Whiskeyjack Beer Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/whiskeyjack-beer-company/"
|
||||
},
|
||||
{
|
||||
"text": "Whitewater Brewing Company",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/whitewater-brewing-company/"
|
||||
},
|
||||
{
|
||||
"text": "Willibald Farm Distillery & Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/willibald-farm-distillery-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Windmill Brewery",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/windmill-brewery/"
|
||||
},
|
||||
{
|
||||
"text": "Wishbone Brewing",
|
||||
"href": "https://ontariocraftbrewers.com/brewery-profile/wishbone-brewing/"
|
||||
}
|
||||
]
|
||||
@@ -8,10 +8,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.OpenApi"
|
||||
Version="9.0.11"
|
||||
/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference
|
||||
Include="FluentValidation.AspNetCore"
|
||||
Version="11.3.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,14 +26,12 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using API.Core.Contracts.Common;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace API.Core.Authentication;
|
||||
|
||||
public class JwtAuthenticationHandler(
|
||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IConfiguration configuration
|
||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
secret = configuration["Jwt:SecretKey"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||
}
|
||||
|
||||
// Check if Authorization header exists
|
||||
if (
|
||||
!Request.Headers.TryGetValue(
|
||||
"Authorization",
|
||||
out var authHeaderValue
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail("Authorization header is missing");
|
||||
}
|
||||
|
||||
var authHeader = authHeaderValue.ToString();
|
||||
if (
|
||||
!authHeader.StartsWith(
|
||||
"Bearer ",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
"Invalid authorization header format"
|
||||
);
|
||||
}
|
||||
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||
token,
|
||||
secret
|
||||
);
|
||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
$"Token validation failed: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.ContentType = "application/json";
|
||||
Response.StatusCode = 401;
|
||||
|
||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||
await Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||
21
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
21
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
using Org.BouncyCastle.Asn1.Cms;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record LoginPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
public record RegistrationPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
20
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal file
20
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using API.Core.Contracts.Common;
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record LoginRequest
|
||||
{
|
||||
public string Username { get; init; } = default!;
|
||||
public string Password { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||
|
||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class RefreshTokenRequestValidator
|
||||
: AbstractValidator<RefreshTokenRequest>
|
||||
{
|
||||
public RefreshTokenRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
using API.Core.Contracts.Common;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
public record RegisterRequest(
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string Password
|
||||
);
|
||||
|
||||
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||
/// length, minimum age based on date of birth, and password strength requirements.
|
||||
/// </summary>
|
||||
public RegisterUserValidator()
|
||||
public RegisterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty()
|
||||
@@ -61,6 +64,8 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage("Password must contain at least one special character");
|
||||
.WithMessage(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace API.Core.Contracts.Common;
|
||||
|
||||
public record ResponseBody<T>
|
||||
{
|
||||
public required string Message { get; init; }
|
||||
public required T Payload { get; init; }
|
||||
}
|
||||
|
||||
public record ResponseBody
|
||||
{
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
111
src/Core/API/API.Core/Controllers/AuthController.cs
Normal file
111
src/Core/API/API.Core/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using API.Core.Contracts.Auth;
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.Auth;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(
|
||||
IRegisterService registerService,
|
||||
ILoginService loginService,
|
||||
IConfirmationService confirmationService,
|
||||
ITokenService tokenService
|
||||
) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<UserAccount>> Register(
|
||||
[FromBody] RegisterRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await registerService.RegisterAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.Empty,
|
||||
Username = req.Username,
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
Email = req.Email,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
},
|
||||
req.Password
|
||||
);
|
||||
|
||||
var response = new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = new RegistrationPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken,
|
||||
rtn.EmailSent
|
||||
),
|
||||
};
|
||||
return Created("/", response);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var rtn = await loginService.LoginAsync(req.Username, req.Password);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||
{
|
||||
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||
Payload = new ConfirmationPayload(
|
||||
rtn.UserId,
|
||||
rtn.ConfirmedAt
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult> Refresh(
|
||||
[FromBody] RefreshTokenRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Core/API/API.Core/Controllers/NotFoundController.cs
Normal file
16
src/Core/API/API.Core/Controllers/NotFoundController.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Route("error")] // required
|
||||
public class NotFoundController : ControllerBase
|
||||
{
|
||||
[HttpGet("404")] //required
|
||||
public IActionResult Handle404()
|
||||
{
|
||||
return NotFound(new { message = "Route not found." });
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using API.Core.Contracts.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class ProtectedController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
{
|
||||
Message = "Protected endpoint accessed successfully",
|
||||
Payload = new { userId, username },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/Core/API/API.Core/Controllers/UserController.cs
Normal file
28
src/Core/API/API.Core/Controllers/UserController.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController(IUserService userService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
var users = await userService.GetAllAsync(limit, offset);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
var user = await userService.GetByIdAsync(id);
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Core/API/API.Core/Dockerfile
Normal file
32
src/Core/API/API.Core/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
ARG APP_UID=1000
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||
RUN dotnet restore "API/API.Core/API.Core.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Core"
|
||||
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "API.Core.dll"]
|
||||
109
src/Core/API/API.Core/GlobalException.cs
Normal file
109
src/Core/API/API.Core/GlobalException.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace API.Core;
|
||||
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
: IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
switch (context.Exception)
|
||||
{
|
||||
case FluentValidation.ValidationException fluentValidationException:
|
||||
var errors = fluentValidationException
|
||||
.Errors.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
);
|
||||
|
||||
context.Result = new BadRequestObjectResult(
|
||||
new { message = "Validation failed", errors }
|
||||
);
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ConflictException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 409,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case NotFoundException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 404,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case UnauthorizedException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 401,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ForbiddenException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 403,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case SqlException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = "A database error occurred." }
|
||||
)
|
||||
{
|
||||
StatusCode = 503,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case Domain.Exceptions.ValidationException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 400,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody
|
||||
{
|
||||
Message = "An unexpected error occurred",
|
||||
}
|
||||
)
|
||||
{
|
||||
StatusCode = 500,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/Core/API/API.Core/Program.cs
Normal file
108
src/Core/API/API.Core/Program.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using API.Core;
|
||||
using API.Core.Authentication;
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Email.Templates;
|
||||
using Infrastructure.Email.Templates.Rendering;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Service.Auth;
|
||||
using Service.Emails;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Global Exception Filter
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Configure logging for container output
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
if (!builder.Environment.IsProduction())
|
||||
{
|
||||
builder.Logging.AddDebug();
|
||||
}
|
||||
|
||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||
|
||||
builder.Services.AddSingleton<
|
||||
ISqlConnectionFactory,
|
||||
DefaultSqlConnectionFactory
|
||||
>();
|
||||
|
||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
||||
builder.Services.AddScoped<IRegisterService, RegisterService>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
|
||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
||||
|
||||
// Register the exception filter
|
||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
|
||||
// Configure JWT Authentication
|
||||
builder
|
||||
.Services.AddAuthentication("JWT")
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
"JWT",
|
||||
options => { }
|
||||
);
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Health check endpoint (used by Docker health checks and orchestrators)
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.MapControllers();
|
||||
app.MapFallbackToController("Handle404", "NotFound");
|
||||
|
||||
// Graceful shutdown handling
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
@@ -24,7 +24,10 @@
|
||||
/>
|
||||
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -39,6 +42,5 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
25
src/Core/API/API.Specs/Dockerfile
Normal file
25
src/Core/API/API.Specs/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Specs"
|
||||
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS final
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/test-results/api-specs
|
||||
WORKDIR /src/API/API.Specs
|
||||
ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/api-specs/results.trx"]
|
||||
@@ -0,0 +1,51 @@
|
||||
Feature: Protected Endpoint Access Token Validation
|
||||
As a backend developer
|
||||
I want protected endpoints to validate access tokens
|
||||
So that unauthorized requests are rejected
|
||||
|
||||
Scenario: Protected endpoint accepts valid access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a request to a protected endpoint with a valid access token
|
||||
Then the response has HTTP status 200
|
||||
|
||||
Scenario: Protected endpoint rejects missing access token
|
||||
Given the API is running
|
||||
When I submit a request to a protected endpoint without an access token
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Protected endpoint rejects invalid access token
|
||||
Given the API is running
|
||||
When I submit a request to a protected endpoint with an invalid access token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects expired access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in with an immediately-expiring access token
|
||||
When I submit a request to a protected endpoint with the expired token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects token signed with wrong secret
|
||||
Given the API is running
|
||||
And I have an access token signed with the wrong secret
|
||||
When I submit a request to a protected endpoint with the tampered token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects refresh token as access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a request to a protected endpoint with my refresh token instead of access token
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Protected endpoint rejects confirmation token as access token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token
|
||||
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
||||
Then the response has HTTP status 401
|
||||
76
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
76
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -0,0 +1,76 @@
|
||||
Feature: User Account Confirmation
|
||||
As a newly registered user
|
||||
I want to confirm my email address via a validation token
|
||||
So that my account is fully activated
|
||||
Scenario: Successful confirmation with valid token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the valid token
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "is confirmed"
|
||||
|
||||
Scenario: Re-confirming an already verified account remains successful
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the valid token
|
||||
And I submit the same confirmation request again
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "is confirmed"
|
||||
|
||||
Scenario: Confirmation fails with invalid token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with an invalid token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails with expired token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have an expired confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the expired token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails with tampered token (wrong secret)
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a confirmation token signed with the wrong secret
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the tampered token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails when token is missing
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with a missing token
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Confirmation endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
And I have a valid confirmation token
|
||||
When I submit a confirmation request using an invalid HTTP method
|
||||
Then the response has HTTP status 404
|
||||
|
||||
Scenario: Confirmation fails with malformed token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with a malformed token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails without an access token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
When I submit a confirmation request with the valid token without an access token
|
||||
Then the response has HTTP status 401
|
||||
39
src/Core/API/API.Specs/Features/Login.feature
Normal file
39
src/Core/API/API.Specs/Features/Login.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
Feature: User Login
|
||||
As a registered user
|
||||
I want to log in to my account
|
||||
So that I receive an authentication token to access authenticated routes
|
||||
|
||||
Scenario: Successful login with valid credentials
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
When I submit a login request with a username and password
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" equal "Logged in successfully."
|
||||
And the response JSON should have an access token
|
||||
|
||||
Scenario: Login fails with invalid credentials
|
||||
Given the API is running
|
||||
And I do not have an existing account
|
||||
When I submit a login request with a username and password
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" equal "Invalid username or password."
|
||||
|
||||
Scenario: Login fails when required missing username
|
||||
Given the API is running
|
||||
When I submit a login request with a missing username
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login fails when required missing password
|
||||
Given the API is running
|
||||
When I submit a login request with a missing password
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login fails when both username and password are missing
|
||||
Given the API is running
|
||||
When I submit a login request with both username and password missing
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
When I submit a login request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
10
src/Core/API/API.Specs/Features/NotFound.feature
Normal file
10
src/Core/API/API.Specs/Features/NotFound.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
Feature: NotFound Responses
|
||||
As a client of the API
|
||||
I want consistent 404 responses
|
||||
So that consumers can gracefully handle missing routes
|
||||
|
||||
Scenario: GET request to an invalid route returns 404
|
||||
Given the API is running
|
||||
When I send an HTTP request "GET" to "/invalid-route"
|
||||
Then the response has HTTP status 404
|
||||
And the response JSON should have "message" equal "Route not found."
|
||||
60
src/Core/API/API.Specs/Features/Registration.feature
Normal file
60
src/Core/API/API.Specs/Features/Registration.feature
Normal file
@@ -0,0 +1,60 @@
|
||||
Feature: User Registration
|
||||
As a new user
|
||||
I want to register an account
|
||||
So that I can log in and access authenticated routes
|
||||
|
||||
Scenario: Successful registration with valid details
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 201
|
||||
And the response JSON should have "message" equal "User registered successfully."
|
||||
And the response JSON should have an access token
|
||||
|
||||
Scenario: Registration fails with existing username
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
|
||||
Scenario: Registration fails with existing email
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
|
||||
Scenario: Registration fails with missing required fields
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| | New | User | | | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration fails with invalid email format
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration fails with weak password
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
When I submit a registration request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
Feature: Token Refresh
|
||||
As an authenticated user
|
||||
I want to refresh my access token using my refresh token
|
||||
So that I can maintain my session without logging in again
|
||||
|
||||
Scenario: Successful token refresh with valid refresh token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a refresh token request with a valid refresh token
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" equal "Token refreshed successfully."
|
||||
And the response JSON should have a new access token
|
||||
And the response JSON should have a new refresh token
|
||||
|
||||
Scenario: Token refresh fails with invalid refresh token
|
||||
Given the API is running
|
||||
When I submit a refresh token request with an invalid refresh token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid"
|
||||
|
||||
Scenario: Token refresh fails with expired refresh token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in with an immediately-expiring refresh token
|
||||
When I submit a refresh token request with the expired refresh token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Token refresh fails when refresh token is missing
|
||||
Given the API is running
|
||||
When I submit a refresh token request with a missing refresh token
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Token refresh endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
And I have a valid refresh token
|
||||
When I submit a refresh token request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
@@ -10,7 +10,12 @@ public class MockEmailProvider : IEmailProvider
|
||||
{
|
||||
public List<SentEmail> SentEmails { get; } = new();
|
||||
|
||||
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||
public Task SendAsync(
|
||||
string to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
@@ -26,7 +31,12 @@ public class MockEmailProvider : IEmailProvider
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
|
||||
public Task SendAsync(
|
||||
IEnumerable<string> to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
38
src/Core/API/API.Specs/Mocks/MockEmailService.cs
Normal file
38
src/Core/API/API.Specs/Mocks/MockEmailService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Domain.Entities;
|
||||
using Service.Emails;
|
||||
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
public class MockEmailService : IEmailService
|
||||
{
|
||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||
|
||||
public Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
SentRegistrationEmails.Add(
|
||||
new RegistrationEmail
|
||||
{
|
||||
UserAccount = createdUser,
|
||||
ConfirmationToken = confirmationToken,
|
||||
SentAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
SentRegistrationEmails.Clear();
|
||||
}
|
||||
|
||||
public class RegistrationEmail
|
||||
{
|
||||
public UserAccount UserAccount { get; init; } = null!;
|
||||
public string ConfirmationToken { get; init; } = string.Empty;
|
||||
public DateTime SentAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using API.Specs;
|
||||
using FluentAssertions;
|
||||
using Reqnroll;
|
||||
|
||||
@@ -15,12 +15,14 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
|
||||
private HttpClient GetClient()
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||
var factory = scenario.TryGetValue<TestApiFactory>(
|
||||
FactoryKey,
|
||||
out TestApiFactory? f
|
||||
out var f
|
||||
)
|
||||
? f
|
||||
: new TestApiFactory();
|
||||
@@ -44,27 +46,37 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
string jsonBody
|
||||
)
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
var client = GetClient();
|
||||
|
||||
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
|
||||
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
|
||||
{
|
||||
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
|
||||
Content = new StringContent(
|
||||
jsonBody,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I send an HTTP request {string} to {string}")]
|
||||
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
|
||||
public async Task WhenISendAnHttpRequestStringToString(
|
||||
string method,
|
||||
string url
|
||||
)
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
new HttpMethod(method),
|
||||
url
|
||||
);
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
@@ -74,7 +86,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
((int)response!.StatusCode).Should().Be(expected);
|
||||
@@ -84,42 +96,57 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue("No response was received from the API");
|
||||
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||
}
|
||||
|
||||
[Then("the response JSON should have {string} equal {string}")]
|
||||
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
|
||||
public void ThenTheResponseJsonShouldHaveStringEqualString(
|
||||
string field,
|
||||
string expected
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out JsonElement value))
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
{
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
value.GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
@@ -130,33 +157,45 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out JsonElement value))
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
{
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||
string? actualValue = value.GetString();
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
var actualValue = value.GetString();
|
||||
actualValue
|
||||
.Should()
|
||||
.Contain(
|
||||
1127
src/Core/API/API.Specs/Steps/AuthSteps.cs
Normal file
1127
src/Core/API/API.Specs/Steps/AuthSteps.cs
Normal file
File diff suppressed because it is too large
Load Diff
46
src/Core/API/API.Specs/TestApiFactory.cs
Normal file
46
src/Core/API/API.Specs/TestApiFactory.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Specs.Mocks;
|
||||
using Infrastructure.Email;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Service.Emails;
|
||||
|
||||
namespace API.Specs
|
||||
{
|
||||
public class TestApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace the real email provider with mock for testing
|
||||
var emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailProvider)
|
||||
);
|
||||
|
||||
if (emailProviderDescriptor != null)
|
||||
{
|
||||
services.Remove(emailProviderDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||
|
||||
// Replace the real email service with mock for testing
|
||||
var emailServiceDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailService)
|
||||
);
|
||||
|
||||
if (emailServiceDescriptor != null)
|
||||
{
|
||||
services.Remove(emailServiceDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailService, MockEmailService>();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/Core/Core.slnx
Normal file
31
src/Core/Core.slnx
Normal file
@@ -0,0 +1,31 @@
|
||||
<Solution>
|
||||
<Folder Name="/API/">
|
||||
<Project Path="API/API.Core/API.Core.csproj" />
|
||||
<Project Path="API/API.Specs/API.Specs.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Database/">
|
||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Domain/">
|
||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Service/">
|
||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
|
||||
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
||||
<Project Path="Service\Service.Auth\Service.Auth.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Database.Migrations</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
171
src/Core/Database/Database.Migrations/Program.cs
Normal file
171
src/Core/Database/Database.Migrations/Program.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using DbUp;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Migrations;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
private static string BuildConnectionString(string? databaseName = null)
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
|
||||
var dbName = databaseName
|
||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
|
||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
|
||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
|
||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||
?? "True";
|
||||
|
||||
var builder = new SqlConnectionStringBuilder
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static readonly string connectionString = BuildConnectionString();
|
||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||
|
||||
private static bool DeployMigrations()
|
||||
{
|
||||
var upgrader = DeployChanges
|
||||
.To.SqlDatabase(connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
var result = upgrader.PerformUpgrade();
|
||||
return result.Successful;
|
||||
}
|
||||
|
||||
private static bool ClearDatabase()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
|
||||
// First, set the database to single user mode to close all connections
|
||||
var setModeCommand = new SqlCommand(
|
||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
||||
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||
myConn);
|
||||
try
|
||||
{
|
||||
setModeCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database set to single user mode.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||
}
|
||||
|
||||
// Then drop the database
|
||||
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||
try
|
||||
{
|
||||
dropCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database cleared successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error dropping database: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error clearing database: {ex}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool CreateDatabaseIfNotExists()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
|
||||
const string str = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
||||
CREATE DATABASE [Biergarten]
|
||||
""";
|
||||
|
||||
var myCommand = new SqlCommand(str, myConn);
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
myCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database creation command executed successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error creating database: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Starting database migrations...");
|
||||
|
||||
try
|
||||
{
|
||||
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||
if (clearDatabase == "true")
|
||||
{
|
||||
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
||||
ClearDatabase();
|
||||
}
|
||||
|
||||
CreateDatabaseIfNotExists();
|
||||
var success = DeployMigrations();
|
||||
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine("Database migrations completed successfully.");
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Database migrations failed.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("An error occurred during database migrations:");
|
||||
Console.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
|
||||
|
||||
UpdatedAt DATETIME,
|
||||
|
||||
DateOfBirth DATE NOT NULL,
|
||||
DateOfBirth DATETIME NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -49,6 +49,7 @@ CREATE TABLE dbo.UserAccount
|
||||
|
||||
CONSTRAINT AK_Email
|
||||
UNIQUE (Email)
|
||||
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -78,8 +79,7 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||
ON Photo(UploadedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -109,10 +109,9 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||
|
||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
)
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
ON UserAvatar(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -126,7 +125,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
VerificationDateTime DATETIME NOT NULL
|
||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||
CONSTRAINT DF_VerificationDateTime
|
||||
DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -142,8 +142,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||
ON UserVerification(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -156,13 +155,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||
CreatedAt DATETIME
|
||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||
|
||||
Expiry DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||
Expiry DATETIME
|
||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
|
||||
|
||||
Hash NVARCHAR(256) NOT NULL,
|
||||
Hash NVARCHAR(MAX) NOT NULL,
|
||||
-- uses argon2
|
||||
|
||||
IsRevoked BIT NOT NULL
|
||||
@@ -178,18 +177,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
CONSTRAINT FK_UserCredential_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE CASCADE,
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
ON UserCredential(UserAccountID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||
INCLUDE (Hash);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -202,8 +195,8 @@ CREATE TABLE UserFollow
|
||||
|
||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||
CreatedAt DATETIME
|
||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -212,27 +205,23 @@ CREATE TABLE UserFollow
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||
FOREIGN KEY (FollowingID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT CK_CannotFollowOwnAccount
|
||||
CHECK (UserAccountID != FollowingID
|
||||
)
|
||||
CHECK (UserAccountID != FollowingID)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
ON UserFollow(UserAccountID, FollowingID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
ON UserFollow(FollowingID, UserAccountID);
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -282,8 +271,7 @@ CREATE TABLE StateProvince
|
||||
REFERENCES Country(CountryID)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_StateProvince_Country
|
||||
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
||||
ON StateProvince(CountryID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -308,10 +296,10 @@ CREATE TABLE City
|
||||
REFERENCES StateProvince(StateProvinceID)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_City_StateProvince
|
||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||
ON City(StateProvinceID);
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -320,8 +308,6 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
BreweryPostID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||
|
||||
BreweryName NVARCHAR(256) NOT NULL,
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Description NVARCHAR(512) NOT NULL,
|
||||
@@ -339,16 +325,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
CONSTRAINT FK_BreweryPost_UserAccount
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
)
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
ON BreweryPost(PostedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BreweryPostLocation
|
||||
(
|
||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||
@@ -364,7 +349,7 @@ CREATE TABLE BreweryPostLocation
|
||||
|
||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Coordinates GEOGRAPHY NULL,
|
||||
Coordinates GEOGRAPHY NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -377,33 +362,15 @@ CREATE TABLE BreweryPostLocation
|
||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||
FOREIGN KEY (BreweryPostID)
|
||||
REFERENCES BreweryPost(BreweryPostID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_BreweryPostLocation_City
|
||||
FOREIGN KEY (CityID)
|
||||
REFERENCES City (CityID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
ON BreweryPostLocation(BreweryPostID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
ON BreweryPostLocation(CityID);
|
||||
|
||||
-- To assess when the time comes:
|
||||
|
||||
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
||||
|
||||
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||
-- ON BreweryPostLocation(Coordinates)
|
||||
-- USING GEOGRAPHY_GRID
|
||||
-- WITH (
|
||||
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||
-- CELLS_PER_OBJECT = 16
|
||||
-- );
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -435,17 +402,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerStyle
|
||||
(
|
||||
BeerStyleID UNIQUEIDENTIFIER
|
||||
@@ -480,7 +444,7 @@ CREATE TABLE BeerPost
|
||||
-- Alcohol By Volume (typically 0-67%)
|
||||
|
||||
IBU INT NOT NULL,
|
||||
-- International Bitterness Units (typically 0-120)
|
||||
-- International Bitterness Units (typically 0-100)
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
@@ -500,8 +464,7 @@ CREATE TABLE BeerPost
|
||||
|
||||
CONSTRAINT FK_BeerPost_PostedBy
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT FK_BeerPost_BeerStyle
|
||||
FOREIGN KEY (BeerStyleID)
|
||||
@@ -518,16 +481,13 @@ CREATE TABLE BeerPost
|
||||
CHECK (IBU >= 0 AND IBU <= 120)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||
ON BeerPost(PostedByID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||
ON BeerPost(BeerStyleID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||
ON BeerPost(BrewedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -561,12 +521,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -581,37 +539,17 @@ CREATE TABLE BeerPostComment
|
||||
|
||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Rating INT NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerPostComment
|
||||
PRIMARY KEY (BeerPostCommentID),
|
||||
|
||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||
FOREIGN KEY (BeerPostID)
|
||||
REFERENCES BeerPost (BeerPostID),
|
||||
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
|
||||
)
|
||||
|
||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||
FOREIGN KEY (CommentedByID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
ON BeerPostComment(BeerPostID)
|
||||
|
||||
CONSTRAINT CHK_BeerPostComment_Rating
|
||||
CHECK (Rating BETWEEN 1 AND 5)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
ON BeerPostComment(BeerPostID);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||
ON BeerPostComment(CommentedByID);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user