mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
16 Commits
3154c2feed
...
a4da01586d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4da01586d | ||
|
|
3087d599ae | ||
|
|
48ee6b0fef | ||
|
|
7a0620337d | ||
|
|
64302286fb | ||
|
|
88abb8e995 | ||
|
|
2ed655e03d | ||
|
|
be0fe9d405 | ||
|
|
492d5d280e | ||
|
|
f3f704da8d | ||
|
|
1d1edd37d0 | ||
|
|
417fa35ced | ||
|
|
66d84dee9e | ||
|
|
fcca12b49d | ||
|
|
84e9e79d81 | ||
|
|
5b2eceb6b8 |
55
README.md
55
README.md
@@ -1,20 +1,24 @@
|
||||
# The Biergarten App
|
||||
|
||||
The Biergarten App is a full-stack directory and discovery platform for
|
||||
breweries. It features a robust user authentication system, a searchable
|
||||
database of brewery locations, and a custom offline data-generation pipeline
|
||||
that uses LLMs (Llama.cpp) and Wikipedia to synthesize realistic seed data.
|
||||
breweries. It provides:
|
||||
|
||||
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 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
|
||||
pipeline notes) lives under `docs/`.
|
||||
|
||||
## Documentation (Start Here)
|
||||
## Documentation
|
||||
|
||||
Website + backend (active stack):
|
||||
|
||||
@@ -37,7 +41,7 @@ Data generation pipeline (C++):
|
||||
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
|
||||
- [Database Schema](docs/website/diagrams-out/database-schema.svg)
|
||||
|
||||
## Current Status
|
||||
## Current status
|
||||
|
||||
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
|
||||
reference
|
||||
|
||||
## Tech Stack
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
- **Infrastructure**: Docker, Docker Compose
|
||||
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation
|
||||
tokens
|
||||
- **Security**: Argon2id password hashing, JSON Web Tokens (JWT) for
|
||||
access/refresh/confirmation
|
||||
- **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).
|
||||
This section is the shortest path to a working dev environment.
|
||||
@@ -101,7 +114,7 @@ npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
## Repository Structure
|
||||
## Repository structure
|
||||
|
||||
```text
|
||||
web/
|
||||
@@ -123,9 +136,10 @@ archive/
|
||||
|
||||
## Testing
|
||||
|
||||
Run the backend test stack with Docker:
|
||||
Create a test environment file, then run the backend test stack with Docker:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -147,5 +161,16 @@ See [Environment Variables](docs/website/environment-variables.md) for details.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
4. Run the checks in [Testing](docs/website/testing.md) (backend tests,
|
||||
`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).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
active website:
|
||||
@@ -29,9 +29,9 @@ For visual representations, see:
|
||||
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
|
||||
relationships
|
||||
|
||||
## Backend Architecture
|
||||
## Backend architecture
|
||||
|
||||
### Vertical Slice Architecture Pattern
|
||||
### Vertical-slice architecture pattern
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
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
|
||||
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
|
||||
its own
|
||||
@@ -108,7 +108,7 @@ its own
|
||||
- No controllers, no business logic, no feature-specific contracts
|
||||
- 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
|
||||
|
||||
@@ -146,7 +146,7 @@ MediatR commands sent from other slices, never over HTTP.
|
||||
MediatR
|
||||
- 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
|
||||
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
|
||||
another slice," not a general-purpose dumping ground
|
||||
|
||||
#### Infrastructure Layer
|
||||
#### Infrastructure layer
|
||||
|
||||
**Purpose**: Technical capabilities and external integrations, shared by
|
||||
whichever slices need them
|
||||
@@ -185,14 +185,14 @@ whichever slices need them
|
||||
**Dependencies**:
|
||||
|
||||
- Domain entities
|
||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
|
||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, and so on)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Implements technical concerns only, no business logic
|
||||
- Reusable across slices
|
||||
|
||||
#### Domain Layer (`Domain.Entities`)
|
||||
#### Domain layer (`Domain.Entities`)
|
||||
|
||||
**Purpose**: Core business entities and models
|
||||
|
||||
@@ -213,9 +213,9 @@ whichever slices need them
|
||||
- No infrastructure references
|
||||
- Represents business concepts
|
||||
|
||||
### Design Patterns
|
||||
### Design patterns
|
||||
|
||||
#### Vertical Slice + MediatR
|
||||
#### Vertical slice + MediatR
|
||||
|
||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||
everything needed to understand or change one capability lives in one
|
||||
@@ -223,9 +223,9 @@ project
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in
|
||||
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (e.g. `GetBreweryByIdQuery`)
|
||||
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (for example, `GetBreweryByIdQuery`)
|
||||
- Controllers bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
@@ -246,7 +246,7 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
|
||||
}
|
||||
```
|
||||
|
||||
#### Repository Pattern
|
||||
#### Repository pattern
|
||||
|
||||
**Purpose**: Abstract database access behind interfaces
|
||||
|
||||
@@ -275,7 +275,7 @@ public interface IAuthRepository
|
||||
}
|
||||
```
|
||||
|
||||
#### Dependency Injection
|
||||
#### Dependency injection
|
||||
|
||||
**Purpose**: Loose coupling and testability
|
||||
|
||||
@@ -289,7 +289,7 @@ extension method that registers its repository and slice-internal services
|
||||
- Singleton: `ISqlConnectionFactory`
|
||||
- Transient: Utilities, helpers
|
||||
|
||||
#### SQL-First Approach
|
||||
#### SQL-first approach
|
||||
|
||||
**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_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
|
||||
enabled.
|
||||
@@ -327,7 +327,7 @@ web/frontend/
|
||||
└── package.json Frontend scripts and dependencies
|
||||
```
|
||||
|
||||
### Frontend Responsibilities
|
||||
### Frontend responsibilities
|
||||
|
||||
- Render the auth demo and theme guide routes
|
||||
- Manage cookie-backed website session state
|
||||
@@ -335,7 +335,7 @@ web/frontend/
|
||||
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||
- Supply Storybook documentation and browser-based component verification
|
||||
|
||||
### Theme System
|
||||
### Theme system
|
||||
|
||||
The active website uses semantic DaisyUI theme tokens backed by four Biergarten
|
||||
themes:
|
||||
@@ -348,15 +348,15 @@ themes:
|
||||
All component styling should prefer semantic tokens such as `primary`,
|
||||
`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/`
|
||||
for reference only. Active product and engineering documentation should point
|
||||
to `web/frontend`.
|
||||
|
||||
## Security Architecture
|
||||
## Security architecture
|
||||
|
||||
### Authentication Flow
|
||||
### Authentication flow
|
||||
|
||||
1. **Registration**:
|
||||
- User submits credentials
|
||||
@@ -375,7 +375,7 @@ to `web/frontend`.
|
||||
- Middleware validates token
|
||||
- Request proceeds if valid
|
||||
|
||||
### Password Security
|
||||
### Password security
|
||||
|
||||
**Algorithm**: Argon2id
|
||||
|
||||
@@ -385,7 +385,7 @@ to `web/frontend`.
|
||||
- Salt: 128-bit (16 bytes)
|
||||
- Hash: 256-bit (32 bytes)
|
||||
|
||||
### JWT Tokens
|
||||
### JWT tokens
|
||||
|
||||
**Algorithm**: HS256 (HMAC-SHA256)
|
||||
|
||||
@@ -409,9 +409,9 @@ to `web/frontend`.
|
||||
}
|
||||
```
|
||||
|
||||
## Database Architecture
|
||||
## Database architecture
|
||||
|
||||
### SQL-First Philosophy
|
||||
### SQL-first philosophy
|
||||
|
||||
**Principles**:
|
||||
|
||||
@@ -427,7 +427,7 @@ to `web/frontend`.
|
||||
- Version-controlled schema (migrations)
|
||||
- Easier query profiling and tuning
|
||||
|
||||
### Migration Strategy
|
||||
### Migration strategy
|
||||
|
||||
**Tool**: DbUp
|
||||
|
||||
@@ -448,7 +448,7 @@ scripts/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Data Seeding
|
||||
### Data seeding
|
||||
|
||||
**Purpose**: Populate development/test databases
|
||||
|
||||
@@ -460,9 +460,9 @@ scripts/
|
||||
- Test user accounts
|
||||
- Sample breweries (future)
|
||||
|
||||
## Deployment Architecture
|
||||
## Deployment architecture
|
||||
|
||||
### Docker Containerization
|
||||
### Docker containerization
|
||||
|
||||
**Container Structure**:
|
||||
|
||||
@@ -479,10 +479,10 @@ scripts/
|
||||
|
||||
For details, see [Docker Guide](website/docker.md).
|
||||
|
||||
### Health Checks
|
||||
### Health checks
|
||||
|
||||
**SQL Server**: Validates database connectivity **API**: Checks service health
|
||||
and dependencies
|
||||
- **SQL Server**: Validates database connectivity
|
||||
- **API**: Checks service health and dependencies
|
||||
|
||||
**Configuration**:
|
||||
|
||||
@@ -494,9 +494,9 @@ healthcheck:
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
## Testing Architecture
|
||||
## Testing architecture
|
||||
|
||||
### Test Pyramid
|
||||
### Test pyramid
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
|
||||
@@ -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,
|
||||
the model's biases, and known issues including hallucinated brewing science and
|
||||
low-resource language failures.
|
||||
|
||||
> Note that all testing was used using `google_gemma-4-E4B-it-Q6_K.gguf`.
|
||||
> 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 Not](#what-this-dataset-is-not)
|
||||
- [Model Bias and Language Quality](#model-bias-and-language-quality)
|
||||
- [Western and Eurocentric Lens](#western-and-eurocentric-lens)
|
||||
- [Wikipedia Enrichment](#wikipedia-enrichment)
|
||||
- [Names-by-Country Dataset](#names-by-country-dataset)
|
||||
- [The "Avoid AI Phrases" Prompt Instruction](#the-avoid-ai-phrases-prompt-instruction)
|
||||
- [Known Issues](#known-issues)
|
||||
- [Hallucinated Brewing Techniques](#hallucinated-brewing-techniques)
|
||||
- [Low-Resource Language Hallucination](#low-resource-language-hallucination)
|
||||
- [Synthetic Postal Codes](#synthetic-postal-codes)
|
||||
- [What this dataset is](#what-this-dataset-is)
|
||||
- [What this dataset is not](#what-this-dataset-is-not)
|
||||
- [Model bias and language quality](#model-bias-and-language-quality)
|
||||
- [Western and Eurocentric lens](#western-and-eurocentric-lens)
|
||||
- [Wikipedia enrichment](#wikipedia-enrichment)
|
||||
- [Names-by-country dataset](#names-by-country-dataset)
|
||||
- [The "avoid AI phrases" prompt instruction](#the-avoid-ai-phrases-prompt-instruction)
|
||||
- [Known issues](#known-issues)
|
||||
- [Hallucinated brewing techniques](#hallucinated-brewing-techniques)
|
||||
- [Low-resource language hallucination](#low-resource-language-hallucination)
|
||||
- [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
|
||||
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
|
||||
cultural representation, or reliable local-language text. Hallucinations such as
|
||||
invented fermentation techniques, or incoherent local-language prose, are
|
||||
expected, observed, and partially documented in [Known Issues](#known-issues)
|
||||
expected, observed, and partially documented in [Known issues](#known-issues)
|
||||
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
|
||||
distributions, rating skews, activity profiles) will be handled the same way.
|
||||
|
||||
**Treat this data as an exercise in prompt engineering and model behaviour, not
|
||||
as a source of truth for brewing techniques or cultural representation.**
|
||||
|
||||
**Natural language processing, although a powerful tool for data analysis and
|
||||
generation is to be taken with scrutiny. Human language is not simply just data
|
||||
points to be analyzed, but it also carries deep cultural and human meaning that
|
||||
artificial intelligence is incapable of.**
|
||||
**Natural language processing is a powerful tool for data analysis and
|
||||
generation, but its output should be treated with scrutiny. Human language is
|
||||
not simply data points to be analyzed — it carries cultural and human meaning
|
||||
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
|
||||
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]
|
||||
|
||||
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
|
||||
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
|
||||
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)** and the **GNU Free Documentation License
|
||||
(GFDL)**.[^wp-license]
|
||||
International License (CC BY-SA 4.0)**[^cc-sa] and the **GNU Free Documentation
|
||||
License (GFDL)**.[^wp-license]
|
||||
|
||||
Wikipedia's own accuracy limitations and editorial biases can propagate into
|
||||
generated descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Names-by-Country Dataset
|
||||
## Names-by-country dataset
|
||||
|
||||
`tooling/pipeline/forenames-by-country.json` and `surnames-by-country.json`
|
||||
(used to sample a `Name` per ISO 3166-1 country code for user generation) are
|
||||
@@ -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
|
||||
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
|
||||
brewing detail," the model frequently hallucinates fermentation science and
|
||||
brewing chemistry. While the resulting sounds confident, it will be nonsensical
|
||||
to reader with brewing and/or scientific expertise.
|
||||
brewing chemistry. While the resulting text sounds confident, it is
|
||||
nonsensical to a reader with brewing or scientific expertise.
|
||||
|
||||
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
||||
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
||||
@@ -155,9 +155,9 @@ and factually wrong output.
|
||||
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
|
||||
```
|
||||
|
||||
A review of the following text for brewing techniques reveals several
|
||||
inaccuracies, and no comments could be made on the local-language version due to
|
||||
my own lack of proficiency in Japanese:
|
||||
A review of the following text for brewing techniques reveals three
|
||||
inaccuracies. No comments are made on the local-language version, since its
|
||||
reviewer does not read Japanese:
|
||||
|
||||
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour
|
||||
|
||||
@@ -191,7 +191,7 @@ fermentable sugars.[^yn-sakeonline]
|
||||
|
||||
**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
|
||||
beer production. Breweries exclude oxygen as a top priority for packaging and
|
||||
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
|
||||
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
|
||||
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
|
||||
variant of French is needed, but will often times just default to a standardized
|
||||
dialect of French, devoid of any cultural or linguistic nuance.
|
||||
When fed into the pipeline, this dataset often causes the model to reason that
|
||||
a local variant of French is needed, but it often defaults to a standardized
|
||||
dialect of French, without cultural or linguistic nuance.
|
||||
|
||||
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
||||
(Sicily, Italy), the model can generate text that looks syntactically plausible
|
||||
@@ -273,7 +273,7 @@ rather than prompt engineering.
|
||||
|
||||
Output sample: [./french-cities.example](french-cities.example)
|
||||
|
||||
#### Proposed Mitigations
|
||||
#### Proposed mitigations
|
||||
|
||||
- **Prevention via allowlist:** introduce a high-resource language allowlist. If
|
||||
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
|
||||
hallucinated text by language tier.
|
||||
|
||||
### Synthetic Postal Codes
|
||||
### Synthetic postal codes
|
||||
|
||||
When the pipeline is run without `--mocked`, `XegerPostalCodeService` generates
|
||||
a fresh postal code per brewery/user address by reverse-matching one of the
|
||||
@@ -312,7 +312,7 @@ not a source of truth.
|
||||
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
||||
|
||||
[^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
|
||||
consistent with models trained predominantly on English-language web
|
||||
corpora.
|
||||
|
||||
@@ -14,8 +14,8 @@ deterministic mock.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [How It Fits The Main App](#how-it-fits-the-main-app)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How it fits the main app](#how-it-fits-the-main-app)
|
||||
- [Quick start](#quick-start)
|
||||
- [Build](#build)
|
||||
- [Model](#model)
|
||||
- [Run](#run)
|
||||
@@ -24,17 +24,17 @@ deterministic mock.
|
||||
- [Pipeline Stages](#pipeline-stages)
|
||||
- [Key Components](#key-components)
|
||||
- [Runtime Behaviour](#runtime-behaviour)
|
||||
- [Generated Output](#generated-output)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Tested Hardware](#tested-hardware)
|
||||
- [Fixture Strategy](#fixture-strategy)
|
||||
- [Repo Layout](#repo-layout)
|
||||
- [Code Tour](#code-tour)
|
||||
- [Next Steps](#next-steps)
|
||||
- [Generated output](#generated-output)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Tested hardware](#tested-hardware)
|
||||
- [Fixture strategy](#fixture-strategy)
|
||||
- [Repo layout](#repo-layout)
|
||||
- [Code tour](#code-tour)
|
||||
- [Next steps](#next-steps)
|
||||
|
||||
---
|
||||
|
||||
## How It Fits The Main App
|
||||
## How it fits the main app
|
||||
|
||||
The pipeline is a data ingestion layer. It sits outside the web app runtime and
|
||||
produces seed records the app imports at startup or during a dedicated seed
|
||||
@@ -50,7 +50,7 @@ step.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
#### CLI Flags
|
||||
#### CLI flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||
| `--prompt-dir` | Directory containing prompt files (e.g. `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||
| `--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`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--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
|
||||
|
||||
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
|
||||
is not already present at the configured path.
|
||||
|
||||
@@ -218,7 +218,7 @@ environment variables listed above to match your run.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pipeline Stages
|
||||
### Pipeline stages
|
||||
|
||||
| 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
|
||||
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
|
||||
|
||||
### Key Components
|
||||
### Key components
|
||||
|
||||
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
||||
- `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
|
||||
(not LLM-authored) unique email and date of birth.
|
||||
|
||||
### Runtime Behaviour
|
||||
### Runtime behaviour
|
||||
|
||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
||||
"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 —
|
||||
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
|
||||
|
||||
### Process Flow - Activity Diagram
|
||||
### Process flow - activity diagram
|
||||
|
||||

|
||||
|
||||
### Architectural Overview - Class Diagram
|
||||
### Architectural overview - class diagram
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Generated Output
|
||||
## Generated output
|
||||
|
||||
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
|
||||
@@ -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
|
||||
code, latitude, and longitude for each entry.
|
||||
|
||||
### Consumer Data Shape
|
||||
### Consumer data shape
|
||||
|
||||
| Field | Why it matters |
|
||||
| ----------------------------------- | ------------------------------------------------ |
|
||||
@@ -341,7 +341,7 @@ code, latitude, and longitude for each entry.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## Tech stack
|
||||
|
||||
- C++20
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
`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 |
|
||||
| -------------------------------------------- | ----------------------------------------------------------- |
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
[`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
|
||||
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
|
||||
available token budget.
|
||||
|
||||
### Beer Generation
|
||||
### Beer generation
|
||||
|
||||
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
|
||||
pairing hints. Link beers back to breweries and cities. Keep style coverage wide
|
||||
enough to exercise search, sort, and category filters.
|
||||
|
||||
### Check-In System
|
||||
### Check-in system
|
||||
|
||||
Produce timestamped check-in events between users and breweries. Use a J-curve
|
||||
activity profile — a small set of users accounts for most check-ins, the rest
|
||||
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||
|
||||
### Beer Ratings
|
||||
### Beer ratings
|
||||
|
||||
Generate rating events with a strong positive skew and a long tail of lower
|
||||
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Docker Guide
|
||||
# Docker guide
|
||||
|
||||
This document covers Docker deployment, configuration, and troubleshooting for
|
||||
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
|
||||
representation.
|
||||
|
||||
## Docker Compose Environments
|
||||
## Docker Compose environments
|
||||
|
||||
### 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`)
|
||||
|
||||
**Purpose**: Automated CI/CD testing in isolated environment
|
||||
**Purpose**: Automated CI/CD testing in an isolated environment
|
||||
|
||||
**Features**:
|
||||
|
||||
@@ -128,18 +128,15 @@ api.core # Production API
|
||||
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:
|
||||
|
||||
```mermaid
|
||||
sqlserver (health check)
|
||||
↓
|
||||
database.migrations (completes successfully)
|
||||
↓
|
||||
database.seed (completes successfully)
|
||||
↓
|
||||
api.core / tests (start when ready)
|
||||
graph TD
|
||||
A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
|
||||
B --> C[database.seed: completes successfully]
|
||||
C --> D[api.core / tests: start when ready]
|
||||
```
|
||||
|
||||
**Health Check Example** (SQL Server):
|
||||
@@ -168,7 +165,7 @@ api.core:
|
||||
|
||||
## Volumes
|
||||
|
||||
### Persistent Volumes
|
||||
### Persistent volumes
|
||||
|
||||
**Development**:
|
||||
|
||||
@@ -184,7 +181,7 @@ api.core:
|
||||
- `sqlserverdata-prod` - Production database files
|
||||
- `nuget-cache-prod` - Production NuGet cache
|
||||
|
||||
### Mounted Volumes
|
||||
### Mounted volumes
|
||||
|
||||
**Test Results**:
|
||||
|
||||
@@ -203,7 +200,7 @@ Each environment uses isolated bridge networks:
|
||||
- `testnet` - Testing network (fully isolated)
|
||||
- `prodnet` - Production network
|
||||
|
||||
## Environment Variables
|
||||
## Environment variables
|
||||
|
||||
All containers are configured via environment variables from `.env` files:
|
||||
|
||||
@@ -224,9 +221,9 @@ environment:
|
||||
|
||||
For complete list, see [Environment Variables](environment-variables.md).
|
||||
|
||||
## Common Commands
|
||||
## Common commands
|
||||
|
||||
### View Services
|
||||
### View services
|
||||
|
||||
```bash
|
||||
# Running services
|
||||
@@ -236,7 +233,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
### View Logs
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
### Execute Commands in Container
|
||||
### Execute commands in container
|
||||
|
||||
```bash
|
||||
# Interactive shell
|
||||
docker exec -it dev-env-api-core bash
|
||||
|
||||
# Run command
|
||||
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C
|
||||
# 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 '<db-password>' -C
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
### Build Images
|
||||
### Build images
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
### Clean up
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
## Dockerfile Structure
|
||||
## Dockerfile structure
|
||||
|
||||
### Multi-Stage Build
|
||||
### Multi-stage build
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
@@ -321,7 +318,7 @@ COPY --from=build /app/build .
|
||||
ENTRYPOINT ["dotnet", "Project.dll"]
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
## Additional resources
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Environment Variables
|
||||
# Environment variables
|
||||
|
||||
This document covers the active environment variables used by the current
|
||||
Biergarten stack.
|
||||
@@ -11,7 +11,7 @@ The application uses environment variables for:
|
||||
- **React Router website** - API base URL and session signing
|
||||
- **Docker containers** - environment-specific orchestration
|
||||
|
||||
## Configuration Patterns
|
||||
## Configuration patterns
|
||||
|
||||
### Backend (.NET API)
|
||||
|
||||
@@ -30,9 +30,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
||||
- `.env.test` - Testing
|
||||
- `.env.prod` - Production
|
||||
|
||||
## Backend Variables (.NET API)
|
||||
## Backend variables (.NET API)
|
||||
|
||||
### Database Connection
|
||||
### Database connection
|
||||
|
||||
**Option 1: Component-Based (Recommended for Docker)**
|
||||
|
||||
@@ -59,7 +59,7 @@ string is built from components.
|
||||
|
||||
**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
|
||||
independent key rotation and validation isolation.
|
||||
@@ -80,9 +80,9 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||
|
||||
**Security Requirements**:
|
||||
|
||||
- Each secret should be minimum 32 characters
|
||||
- Recommend 127+ characters for production
|
||||
- Generate using cryptographically secure random functions
|
||||
- Use at least 32 characters per secret
|
||||
- Use 127 or more characters in production
|
||||
- Generate secrets with a cryptographically secure random function
|
||||
- Never reuse secrets across token types or environments
|
||||
- Rotate secrets periodically in production
|
||||
|
||||
@@ -110,19 +110,20 @@ openssl rand -base64 127
|
||||
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||
- **Validation**: Token signature, expiration, and malformed token checks
|
||||
|
||||
### Migration Control
|
||||
### Migration control
|
||||
|
||||
```bash
|
||||
CLEAR_DATABASE=true
|
||||
```
|
||||
|
||||
- **Required**: No
|
||||
- **Default**: false
|
||||
- **Effect**: If "true", drops and recreates database during migrations
|
||||
- **Usage**: Development and testing environments ONLY
|
||||
- **Warning**: NEVER use in production
|
||||
- **Default**: `false`
|
||||
- **Effect**: If set to `true`, drops and recreates the database during
|
||||
migrations
|
||||
- **Usage**: Development and testing environments only
|
||||
- **Warning**: Never use in production
|
||||
|
||||
### ASP.NET Core Configuration
|
||||
### ASP.NET Core configuration
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Frontend Variables (`web/frontend`)
|
||||
## Frontend variables (`web/frontend`)
|
||||
|
||||
The active website does not use the old Next.js/Prisma environment model. Its
|
||||
core runtime variables are:
|
||||
@@ -141,7 +142,7 @@ SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||
NODE_ENV=development # Standard Node runtime mode
|
||||
```
|
||||
|
||||
### Frontend Variable Details
|
||||
### Frontend variable details
|
||||
|
||||
#### `API_BASE_URL`
|
||||
|
||||
@@ -163,7 +164,7 @@ NODE_ENV=development # Standard Node runtime mode
|
||||
- **Typical values**: `development`, `production`, `test`
|
||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||
|
||||
### SMTP Configuration (Backend)
|
||||
### SMTP configuration (backend)
|
||||
|
||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
||||
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
|
||||
UI on http://localhost:8025)
|
||||
- **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
|
||||
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
|
||||
- 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)
|
||||
@@ -219,7 +220,7 @@ cp web/.env.example web/.env.dev
|
||||
# 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
|
||||
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.prod.yaml` → `web/.env.prod`
|
||||
|
||||
## Variable Reference Table
|
||||
## Variable reference table
|
||||
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||
@@ -268,7 +269,7 @@ reference only and is not run as part of the active stack.
|
||||
|
||||
## Validation
|
||||
|
||||
### Backend Validation
|
||||
### Backend validation
|
||||
|
||||
Variables are validated at startup:
|
||||
|
||||
@@ -279,7 +280,7 @@ Variables are validated at startup:
|
||||
"minimum 32 characters" guidance above is a recommendation, not an
|
||||
enforced check
|
||||
|
||||
### Frontend Validation
|
||||
### Frontend validation
|
||||
|
||||
The active website relies on runtime defaults for local development and the
|
||||
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
|
||||
- `NODE_ENV` controls secure cookie behavior
|
||||
|
||||
## Example Configuration Files
|
||||
## Example configuration files
|
||||
|
||||
### `.env.dev` (Backend/Docker)
|
||||
### `.env.dev` (backend/Docker)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Getting Started
|
||||
# Getting started
|
||||
|
||||
This guide covers local setup for the current Biergarten stack: the .NET backend
|
||||
in `web/backend` and the active React Router frontend in `web/frontend`.
|
||||
@@ -10,16 +10,16 @@ in `web/backend` and the active React Router frontend in `web/frontend`.
|
||||
- **Docker Desktop** or equivalent Docker Engine setup
|
||||
- **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
|
||||
git clone <repository-url>
|
||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||
cd the-biergarten-app
|
||||
```
|
||||
|
||||
### 2. Configure Backend Environment Variables
|
||||
### 2. Configure backend environment variables
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
### 3. Start the Backend Stack
|
||||
### 3. Start the backend stack
|
||||
|
||||
```bash
|
||||
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
|
||||
- Mailpit UI: http://localhost:8025
|
||||
|
||||
### 4. Start the Active Frontend
|
||||
### 4. Start the active frontend
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -69,7 +69,7 @@ Required frontend runtime variables for local work:
|
||||
- `API_BASE_URL` - Base URL for the .NET API
|
||||
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||
|
||||
### 5. Optional: Run Storybook
|
||||
### 5. Optional: run Storybook
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -78,7 +78,7 @@ npm run storybook
|
||||
|
||||
Storybook runs at http://localhost:6006 by default.
|
||||
|
||||
## Useful Commands
|
||||
## Useful commands
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -99,11 +99,11 @@ npm run test:storybook
|
||||
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.
|
||||
|
||||
### 1. Set Environment Variables
|
||||
### 1. Set environment variables
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
### 2. Run Migrations and Seed
|
||||
### 2. Run migrations and seed
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Legacy Frontend Note
|
||||
## Legacy frontend note
|
||||
|
||||
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
|
||||
only and is not the active website.
|
||||
|
||||
## Next Steps
|
||||
## Next steps
|
||||
|
||||
- Review [Architecture](../architecture.md)
|
||||
- Run backend and frontend checks from [Testing](testing.md)
|
||||
|
||||
@@ -19,7 +19,7 @@ The project uses a multi-layered testing approach across backend and frontend:
|
||||
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
||||
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
|
||||
isolated test environment:
|
||||
@@ -37,7 +37,7 @@ This command:
|
||||
5. Exports results to `./test-results/`
|
||||
6. Exits when tests complete
|
||||
|
||||
### View Test Results
|
||||
### View test results
|
||||
|
||||
```bash
|
||||
# List test result files
|
||||
@@ -51,18 +51,18 @@ cat test-results/Features.UserManagement.Tests.trx
|
||||
cat test-results/Features.Emails.Tests.trx
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
### Clean up
|
||||
|
||||
```bash
|
||||
# Remove test containers and volumes
|
||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
## Running tests locally
|
||||
|
||||
You can run individual test projects locally without Docker:
|
||||
|
||||
### Integration Tests (API.Specs)
|
||||
### Integration tests (API.Specs)
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
@@ -75,7 +75,7 @@ dotnet test API/API.Specs/API.Specs.csproj
|
||||
- Database migrated and seeded
|
||||
- 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
|
||||
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
|
||||
simulate SQL Server responses)
|
||||
|
||||
### Frontend Storybook Tests
|
||||
### Frontend Storybook tests
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -110,7 +110,7 @@ npm run test:storybook
|
||||
toasts, and the theme gallery
|
||||
- Runs in browser mode via Vitest and Storybook integration
|
||||
|
||||
### Frontend Playwright Storybook Tests
|
||||
### Frontend Playwright Storybook tests
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
@@ -125,9 +125,9 @@ npm run test:storybook:playwright
|
||||
- The command will start or reuse the Storybook server defined in
|
||||
`playwright.storybook.config.ts`
|
||||
|
||||
## Test Coverage
|
||||
## Test coverage
|
||||
|
||||
### Current Coverage
|
||||
### Current coverage
|
||||
|
||||
**Features.Auth.Tests**:
|
||||
|
||||
@@ -160,7 +160,7 @@ npm run test:storybook:playwright
|
||||
- Theme gallery rendering across Biergarten themes
|
||||
- Toast interactions and themed notification display
|
||||
|
||||
### Planned Coverage
|
||||
### Planned coverage
|
||||
|
||||
- [ ] Password reset functionality
|
||||
- [ ] Beer post operations
|
||||
@@ -168,7 +168,7 @@ npm run test:storybook:playwright
|
||||
- [ ] Image upload service
|
||||
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||
|
||||
## Testing Frameworks & Tools
|
||||
## Testing frameworks and tools
|
||||
|
||||
### xUnit
|
||||
|
||||
@@ -200,9 +200,9 @@ npm run test:storybook:playwright
|
||||
- Simulates SQL Server responses
|
||||
- No real database required for unit tests
|
||||
|
||||
## Test Structure
|
||||
## Test structure
|
||||
|
||||
### API.Specs (Integration Tests)
|
||||
### API.Specs (integration tests)
|
||||
|
||||
```
|
||||
API.Specs/
|
||||
@@ -260,9 +260,9 @@ Each of the other three slices (`Features.Breweries.Tests`,
|
||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
|
||||
shape: a `Commands/`/`Queries/` folder with one test file per handler.
|
||||
|
||||
## Writing Tests
|
||||
## Writing tests
|
||||
|
||||
### Unit Test Example (xUnit)
|
||||
### Unit test example (xUnit)
|
||||
|
||||
```csharp
|
||||
public class LoginHandlerTests
|
||||
@@ -287,7 +287,7 @@ public class LoginHandlerTests
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Example (Reqnroll)
|
||||
### Integration test example (Reqnroll)
|
||||
|
||||
```gherkin
|
||||
Scenario: User login with valid credentials
|
||||
@@ -297,7 +297,7 @@ Scenario: User login with valid credentials
|
||||
And the response should contain a JWT token
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
## Continuous integration
|
||||
|
||||
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
||||
configuration:
|
||||
@@ -326,7 +326,7 @@ npm run test:storybook:playwright
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Failing Due to Database Connection
|
||||
### Tests failing due to database connection
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
### Port conflicts
|
||||
|
||||
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||
`docker-compose.test.yaml`.
|
||||
|
||||
### Stale Test Data
|
||||
### Stale test data
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### View Container Logs
|
||||
### View container logs
|
||||
|
||||
```bash
|
||||
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
|
||||
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Token Validation Architecture
|
||||
# Token validation architecture
|
||||
|
||||
## 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
|
||||
- **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
|
||||
|
||||
### Infrastructure Layer
|
||||
### Infrastructure layer
|
||||
|
||||
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||
|
||||
@@ -30,7 +30,7 @@ Low-level JWT operations.
|
||||
- Algorithm: HS256 (HMAC-SHA256)
|
||||
- Validates token lifetime, signature, and well-formedness
|
||||
|
||||
### Features.Auth Slice
|
||||
### Features.Auth slice
|
||||
|
||||
#### [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
|
||||
it directly.
|
||||
|
||||
### Integration Points
|
||||
### Integration points
|
||||
|
||||
#### [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/refresh` - Refresh access token
|
||||
|
||||
## Validation Security
|
||||
## Validation security
|
||||
|
||||
### Token Secrets
|
||||
### Token secrets
|
||||
|
||||
Three independent secrets enable:
|
||||
|
||||
@@ -130,7 +130,7 @@ REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
||||
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
||||
```
|
||||
|
||||
### Validation Checks
|
||||
### Validation checks
|
||||
|
||||
Each token is validated for:
|
||||
|
||||
@@ -139,7 +139,7 @@ Each token is validated for:
|
||||
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
||||
4. **Claims Format** - UserId claim must be a valid GUID
|
||||
|
||||
### Error Handling
|
||||
### Error handling
|
||||
|
||||
Validation failures return HTTP 401 Unauthorized:
|
||||
|
||||
@@ -148,9 +148,9 @@ Validation failures return HTTP 401 Unauthorized:
|
||||
- Missing claims → "Invalid token"
|
||||
- Malformed claims → "Invalid token"
|
||||
|
||||
## Token Lifecycle
|
||||
## Token lifecycle
|
||||
|
||||
### Access Token Lifecycle
|
||||
### Access token lifecycle
|
||||
|
||||
1. **Generation**: During login (1-hour validity)
|
||||
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
|
||||
5. **Refresh**: Use refresh token to obtain new access token
|
||||
|
||||
### Refresh Token Lifecycle
|
||||
### Refresh token lifecycle
|
||||
|
||||
1. **Generation**: During login (21-day validity)
|
||||
2. **Storage**: Client-side (secure storage)
|
||||
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
|
||||
6. **Expiration**: Token becomes invalid after 21 days
|
||||
|
||||
### Confirmation Token Lifecycle
|
||||
### Confirmation token lifecycle
|
||||
|
||||
1. **Generation**: During user registration (30-minute validity)
|
||||
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`.
|
||||
|
||||
### Unit Tests
|
||||
### Unit tests
|
||||
|
||||
**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
|
||||
- No-ops when the user doesn't exist or is already verified
|
||||
|
||||
### BDD Tests (Reqnroll)
|
||||
### BDD tests (Reqnroll)
|
||||
|
||||
**TokenRefresh.feature**
|
||||
|
||||
@@ -230,19 +230,19 @@ All of the following live in `Features.Auth.Tests`.
|
||||
- Invalid/expired access token rejection
|
||||
- Token type mismatch (refresh used as access token)
|
||||
|
||||
## Future Enhancements
|
||||
## Future enhancements
|
||||
|
||||
### Stretch Goals
|
||||
### Stretch goals
|
||||
|
||||
1. **Middleware for Access Token Validation**
|
||||
- Automatically validate access tokens on protected routes
|
||||
- Populate HttpContext.User from token claims
|
||||
- Return 401 for invalid/missing tokens
|
||||
|
||||
2. **Token Blacklisting**
|
||||
- Implement token revocation (e.g., on logout)
|
||||
- Store blacklisted tokens in cache/database
|
||||
- Check blacklist during validation
|
||||
2. **Token Denylisting**
|
||||
- Implement token revocation (for example, on logout)
|
||||
- Store denylisted tokens in cache/database
|
||||
- Check the denylist during validation
|
||||
|
||||
3. **Refresh Token Rotation Strategy**
|
||||
- Detect token reuse (replay attacks)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -111,9 +111,10 @@ if(NOT BIERGARTEN_MOCK_ONLY)
|
||||
)
|
||||
FetchContent_MakeAvailable(llama-cpp)
|
||||
endif()
|
||||
|
||||
if (NOT APPLE)
|
||||
find_package(OpenMP REQUIRED)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# spdlog
|
||||
FetchContent_Declare(
|
||||
@@ -249,9 +250,10 @@ target_include_directories(${PROJECT_NAME} PRIVATE
|
||||
includes
|
||||
)
|
||||
|
||||
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE
|
||||
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama>
|
||||
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:OpenMP::OpenMP_CXX>
|
||||
$<TARGET_NAME_IF_EXISTS:OpenMP::OpenMP_CXX>
|
||||
boost::di
|
||||
Boost::json
|
||||
Boost::program_options
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
```bash
|
||||
touch runpod/start.sh
|
||||
docker build \
|
||||
--progress=plain \
|
||||
-t biergarten-pipeline:latest \
|
||||
-f runpod/Dockerfile \
|
||||
. 2>&1 | tee build.log
|
||||
```
|
||||
# RunPod container
|
||||
|
||||
Dockerfile, launcher (`start.sh`), and pod template for running the
|
||||
Biergarten pipeline on a GPU host. See
|
||||
[Docker / RunPod](../../../docs/pipeline/README.md#docker--runpod) in the
|
||||
pipeline README for the full build and run instructions, environment
|
||||
variable reference, and RunPod deployment notes.
|
||||
|
||||
@@ -54,11 +54,11 @@ void XegerGenerator::Visit(const Alternation& node) {
|
||||
}
|
||||
|
||||
void XegerGenerator::Visit(const Repeat& node) {
|
||||
const int lo = node.min;
|
||||
const int hi = (node.max < 0) ? node.min + unbounded_cap_ : node.max;
|
||||
std::uniform_int_distribution<int> dist(lo, hi);
|
||||
const int count = dist(rng_);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const unsigned int low = node.min;
|
||||
const unsigned int high = node.max.value_or(node.min + unbounded_cap_);
|
||||
std::uniform_int_distribution<unsigned int> dist(low, high);
|
||||
const unsigned int count = dist(rng_);
|
||||
for (unsigned int i = 0; i < count; ++i) {
|
||||
node.child->Accept(*this);
|
||||
}
|
||||
}
|
||||
|
||||
13
web/backend/.config/dotnet-tools.json
Normal file
13
web/backend/.config/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"csharpier": {
|
||||
"version": "1.3.0",
|
||||
"commands": [
|
||||
"csharpier"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,6 @@ namespace API.Core.Authentication;
|
||||
/// 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.
|
||||
/// </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(
|
||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
@@ -31,15 +26,9 @@ public class JwtAuthenticationHandler(
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
||||
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
||||
/// token fails validation.
|
||||
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
|
||||
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
||||
/// </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()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
@@ -78,8 +67,6 @@ public class JwtAuthenticationHandler(
|
||||
/// <summary>
|
||||
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
||||
/// </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)
|
||||
{
|
||||
Response.ContentType = "application/json";
|
||||
@@ -96,5 +83,4 @@ public class JwtAuthenticationHandler(
|
||||
/// <summary>
|
||||
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
|
||||
/// </summary>
|
||||
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
|
||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace API.Core.Controllers;
|
||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||
/// </summary>
|
||||
/// <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
|
||||
/// <c>Program.cs</c>.
|
||||
/// </remarks>
|
||||
@@ -15,10 +14,6 @@ namespace API.Core.Controllers;
|
||||
[Route("error")] // required
|
||||
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
|
||||
public IActionResult Handle404()
|
||||
{
|
||||
|
||||
@@ -14,13 +14,9 @@ namespace API.Core.Controllers;
|
||||
public class ProtectedController : ControllerBase
|
||||
{
|
||||
/// <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>
|
||||
/// <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>
|
||||
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
|
||||
@@ -13,67 +13,17 @@ namespace API.Core;
|
||||
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||
/// </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
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
|
||||
/// marking the exception as handled.
|
||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Maps exception types to responses as follows:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <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>
|
||||
/// Maps <see cref="FluentValidation.ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||
/// </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)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
@@ -3,8 +3,7 @@ using Infrastructure.Email;
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// Mock email provider for testing that doesn't actually send emails.
|
||||
/// Tracks sent emails for verification in tests if needed.
|
||||
/// Test double for <see cref="IEmailProvider" /> that records sent emails instead of delivering them.
|
||||
/// </summary>
|
||||
public class MockEmailProvider : IEmailProvider
|
||||
{
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
|
||||
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
|
||||
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Features/">
|
||||
|
||||
@@ -7,215 +7,210 @@ using Microsoft.Data.SqlClient;
|
||||
namespace Database.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the database migration runner. Reads connection details from
|
||||
/// environment variables, optionally clears and recreates the target database, and
|
||||
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||
/// Entry point for the database migration runner. Reads connection details from environment
|
||||
/// variables, optionally drops and recreates the target database, and applies all SQL
|
||||
/// migration scripts embedded in this assembly using DbUp.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||
private static readonly string connectionString = BuildConnectionString();
|
||||
/// <summary>
|
||||
/// 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>
|
||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||
try
|
||||
{
|
||||
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>
|
||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||
/// environment variables.
|
||||
/// Drops the target database if it exists, first forcing it into single-user mode with
|
||||
/// rollback immediate so that any existing connections are terminated.
|
||||
/// </summary>
|
||||
/// <param name="databaseName">
|
||||
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
||||
/// <c>DB_NAME</c> environment variable.
|
||||
/// </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)
|
||||
/// <param name="settings">The resolved database settings.</param>
|
||||
/// <exception cref="SqlException">Thrown if the connection or the drop operation fails.</exception>
|
||||
private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings)
|
||||
{
|
||||
string server =
|
||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
const string sql = """
|
||||
DECLARE @statement nvarchar(max);
|
||||
|
||||
string dbName =
|
||||
databaseName
|
||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
|
||||
BEGIN
|
||||
SET @statement =
|
||||
N'ALTER DATABASE ' + QUOTENAME(@databaseName)
|
||||
+ N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;';
|
||||
EXEC sys.sp_executesql @statement;
|
||||
|
||||
string user =
|
||||
Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
|
||||
EXEC sys.sp_executesql @statement;
|
||||
END
|
||||
""";
|
||||
|
||||
string password =
|
||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
|
||||
Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist.");
|
||||
}
|
||||
|
||||
string trustServerCertificate =
|
||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||
|
||||
SqlConnectionStringBuilder builder = new()
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true,
|
||||
};
|
||||
const string sql = """
|
||||
DECLARE @statement nvarchar(max);
|
||||
|
||||
return builder.ConnectionString;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
|
||||
BEGIN
|
||||
SET @statement = N'CREATE DATABASE ' + QUOTENAME(@databaseName) + N';';
|
||||
EXEC sys.sp_executesql @statement;
|
||||
END
|
||||
""";
|
||||
|
||||
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>
|
||||
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||
/// database using DbUp, logging progress to the console.
|
||||
/// </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>
|
||||
private static bool DeployMigrations()
|
||||
private static bool DeployMigrations(string connectionString)
|
||||
{
|
||||
UpgradeEngine? upgrader = DeployChanges
|
||||
UpgradeEngine upgrader = DeployChanges
|
||||
.To.SqlDatabase(connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||
.WithTransactionPerScript()
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
|
||||
return result.Successful;
|
||||
}
|
||||
DatabaseUpgradeResult result = upgrader.PerformUpgrade();
|
||||
|
||||
/// <summary>
|
||||
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
|
||||
/// mode with rollback to terminate any existing connections.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
|
||||
/// if an error occurred while connecting or dropping the database.
|
||||
/// </returns>
|
||||
private static bool ClearDatabase()
|
||||
{
|
||||
SqlConnection myConn = new(masterConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
|
||||
// First, set the database to single user mode to close all connections
|
||||
SqlCommand setModeCommand = new(
|
||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
|
||||
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||
myConn
|
||||
);
|
||||
try
|
||||
{
|
||||
setModeCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database set to single user mode.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||
}
|
||||
|
||||
// Then drop the database
|
||||
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||
try
|
||||
{
|
||||
dropCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database cleared successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error dropping database: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error clearing database: {ex}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
myConn.Close();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
if (result.Successful)
|
||||
{
|
||||
Console.WriteLine("Database migrations completed successfully.");
|
||||
return 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.WriteLine("Database migrations failed.");
|
||||
return 1;
|
||||
Console.Error.WriteLine($"Database migrations failed: {result.Error}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
/// <summary>
|
||||
/// Connection details and runtime options resolved from environment variables.
|
||||
/// </summary>
|
||||
/// <param name="ConnectionString">The connection string for the target application database.</param>
|
||||
/// <param name="MasterConnectionString">
|
||||
/// The connection string for the <c>master</c> database, used for create and drop operations.
|
||||
/// </param>
|
||||
/// <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
|
||||
)
|
||||
{
|
||||
Console.WriteLine("An error occurred during database migrations:");
|
||||
Console.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
/// <summary>
|
||||
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
|
||||
/// <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()
|
||||
{
|
||||
string server = Required("DB_SERVER");
|
||||
string databaseName = Required("DB_NAME");
|
||||
string user = Required("DB_USER");
|
||||
string password = Required("DB_PASSWORD");
|
||||
|
||||
bool trustServerCertificate =
|
||||
!bool.TryParse(
|
||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
|
||||
out bool parsedTrust
|
||||
) || parsedTrust;
|
||||
|
||||
bool clearDatabase = string.Equals(
|
||||
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
|
||||
"true",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
return new DatabaseSettings(
|
||||
Build(databaseName),
|
||||
Build("master"),
|
||||
databaseName,
|
||||
clearDatabase
|
||||
);
|
||||
|
||||
string Build(string initialCatalog) =>
|
||||
new SqlConnectionStringBuilder
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = initialCatalog,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
Encrypt = true,
|
||||
TrustServerCertificate = trustServerCertificate,
|
||||
}.ConnectionString;
|
||||
}
|
||||
|
||||
private static string Required(string variableName) =>
|
||||
Environment.GetEnvironmentVariable(variableName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"The {variableName} environment variable is not set."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||
(
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
|
||||
@CountryCode NVARCHAR(2)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||
(
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_CreateUserAccount
|
||||
(
|
||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
|
||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||
@Username VARCHAR(64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@@ -15,7 +12,10 @@ BEGIN
|
||||
NOCOUNT ON;
|
||||
|
||||
DECLARE
|
||||
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||
@Inserted TABLE
|
||||
(
|
||||
UserAccountID UNIQUEIDENTIFIER
|
||||
);
|
||||
|
||||
INSERT INTO UserAccount
|
||||
(Username,
|
||||
@@ -24,10 +24,7 @@ INSERT INTO UserAccount
|
||||
DateOfBirth,
|
||||
Email)
|
||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||
VALUES
|
||||
(
|
||||
@Username, @FirstName, @LastName, @DateOfBirth, @Email
|
||||
);
|
||||
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||
|
||||
SELECT @UserAccountId = UserAccountID
|
||||
FROM @Inserted;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_DeleteUserAccount
|
||||
(
|
||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET
|
||||
NOCOUNT ON
|
||||
SET NOCOUNT ON
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||
BEGIN
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetAllUserAccounts
|
||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
||||
AS
|
||||
BEGIN
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SELECT UserAccountID,
|
||||
Username,
|
||||
FirstName,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||
@Email VARCHAR(128)
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE USP_GetUserAccountById(
|
||||
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||
@Username VARCHAR(64)
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_UpdateUserAccount(
|
||||
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
|
||||
@Username VARCHAR(64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
@Username VARCHAR(64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||
@Hash NVARCHAR(MAX)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||
@VerificationDateTime DATETIME = NULL
|
||||
AS
|
||||
BEGIN
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateCity(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
||||
@CityName NVARCHAR(100),
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||
@CountryName NVARCHAR(100),
|
||||
@ISO3166_1 NVARCHAR(2)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||
@StateProvinceName NVARCHAR(100),
|
||||
@ISO3166_2 NVARCHAR(6),
|
||||
@CountryCode NVARCHAR(2)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
@BreweryName NVARCHAR(256),
|
||||
@Description NVARCHAR(512),
|
||||
@PostedByID UNIQUEIDENTIFIER,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_DeleteBrewery
|
||||
@BreweryPostID UNIQUEIDENTIFIER
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||
@Limit INT = NULL,
|
||||
@Offset INT = NULL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SELECT *
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||
@BreweryPostID UNIQUEIDENTIFIER,
|
||||
@BreweryName NVARCHAR(256),
|
||||
@Description NVARCHAR(512),
|
||||
@@ -55,7 +53,8 @@ DELETE
|
||||
FROM dbo.BreweryPostLocation
|
||||
WHERE BreweryPostID = @BreweryPostID;
|
||||
END
|
||||
ELSE IF EXISTS (SELECT 1
|
||||
ELSE
|
||||
IF EXISTS (SELECT 1
|
||||
FROM dbo.BreweryPostLocation
|
||||
WHERE BreweryPostID = @BreweryPostID)
|
||||
BEGIN
|
||||
@@ -74,6 +73,5 @@ INSERT INTO dbo.BreweryPostLocation
|
||||
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
||||
@PostalCode, @Coordinates);
|
||||
END
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END
|
||||
|
||||
@@ -8,20 +8,30 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
|
||||
<!--
|
||||
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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
</Project>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
#region
|
||||
|
||||
namespace Database.Seed;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
#endregion
|
||||
|
||||
namespace Database.Seed.DatabaseHelpers;
|
||||
|
||||
public class ConnectionStrings
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||
/// 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>
|
||||
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
||||
public static string SqliteConnectionString =>
|
||||
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||
|
||||
private static string GetSqlServerConnectionString()
|
||||
{
|
||||
string server =
|
||||
@@ -47,8 +45,4 @@ public class ConnectionStrings
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
|
||||
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
||||
public static string SqliteConnectionString => "Data Source=seed.sqlite";
|
||||
}
|
||||
@@ -10,7 +10,7 @@ FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
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
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Database.Seed.dll"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Database.Seed.PipelineData;
|
||||
|
||||
public sealed record BreweryAddress
|
||||
{
|
||||
public required City City { get; init; }
|
||||
|
||||
public required string PostalCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Database.Seed.PipelineData;
|
||||
|
||||
public sealed record BreweryRecord
|
||||
{
|
||||
public required BreweryAddress Address { get; init; }
|
||||
|
||||
public required BreweryResult Brewery { get; init; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
18
web/backend/Database/Database.Seed/PipelineData/City.cs
Normal file
18
web/backend/Database/Database.Seed/PipelineData/City.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Database.Seed.PipelineData;
|
||||
|
||||
public sealed record UserAddress
|
||||
{
|
||||
public required City City { get; init; }
|
||||
|
||||
public required string PostalCode { get; init; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
118
web/backend/Database/Database.Seed/Program.cs
Normal file
118
web/backend/Database/Database.Seed/Program.cs
Normal 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();
|
||||
Binary file not shown.
BIN
web/backend/Database/Database.Seed/SeedData/seed.sqlite
Normal file
BIN
web/backend/Database/Database.Seed/SeedData/seed.sqlite
Normal file
Binary file not shown.
@@ -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) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace Database.Seed;
|
||||
|
||||
|
||||
public interface IExportDatabase
|
||||
{
|
||||
void SeedLocations(IEnumerable<Location> locations);
|
||||
|
||||
@@ -1,50 +1,14 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the brewery being posted about.
|
||||
/// </summary>
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Free-text description of the brewery.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The date and time the post was created.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
|
||||
/// navigation property.
|
||||
/// </summary>
|
||||
public UserAccount PostedBy { get; set; } = null!;
|
||||
public BreweryPostLocation? Location { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The primary street address line.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The postal/ZIP code of the location.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <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 BreweryPost BreweryPost { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,17 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The user's first name.
|
||||
/// </summary>
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The user's last name.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The date and time the account was created.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The user's date of birth.
|
||||
/// </summary>
|
||||
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 ICollection<BreweryPost> BreweryPosts { get; set; } = [];
|
||||
public ICollection<UserCredential> UserCredentials { get; set; } = [];
|
||||
public UserVerification? Verification { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The date and time the credential was issued.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date and time after which the credential is no longer valid.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <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 UserAccount UserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The date and time at which the user account was verified.
|
||||
/// </summary>
|
||||
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 UserAccount UserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
namespace Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
||||
/// Maps to HTTP 409 Conflict.
|
||||
/// The exception that is thrown when a resource conflict occurs (e.g. duplicate username, email already
|
||||
/// in use). Maps to HTTP 409 Conflict.
|
||||
/// </summary>
|
||||
public class ConflictException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a requested resource is not found.
|
||||
/// Maps to HTTP 404 Not Found.
|
||||
/// The exception that is thrown when a requested resource is not found. Maps to HTTP 404 Not Found.
|
||||
/// </summary>
|
||||
public class NotFoundException(string message) : Exception(message);
|
||||
|
||||
// Domain.Exceptions/UnauthorizedException.cs
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when authentication fails or is required.
|
||||
/// Maps to HTTP 401 Unauthorized.
|
||||
/// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
|
||||
/// </summary>
|
||||
public class UnauthorizedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
||||
/// Maps to HTTP 403 Forbidden.
|
||||
/// The exception that is thrown when an authenticated user lacks permission to access a resource. Maps to
|
||||
/// HTTP 403 Forbidden.
|
||||
/// </summary>
|
||||
public class ForbiddenException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
||||
/// Maps to HTTP 400 Bad Request.
|
||||
/// The exception that is thrown when business rule validation fails (distinct from FluentValidation's
|
||||
/// <see cref="FluentValidation.ValidationException" />). Maps to HTTP 400 Bad Request.
|
||||
/// </summary>
|
||||
public class ValidationException(string message) : Exception(message);
|
||||
|
||||
@@ -68,6 +68,7 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
@@ -76,6 +77,7 @@ public class RegisterUserHandlerTests
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
@@ -139,15 +141,7 @@ public class RegisterUserHandlerTests
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -192,12 +186,7 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
hashedPassword
|
||||
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
@@ -215,12 +204,14 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
)
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
@@ -239,16 +230,7 @@ public class RegisterUserHandlerTests
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
)
|
||||
)
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
|
||||
@@ -50,12 +51,14 @@ public class AuthRepositoryTests
|
||||
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount result = await repo.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
"testuser",
|
||||
"Test",
|
||||
"User",
|
||||
"test@example.com",
|
||||
new DateTime(1990, 1, 1),
|
||||
"hashedpassword123"
|
||||
)
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
@@ -3,8 +3,5 @@ using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
/// <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>
|
||||
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
|
||||
@@ -11,8 +11,6 @@ namespace Features.Auth.Commands.ConfirmUser;
|
||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// </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)
|
||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||
/// access/refresh token pair.
|
||||
/// </summary>
|
||||
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||
{
|
||||
|
||||
@@ -2,14 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
|
||||
/// </summary>
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||
|
||||
@@ -14,10 +14,6 @@ namespace Features.Auth.Commands.RegisterUser;
|
||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||
/// confirmation email via Features.Emails.
|
||||
/// </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(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
@@ -38,12 +34,14 @@ public class RegisterUserHandler(
|
||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
request.Username,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Email,
|
||||
request.DateOfBirth,
|
||||
hashed
|
||||
)
|
||||
);
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
|
||||
@@ -2,15 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
|
||||
@@ -2,8 +2,5 @@ using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
|
||||
/// <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>
|
||||
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
|
||||
@@ -10,9 +10,6 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
||||
/// sending it via Features.Emails.
|
||||
/// </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>
|
||||
/// 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.
|
||||
|
||||
@@ -18,21 +18,11 @@ namespace Features.Auth.Controllers;
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
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]
|
||||
[HttpPost("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]
|
||||
[HttpPost("login")]
|
||||
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")]
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||
[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")]
|
||||
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" });
|
||||
}
|
||||
|
||||
/// <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]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
|
||||
@@ -5,9 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services owned by the Auth feature slice.
|
||||
/// </summary>
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
namespace Features.Auth.Dtos;
|
||||
|
||||
/// <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>
|
||||
/// <summary>Payload returned to the client after a successful login or token refresh.</summary>
|
||||
public record LoginPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
@@ -14,14 +8,7 @@ public record LoginPayload(
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Payload returned to the client after a successful registration.</summary>
|
||||
public record RegistrationPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
@@ -30,9 +17,5 @@ public record RegistrationPayload(
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Features.Auth.Dtos;
|
||||
|
||||
public record UserRegistrationDto(
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string PasswordHash
|
||||
);
|
||||
@@ -11,12 +11,6 @@ namespace Features.Auth.Queries.Login;
|
||||
/// <summary>
|
||||
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
||||
/// </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(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
|
||||
@@ -2,15 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Queries.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="LoginQuery" /> instances before they are processed.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Dtos;
|
||||
using Infrastructure.Sql;
|
||||
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,
|
||||
/// handling user registration, credential lookup/rotation, and account verification.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
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
|
||||
/// subsequent lookup to fail.
|
||||
/// </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="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string email,
|
||||
DateTime dateOfBirth,
|
||||
string passwordHash
|
||||
)
|
||||
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -49,6 +34,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
command.CommandText = "USP_RegisterUser";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
|
||||
|
||||
AddParameter(command, "@Username", username);
|
||||
AddParameter(command, "@FirstName", firstName);
|
||||
AddParameter(command, "@LastName", lastName);
|
||||
@@ -85,13 +72,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -105,13 +86,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
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
|
||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||
/// </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)
|
||||
{
|
||||
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,
|
||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||
/// </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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -165,12 +134,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
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
|
||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||
/// </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">
|
||||
/// Thrown when the database command fails for a reason other than
|
||||
/// a duplicate verification record.
|
||||
@@ -226,13 +188,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -247,23 +203,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return result != null && result != DBNull.Value;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||
{
|
||||
// 2601/2627 are duplicate key violations in SQL Server.
|
||||
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)
|
||||
{
|
||||
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
|
||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||
/// </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)
|
||||
{
|
||||
UserCredential entity = new()
|
||||
@@ -320,13 +264,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
DbParameter p = command.CreateParameter();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Dtos;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
@@ -7,76 +8,21 @@ namespace Features.Auth.Repository;
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
);
|
||||
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -18,20 +18,10 @@ public enum TokenType
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Represents the result of successfully validating a token.</summary>
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Represents the result of refreshing a user's session.</summary>
|
||||
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -39,14 +29,13 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
||||
/// </summary>
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||
public const double RefreshTokenHours = 504; // 21 days
|
||||
/// <summary>21 days.</summary>
|
||||
public const double RefreshTokenHours = 504;
|
||||
|
||||
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||
/// <summary>30 minutes.</summary>
|
||||
public const double ConfirmationTokenHours = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,61 +43,23 @@ public static class TokenServiceExpirationHours
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
|
||||
/// </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>
|
||||
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
||||
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
|
||||
string GenerateToken<T>(UserAccount user)
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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>
|
||||
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
|
||||
@@ -19,12 +19,7 @@ public class TokenService : ITokenService
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
|
||||
/// <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>
|
||||
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 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.
|
||||
@@ -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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
@@ -111,11 +88,9 @@ public class TokenService : ITokenService
|
||||
/// <summary>
|
||||
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
||||
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
||||
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
|
||||
/// <see cref="GenerateConfirmationToken" />.
|
||||
/// </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>
|
||||
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||
/// </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">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
@@ -152,11 +122,6 @@ public class TokenService : ITokenService
|
||||
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">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
@@ -165,11 +130,6 @@ public class TokenService : ITokenService
|
||||
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">
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
@@ -178,12 +138,6 @@ public class TokenService : ITokenService
|
||||
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">
|
||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||
/// </exception>
|
||||
@@ -204,10 +158,7 @@ public class TokenService : ITokenService
|
||||
/// 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.
|
||||
/// </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>
|
||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||
/// 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).
|
||||
|
||||
@@ -3,9 +3,6 @@ using MediatR;
|
||||
|
||||
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(
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
|
||||
@@ -5,19 +5,10 @@ using MediatR;
|
||||
|
||||
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)
|
||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||
{
|
||||
/// <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>
|
||||
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
|
||||
public async Task<BreweryDto> Handle(
|
||||
CreateBreweryCommand request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -2,17 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace Features.Breweries.Commands.CreateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
|
||||
|
||||
@@ -2,7 +2,4 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||
|
||||
@@ -3,10 +3,6 @@ using MediatR;
|
||||
|
||||
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)
|
||||
: IRequestHandler<DeleteBreweryCommand>
|
||||
{
|
||||
|
||||
@@ -3,9 +3,6 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||
|
||||
/// <summary>
|
||||
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand" />.
|
||||
/// </summary>
|
||||
public record UpdateBreweryLocation(
|
||||
Guid BreweryPostLocationId,
|
||||
Guid CityId,
|
||||
|
||||
@@ -5,10 +5,6 @@ using MediatR;
|
||||
|
||||
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)
|
||||
: 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>,
|
||||
/// the brewery's location is cleared.
|
||||
/// </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(
|
||||
UpdateBreweryCommand request,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -11,28 +11,17 @@ using Shared.Contracts;
|
||||
|
||||
namespace Features.Breweries.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides CRUD endpoints for managing brewery posts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
||||
/// (<see cref="GetById" /> and <see cref="GetAll" />) opt out via <c>[AllowAnonymous]</c>.
|
||||
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
|
||||
/// <c>[AllowAnonymous]</c>.
|
||||
/// </remarks>
|
||||
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class BreweryController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
||||
/// <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>
|
||||
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
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>
|
||||
/// <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]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
@@ -76,14 +59,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||
[FromBody] CreateBreweryCommand command
|
||||
@@ -100,22 +76,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post.
|
||||
/// </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>
|
||||
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
|
||||
/// <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>
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
||||
Guid id,
|
||||
@@ -135,11 +97,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <returns><c>200 OK</c> confirming the deletion.</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||
{
|
||||
|
||||
@@ -1,89 +1,39 @@
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto" />.
|
||||
/// </summary>
|
||||
public class BreweryLocationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the brewery's location record.
|
||||
/// </summary>
|
||||
public Guid BreweryPostLocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique identifier of the brewery post that this location belongs to.
|
||||
/// </summary>
|
||||
public Guid BreweryPostId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique identifier of the city in which the brewery is located.
|
||||
/// </summary>
|
||||
public Guid CityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The primary street address line of the brewery.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// The postal/ZIP code of the brewery's address.
|
||||
/// </summary>
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
||||
/// </summary>
|
||||
/// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the brewery post.
|
||||
/// </summary>
|
||||
public Guid BreweryPostId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique identifier of the user account that created the brewery post.
|
||||
/// </summary>
|
||||
public Guid PostedById { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the brewery.
|
||||
/// </summary>
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// A description of the brewery.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
|
||||
/// </summary>
|
||||
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -2,17 +2,8 @@ using Domain.Entities;
|
||||
|
||||
namespace Features.Breweries.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="BreweryPost" /> domain entities to their <see cref="BreweryDto" /> wire representation.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
return new BreweryDto
|
||||
|
||||
@@ -5,10 +5,6 @@ using MediatR;
|
||||
|
||||
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)
|
||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||
{
|
||||
|
||||
@@ -5,10 +5,6 @@ using MediatR;
|
||||
|
||||
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)
|
||||
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||
{
|
||||
|
||||
@@ -3,7 +3,4 @@ using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryById;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||
|
||||
@@ -9,17 +9,11 @@ namespace Features.Breweries.Repository;
|
||||
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
|
||||
/// procedures.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory),
|
||||
IBreweryRepository
|
||||
{
|
||||
/// <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>
|
||||
/// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
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
|
||||
/// corresponding argument has a value.
|
||||
/// </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)
|
||||
{
|
||||
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>null</c>, any existing location for the brewery is removed.
|
||||
/// </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)
|
||||
{
|
||||
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
|
||||
/// and photos are removed via cascading foreign keys.
|
||||
/// </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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -110,12 +96,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
|
||||
/// <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)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
@@ -139,12 +121,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the current row of a data reader to a <see cref="BreweryPost" /> entity, including its
|
||||
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its
|
||||
/// associated <see cref="BreweryPostLocation" />.
|
||||
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
|
||||
/// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
|
||||
/// </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)
|
||||
{
|
||||
BreweryPost brewery = new();
|
||||
@@ -212,13 +191,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
return brewery;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
DbParameter p = command.CreateParameter();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user