Compare commits

..

16 Commits

Author SHA1 Message Date
Aaron Po
a4da01586d Documentation updates (#236)
Remove vague or flowery language, use Simplified Technical English whenever possible
2026-08-04 09:38:24 -04:00
Aaron Po
3087d599ae remove comment 2026-08-04 06:30:44 -04:00
Aaron Po
48ee6b0fef Format 2026-08-04 06:30:44 -04:00
Aaron Po
7a0620337d update backend/shared docs 2026-08-04 06:30:44 -04:00
Aaron Po
64302286fb update infra docs 2026-08-04 06:30:44 -04:00
Aaron Po
88abb8e995 update features docs 2026-08-04 06:30:44 -04:00
Aaron Po
2ed655e03d update domain docs 2026-08-04 06:30:44 -04:00
Aaron Po
be0fe9d405 update api docs 2026-08-04 06:30:44 -04:00
Aaron Po
492d5d280e Add navigation properties to entity types 2026-08-04 06:30:44 -04:00
Aaron Po
f3f704da8d format sql 2026-08-04 06:30:44 -04:00
Aaron Po
1d1edd37d0 Refactor user registration process to use UserRegistrationDto;
implement user registration in seed app
2026-08-04 06:30:44 -04:00
Aaron Po
417fa35ced Update database migration and seeding logic; adjust Docker port mapping 2026-08-04 06:30:44 -04:00
Aaron Po
66d84dee9e add wiring 2026-08-04 06:30:44 -04:00
Aaron Po
fcca12b49d Update viewer 2026-08-04 06:30:44 -04:00
Aaron Po
84e9e79d81 Create biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite 2026-08-04 06:30:44 -04:00
Aaron Po
5b2eceb6b8 Add sqlite load into Database.Seed 2026-08-04 06:30:44 -04:00
130 changed files with 1424 additions and 2079 deletions

View File

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

View File

@@ -2,7 +2,7 @@
This document describes the active architecture of The Biergarten App. This document describes the active architecture of The Biergarten App.
## High-Level Overview ## High-level overview
The Biergarten App is a monorepo with a clear split between the backend and the The Biergarten App is a monorepo with a clear split between the backend and the
active website: active website:
@@ -29,9 +29,9 @@ For visual representations, see:
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database - [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
relationships relationships
## Backend Architecture ## Backend architecture
### Vertical Slice Architecture Pattern ### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`, technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
@@ -80,9 +80,9 @@ interaction (`Features.Auth` triggering a confirmation email handled by
whose contract lives in `Shared.Application`, so neither slice takes a whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other. project reference on the other.
### Layer Responsibilities ### Layer responsibilities
#### API Layer (`API.Core`) #### API layer (`API.Core`)
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of **Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own its own
@@ -108,7 +108,7 @@ its own
- No controllers, no business logic, no feature-specific contracts - No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices - Exists purely to host and wire up the feature slices
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`) #### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
**Purpose**: Each slice is the complete vertical for one business capability **Purpose**: Each slice is the complete vertical for one business capability
@@ -146,7 +146,7 @@ MediatR commands sent from other slices, never over HTTP.
MediatR MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly - Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared Projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every **Purpose**: The minimum cross-slice surface area required because every
slice needs it, or because duplicating it four times would be worse than slice needs it, or because duplicating it four times would be worse than
@@ -166,7 +166,7 @@ sharing it
- Kept deliberately small: this is the exception to "no slice depends on - Kept deliberately small: this is the exception to "no slice depends on
another slice," not a general-purpose dumping ground another slice," not a general-purpose dumping ground
#### Infrastructure Layer #### Infrastructure layer
**Purpose**: Technical capabilities and external integrations, shared by **Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them whichever slices need them
@@ -185,14 +185,14 @@ whichever slices need them
**Dependencies**: **Dependencies**:
- Domain entities - Domain entities
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.) - External libraries (ADO.NET, JWT, Argon2, MailKit, and so on)
**Rules**: **Rules**:
- Implements technical concerns only, no business logic - Implements technical concerns only, no business logic
- Reusable across slices - Reusable across slices
#### Domain Layer (`Domain.Entities`) #### Domain layer (`Domain.Entities`)
**Purpose**: Core business entities and models **Purpose**: Core business entities and models
@@ -213,9 +213,9 @@ whichever slices need them
- No infrastructure references - No infrastructure references
- Represents business concepts - Represents business concepts
### Design Patterns ### Design patterns
#### Vertical Slice + MediatR #### Vertical slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so **Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one everything needed to understand or change one capability lives in one
@@ -223,9 +223,9 @@ project
**Implementation**: **Implementation**:
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in - Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (e.g. `GetBreweryByIdQuery`) `Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body; - Controllers bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes there is no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
@@ -246,7 +246,7 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
} }
``` ```
#### Repository Pattern #### Repository pattern
**Purpose**: Abstract database access behind interfaces **Purpose**: Abstract database access behind interfaces
@@ -275,7 +275,7 @@ public interface IAuthRepository
} }
``` ```
#### Dependency Injection #### Dependency injection
**Purpose**: Loose coupling and testability **Purpose**: Loose coupling and testability
@@ -289,7 +289,7 @@ extension method that registers its repository and slice-internal services
- Singleton: `ISqlConnectionFactory` - Singleton: `ISqlConnectionFactory`
- Transient: Utilities, helpers - Transient: Utilities, helpers
#### SQL-First Approach #### SQL-first approach
**Purpose**: Push complex logic into the database **Purpose**: Push complex logic into the database
@@ -306,9 +306,9 @@ extension method that registers its repository and slice-internal services
- `USP_GetUserAccountByUsername` - User lookup - `USP_GetUserAccountByUsername` - User lookup
- `USP_RotateUserCredential` - Password update - `USP_RotateUserCredential` - Password update
## Frontend Architecture ## Frontend architecture
### Active Website (`web/frontend`) ### Active website (`web/frontend`)
The current website is a React Router 7 application with server-side rendering The current website is a React Router 7 application with server-side rendering
enabled. enabled.
@@ -327,7 +327,7 @@ web/frontend/
└── package.json Frontend scripts and dependencies └── package.json Frontend scripts and dependencies
``` ```
### Frontend Responsibilities ### Frontend responsibilities
- Render the auth demo and theme guide routes - Render the auth demo and theme guide routes
- Manage cookie-backed website session state - Manage cookie-backed website session state
@@ -335,7 +335,7 @@ web/frontend/
- Provide shared UI building blocks for forms, navigation, themes, and toasts - Provide shared UI building blocks for forms, navigation, themes, and toasts
- Supply Storybook documentation and browser-based component verification - Supply Storybook documentation and browser-based component verification
### Theme System ### Theme system
The active website uses semantic DaisyUI theme tokens backed by four Biergarten The active website uses semantic DaisyUI theme tokens backed by four Biergarten
themes: themes:
@@ -348,15 +348,15 @@ themes:
All component styling should prefer semantic tokens such as `primary`, All component styling should prefer semantic tokens such as `primary`,
`success`, `surface`, and `highlight` instead of hard-coded color values. `success`, `surface`, and `highlight` instead of hard-coded color values.
### Legacy Frontend ### Legacy frontend
The previous Next.js frontend has been archived at `archive/next-js-web-app/` The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point for reference only. Active product and engineering documentation should point
to `web/frontend`. to `web/frontend`.
## Security Architecture ## Security architecture
### Authentication Flow ### Authentication flow
1. **Registration**: 1. **Registration**:
- User submits credentials - User submits credentials
@@ -375,7 +375,7 @@ to `web/frontend`.
- Middleware validates token - Middleware validates token
- Request proceeds if valid - Request proceeds if valid
### Password Security ### Password security
**Algorithm**: Argon2id **Algorithm**: Argon2id
@@ -385,7 +385,7 @@ to `web/frontend`.
- Salt: 128-bit (16 bytes) - Salt: 128-bit (16 bytes)
- Hash: 256-bit (32 bytes) - Hash: 256-bit (32 bytes)
### JWT Tokens ### JWT tokens
**Algorithm**: HS256 (HMAC-SHA256) **Algorithm**: HS256 (HMAC-SHA256)
@@ -409,9 +409,9 @@ to `web/frontend`.
} }
``` ```
## Database Architecture ## Database architecture
### SQL-First Philosophy ### SQL-first philosophy
**Principles**: **Principles**:
@@ -427,7 +427,7 @@ to `web/frontend`.
- Version-controlled schema (migrations) - Version-controlled schema (migrations)
- Easier query profiling and tuning - Easier query profiling and tuning
### Migration Strategy ### Migration strategy
**Tool**: DbUp **Tool**: DbUp
@@ -448,7 +448,7 @@ scripts/
└── ... └── ...
``` ```
### Data Seeding ### Data seeding
**Purpose**: Populate development/test databases **Purpose**: Populate development/test databases
@@ -460,9 +460,9 @@ scripts/
- Test user accounts - Test user accounts
- Sample breweries (future) - Sample breweries (future)
## Deployment Architecture ## Deployment architecture
### Docker Containerization ### Docker containerization
**Container Structure**: **Container Structure**:
@@ -479,10 +479,10 @@ scripts/
For details, see [Docker Guide](website/docker.md). For details, see [Docker Guide](website/docker.md).
### Health Checks ### Health checks
**SQL Server**: Validates database connectivity **API**: Checks service health - **SQL Server**: Validates database connectivity
and dependencies - **API**: Checks service health and dependencies
**Configuration**: **Configuration**:
@@ -494,9 +494,9 @@ healthcheck:
start_period: 30s start_period: 30s
``` ```
## Testing Architecture ## Testing architecture
### Test Pyramid ### Test pyramid
``` ```
┌──────────────┐ ┌──────────────┐

View File

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

View File

@@ -14,8 +14,8 @@ deterministic mock.
## Table of Contents ## Table of Contents
- [How It Fits The Main App](#how-it-fits-the-main-app) - [How it fits the main app](#how-it-fits-the-main-app)
- [Quick Start](#quick-start) - [Quick start](#quick-start)
- [Build](#build) - [Build](#build)
- [Model](#model) - [Model](#model)
- [Run](#run) - [Run](#run)
@@ -24,17 +24,17 @@ deterministic mock.
- [Pipeline Stages](#pipeline-stages) - [Pipeline Stages](#pipeline-stages)
- [Key Components](#key-components) - [Key Components](#key-components)
- [Runtime Behaviour](#runtime-behaviour) - [Runtime Behaviour](#runtime-behaviour)
- [Generated Output](#generated-output) - [Generated output](#generated-output)
- [Tech Stack](#tech-stack) - [Tech stack](#tech-stack)
- [Tested Hardware](#tested-hardware) - [Tested hardware](#tested-hardware)
- [Fixture Strategy](#fixture-strategy) - [Fixture strategy](#fixture-strategy)
- [Repo Layout](#repo-layout) - [Repo layout](#repo-layout)
- [Code Tour](#code-tour) - [Code tour](#code-tour)
- [Next Steps](#next-steps) - [Next steps](#next-steps)
--- ---
## How It Fits The Main App ## How it fits the main app
The pipeline is a data ingestion layer. It sits outside the web app runtime and The pipeline is a data ingestion layer. It sits outside the web app runtime and
produces seed records the app imports at startup or during a dedicated seed produces seed records the app imports at startup or during a dedicated seed
@@ -50,7 +50,7 @@ step.
--- ---
## Quick Start ## Quick start
### Build ### Build
@@ -99,13 +99,13 @@ Each run writes a fresh dated SQLite file such as
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1 --temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
``` ```
#### CLI Flags #### CLI flags
| Flag | Purpose | | Flag | Purpose |
| ------------------ | ---------------------------------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------------------------------------------------------- |
| `--mocked` | Deterministic mock generator, no model required. | | `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. | | `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
| `--prompt-dir` | Directory containing prompt files (e.g. `BREWERY_GENERATION.md`). Required unless `--mocked` is set. | | `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | | `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. | | `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | | `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
@@ -176,7 +176,7 @@ output to confirm the fast path was taken.
### Run the container ### Run the container
The container always runs the model-backed path; there is no `--mocked` 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)). 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 The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
is not already present at the configured path. is not already present at the configured path.
@@ -218,7 +218,7 @@ environment variables listed above to match your run.
## Architecture ## Architecture
### Pipeline Stages ### Pipeline stages
| Stage | Implementation | | Stage | Implementation |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -234,7 +234,7 @@ If name sampling, enrichment, or generation fails for a city, that city is
skipped and the pipeline continues. `GenerateUsers()` runs before skipped and the pipeline continues. `GenerateUsers()` runs before
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`. `GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
### Key Components ### Key components
- `src/main.cc` — argument parsing and Boost.DI composition root. - `src/main.cc` — argument parsing and Boost.DI composition root.
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a - `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
@@ -260,7 +260,7 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
LLM-generated username/bio/activity weight, and a programmatically generated LLM-generated username/bio/activity weight, and a programmatically generated
(not LLM-authored) unique email and date of birth. (not LLM-authored) unique email and date of birth.
### Runtime Behaviour ### Runtime behaviour
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic `WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
"brewing" extract and a "beer in `{country}`" extract. It does not currently "brewing" extract and a "beer in `{country}`" extract. It does not currently
@@ -290,17 +290,17 @@ 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 — 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. see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
### Process Flow - Activity Diagram ### Process flow - activity diagram
![An activity diagram](./diagrams/current/output/activity.svg) ![An activity diagram](./diagrams/current/output/activity.svg)
### Architectural Overview - Class Diagram ### Architectural overview - class diagram
![A class diagram](./diagrams/current/output/class.svg) ![A class diagram](./diagrams/current/output/class.svg)
--- ---
## Generated Output ## Generated output
Each successful run stores a `BreweryRecord` pair with the source location and a Each successful run stores a `BreweryRecord` pair with the source location and a
`BreweryResult` payload, and a `UserRecord` pair with the source location and a `BreweryResult` payload, and a `UserRecord` pair with the source location and a
@@ -328,7 +328,7 @@ SQLite export file named with the current UTC timestamp.
The log dump also includes city, country, state or province, ISO subdivision The log dump also includes city, country, state or province, ISO subdivision
code, latitude, and longitude for each entry. code, latitude, and longitude for each entry.
### Consumer Data Shape ### Consumer data shape
| Field | Why it matters | | Field | Why it matters |
| ----------------------------------- | ------------------------------------------------ | | ----------------------------------- | ------------------------------------------------ |
@@ -341,7 +341,7 @@ code, latitude, and longitude for each entry.
--- ---
## Tech Stack ## Tech stack
- C++20 - C++20
- CMake 3.31+ - CMake 3.31+
@@ -363,7 +363,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
--- ---
## Tested Hardware ## Tested hardware
### ARM macOS — M1 Pro ### ARM macOS — M1 Pro
@@ -398,7 +398,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
--- ---
## Fixture Strategy ## Fixture strategy
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs. - `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
`MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture `MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture
@@ -414,7 +414,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
--- ---
## Repo Layout ## Repo layout
| Path | Purpose | | Path | Purpose |
| -------------------------------------------- | ----------------------------------------------------------- | | -------------------------------------------- | ----------------------------------------------------------- |
@@ -431,7 +431,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
--- ---
## Code Tour ## Code tour
Paths below are relative to `tooling/pipeline/`. Paths below are relative to `tooling/pipeline/`.
@@ -450,7 +450,7 @@ Paths below are relative to `tooling/pipeline/`.
--- ---
## Next Steps ## Next steps
The pipeline currently produces city-aware brewery and user records and dated 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 SQLite exports. The next passes add additional fixture types so the app can
@@ -458,7 +458,7 @@ exercise the full brewery and social domains without live data. For the detailed
engineering breakdown of what's needed to reach the architecture in engineering breakdown of what's needed to reach the architecture in
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md). [`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
### Testing — Very High Priority ### Testing — very high priority
- Unit test JSON validation and retry logic against malformed, truncated, and - Unit test JSON validation and retry logic against malformed, truncated, and
empty model outputs. empty model outputs.
@@ -472,19 +472,19 @@ engineering breakdown of what's needed to reach the architecture in
- Confirm the retry path is reachable when the reasoning block consumes - Confirm the retry path is reachable when the reasoning block consumes
available token budget. available token budget.
### Beer Generation ### Beer generation
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
pairing hints. Link beers back to breweries and cities. Keep style coverage wide pairing hints. Link beers back to breweries and cities. Keep style coverage wide
enough to exercise search, sort, and category filters. enough to exercise search, sort, and category filters.
### Check-In System ### Check-in system
Produce timestamped check-in events between users and breweries. Use a J-curve Produce timestamped check-in events between users and breweries. Use a J-curve
activity profile — a small set of users accounts for most check-ins, the rest activity profile — a small set of users accounts for most check-ins, the rest
appear occasionally. Add bursty behaviour around weekends and travel periods. appear occasionally. Add bursty behaviour around weekends and travel periods.
### Beer Ratings ### Beer ratings
Generate rating events with a strong positive skew and a long tail of lower Generate rating events with a strong positive skew and a long tail of lower
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app scores. Avoid uniform distributions. Attach timestamps and user IDs so the app

View File

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

View File

@@ -1,4 +1,4 @@
# Environment Variables # Environment variables
This document covers the active environment variables used by the current This document covers the active environment variables used by the current
Biergarten stack. Biergarten stack.
@@ -11,7 +11,7 @@ The application uses environment variables for:
- **React Router website** - API base URL and session signing - **React Router website** - API base URL and session signing
- **Docker containers** - environment-specific orchestration - **Docker containers** - environment-specific orchestration
## Configuration Patterns ## Configuration patterns
### Backend (.NET API) ### Backend (.NET API)
@@ -30,9 +30,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.test` - Testing - `.env.test` - Testing
- `.env.prod` - Production - `.env.prod` - Production
## Backend Variables (.NET API) ## Backend variables (.NET API)
### Database Connection ### Database connection
**Option 1: Component-Based (Recommended for Docker)** **Option 1: Component-Based (Recommended for Docker)**
@@ -59,7 +59,7 @@ string is built from components.
**Implementation**: See `DefaultSqlConnectionFactory.cs` **Implementation**: See `DefaultSqlConnectionFactory.cs`
### JWT Authentication Secrets (Backend) ### JSON Web Token (JWT) authentication secrets (backend)
The backend uses separate secrets for different token types to enable The backend uses separate secrets for different token types to enable
independent key rotation and validation isolation. independent key rotation and validation isolation.
@@ -80,9 +80,9 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
**Security Requirements**: **Security Requirements**:
- Each secret should be minimum 32 characters - Use at least 32 characters per secret
- Recommend 127+ characters for production - Use 127 or more characters in production
- Generate using cryptographically secure random functions - Generate secrets with a cryptographically secure random function
- Never reuse secrets across token types or environments - Never reuse secrets across token types or environments
- Rotate secrets periodically in production - Rotate secrets periodically in production
@@ -110,19 +110,20 @@ openssl rand -base64 127
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- **Validation**: Token signature, expiration, and malformed token checks - **Validation**: Token signature, expiration, and malformed token checks
### Migration Control ### Migration control
```bash ```bash
CLEAR_DATABASE=true CLEAR_DATABASE=true
``` ```
- **Required**: No - **Required**: No
- **Default**: false - **Default**: `false`
- **Effect**: If "true", drops and recreates database during migrations - **Effect**: If set to `true`, drops and recreates the database during
- **Usage**: Development and testing environments ONLY migrations
- **Warning**: NEVER use in production - **Usage**: Development and testing environments only
- **Warning**: Never use in production
### ASP.NET Core Configuration ### ASP.NET Core configuration
```bash ```bash
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
@@ -130,7 +131,7 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
``` ```
## Frontend Variables (`web/frontend`) ## Frontend variables (`web/frontend`)
The active website does not use the old Next.js/Prisma environment model. Its The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are: core runtime variables are:
@@ -141,7 +142,7 @@ SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode NODE_ENV=development # Standard Node runtime mode
``` ```
### Frontend Variable Details ### Frontend variable details
#### `API_BASE_URL` #### `API_BASE_URL`
@@ -163,7 +164,7 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test` - **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode - **Purpose**: Controls secure cookie behavior and runtime mode
### SMTP Configuration (Backend) ### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
and account emails. and account emails.
@@ -183,11 +184,11 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web - **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025) UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, - **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
etc.) and so on)
## Docker-Specific Variables ## Docker-specific variables
### SQL Server Container ### SQL Server container
```bash ```bash
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
@@ -201,9 +202,9 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
- Uppercase, lowercase, digits, and special characters - Uppercase, lowercase, digits, and special characters
- Maps to `DB_PASSWORD` for application containers - Maps to `DB_PASSWORD` for application containers
## Environment File Structure ## Environment file structure
### Backend/Docker (`web/` Directory) ### Backend/Docker (`web/` directory)
``` ```
web/.env.example # Template (tracked in Git) web/.env.example # Template (tracked in Git)
@@ -219,7 +220,7 @@ cp web/.env.example web/.env.dev
# Edit web/.env.dev with your values # Edit web/.env.dev with your values
``` ```
## Legacy Frontend Variables ## Legacy frontend variables
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
been removed from this active reference, since that app is retained for been removed from this active reference, since that app is retained for
@@ -231,7 +232,7 @@ reference only and is not run as part of the active stack.
- `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod` - `web/docker-compose.prod.yaml` → `web/.env.prod`
## Variable Reference Table ## Variable reference table
| Variable | Backend | Frontend | Docker | Required | Notes | | Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- | | ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
@@ -268,7 +269,7 @@ reference only and is not run as part of the active stack.
## Validation ## Validation
### Backend Validation ### Backend validation
Variables are validated at startup: Variables are validated at startup:
@@ -279,7 +280,7 @@ Variables are validated at startup:
"minimum 32 characters" guidance above is a recommendation, not an "minimum 32 characters" guidance above is a recommendation, not an
enforced check enforced check
### Frontend Validation ### Frontend validation
The active website relies on runtime defaults for local development and the The active website relies on runtime defaults for local development and the
surrounding server environment in deployed environments. surrounding server environment in deployed environments.
@@ -288,9 +289,9 @@ surrounding server environment in deployed environments.
- `SESSION_SECRET` falls back to a development-only local secret - `SESSION_SECRET` falls back to a development-only local secret
- `NODE_ENV` controls secure cookie behavior - `NODE_ENV` controls secure cookie behavior
## Example Configuration Files ## Example configuration files
### `.env.dev` (Backend/Docker) ### `.env.dev` (backend/Docker)
```bash ```bash
# Database # Database

View File

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

View File

@@ -19,7 +19,7 @@ The project uses a multi-layered testing approach across backend and frontend:
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
components components
## Running Tests with Docker (Recommended) ## Running tests with Docker (recommended)
The easiest way to run all tests is using Docker Compose, which sets up an The easiest way to run all tests is using Docker Compose, which sets up an
isolated test environment: isolated test environment:
@@ -37,7 +37,7 @@ This command:
5. Exports results to `./test-results/` 5. Exports results to `./test-results/`
6. Exits when tests complete 6. Exits when tests complete
### View Test Results ### View test results
```bash ```bash
# List test result files # List test result files
@@ -51,18 +51,18 @@ cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx cat test-results/Features.Emails.Tests.trx
``` ```
### Clean Up ### Clean up
```bash ```bash
# Remove test containers and volumes # Remove test containers and volumes
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
## Running Tests Locally ## Running tests locally
You can run individual test projects locally without Docker: You can run individual test projects locally without Docker:
### Integration Tests (API.Specs) ### Integration tests (API.Specs)
```bash ```bash
cd web/backend cd web/backend
@@ -75,7 +75,7 @@ dotnet test API/API.Specs/API.Specs.csproj
- Database migrated and seeded - Database migrated and seeded
- Environment variables set (DB connection, JWT secret) - Environment variables set (DB connection, JWT secret)
### Feature Slice Unit Tests ### Feature slice unit tests
Each feature slice has its own test project, covering its command/query Each feature slice has its own test project, covering its command/query
handlers and repository: handlers and repository:
@@ -96,7 +96,7 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
- No database required (handlers use Moq; repository tests use DbMocker to - No database required (handlers use Moq; repository tests use DbMocker to
simulate SQL Server responses) simulate SQL Server responses)
### Frontend Storybook Tests ### Frontend Storybook tests
```bash ```bash
cd web/frontend cd web/frontend
@@ -110,7 +110,7 @@ npm run test:storybook
toasts, and the theme gallery toasts, and the theme gallery
- Runs in browser mode via Vitest and Storybook integration - Runs in browser mode via Vitest and Storybook integration
### Frontend Playwright Storybook Tests ### Frontend Playwright Storybook tests
```bash ```bash
cd web/frontend cd web/frontend
@@ -125,9 +125,9 @@ npm run test:storybook:playwright
- The command will start or reuse the Storybook server defined in - The command will start or reuse the Storybook server defined in
`playwright.storybook.config.ts` `playwright.storybook.config.ts`
## Test Coverage ## Test coverage
### Current Coverage ### Current coverage
**Features.Auth.Tests**: **Features.Auth.Tests**:
@@ -160,7 +160,7 @@ npm run test:storybook:playwright
- Theme gallery rendering across Biergarten themes - Theme gallery rendering across Biergarten themes
- Toast interactions and themed notification display - Toast interactions and themed notification display
### Planned Coverage ### Planned coverage
- [ ] Password reset functionality - [ ] Password reset functionality
- [ ] Beer post operations - [ ] Beer post operations
@@ -168,7 +168,7 @@ npm run test:storybook:playwright
- [ ] Image upload service - [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories - [ ] Frontend route integration coverage beyond Storybook stories
## Testing Frameworks & Tools ## Testing frameworks and tools
### xUnit ### xUnit
@@ -200,9 +200,9 @@ npm run test:storybook:playwright
- Simulates SQL Server responses - Simulates SQL Server responses
- No real database required for unit tests - No real database required for unit tests
## Test Structure ## Test structure
### API.Specs (Integration Tests) ### API.Specs (integration tests)
``` ```
API.Specs/ API.Specs/
@@ -260,9 +260,9 @@ Each of the other three slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same `Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
shape: a `Commands/`/`Queries/` folder with one test file per handler. shape: a `Commands/`/`Queries/` folder with one test file per handler.
## Writing Tests ## Writing tests
### Unit Test Example (xUnit) ### Unit test example (xUnit)
```csharp ```csharp
public class LoginHandlerTests public class LoginHandlerTests
@@ -287,7 +287,7 @@ public class LoginHandlerTests
} }
``` ```
### Integration Test Example (Reqnroll) ### Integration test example (Reqnroll)
```gherkin ```gherkin
Scenario: User login with valid credentials Scenario: User login with valid credentials
@@ -297,7 +297,7 @@ Scenario: User login with valid credentials
And the response should contain a JWT token And the response should contain a JWT token
``` ```
## Continuous Integration ## Continuous integration
Tests run automatically in CI/CD pipelines using the test Docker Compose Tests run automatically in CI/CD pipelines using the test Docker Compose
configuration: configuration:
@@ -326,7 +326,7 @@ npm run test:storybook:playwright
## Troubleshooting ## Troubleshooting
### Tests Failing Due to Database Connection ### Tests failing due to database connection
Ensure SQL Server is running and environment variables are set: Ensure SQL Server is running and environment variables are set:
@@ -334,12 +334,12 @@ Ensure SQL Server is running and environment variables are set:
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
``` ```
### Port Conflicts ### Port conflicts
If port 1433 is in use, stop other SQL Server instances or modify the port in If port 1433 is in use, stop other SQL Server instances or modify the port in
`docker-compose.test.yaml`. `docker-compose.test.yaml`.
### Stale Test Data ### Stale test data
Clean up test database: Clean up test database:
@@ -347,13 +347,13 @@ Clean up test database:
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
### View Container Logs ### View container logs
```bash ```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name> docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
``` ```
## Best Practices ## Best practices
1. **Isolation**: Each test should be independent and not rely on other tests 1. **Isolation**: Each test should be independent and not rely on other tests
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup 2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup

View File

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

View File

@@ -111,8 +111,9 @@ if(NOT BIERGARTEN_MOCK_ONLY)
) )
FetchContent_MakeAvailable(llama-cpp) FetchContent_MakeAvailable(llama-cpp)
endif() endif()
if (NOT APPLE)
find_package(OpenMP REQUIRED) find_package(OpenMP REQUIRED)
endif()
endif() endif()
# spdlog # spdlog
@@ -249,9 +250,10 @@ target_include_directories(${PROJECT_NAME} PRIVATE
includes includes
) )
target_link_libraries(${PROJECT_NAME} PRIVATE target_link_libraries(${PROJECT_NAME} PRIVATE
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama> $<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama>
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:OpenMP::OpenMP_CXX> $<TARGET_NAME_IF_EXISTS:OpenMP::OpenMP_CXX>
boost::di boost::di
Boost::json Boost::json
Boost::program_options Boost::program_options

View File

@@ -1,8 +1,7 @@
```bash # RunPod container
touch runpod/start.sh
docker build \ Dockerfile, launcher (`start.sh`), and pod template for running the
--progress=plain \ Biergarten pipeline on a GPU host. See
-t biergarten-pipeline:latest \ [Docker / RunPod](../../../docs/pipeline/README.md#docker--runpod) in the
-f runpod/Dockerfile \ pipeline README for the full build and run instructions, environment
. 2>&1 | tee build.log variable reference, and RunPod deployment notes.
```

View File

@@ -54,11 +54,11 @@ void XegerGenerator::Visit(const Alternation& node) {
} }
void XegerGenerator::Visit(const Repeat& node) { void XegerGenerator::Visit(const Repeat& node) {
const int lo = node.min; const unsigned int low = node.min;
const int hi = (node.max < 0) ? node.min + unbounded_cap_ : node.max; const unsigned int high = node.max.value_or(node.min + unbounded_cap_);
std::uniform_int_distribution<int> dist(lo, hi); std::uniform_int_distribution<unsigned int> dist(low, high);
const int count = dist(rng_); const unsigned int count = dist(rng_);
for (int i = 0; i < count; ++i) { for (unsigned int i = 0; i < count; ++i) {
node.child->Accept(*this); node.child->Accept(*this);
} }
} }

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}

View File

@@ -12,11 +12,6 @@ namespace API.Core.Authentication;
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens /// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme. /// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
/// </summary> /// </summary>
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
public class JwtAuthenticationHandler( public class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options, IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
@@ -31,15 +26,9 @@ public class JwtAuthenticationHandler(
/// <remarks> /// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling /// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used /// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Authentication fails if the secret is not configured, the /// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the /// missing or not <c>Bearer</c>-scheme, or the token fails validation.
/// token fails validation.
/// </remarks> /// </remarks>
/// <returns>
/// A successful <see cref="AuthenticateResult" /> containing the validated
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Use the same access-token secret source as TokenService to avoid mismatched validation. // Use the same access-token secret source as TokenService to avoid mismatched validation.
@@ -78,8 +67,6 @@ public class JwtAuthenticationHandler(
/// <summary> /// <summary>
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied. /// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
/// </summary> /// </summary>
/// <param name="properties">The authentication properties associated with the challenge.</param>
/// <returns>A task that completes once the response body has been written.</returns>
protected override async Task HandleChallengeAsync(AuthenticationProperties properties) protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{ {
Response.ContentType = "application/json"; Response.ContentType = "application/json";
@@ -96,5 +83,4 @@ public class JwtAuthenticationHandler(
/// <summary> /// <summary>
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />. /// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary> /// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { } public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }

View File

@@ -6,7 +6,6 @@ namespace API.Core.Controllers;
/// Handles requests that do not match any other route, used as the application's fallback controller. /// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in /// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>. /// <c>Program.cs</c>.
/// </remarks> /// </remarks>
@@ -15,10 +14,6 @@ namespace API.Core.Controllers;
[Route("error")] // required [Route("error")] // required
public class NotFoundController : ControllerBase public class NotFoundController : ControllerBase
{ {
/// <summary>
/// Returns a generic 404 response for any request that did not match a defined route.
/// </summary>
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
[HttpGet("404")] //required [HttpGet("404")] //required
public IActionResult Handle404() public IActionResult Handle404()
{ {

View File

@@ -14,13 +14,9 @@ namespace API.Core.Controllers;
public class ProtectedController : ControllerBase public class ProtectedController : ControllerBase
{ {
/// <summary> /// <summary>
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims. /// Returns the authenticated caller's user ID and username extracted from the JWT claims.
/// </summary> /// </summary>
/// <returns> /// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns>
[HttpGet] [HttpGet]
public ActionResult<ResponseBody<object>> Get() public ActionResult<ResponseBody<object>> Get()
{ {

View File

@@ -13,67 +13,17 @@ namespace API.Core;
/// MVC exception filter that converts unhandled exceptions raised by controller actions into /// MVC exception filter that converts unhandled exceptions raised by controller actions into
/// consistent JSON error responses with appropriate HTTP status codes. /// consistent JSON error responses with appropriate HTTP status codes.
/// </summary> /// </summary>
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{ {
/// <summary> /// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response, /// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
/// marking the exception as handled.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Maps exception types to responses as follows: /// Maps <see cref="FluentValidation.ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
/// <list type="bullet"> /// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
/// <item> /// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
/// <description> /// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// validation error messages.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
/// </item>
/// </list>
/// </remarks> /// </remarks>
/// <param name="context">
/// The context for the unhandled exception, including the exception itself and the result to
/// populate.
/// </param>
public void OnException(ExceptionContext context) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");

View File

@@ -3,8 +3,7 @@ using Infrastructure.Email;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;
/// <summary> /// <summary>
/// Mock email provider for testing that doesn't actually send emails. /// Test double for <see cref="IEmailProvider" /> that records sent emails instead of delivering them.
/// Tracks sent emails for verification in tests if needed.
/// </summary> /// </summary>
public class MockEmailProvider : IEmailProvider public class MockEmailProvider : IEmailProvider
{ {

View File

@@ -1,37 +1,35 @@
<Solution> <Solution>
<Folder Name="/API/"> <Folder Name="/API/">
<Project Path="API/API.Core/API.Core.csproj"/> <Project Path="API/API.Core/API.Core.csproj" />
<Project Path="API/API.Specs/API.Specs.csproj"/> <Project Path="API/API.Specs/API.Specs.csproj" />
</Folder> </Folder>
<Folder Name="/Database/"> <Folder Name="/Database/">
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/> <Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
<Project Path="Database/Database.Seed/Database.Seed.csproj"/> <Project Path="Database/Database.Seed/Database.Seed.csproj" />
</Folder> </Folder>
<Folder Name="/Domain/"> <Folder Name="/Domain/">
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder> </Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/> <Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project <Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/> <Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project <Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
</Folder> </Folder>
<Folder Name="/Features/"> <Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj"/> <Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/> <Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" />
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/> <Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" />
<Project Path="Features/Features.Auth/Features.Auth.csproj"/> <Project Path="Features/Features.Auth/Features.Auth.csproj" />
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/> <Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
<Project Path="Features/Features.Emails/Features.Emails.csproj"/> <Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
<Project Path="Shared/Shared.Application/Shared.Application.csproj"/> <Project Path="Shared/Shared.Application/Shared.Application.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -7,215 +7,210 @@ using Microsoft.Data.SqlClient;
namespace Database.Migrations; namespace Database.Migrations;
/// <summary> /// <summary>
/// Entry point for the database migration runner. Reads connection details from /// Entry point for the database migration runner. Reads connection details from environment
/// environment variables, optionally clears and recreates the target database, and /// variables, optionally drops and recreates the target database, and applies all SQL
/// applies all SQL migration scripts embedded in this assembly using DbUp. /// migration scripts embedded in this assembly using DbUp.
/// </summary> /// </summary>
public static class Program public static class Program
{ {
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary> /// <summary>
private static readonly string connectionString = BuildConnectionString(); /// Migration runner entry point. Drops the target database when <c>CLEAR_DATABASE</c> is
/// set to <c>true</c>, ensures the database exists, then deploys all pending migrations.
/// </summary>
/// <returns>
/// <c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.
/// </returns>
public static async Task<int> Main()
{
Console.WriteLine("Starting database migrations...");
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary> try
private static readonly string masterConnectionString = BuildConnectionString("master"); {
DatabaseSettings settings = DatabaseSettings.FromEnvironment();
if (settings.ClearDatabase)
{
Console.WriteLine(
$"CLEAR_DATABASE is enabled. Dropping database '{settings.DatabaseName}'..."
);
await DropDatabaseIfExistsAsync(settings);
}
await CreateDatabaseIfNotExistsAsync(settings);
return DeployMigrations(settings.ConnectionString) ? 0 : 1;
}
catch (Exception ex) when (ex is InvalidOperationException or SqlException)
{
Console.Error.WriteLine("An error occurred during database migrations:");
Console.Error.WriteLine(ex.Message);
return 1;
}
}
/// <summary> /// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>, /// Drops the target database if it exists, first forcing it into single-user mode with
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c> /// rollback immediate so that any existing connections are terminated.
/// environment variables.
/// </summary> /// </summary>
/// <param name="databaseName"> /// <param name="settings">The resolved database settings.</param>
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the /// <exception cref="SqlException">Thrown if the connection or the drop operation fails.</exception>
/// <c>DB_NAME</c> environment variable. private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings)
/// </param>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception>
private static string BuildConnectionString(string? databaseName = null)
{ {
string server = const string sql = """
Environment.GetEnvironmentVariable("DB_SERVER") DECLARE @statement nvarchar(max);
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
string dbName = IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
databaseName BEGIN
?? Environment.GetEnvironmentVariable("DB_NAME") SET @statement =
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); N'ALTER DATABASE ' + QUOTENAME(@databaseName)
+ N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;';
EXEC sys.sp_executesql @statement;
string user = SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
Environment.GetEnvironmentVariable("DB_USER") EXEC sys.sp_executesql @statement;
?? throw new InvalidOperationException("DB_USER environment variable is not set"); END
""";
string password = await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
Environment.GetEnvironmentVariable("DB_PASSWORD") Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist.");
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); }
string trustServerCertificate = /// <summary>
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True"; /// Creates the target database on the <c>master</c> connection if it does not already exist.
/// </summary>
/// <param name="settings">The resolved database settings.</param>
/// <exception cref="SqlException">Thrown if the connection or the create operation fails.</exception>
private static async Task CreateDatabaseIfNotExistsAsync(DatabaseSettings settings)
{
const string sql = """
DECLARE @statement nvarchar(max);
SqlConnectionStringBuilder builder = new() IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
{ BEGIN
DataSource = server, SET @statement = N'CREATE DATABASE ' + QUOTENAME(@databaseName) + N';';
InitialCatalog = dbName, EXEC sys.sp_executesql @statement;
UserID = user, END
Password = password, """;
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true,
};
return builder.ConnectionString; await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
}
/// <summary>
/// Opens a connection, executes a non-query batch parameterised by database name, and
/// disposes both the command and the connection.
/// </summary>
/// <param name="connectionString">The connection string to open.</param>
/// <param name="sql">The Transact-SQL batch to execute.</param>
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
private static async Task ExecuteNonQueryAsync(
string connectionString,
string sql,
string databaseName
)
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await using SqlCommand command = new(sql, connection);
command.Parameters.Add("@databaseName", SqlDbType.NVarChar, 128).Value = databaseName;
await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target /// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console. /// database using DbUp, logging progress to the console.
/// </summary> /// </summary>
/// <param name="connectionString">The connection string for the target application database.</param>
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns> /// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations() private static bool DeployMigrations(string connectionString)
{ {
UpgradeEngine? upgrader = DeployChanges UpgradeEngine upgrader = DeployChanges
.To.SqlDatabase(connectionString) .To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole() .LogToConsole()
.Build(); .Build();
DatabaseUpgradeResult? result = upgrader.PerformUpgrade(); DatabaseUpgradeResult result = upgrader.PerformUpgrade();
return result.Successful;
if (result.Successful)
{
Console.WriteLine("Database migrations completed successfully.");
return true;
}
Console.Error.WriteLine($"Database migrations failed: {result.Error}");
return false;
} }
/// <summary> /// <summary>
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user /// Connection details and runtime options resolved from environment variables.
/// mode with rollback to terminate any existing connections.
/// </summary> /// </summary>
/// <returns> /// <param name="ConnectionString">The connection string for the target application database.</param>
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c> /// <param name="MasterConnectionString">
/// if an error occurred while connecting or dropping the database. /// The connection string for the <c>master</c> database, used for create and drop operations.
/// </returns> /// </param>
private static bool ClearDatabase() /// <param name="DatabaseName">The name of the target application database.</param>
/// <param name="ClearDatabase">Whether the target database should be dropped before migrating.</param>
private sealed record DatabaseSettings(
string ConnectionString,
string MasterConnectionString,
string DatabaseName,
bool ClearDatabase
)
{ {
SqlConnection myConn = new(masterConnectionString); /// <summary>
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
try /// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
/// environment variables.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when a required environment variable is not set.
/// </exception>
public static DatabaseSettings FromEnvironment()
{ {
myConn.Open(); string server = Required("DB_SERVER");
string databaseName = Required("DB_NAME");
string user = Required("DB_USER");
string password = Required("DB_PASSWORD");
// First, set the database to single user mode to close all connections bool trustServerCertificate =
SqlCommand setModeCommand = new( !bool.TryParse(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", out bool parsedTrust
myConn ) || parsedTrust;
bool clearDatabase = string.Equals(
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
"true",
StringComparison.OrdinalIgnoreCase
); );
try
{
setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode.");
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
}
// Then drop the database return new DatabaseSettings(
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn); Build(databaseName),
try Build("master"),
{ databaseName,
dropCommand.ExecuteNonQuery(); clearDatabase
Console.WriteLine("Database cleared successfully."); );
}
catch (Exception ex) string Build(string initialCatalog) =>
{ new SqlConnectionStringBuilder
Console.WriteLine($"Error dropping database: {ex}"); {
return false; DataSource = server,
} InitialCatalog = initialCatalog,
} UserID = user,
catch (Exception ex) Password = password,
{ Encrypt = true,
Console.WriteLine($"Error clearing database: {ex}"); TrustServerCertificate = trustServerCertificate,
return false; }.ConnectionString;
}
finally
{
if (myConn.State == ConnectionState.Open)
myConn.Close();
} }
return true; private static string Required(string variableName) =>
} Environment.GetEnvironmentVariable(variableName)
?? throw new InvalidOperationException(
/// <summary> $"The {variableName} environment variable is not set."
/// Creates the <c>Biergarten</c> database on the master connection if it does not );
/// already exist. Errors encountered while creating the database are logged but do
/// not stop execution.
/// </summary>
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists()
{
SqlConnection myConn = new(masterConnectionString);
const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten]
""";
SqlCommand myCommand = new(str, myConn);
try
{
myConn.Open();
myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully.");
}
catch (Exception ex)
{
Console.WriteLine($"Error creating database: {ex}");
}
finally
{
if (myConn.State == ConnectionState.Open)
myConn.Close();
}
return true;
}
/// <summary>
/// Migration runner entry point. Optionally clears the existing database when the
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
/// database exists, then deploys all pending migrations.
/// </summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
public static int Main(string[] args)
{
Console.WriteLine("Starting database migrations...");
try
{
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true")
{
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
ClearDatabase();
}
CreateDatabaseIfNotExists();
bool success = DeployMigrations();
if (success)
{
Console.WriteLine("Database migrations completed successfully.");
return 0;
}
Console.WriteLine("Database migrations failed.");
return 1;
}
catch (Exception ex)
{
Console.WriteLine("An error occurred during database migrations:");
Console.WriteLine(ex.Message);
return 1;
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,20 +8,30 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="dbup" Version="5.0.41" />
<!-- <!--
remove this override once microsoft ships official fix to vulnerability remove this override once microsoft ships official fix to vulnerability
https://github.com/advisories/GHSA-2m69-gcr7-jv3q https:
--> -->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" /> <PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. -->
<None
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,19 +1,17 @@
using Microsoft.Data.SqlClient; #region
namespace Database.Seed; using Microsoft.Data.SqlClient;
#endregion
namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings public class ConnectionStrings
{ {
/// <summary> public static string SqlServerConnectionString => GetSqlServerConnectionString();
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>, public static string SqliteConnectionString =>
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c> "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
/// environment variables.
/// </summary>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
/// is not set.
/// </exception>
private static string GetSqlServerConnectionString() private static string GetSqlServerConnectionString()
{ {
string server = string server =
@@ -47,8 +45,4 @@ public class ConnectionStrings
return builder.ConnectionString; return builder.ConnectionString;
} }
public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqliteConnectionString => "Data Source=seed.sqlite";
} }

View File

@@ -10,7 +10,7 @@ FROM build AS publish
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Database.Seed.dll"] ENTRYPOINT ["dotnet", "Database.Seed.dll"]

View File

@@ -0,0 +1,8 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryAddress
{
public required City City { get; init; }
public required string PostalCode { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryRecord
{
public required BreweryAddress Address { get; init; }
public required BreweryResult Brewery { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryResult
{
public required string NameEn { get; init; }
public required string DescriptionEn { get; init; }
public required string NameLocal { get; init; }
public required string DescriptionLocal { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace Database.Seed.PipelineData;
public sealed record City
{
public required string CityName { get; init; }
public required string StateProvince { get; init; }
public required string Iso31662 { get; init; }
public required string Country { get; init; }
public required string Iso31661 { get; init; }
public IReadOnlyList<string> LocalLanguages { get; init; } = [];
public required PostalCodeSpec PostalCode { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Database.Seed.PipelineData;
public sealed record PostalCodeSpec
{
public required string CountryFormatRegex { get; init; }
public IReadOnlyList<string> CityRegexes { get; init; } = [];
public IReadOnlyList<string> Examples { get; init; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace Database.Seed.PipelineData;
public sealed record UserAddress
{
public required City City { get; init; }
public required string PostalCode { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Database.Seed.PipelineData;
public sealed record UserRecord
{
public required UserAddress Address { get; init; }
public required UserResult User { get; init; }
public required string Email { get; init; }
public required string DateOfBirth { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace Database.Seed.PipelineData;
public sealed record UserResult
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required string Gender { get; init; }
public required string Username { get; init; }
public required string Bio { get; init; }
public required float ActivityWeight { get; init; }
}

View File

@@ -0,0 +1,118 @@
using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData;
using Database.Seed.Sqlite;
using Domain.Entities;
using Features.Auth.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository;
using idunno.Password;
using Infrastructure.PasswordHashing;
using Infrastructure.Sql;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
static async Task<int> main()
{
IReadOnlyList<BreweryRecord> breweries;
IReadOnlyList<UserRecord> users;
try
{
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
breweries = reader.ReadBreweryRecords();
users = reader.ReadUserRecords();
}
catch (SqliteException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error opening database connection: {ex.Message}");
Console.ResetColor();
return 1;
}
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddFeaturesBreweries();
services.AddFeaturesAuth();
using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository;
IAuthRepository authRepository;
IPasswordInfrastructure passwordInfrastructure;
try
{
breweryRepository = provider.GetRequiredService<IBreweryRepository>();
authRepository = provider.GetRequiredService<IAuthRepository>();
passwordInfrastructure = provider.GetRequiredService<IPasswordInfrastructure>();
}
catch (InvalidOperationException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error configuring database connection: {ex.Message}");
Console.ResetColor();
return 1;
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {breweries.Count} breweries.");
Console.ResetColor();
for (int i = 0; i < breweries.Count; i++)
{
BreweryRecord brewery = breweries[i];
Console.WriteLine(
$"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"
);
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {users.Count} users.");
Console.ResetColor();
foreach (UserRecord user in users)
{
string username = user.User.Username;
string firstName = user.User.FirstName;
string lastName = user.User.LastName;
string email = user.Email;
DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth);
string generatedPassword = PasswordGenerator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string hashedPassword = passwordInfrastructure.Hash(generatedPassword);
UserRegistrationDto registrationDto = new(
username,
firstName,
lastName,
email,
dateOfBirth,
hashedPassword
);
try
{
UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto);
Console.WriteLine(
$"Created user: {createdUser.Username} ({createdUser.UserAccountId})"
);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error creating user {username}: {ex.Message}");
Console.ResetColor();
}
}
return 0;
}
return await main();

View File

@@ -0,0 +1,158 @@
#region
using System.Text.Json;
using Database.Seed.PipelineData;
using Microsoft.Data.Sqlite;
#endregion
namespace Database.Seed.Sqlite;
public sealed class PipelineSeedDataReader
{
private readonly string _connectionString;
public PipelineSeedDataReader(string connectionString)
{
_connectionString = connectionString;
}
public IReadOnlyList<BreweryRecord> ReadBreweryRecords()
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
const string sql = """
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
ba.city_id, ba.postal_code
FROM breweries b
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
List<BreweryRecord> records = [];
while (reader.Read())
records.Add(
new BreweryRecord
{
Brewery = new BreweryResult
{
NameEn = reader.GetString(0),
DescriptionEn = reader.GetString(1),
NameLocal = reader.GetString(2),
DescriptionLocal = reader.GetString(3),
},
Address = new BreweryAddress
{
City = cities[reader.GetInt64(4)],
PostalCode = reader.GetString(5),
},
}
);
return records;
}
public IReadOnlyList<UserRecord> ReadUserRecords()
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
const string sql = """
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
u.activity_weight, u.email, u.date_of_birth,
ua.city_id, ua.postal_code
FROM users u
JOIN user_addresses ua ON ua.user_id = u.id;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
List<UserRecord> records = [];
while (reader.Read())
records.Add(
new UserRecord
{
User = new UserResult
{
FirstName = reader.GetString(0),
LastName = reader.GetString(1),
Gender = reader.GetString(2),
Username = reader.GetString(3),
Bio = reader.GetString(4),
ActivityWeight = (float)reader.GetDouble(5),
},
Email = reader.GetString(6),
DateOfBirth = reader.GetString(7),
Address = new UserAddress
{
City = cities[reader.GetInt64(8)],
PostalCode = reader.GetString(9),
},
}
);
return records;
}
private static IReadOnlyDictionary<long, City> ReadCities(SqliteConnection connection)
{
const string sql = """
SELECT
id,
city,
state_province,
iso3166_2,
country,
iso3166_1,
local_languages_json,
postal_code_country_format_regex,
postal_code_city_regex_json
FROM cities;
""";
using SqliteCommand command = new(sql, connection);
using SqliteDataReader reader = command.ExecuteReader();
Dictionary<long, City> cities = [];
const int cityNameBindIndex = 1;
const int stateProvinceBindIndex = 2;
const int iso31662BindIndex = 3;
const int countryBindIndex = 4;
const int iso31661BindIndex = 5;
const int localLanguagesBindIndex = 6;
const int countryFormatRegexBindIndex = 7;
const int cityRegexesBindIndex = 8;
while (reader.Read())
cities[reader.GetInt64(0)] = new City
{
CityName = reader.GetString(cityNameBindIndex),
StateProvince = reader.GetString(stateProvinceBindIndex),
Iso31662 = reader.GetString(iso31662BindIndex),
Country = reader.GetString(countryBindIndex),
Iso31661 = reader.GetString(iso31661BindIndex),
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)),
PostalCode = new PostalCodeSpec
{
CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex),
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)),
},
};
return cities;
}
private static IReadOnlyList<string> DeserializeStringArray(string json)
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
}

View File

@@ -1,18 +1,17 @@
namespace Database.Seed; namespace Database.Seed;
public interface IExportDatabase public interface IExportDatabase
{ {
void SeedLocations(IEnumerable<Location> locations); void SeedLocations(IEnumerable<Location> locations);
void SeedUsers(IEnumerable<User> users); void SeedUsers(IEnumerable<User> users);
void SeedBreweries(IEnumerable<Brewery> breweries); void SeedBreweries(IEnumerable<Brewery> breweries);
} }
public interface IImportDatabase public interface IImportDatabase
{ {
IEnumerable<Location> GetLocations(); IEnumerable<Location> GetLocations();
IEnumerable<User> GetUsers(); IEnumerable<User> GetUsers();
IEnumerable<Brewery> GetBreweries(); IEnumerable<Brewery> GetBreweries();
} }

View File

@@ -1,50 +1,14 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary>
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
/// A user account cannot be deleted while it has an associated brewery post.
/// </summary>
public class BreweryPost public class BreweryPost
{ {
/// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
/// </summary>
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery being posted about.
/// </summary>
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// Free-text description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time the post was created.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
public UserAccount PostedBy { get; set; } = null!;
/// <summary>
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
/// navigation property.
/// </summary>
public BreweryPostLocation? Location { get; set; } public BreweryPostLocation? Location { get; set; }
} }

View File

@@ -1,52 +1,14 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary>
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
/// deleted.
/// </summary>
public class BreweryPostLocation public class BreweryPostLocation
{ {
/// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary>
public Guid BreweryPostLocationId { get; set; } public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The primary street address line.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty; public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g., suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; } public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the location.
/// </summary>
public string PostalCode { get; set; } = string.Empty; public string PostalCode { get; set; } = string.Empty;
/// <summary>
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
/// </summary>
public Guid CityId { get; set; } public Guid CityId { get; set; }
/// <summary>
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
/// <c>null</c> if not set.
/// </summary>
public byte[]? Coordinates { get; set; } public byte[]? Coordinates { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
public BreweryPost BreweryPost { get; set; } = null!;
} }

View File

@@ -1,55 +1,17 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
/// data.
/// </summary>
public class UserAccount public class UserAccount
{ {
/// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary>
/// The user's unique username, used for login and display. Enforced unique at the database level.
/// </summary>
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
/// <summary>
/// The user's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
/// <summary>
/// The user's last name.
/// </summary>
public string LastName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;
/// <summary>
/// The user's email address. Enforced unique at the database level.
/// </summary>
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
/// <summary>
/// The date and time the account was created.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary>
/// The user's date of birth.
/// </summary>
public DateTime DateOfBirth { get; set; } public DateTime DateOfBirth { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
public ICollection<BreweryPost> BreweryPosts { get; set; } = [];
public ICollection<UserCredential> UserCredentials { get; set; } = [];
public UserVerification? Verification { get; set; }
} }

View File

@@ -1,40 +1,12 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
/// deleted.
/// </summary>
public class UserCredential public class UserCredential
{ {
/// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary>
public Guid UserCredentialId { get; set; } public Guid UserCredentialId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time the credential was issued.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time after which the credential is no longer valid.
/// </summary>
public DateTime Expiry { get; set; } public DateTime Expiry { get; set; }
/// <summary>
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
/// </summary>
public string Hash { get; set; } = string.Empty; public string Hash { get; set; } = string.Empty;
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
public UserAccount UserAccount { get; set; } = null!;
} }

View File

@@ -1,31 +1,10 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary>
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
/// and it is deleted automatically when the owning user account is deleted.
/// </summary>
public class UserVerification public class UserVerification
{ {
/// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary>
public Guid UserVerificationId { get; set; } public Guid UserVerificationId { get; set; }
/// <summary>
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time at which the user account was verified.
/// </summary>
public DateTime VerificationDateTime { get; set; } public DateTime VerificationDateTime { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
public UserAccount UserAccount { get; set; } = null!;
} }

View File

@@ -1,33 +1,31 @@
namespace Domain.Exceptions; namespace Domain.Exceptions;
/// <summary> /// <summary>
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use). /// The exception that is thrown when a resource conflict occurs (e.g. duplicate username, email already
/// Maps to HTTP 409 Conflict. /// in use). Maps to HTTP 409 Conflict.
/// </summary> /// </summary>
public class ConflictException(string message) : Exception(message); public class ConflictException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when a requested resource is not found. /// The exception that is thrown when a requested resource is not found. Maps to HTTP 404 Not Found.
/// Maps to HTTP 404 Not Found.
/// </summary> /// </summary>
public class NotFoundException(string message) : Exception(message); public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs // Domain.Exceptions/UnauthorizedException.cs
/// <summary> /// <summary>
/// Exception thrown when authentication fails or is required. /// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
/// Maps to HTTP 401 Unauthorized.
/// </summary> /// </summary>
public class UnauthorizedException(string message) : Exception(message); public class UnauthorizedException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when a user is authenticated but lacks permission to access a resource. /// The exception that is thrown when an authenticated user lacks permission to access a resource. Maps to
/// Maps to HTTP 403 Forbidden. /// HTTP 403 Forbidden.
/// </summary> /// </summary>
public class ForbiddenException(string message) : Exception(message); public class ForbiddenException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when business rule validation fails (distinct from FluentValidation). /// The exception that is thrown when business rule validation fails (distinct from FluentValidation's
/// Maps to HTTP 400 Bad Request. /// <see cref="FluentValidation.ValidationException" />). Maps to HTTP 400 Bad Request.
/// </summary> /// </summary>
public class ValidationException(string message) : Exception(message); public class ValidationException(string message) : Exception(message);

View File

@@ -68,12 +68,14 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
command.Username, new UserRegistrationDto(
command.FirstName, command.Username,
command.LastName, command.FirstName,
command.Email, command.LastName,
command.DateOfBirth, command.Email,
hashedPassword command.DateOfBirth,
hashedPassword
)
) )
) )
.ReturnsAsync( .ReturnsAsync(
@@ -139,15 +141,7 @@ public class RegisterUserHandlerTests
.WithMessage("Username or email already exists"); .WithMessage("Username or email already exists");
_authRepoMock.Verify( _authRepoMock.Verify(
x => x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Never Times.Never
); );
} }
@@ -192,12 +186,7 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
It.IsAny<string>(), It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
hashedPassword
) )
) )
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
@@ -215,12 +204,14 @@ public class RegisterUserHandlerTests
_authRepoMock.Verify( _authRepoMock.Verify(
x => x =>
x.RegisterUserAsync( x.RegisterUserAsync(
command.Username, new UserRegistrationDto(
command.FirstName, command.Username,
command.LastName, command.FirstName,
command.Email, command.LastName,
command.DateOfBirth, command.Email,
hashedPassword command.DateOfBirth,
hashedPassword
)
), ),
Times.Once Times.Once
); );
@@ -239,16 +230,7 @@ public class RegisterUserHandlerTests
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed"); _passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock _authRepoMock
.Setup(x => .Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync( .ReturnsAsync(
new UserAccount new UserAccount
{ {

View File

@@ -1,5 +1,6 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
using FluentAssertions; using FluentAssertions;
@@ -50,12 +51,14 @@ public class AuthRepositoryTests
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync( UserAccount result = await repo.RegisterUserAsync(
"testuser", new UserRegistrationDto(
"Test", "testuser",
"User", "Test",
"test@example.com", "User",
new DateTime(1990, 1, 1), "test@example.com",
"hashedpassword123" new DateTime(1990, 1, 1),
"hashedpassword123"
)
); );
result.Should().NotBeNull(); result.Should().NotBeNull();

View File

@@ -3,8 +3,5 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
/// <summary> /// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>; public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -11,8 +11,6 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed. /// corresponding user account as confirmed.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService) public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload> : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {

View File

@@ -8,7 +8,6 @@ namespace Features.Auth.Commands.RefreshToken;
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new /// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair. /// access/refresh token pair.
/// </summary> /// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload> : IRequestHandler<RefreshTokenCommand, LoginPayload>
{ {

View File

@@ -2,14 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand> public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{ {
/// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required"); RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");

View File

@@ -14,10 +14,6 @@ namespace Features.Auth.Commands.RegisterUser;
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration /// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails. /// confirmation email via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
public class RegisterUserHandler( public class RegisterUserHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,
@@ -38,12 +34,14 @@ public class RegisterUserHandler(
string hashed = passwordInfrastructure.Hash(request.Password); string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync( UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username, new UserRegistrationDto(
request.FirstName, request.Username,
request.LastName, request.FirstName,
request.Email, request.LastName,
request.DateOfBirth, request.Email,
hashed request.DateOfBirth,
hashed
)
); );
string accessToken = tokenService.GenerateAccessToken(createdUser); string accessToken = tokenService.GenerateAccessToken(createdUser);

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand> public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{ {
/// <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 RegisterUserValidator()
{ {
RuleFor(x => x.Username) RuleFor(x => x.Username)

View File

@@ -2,8 +2,5 @@ using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary> /// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -10,9 +10,6 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails. /// sending it via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
/// <param name="tokenService">Service used to generate the confirmation token.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks> /// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) /// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
/// or if the user's account is already verified. /// or if the user's account is already verified.

View File

@@ -18,21 +18,11 @@ namespace Features.Auth.Controllers;
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most /// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token. /// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase public class AuthController(IMediator mediator) : ControllerBase
{ {
/// <summary>
/// Registers a new user account.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register( public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
@@ -50,15 +40,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and a newly issued refresh/access token pair.
/// </remarks>
/// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
@@ -73,15 +54,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm( public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token [FromQuery] string token
@@ -97,14 +69,6 @@ public class AuthController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")] [HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId) public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{ {
@@ -112,15 +76,6 @@ public class AuthController(IMediator mediator) : ControllerBase
return Ok(new ResponseBody { Message = "confirmation email has been resent" }); return Ok(new ResponseBody { Message = "confirmation email has been resent" });
} }
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and the newly issued refresh/access token pair.
/// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh( public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(

View File

@@ -5,9 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection; namespace Features.Auth.DependencyInjection;
/// <summary>
/// Registers the services owned by the Auth feature slice.
/// </summary>
public static class FeaturesAuthServiceCollectionExtensions public static class FeaturesAuthServiceCollectionExtensions
{ {
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services) public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)

View File

@@ -1,12 +1,6 @@
namespace Features.Auth.Dtos; namespace Features.Auth.Dtos;
/// <summary> /// <summary>Payload returned to the client after a successful login or token refresh.</summary>
/// Payload returned to the client after a successful login or token refresh.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
/// <param name="Username">The username of the authenticated user account.</param>
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
public record LoginPayload( public record LoginPayload(
Guid UserAccountId, Guid UserAccountId,
string Username, string Username,
@@ -14,14 +8,7 @@ public record LoginPayload(
string AccessToken string AccessToken
); );
/// <summary> /// <summary>Payload returned to the client after a successful registration.</summary>
/// Payload returned to the client after a successful registration.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
/// <param name="Username">The username of the newly created user account.</param>
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
public record RegistrationPayload( public record RegistrationPayload(
Guid UserAccountId, Guid UserAccountId,
string Username, string Username,
@@ -30,9 +17,5 @@ public record RegistrationPayload(
bool ConfirmationEmailSent bool ConfirmationEmailSent
); );
/// <summary> /// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
/// Payload returned to the client after a user account's email has been confirmed.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -0,0 +1,10 @@
namespace Features.Auth.Dtos;
public record UserRegistrationDto(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string PasswordHash
);

View File

@@ -11,12 +11,6 @@ namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens. /// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler( public class LoginHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary>
/// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary>
public class LoginValidator : AbstractValidator<LoginQuery> public class LoginValidator : AbstractValidator<LoginQuery>
{ {
/// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary>
public LoginValidator() public LoginValidator()
{ {
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");

View File

@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
using Infrastructure.Sql; using Infrastructure.Sql;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -10,7 +11,6 @@ namespace Features.Auth.Repository;
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures, /// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification. /// handling user registration, credential lookup/rotation, and account verification.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory) public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IAuthRepository IAuthRepository
@@ -25,23 +25,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the /// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail. /// subsequent lookup to fail.
/// </remarks> /// </remarks>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception> /// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
public async Task<UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -49,6 +34,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
command.CommandText = "USP_RegisterUser"; command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
AddParameter(command, "@Username", username); AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName); AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName); AddParameter(command, "@LastName", lastName);
@@ -85,13 +72,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
?? throw new Exception("Failed to retrieve newly registered user."); ?? throw new Exception("Failed to retrieve newly registered user.");
} }
/// <summary> /// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
/// Retrieves a user account by email address (typically used for login) using the
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByEmailAsync(string email) public async Task<UserAccount?> GetUserByEmailAsync(string email)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -105,13 +86,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
/// Retrieves a user account by username (typically used for login) using the
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByUsernameAsync(string username) public async Task<UserAccount?> GetUserByUsernameAsync(string username)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -129,9 +104,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Retrieves the active (non-revoked) credential for a user account using the /// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure. /// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId) public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -149,9 +121,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Rotates a user's credential by invalidating all existing credentials and creating a new one, /// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure. /// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash) public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -165,12 +134,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId) public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -190,8 +154,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user /// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException"> /// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than /// Thrown when the database command fails for a reason other than
/// a duplicate verification record. /// a duplicate verification record.
@@ -226,13 +188,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
/// <summary> /// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
/// Checks whether a user account has been verified by querying the
/// <c>dbo.UserVerification</c> table for a matching record.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -247,23 +203,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return result != null && result != DBNull.Value; return result != null && result != DBNull.Value;
} }
/// <summary> /// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary>
/// <param name="ex">The SQL exception to inspect.</param>
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
private static bool IsDuplicateVerificationViolation(SqlException ex) private static bool IsDuplicateVerificationViolation(SqlException ex)
{ {
// 2601/2627 are duplicate key violations in SQL Server. // 2601/2627 are duplicate key violations in SQL Server.
return ex.Number == 2601 || ex.Number == 2627; return ex.Number == 2601 || ex.Number == 2627;
} }
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity(DbDataReader reader) protected override UserAccount MapToEntity(DbDataReader reader)
{ {
return new UserAccount return new UserAccount
@@ -286,8 +232,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if /// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it. /// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader) private static UserCredential MapToCredentialEntity(DbDataReader reader)
{ {
UserCredential entity = new() UserCredential entity = new()
@@ -320,13 +264,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return entity; return entity;
} }
/// <summary> /// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value) private static void AddParameter(DbCommand command, string name, object? value)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();

View File

@@ -1,4 +1,5 @@
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
namespace Features.Auth.Repository; namespace Features.Auth.Repository;
@@ -7,76 +8,21 @@ namespace Features.Auth.Repository;
/// </summary> /// </summary>
public interface IAuthRepository public interface IAuthRepository
{ {
/// <summary> Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByEmailAsync(string email); Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByUsernameAsync(string username); Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId); Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary> /// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary> /// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
/// Marks a user account as confirmed.
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId); Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary>
/// Retrieves a user account by ID.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId); Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -18,20 +18,10 @@ public enum TokenType
ConfirmationToken, ConfirmationToken,
} }
/// <summary> /// <summary>Represents the result of successfully validating a token.</summary>
/// Represents the result of successfully validating a token.
/// </summary>
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
/// <param name="Username">The username extracted from the token's claims.</param>
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary> /// <summary>Represents the result of refreshing a user's session.</summary>
/// Represents the result of refreshing a user's session.
/// </summary>
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param>
/// <param name="AccessToken">The newly issued access token.</param>
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken); public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
/// <summary> /// <summary>
@@ -39,14 +29,13 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
/// </summary> /// </summary>
public static class TokenServiceExpirationHours public static class TokenServiceExpirationHours
{ {
/// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1; public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary> /// <summary>21 days.</summary>
public const double RefreshTokenHours = 504; // 21 days public const double RefreshTokenHours = 504;
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary> /// <summary>30 minutes.</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes public const double ConfirmationTokenHours = 0.5;
} }
/// <summary> /// <summary>
@@ -54,61 +43,23 @@ public static class TokenServiceExpirationHours
/// </summary> /// </summary>
public interface ITokenService public interface ITokenService
{ {
/// <summary>
/// Generates a new access token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
string GenerateAccessToken(UserAccount user); string GenerateAccessToken(UserAccount user);
/// <summary>
/// Generates a new refresh token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
string GenerateRefreshToken(UserAccount user); string GenerateRefreshToken(UserAccount user);
/// <summary>
/// Generates a new confirmation token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
string GenerateConfirmationToken(UserAccount user); string GenerateConfirmationToken(UserAccount user);
/// <summary> /// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />. /// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) string GenerateToken<T>(UserAccount user)
where T : struct, Enum; where T : struct, Enum;
/// <summary>
/// Validates an access token.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateAccessTokenAsync(string token); Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary>
/// Validates a refresh token.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token); Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary>
/// Validates a confirmation token.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token); Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary> /// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString); Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
} }

View File

@@ -19,12 +19,7 @@ public class TokenService : ITokenService
private readonly string _refreshTokenSecret; private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure; private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary> /// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables.
/// </summary>
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or /// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set. /// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
@@ -53,12 +48,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user) public string GenerateAccessToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
@@ -70,12 +59,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours( DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -89,12 +72,6 @@ public class TokenService : ITokenService
); );
} }
/// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours( DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -111,11 +88,9 @@ public class TokenService : ITokenService
/// <summary> /// <summary>
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to /// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or /// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value. /// <see cref="GenerateConfirmationToken" />.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam> /// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type. /// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception> /// </exception>
@@ -139,11 +114,6 @@ public class TokenService : ITokenService
}; };
} }
/// <summary>
/// Validates an access token against the access token secret.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -152,11 +122,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
} }
/// <summary>
/// Validates a refresh token against the refresh token secret.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -165,11 +130,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
} }
/// <summary>
/// Validates a confirmation token against the confirmation token secret.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
@@ -178,12 +138,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
} }
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception> /// </exception>
@@ -204,10 +158,7 @@ public class TokenService : ITokenService
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary> /// </summary>
/// <param name="token">The token string to validate.</param>
/// <param name="secret">The secret key to validate the token's signature against.</param>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).

View File

@@ -3,9 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
/// </summary>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,
string AddressLine1, string AddressLine1,

View File

@@ -5,19 +5,10 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
/// </summary>
/// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto> : IRequestHandler<CreateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
/// Creates a new brewery post, generating new identifiers for the post and its location.
/// </summary>
/// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
CreateBreweryCommand request, CreateBreweryCommand request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -2,17 +2,8 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
/// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
/// <summary>
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
/// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
/// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
/// address line 1, and postal code fields.
/// </summary>
public CreateBreweryValidator() public CreateBreweryValidator()
{ {
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required."); RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");

View File

@@ -2,7 +2,4 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;

View File

@@ -3,10 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Handles <see cref="DeleteBreweryCommand" /> by deleting the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to delete the brewery post.</param>
public class DeleteBreweryHandler(IBreweryRepository repository) public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand> : IRequestHandler<DeleteBreweryCommand>
{ {

View File

@@ -3,9 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand" />.
/// </summary>
public record UpdateBreweryLocation( public record UpdateBreweryLocation(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>
/// Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.
/// </summary>
/// <param name="repository">Repository used to persist the updated brewery post.</param>
public class UpdateBreweryHandler(IBreweryRepository repository) public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto> : IRequestHandler<UpdateBreweryCommand, BreweryDto>
{ {
@@ -16,9 +12,6 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>, /// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
/// the brewery's location is cleared. /// the brewery's location is cleared.
/// </summary> /// </summary>
/// <param name="request">The updated details of the brewery post.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
UpdateBreweryCommand request, UpdateBreweryCommand request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -11,28 +11,17 @@ using Shared.Contracts;
namespace Features.Breweries.Controllers; namespace Features.Breweries.Controllers;
/// <summary>
/// Provides CRUD endpoints for managing brewery posts.
/// </summary>
/// <remarks> /// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints /// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
/// (<see cref="GetById" /> and <see cref="GetAll" />) opt out via <c>[AllowAnonymous]</c>. /// <c>[AllowAnonymous]</c>.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IMediator mediator) : ControllerBase public class BreweryController(IMediator mediator) : ControllerBase
{ {
/// <summary>
/// Retrieves a single brewery post by its unique identifier.
/// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="id">The unique identifier of the brewery post to retrieve.</param> /// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="BreweryDto" /> if found;
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody" /> error message.
/// </returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id) public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
@@ -50,13 +39,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of a collection of <see cref="BreweryDto" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
@@ -76,14 +59,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <returns><c>201 Created</c> with the newly created brewery.</returns>
/// Creates a new brewery post.
/// </summary>
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
/// <returns>
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of the newly created
/// <see cref="BreweryDto" />.
/// </returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create( public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryCommand command [FromBody] CreateBreweryCommand command
@@ -100,22 +76,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
/// Updates an existing brewery post. /// <returns><c>200 OK</c> with the updated brewery; or <c>400 Bad Request</c> if the route ID does not match the payload ID.</returns>
/// </summary>
/// <param name="id">
/// The unique identifier of the brewery post from the route, which must match <paramref name="command" />
/// 's ID.
/// </param>
/// <param name="command">
/// The updated brewery details, including the posting user, name, description, and optional
/// location.
/// </param>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of the updated <see cref="BreweryDto" /> if the
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
/// when the route ID does not match the payload ID.
/// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update( public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id, Guid id,
@@ -135,11 +97,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary> /// <returns><c>200 OK</c> confirming the deletion.</returns>
/// Deletes a brewery post.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the deletion.</returns>
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id) public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{ {

View File

@@ -1,89 +1,39 @@
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto" />.
/// </summary>
public class BreweryLocationDto public class BreweryLocationDto
{ {
/// <summary>
/// The unique identifier of the brewery's location record.
/// </summary>
public Guid BreweryPostLocationId { get; set; } public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// The unique identifier of the brewery post that this location belongs to.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the city in which the brewery is located.
/// </summary>
public Guid CityId { get; set; } public Guid CityId { get; set; }
/// <summary>
/// The primary street address line of the brewery.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty; public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g. suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; } public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the brewery's address.
/// </summary>
public string PostalCode { get; set; } = string.Empty; public string PostalCode { get; set; } = string.Empty;
/// <summary> /// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
/// The optional geographic coordinates of the brewery, in a raw binary representation.
/// </summary>
public byte[]? Coordinates { get; set; } public byte[]? Coordinates { get; set; }
} }
/// <summary>
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
/// optional location.
/// </summary>
public class BreweryDto public class BreweryDto
{ {
/// <summary>
/// The unique identifier of the brewery post.
/// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the user account that created the brewery post.
/// </summary>
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery.
/// </summary>
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// A description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time at which the brewery post was created.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary> /// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
/// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
/// <summary>
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
/// </summary>
public BreweryLocationDto? Location { get; set; } public BreweryLocationDto? Location { get; set; }
} }

View File

@@ -2,17 +2,8 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>
/// Maps <see cref="BreweryPost" /> domain entities to their <see cref="BreweryDto" /> wire representation.
/// </summary>
public static class BreweryDtoMapper public static class BreweryDtoMapper
{ {
/// <summary>
/// Maps a <see cref="BreweryPost" /> domain entity to its <see cref="BreweryDto" /> representation,
/// including its location, if present.
/// </summary>
/// <param name="brewery">The brewery post entity to map.</param>
/// <returns>The mapped <see cref="BreweryDto" />.</returns>
public static BreweryDto ToDto(this BreweryPost brewery) public static BreweryDto ToDto(this BreweryPost brewery)
{ {
return new BreweryDto return new BreweryDto

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Handles <see cref="GetAllBreweriesQuery" /> by retrieving a paginated list of brewery posts.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {

View File

@@ -5,10 +5,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Handles <see cref="GetBreweryByIdQuery" /> by looking up the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Retrieves a single brewery post by its unique identifier.
/// </summary>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>; public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -9,17 +9,11 @@ namespace Features.Breweries.Repository;
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored /// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
/// procedures. /// procedures.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), : Repository<BreweryPost>(connectionFactory),
IBreweryRepository IBreweryRepository
{ {
/// <summary> /// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
/// </summary>
/// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<BreweryPost?> GetByIdAsync(Guid id) public async Task<BreweryPost?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -40,10 +34,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their /// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value. /// corresponding argument has a value.
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -71,8 +61,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is /// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed. /// <c>null</c>, any existing location for the brewery is removed.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery) public async Task UpdateAsync(BreweryPost brewery)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -97,8 +85,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location /// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys. /// and photos are removed via cascading foreign keys.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id) public async Task DeleteAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -110,12 +96,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
/// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception> /// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task CreateAsync(BreweryPost brewery) public async Task CreateAsync(BreweryPost brewery)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -139,12 +121,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Maps the current row of a data reader to a <see cref="BreweryPost" /> entity, including its /// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its /// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
/// associated <see cref="BreweryPostLocation" />.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
protected override BreweryPost MapToEntity(DbDataReader reader) protected override BreweryPost MapToEntity(DbDataReader reader)
{ {
BreweryPost brewery = new(); BreweryPost brewery = new();
@@ -212,13 +191,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
return brewery; return brewery;
} }
/// <summary> /// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value) private static void AddParameter(DbCommand command, string name, object? value)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();

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